diff --git a/cpp/modmesh/pilot/RWorldRenderer2d.cpp b/cpp/modmesh/pilot/RWorldRenderer2d.cpp index 6911aeb7..fbcc097d 100644 --- a/cpp/modmesh/pilot/RWorldRenderer2d.cpp +++ b/cpp/modmesh/pilot/RWorldRenderer2d.cpp @@ -29,6 +29,7 @@ #include // Must be the first include. #include +#include #include #include #include @@ -39,6 +40,9 @@ namespace modmesh namespace { +// Match R2DWidget's canvas backdrop. +QColor const BACKGROUND(32, 32, 36); + QColor const GEOMETRY(120, 180, 240); // Cosmetic (zoom-independent) screen widths for world geometry. @@ -102,6 +106,23 @@ void RWorldRenderer2d::paint(QPainter & painter) const } } +QImage RWorldRenderer2d::render_image(int width, int height) const +{ + 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: diff --git a/cpp/modmesh/pilot/RWorldRenderer2d.hpp b/cpp/modmesh/pilot/RWorldRenderer2d.hpp index 50efd5fa..8930bd41 100644 --- a/cpp/modmesh/pilot/RWorldRenderer2d.hpp +++ b/cpp/modmesh/pilot/RWorldRenderer2d.hpp @@ -35,6 +35,7 @@ #include +class QImage; class QPainter; namespace modmesh @@ -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; + private: // Map math-convention world (x, y) to Qt screen pixels; z is dropped. QPointF map(double world_x, double world_y) const; diff --git a/cpp/modmesh/pilot/wrap_pilot.cpp b/cpp/modmesh/pilot/wrap_pilot.cpp index 4684194c..3707ec90 100644 --- a/cpp/modmesh/pilot/wrap_pilot.cpp +++ b/cpp/modmesh/pilot/wrap_pilot.cpp @@ -31,10 +31,15 @@ #include #include +#include -#include +#include +#include #include +#include #include +#include +#include // Usually MODMESH_PYSIDE6_FULL is not defined unless for debugging. #ifdef MODMESH_PYSIDE6_FULL @@ -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", + [](std::shared_ptr 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(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", + [](std::shared_ptr 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 diff --git a/modmesh/pilot/__init__.py b/modmesh/pilot/__init__.py index 5f6351f8..6dca4e94 100644 --- a/modmesh/pilot/__init__.py +++ b/modmesh/pilot/__init__.py @@ -42,6 +42,8 @@ RPythonConsoleDockWidget, RManager, RCameraController, + render_world_2d, + save_world_2d, ) if enable: from ._gui import ( # noqa: F401 diff --git a/modmesh/pilot/_pilot_core.py b/modmesh/pilot/_pilot_core.py index 1a9dc90b..94121020 100644 --- a/modmesh/pilot/_pilot_core.py +++ b/modmesh/pilot/_pilot_core.py @@ -74,13 +74,20 @@ 'RCameraController', ] +# RWorldRenderer2d.hpp/.cpp (bound in wrap_pilot.cpp) +list_of_rworldrenderer2d = [ + '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 @@ -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 diff --git a/tests/test_pilot_2d.py b/tests/test_pilot_2d.py index 71e7e96a..25560457 100644 --- a/tests/test_pilot_2d.py +++ b/tests/test_pilot_2d.py @@ -36,6 +36,8 @@ the planned pixel-level coverage and what it needs first. """ +import os +import tempfile import unittest import modmesh @@ -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 @@ -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): + """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()