-
Notifications
You must be signed in to change notification settings - Fork 64
pilot: Add offscreen 2D world renderer and Python binding (issue #754 PR2) #900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ | |
| #include <modmesh/pilot/RWorldRenderer2d.hpp> // Must be the first include. | ||
|
|
||
| #include <QColor> | ||
| #include <QImage> | ||
| #include <QPainter> | ||
| #include <QPainterPath> | ||
| #include <QPen> | ||
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR2 core: |
||
| { | ||
| 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: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
|
|
||
| #include <QPointF> | ||
|
|
||
| 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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR2: |
||
| [](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", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR2: |
||
| [](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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,8 @@ | |
| RPythonConsoleDockWidget, | ||
| RManager, | ||
| RCameraController, | ||
| render_world_2d, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,13 +74,20 @@ | |
| 'RCameraController', | ||
| ] | ||
|
|
||
| # RWorldRenderer2d.hpp/.cpp (bound in wrap_pilot.cpp) | ||
| list_of_rworldrenderer2d = [ | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.