Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cpp/modmesh/pilot/RWorldRenderer2d.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
#include <modmesh/pilot/RWorldRenderer2d.hpp> // Must be the first include.

#include <QColor>
#include <QImage>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
Expand All @@ -39,6 +40,9 @@ namespace modmesh
namespace
{

// Match R2DWidget's canvas backdrop.
QColor const BACKGROUND(32, 32, 36);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: offscreen canvas backdrop, kept in sync with R2DWidget's background so headless captures match the on-screen widget.


QColor const GEOMETRY(120, 180, 240);

// Cosmetic (zoom-independent) screen widths for world geometry.
Expand Down Expand Up @@ -102,6 +106,23 @@ void RWorldRenderer2d::paint(QPainter & painter) const
}
}

QImage RWorldRenderer2d::render_image(int width, int height) const

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2 core: render_image paints the held World into an offscreen ARGB32 QImage via the existing paint(), with no widget and no event loop. Returns a null QImage on non-positive size (the bindings translate that to ValueError).

{
if (width <= 0 || height <= 0)
{
return QImage();
}

QImage image(width, height, QImage::Format_ARGB32);
image.fill(BACKGROUND);

QPainter painter(&image);
paint(painter);
painter.end();

return image;
}

} /* end namespace modmesh */

// vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4:
4 changes: 4 additions & 0 deletions cpp/modmesh/pilot/RWorldRenderer2d.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

#include <QPointF>

class QImage;
class QPainter;

namespace modmesh
Expand All @@ -60,6 +61,9 @@ class RWorldRenderer2d

void paint(QPainter & painter) const;

/// render the world to a new image of the given size
QImage render_image(int width, int height) const;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: public entry point for the offscreen render.


private:
// Map math-convention world (x, y) to Qt screen pixels; z is dropped.
QPointF map(double world_x, double world_y) const;
Expand Down
57 changes: 56 additions & 1 deletion cpp/modmesh/pilot/wrap_pilot.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@
#include <pybind11/stl.h>

#include <modmesh/pilot/pilot.hpp>
#include <modmesh/pilot/RWorldRenderer2d.hpp>

#include <QPointer>
#include <QBuffer>
#include <QByteArray>
#include <QClipboard>
#include <QImage>
#include <QMenu>
#include <QPointer>
#include <QString>

// Usually MODMESH_PYSIDE6_FULL is not defined unless for debugging.
#ifdef MODMESH_PYSIDE6_FULL
Expand Down Expand Up @@ -600,6 +605,56 @@ void wrap_pilot(pybind11::module & mod)
WrapRManager::commit(mod, "RManager", "RManager");
WrapRManagerProxy::commit(mod, "RManagerProxy", "RManagerProxy");

// Offscreen 2D capture (no widget or event loop) for headless tests/corpus.
mod.def(
"render_world_2d",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: pilot.render_world_2d -> PNG-encoded bytes. Raises ValueError on a None world or non-positive size.

[](std::shared_ptr<WorldFp64> const & world, ViewTransform2dFp64 const & view, int width, int height) -> py::bytes
{
if (!world)
{
throw std::invalid_argument("world must not be None");
}
QImage const image = RWorldRenderer2d(*world, view).render_image(width, height);
if (image.isNull())
{
throw std::invalid_argument("width and height must be positive");
}
QByteArray bytes;
QBuffer buffer(&bytes);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
return py::bytes(bytes.constData(), static_cast<size_t>(bytes.size()));
},
py::arg("world"),
py::arg("view"),
py::arg("width"),
py::arg("height"),
"Render a world to PNG-encoded bytes.");
mod.def(
"save_world_2d",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: pilot.save_world_2d -> writes a file whose format follows the filename suffix; raises on save failure.

[](std::shared_ptr<WorldFp64> const & world, ViewTransform2dFp64 const & view, int width, int height, std::string const & filename)
{
if (!world)
{
throw std::invalid_argument("world must not be None");
}
QImage const image = RWorldRenderer2d(*world, view).render_image(width, height);
if (image.isNull())
{
throw std::invalid_argument("width and height must be positive");
}
if (!image.save(QString::fromStdString(filename)))
{
throw std::runtime_error("failed to save image to " + filename);
}
},
py::arg("world"),
py::arg("view"),
py::arg("width"),
py::arg("height"),
py::arg("filename"),
"Render a world to an image file; format follows the filename suffix.");

mod.attr("mgr") = RManagerProxy();

try
Expand Down
2 changes: 2 additions & 0 deletions modmesh/pilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
RPythonConsoleDockWidget,
RManager,
RCameraController,
render_world_2d,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: export the two new offscreen-render functions from the pilot package.

save_world_2d,
)
if enable:
from ._gui import ( # noqa: F401
Expand Down
10 changes: 9 additions & 1 deletion modmesh/pilot/_pilot_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,20 @@
'RCameraController',
]

# RWorldRenderer2d.hpp/.cpp (bound in wrap_pilot.cpp)
list_of_rworldrenderer2d = [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2: register the new symbols with the _pilot_core bridge so they load from the C++ extension.

'render_world_2d',
'save_world_2d',
]

_from_impl = ( # noqa: F822
list_of_r3dwidget +
list_of_r2dwidget +
list_of_raxismark +
list_of_rmanager +
list_of_rpythonconsole +
list_of_rcameracontroller
list_of_rcameracontroller +
list_of_rworldrenderer2d
)

__all__ = _from_impl + [ # noqa: F822
Expand All @@ -103,6 +110,7 @@ def _load(symbol_list):
_load(list_of_rmanager)
_load(list_of_rpythonconsole)
_load(list_of_rcameracontroller)
_load(list_of_rworldrenderer2d)

del _load

Expand Down
70 changes: 70 additions & 0 deletions tests/test_pilot_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
the planned pixel-level coverage and what it needs first.
"""

import os
import tempfile
import unittest

import modmesh
Expand All @@ -45,6 +47,17 @@
except ImportError:
pilot = None

_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'


def _png_size(data):
"""Width and height from a PNG's IHDR chunk, without an image library:
big-endian uint32s at byte offsets 16 and 20.
"""
assert data[:8] == _PNG_MAGIC
return (int.from_bytes(data[16:20], 'big'),
int.from_bytes(data[20:24], 'big'))


def _build_world():
"""A world exercising every primitive R2DWidget paints, plus the two
Expand Down Expand Up @@ -155,6 +168,63 @@ def test_dead_shape_culling_renders_pixels(self):
raise NotImplementedError


@unittest.skipUnless(modmesh.HAS_PILOT, "Qt pilot is not built")
class RenderWorld2dTC(unittest.TestCase):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR2 tests: render a known world headless and check the PNG magic/size, that geometry changes pixels, determinism across runs, and the None-world / non-positive-size error paths. The endpoint-to-pixel gate remains PR3.

"""Offscreen 2D render path (issue #754 PR2): render a World with no widget
or event loop. Smoke guards only; the endpoint-to-pixel gate is PR3.
"""

def _centered_view(self, width, height, zoom=20.0):
"""Place the world origin at the image center so test worlds fit."""
v = modmesh.ViewTransform2dFp64()
v.pan(width / 2.0, height / 2.0)
v.zoom = zoom
return v

def test_render_returns_valid_png(self):
data = pilot.render_world_2d(_build_world(),
self._centered_view(400, 300), 400, 300)
self.assertEqual(data[:8], _PNG_MAGIC)
self.assertEqual(_png_size(data), (400, 300))

def test_geometry_changes_pixels(self):
"""A world with geometry must not encode identically to an empty one:
something is actually painted.
"""
view = self._centered_view(200, 200)
empty = pilot.render_world_2d(modmesh.WorldFp64(), view, 200, 200)
drawn = pilot.render_world_2d(_build_world(), view, 200, 200)
self.assertEqual(_png_size(empty), (200, 200))
self.assertNotEqual(empty, drawn)

def test_render_is_deterministic(self):
"""Byte-stable output so the corpus can compare images across runs."""
w = _build_world()
view = self._centered_view(200, 200)
self.assertEqual(pilot.render_world_2d(w, view, 200, 200),
pilot.render_world_2d(w, view, 200, 200))

def test_invalid_size_raises(self):
view = self._centered_view(100, 100)
with self.assertRaises(ValueError):
pilot.render_world_2d(_build_world(), view, 0, 100)

def test_none_world_raises(self):
view = self._centered_view(100, 100)
with self.assertRaises(ValueError):
pilot.render_world_2d(None, view, 100, 100)

def test_save_writes_png_file(self):
w = _build_world()
view = self._centered_view(120, 120)
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "render.png")
pilot.save_world_2d(w, view, 120, 120, path)
self.assertTrue(os.path.exists(path))
with open(path, 'rb') as stream:
self.assertEqual(stream.read(8), _PNG_MAGIC)


if __name__ == '__main__':
unittest.main()

Expand Down
Loading