diff --git a/cpp/modmesh/pilot/CMakeLists.txt b/cpp/modmesh/pilot/CMakeLists.txt index 2b942489..175d7548 100644 --- a/cpp/modmesh/pilot/CMakeLists.txt +++ b/cpp/modmesh/pilot/CMakeLists.txt @@ -6,6 +6,7 @@ cmake_minimum_required(VERSION 4.0.1) set(MODMESH_PILOT_PYMODHEADERS ${CMAKE_CURRENT_SOURCE_DIR}/R3DWidget.hpp ${CMAKE_CURRENT_SOURCE_DIR}/R2DWidget.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/RPainter2d.hpp ${CMAKE_CURRENT_SOURCE_DIR}/RWorld.hpp ${CMAKE_CURRENT_SOURCE_DIR}/RManager.hpp ${CMAKE_CURRENT_SOURCE_DIR}/RAxisMark.hpp @@ -21,6 +22,7 @@ set(MODMESH_PILOT_PYMODHEADERS set(MODMESH_PILOT_PYMODSOURCES ${CMAKE_CURRENT_SOURCE_DIR}/R3DWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/R2DWidget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/RPainter2d.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RWorld.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RAxisMark.cpp diff --git a/cpp/modmesh/pilot/R2DWidget.cpp b/cpp/modmesh/pilot/R2DWidget.cpp index bead5e0d..bff9dc55 100644 --- a/cpp/modmesh/pilot/R2DWidget.cpp +++ b/cpp/modmesh/pilot/R2DWidget.cpp @@ -28,13 +28,14 @@ #include // Must be the first include. +#include + #include #include #include #include #include -#include #include #include #include @@ -59,11 +60,6 @@ QColor const BACKGROUND(32, 32, 36); QColor const MINOR_GRID(64, 64, 70); QColor const AXIS(200, 200, 80); QColor const ORIGIN(220, 80, 80); -QColor const GEOMETRY(120, 180, 240); - -// Cosmetic (zoom-independent) screen widths for world geometry. -constexpr double GEOMETRY_LINE_WIDTH_PX = 1.5; -constexpr int GEOMETRY_POINT_WIDTH_PX = 5; double clamp_zoom(double zoom) { @@ -131,67 +127,6 @@ void R2DWidget::centerViewOnOrigin() m_view.set_pan_y(static_cast(height()) * 0.5); } -void R2DWidget::paintWorld(QPainter & painter) const -{ - if (!m_world) - { - return; - } - - // Map math-convention world (x, y) to Qt screen pixels; z is dropped. - auto map = [this](double world_x, double world_y) - { - double screen_x = 0.0; - double screen_y = 0.0; - m_view.screen_from_world(world_x, world_y, screen_x, screen_y); - return QPointF(screen_x, screen_y); - }; - - // Segments and flattened curves share one cosmetic stroke pen. - QPen geom_pen(GEOMETRY); - geom_pen.setCosmetic(true); - geom_pen.setWidthF(GEOMETRY_LINE_WIDTH_PX); - painter.setPen(geom_pen); - - // 1D straight segments - std::shared_ptr segments = m_world->collect_live_segments(); - for (size_t i = 0; i < segments->size(); ++i) - { - painter.drawLine(map(segments->x0(i), segments->y0(i)), - map(segments->x1(i), segments->y1(i))); - } - - // Cubic Beziers; QPainterPath flattens them adaptively, so no sampling. - std::shared_ptr curves = m_world->collect_live_curves(); - if (curves->size() > 0) - { - QPainterPath path; - for (size_t i = 0; i < curves->size(); ++i) - { - Bezier3dFp64 const c = curves->get(i); - path.moveTo(map(c.x0(), c.y0())); - path.cubicTo(map(c.x1(), c.y1()), map(c.x2(), c.y2()), map(c.x3(), c.y3())); - } - painter.setBrush(Qt::NoBrush); // stroke the outline only, never fill - painter.drawPath(path); - } - - // 0D standalone points as dots with a fixed pixel size at any zoom. - std::shared_ptr const & points = m_world->points(); - if (points->size() > 0) - { - QPen point_pen(GEOMETRY); - point_pen.setCosmetic(true); - point_pen.setWidth(GEOMETRY_POINT_WIDTH_PX); - point_pen.setCapStyle(Qt::RoundCap); - painter.setPen(point_pen); - for (size_t i = 0; i < points->size(); ++i) - { - painter.drawPoint(map(points->x(i), points->y(i))); - } - } -} - void R2DWidget::paintEvent(QPaintEvent * /*event*/) { QPainter painter(this); @@ -259,7 +194,10 @@ void R2DWidget::paintEvent(QPaintEvent * /*event*/) } // World geometry on top of the grid, under the origin marker. - paintWorld(painter); + if (m_world) + { + paint_world_2d(painter, *m_world, m_view); + } // Origin dot (cosmetic, fixed pixel size regardless of zoom). QPen origin_pen(ORIGIN); diff --git a/cpp/modmesh/pilot/R2DWidget.hpp b/cpp/modmesh/pilot/R2DWidget.hpp index 99ee16ac..16cb8357 100644 --- a/cpp/modmesh/pilot/R2DWidget.hpp +++ b/cpp/modmesh/pilot/R2DWidget.hpp @@ -39,7 +39,6 @@ #include class QMouseEvent; -class QPainter; class QPaintEvent; class QResizeEvent; class QWheelEvent; @@ -96,9 +95,6 @@ class R2DWidget void centerViewOnOrigin(); - /// Paint the world's live points, segments, and curves in screen space. - void paintWorld(QPainter & painter) const; - ViewTransform2dFp64 m_view; std::shared_ptr m_world; bool m_panning = false; diff --git a/cpp/modmesh/pilot/RPainter2d.cpp b/cpp/modmesh/pilot/RPainter2d.cpp new file mode 100644 index 00000000..c1c76dd8 --- /dev/null +++ b/cpp/modmesh/pilot/RPainter2d.cpp @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, An-Chi Liu + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include // Must be the first include. + +#include +#include +#include +#include + +namespace modmesh +{ + +namespace +{ + +QColor const GEOMETRY(120, 180, 240); + +// Cosmetic (zoom-independent) screen widths for world geometry. +constexpr double GEOMETRY_LINE_WIDTH_PX = 1.5; +constexpr int GEOMETRY_POINT_WIDTH_PX = 5; + +} // unnamed namespace + +void paint_world_2d(QPainter & painter, WorldFp64 const & world, ViewTransform2dFp64 const & view) +{ + // Map math-convention world (x, y) to Qt screen pixels; z is dropped. + auto map = [&view](double world_x, double world_y) + { + double screen_x = 0.0; + double screen_y = 0.0; + view.screen_from_world(world_x, world_y, screen_x, screen_y); + return QPointF(screen_x, screen_y); + }; + + // Segments and flattened curves share one cosmetic stroke pen. + QPen geom_pen(GEOMETRY); + geom_pen.setCosmetic(true); + geom_pen.setWidthF(GEOMETRY_LINE_WIDTH_PX); + painter.setPen(geom_pen); + + // 1D straight segments + std::shared_ptr segments = world.collect_live_segments(); + for (size_t i = 0; i < segments->size(); ++i) + { + painter.drawLine(map(segments->x0(i), segments->y0(i)), + map(segments->x1(i), segments->y1(i))); + } + + // Cubic Beziers; QPainterPath flattens them adaptively, so no sampling. + std::shared_ptr curves = world.collect_live_curves(); + if (curves->size() > 0) + { + QPainterPath path; + for (size_t i = 0; i < curves->size(); ++i) + { + Bezier3dFp64 const c = curves->get(i); + path.moveTo(map(c.x0(), c.y0())); + path.cubicTo(map(c.x1(), c.y1()), map(c.x2(), c.y2()), map(c.x3(), c.y3())); + } + painter.setBrush(Qt::NoBrush); // stroke the outline only, never fill + painter.drawPath(path); + } + + // 0D standalone points as dots with a fixed pixel size at any zoom. + std::shared_ptr const & points = world.points(); + if (points->size() > 0) + { + QPen point_pen(GEOMETRY); + point_pen.setCosmetic(true); + point_pen.setWidth(GEOMETRY_POINT_WIDTH_PX); + point_pen.setCapStyle(Qt::RoundCap); + painter.setPen(point_pen); + for (size_t i = 0; i < points->size(); ++i) + { + painter.drawPoint(map(points->x(i), points->y(i))); + } + } +} + +} /* end namespace modmesh */ + +// vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: diff --git a/cpp/modmesh/pilot/RPainter2d.hpp b/cpp/modmesh/pilot/RPainter2d.hpp new file mode 100644 index 00000000..e6ff869f --- /dev/null +++ b/cpp/modmesh/pilot/RPainter2d.hpp @@ -0,0 +1,53 @@ +#pragma once + +/* + * Copyright (c) 2026, An-Chi Liu + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include // Must be the first include. + +#include +#include + +class QPainter; + +namespace modmesh +{ + +/** + * Paint a world's live points, segments, and curves into a QPainter in + * screen space, mapping math-convention world coordinates through the given + * 2D view transform. This is the shared routine used by both the on-screen + * R2DWidget and the offscreen image renderer, so neither path can drift from + * the other. It paints only the geometry; the caller owns the background, + * grid, axes, and any origin marker. + */ +void paint_world_2d(QPainter & painter, WorldFp64 const & world, ViewTransform2dFp64 const & view); + +} /* end namespace modmesh */ + +// vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: diff --git a/cpp/modmesh/universe/World.hpp b/cpp/modmesh/universe/World.hpp index 10e8ef3d..e00ac8af 100644 --- a/cpp/modmesh/universe/World.hpp +++ b/cpp/modmesh/universe/World.hpp @@ -156,7 +156,7 @@ class World check_size(i, m_points->size(), "point"); return m_points->get(i); } - std::shared_ptr const & points() { return m_points; } + std::shared_ptr const & points() const { return m_points; } void add_segment(segment_type const & segment) { diff --git a/tests/test_pilot_2d.py b/tests/test_pilot_2d.py index 16d6e42f..c208dca2 100644 --- a/tests/test_pilot_2d.py +++ b/tests/test_pilot_2d.py @@ -97,7 +97,7 @@ def test_update_world_accepts_mixed_geometry(self): def test_update_world_none_clears(self): """A null world clears the canvas to its grid backdrop instead of - crashing; paintWorld early-returns when no world is set. + crashing; paint_world_2d is skipped when no world is set. """ self.widget.updateWorld(_build_world()) self.widget.updateWorld(None) @@ -117,7 +117,7 @@ def test_resync_after_mutating_world(self): def test_empty_world_paints_without_error(self): """A world with no geometry is valid: the loops are simply empty. - Catches off-by-one / null-pad assumptions in paintWorld. + Catches off-by-one / null-pad assumptions in paint_world_2d. """ self.widget.updateWorld(modmesh.WorldFp64()) self.widget.requestRepaint()