Skip to content
Draft
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
2 changes: 2 additions & 0 deletions cpp/modmesh/pilot/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like the namd RPainter2d. The names of R3DWidget and R2DWidget should follow. But "widget" is not informative. Do not rename hastily. Let's give it more thoughts.

The only complain I have with "painter" is that it sounds like a pixel facility, like the Windows legacy suggested. But it's not a big deal. Just to share an instinctive feeling.

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.

I changed to RWorldRenderer2d.

${CMAKE_CURRENT_SOURCE_DIR}/RWorld.hpp
${CMAKE_CURRENT_SOURCE_DIR}/RManager.hpp
${CMAKE_CURRENT_SOURCE_DIR}/RAxisMark.hpp
Expand All @@ -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
Expand Down
74 changes: 6 additions & 68 deletions cpp/modmesh/pilot/R2DWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@

#include <modmesh/pilot/R2DWidget.hpp> // Must be the first include.

#include <modmesh/pilot/RPainter2d.hpp>

#include <cmath>

#include <QColor>
#include <QMouseEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QResizeEvent>
#include <QWheelEvent>
Expand All @@ -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)
{
Expand Down Expand Up @@ -131,67 +127,6 @@ void R2DWidget::centerViewOnOrigin()
m_view.set_pan_y(static_cast<double>(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<SegmentPadFp64> 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<CurvePadFp64> 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<PointPadFp64> 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);
Expand Down Expand Up @@ -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);
}
Comment on lines +198 to +200

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.

paint_world_2d is called here.


// Origin dot (cosmetic, fixed pixel size regardless of zoom).
QPen origin_pen(ORIGIN);
Expand Down
4 changes: 0 additions & 4 deletions cpp/modmesh/pilot/R2DWidget.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
#include <QWidget>

class QMouseEvent;
class QPainter;
class QPaintEvent;
class QResizeEvent;
class QWheelEvent;
Expand Down Expand Up @@ -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<WorldFp64> m_world;
bool m_panning = false;
Expand Down
108 changes: 108 additions & 0 deletions cpp/modmesh/pilot/RPainter2d.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2026, An-Chi Liu <phy.tiger@gmail.com>
*
* 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 <modmesh/pilot/RPainter2d.hpp> // Must be the first include.

#include <QColor>
#include <QPainter>
#include <QPainterPath>
#include <QPen>

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's called anonymous namespace. "Unnamed" is unconventional.

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.

Got it.


void paint_world_2d(QPainter & painter, WorldFp64 const & world, ViewTransform2dFp64 const & view)

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.

Refactor the function to here. QPainter can take the argument from R2DWidget or future QImage

{
// Map math-convention world (x, y) to Qt screen pixels; z is dropped.
auto map = [&view](double world_x, double world_y)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When a lambda/closure is used but not for a functional purpose, it is a sign that the enclosing code should be made as a class and the lambda should be a member function.

But a function like this size (paint_world_2d) is short enough and readable. It's OK to write code like this.

A more extensible design is to make it a class now. By holding WorldFp64 & as a required member datum, the architecture explains itself.

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.

Sure, let's make it a class directly.

{
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<SegmentPadFp64> 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<CurvePadFp64> 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<PointPadFp64> 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:
53 changes: 53 additions & 0 deletions cpp/modmesh/pilot/RPainter2d.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#pragma once

/*
* Copyright (c) 2026, An-Chi Liu <phy.tiger@gmail.com>
*
* 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 <modmesh/pilot/common_detail.hpp> // Must be the first include.

#include <modmesh/universe/ViewTransform2d.hpp>
#include <modmesh/universe/World.hpp>

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:
2 changes: 1 addition & 1 deletion cpp/modmesh/universe/World.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ class World
check_size(i, m_points->size(), "point");
return m_points->get(i);
}
std::shared_ptr<point_pad_type> const & points() { return m_points; }
std::shared_ptr<point_pad_type> const & points() const { return m_points; }

void add_segment(segment_type const & segment)
{
Expand Down
4 changes: 2 additions & 2 deletions tests/test_pilot_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Loading