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
1 change: 1 addition & 0 deletions modmesh/pilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
launch,
)
from . import airfoil # noqa: F401
from . import _canvas # noqa: F401

# NOTE: intentionally omit __all__ for now

Expand Down
210 changes: 177 additions & 33 deletions modmesh/pilot/_canvas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright (c) 2026, Han-Xuan Huang <c1ydehhx@gmail.com>
# Copyright (c) 2026, Anchi Liu <phy.tiger@gmail.com>
Copy link
Member

Choose a reason for hiding this comment

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

The convention is to keep only the file creator. We follow the convention.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually, I think this is a good time to discuss the copyright line. I would suggest listing all authors in the copyright comment. For example, the first author contributed around 100 lines, while the second author added another 300 lines. The current copyright comment does not accurately reflect the actual contributions.

Copy link
Member

Choose a reason for hiding this comment

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

We can use a distinct discussion for this. Feel free to create one.

#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
Expand All @@ -24,40 +25,30 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
Canvas utilities and curve/conic drawing utilities for pilot GUI.
"""

from .. import core, plot

from ._gui_common import PilotFeature

__all__ = [
Copy link
Member

Choose a reason for hiding this comment

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

Good addition of the explicit __all__ list. Thanks.

'Canvas',
'BezierSample',
'BezierSampler',
]


class Canvas(PilotFeature):
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I thnk it make more senses to keep just Canvas.

Copy link
Member

Choose a reason for hiding this comment

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

Agree. Menu is just part of the sub-system.

"""
Create canvas windows for render layer.
Canvas feature providing menu items for drawing curves and polygons.
"""

def __init__(self):
self.world = core.WorldFp64()

def draw_layer(self, layer):
P = core.Point3dFp64

for poly in layer.get_polys():
spad = core.SegmentPadFp64(ndim=2)

for coord in poly:
spad.append(core.Segment3dFp64(
P(coord[0][0], coord[0][1]),
P(coord[1][0], coord[1][1])))

self.world.add_segments(pad=spad)

def get_world(self):
return self.world


class CanvasMenu(PilotFeature):
"""
Create sample canvas windows.
"""
def __init__(self, *args, **kw):
super(Canvas, self).__init__(*args, **kw)
self._world = core.WorldFp64()
self._widget = None

def populate_menu(self):
self._add_menu_item(
Expand All @@ -67,6 +58,63 @@ def populate_menu(self):
func=self.mesh_iccad_2013,
)

tip = "Draw a sample S-shaped cubic Bezier curve with control points"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

New samples are under "Canvas" menu.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

New items for the followings.

self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Bezier S-curve",
tip=tip,
func=self._bezier_s_curve,
)
self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Bezier Arch",
tip="Draw a sample arch-shaped cubic Bezier curve with control "
"points",
func=self._bezier_arch,
)
self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Bezier Loop",
tip="Draw a sample loop-like cubic Bezier curve with control "
"points",
func=self._bezier_loop,
)
# TODO: Add more curve/conic samples in the next PRs,
# e.g. ellipse, parabola, hyperbola, etc.
self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Ellipse",
tip="Draw a sample ellipse (a=2, b=1)",
func=self._ellipse,
)
self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Parabola",
tip="Draw a sample parabola (y = 0.5*x^2)",
func=self._parabola,
)
self._add_menu_item(
menu=self._mgr.canvasMenu,
text="Sample: Hyperbola",
tip="Draw a sample hyperbola (both branches)",
func=self._hyperbola,
)

@staticmethod
def _draw_layer(world, layer):
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

only for mesh_iccad_2013.

point_type = core.Point3dFp64

for polygon in layer.get_polys():
segment_pad = core.SegmentPadFp64(ndim=2)

for coord in polygon:
segment_pad.append(core.Segment3dFp64(
point_type(coord[0][0], coord[0][1]),
point_type(coord[1][0], coord[1][1])
))

world.add_segments(pad=segment_pad)

def mesh_iccad_2013(self):
layer = plot.plane_layer.PlaneLayer()
layer.add_figure("RECT N M1 70 800 180 40")
Expand All @@ -80,11 +128,107 @@ def mesh_iccad_2013(self):
"70 1140 370 1140 370 1020 70 1020"
)

canvas = Canvas()
canvas.draw_layer(layer)

wid = self._mgr.add3DWidget()
wid.updateWorld(canvas.get_world())
wid.showMark()

# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4:
self._draw_layer(self._world, layer)
self._update_widget()

def _update_widget(self):
if self._widget is None:
self._widget = self._mgr.add3DWidget()
self._widget.updateWorld(self._world)
self._widget.showMark()

def _bezier_s_curve(self):
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The drawer of the new sample.

bezier_sample = BezierSample.s_curve()
sampler = BezierSampler(self._world, bezier_sample)
sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0)
self._update_widget()

def _bezier_arch(self):
bezier_sample = BezierSample.arch()
sampler = BezierSampler(self._world, bezier_sample)
sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0)
self._update_widget()

def _bezier_loop(self):
bezier_sample = BezierSample.loop()
sampler = BezierSampler(self._world, bezier_sample)
sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0)
self._update_widget()

def _ellipse(self):
# TODO: Make it in the next PR.
raise NotImplementedError("Ellipse sample is not implemented yet")

def _parabola(self):
# TODO: Make it in the next PR.
raise NotImplementedError("Parabola sample is not implemented yet")

def _hyperbola(self):
# TODO: Make it in the next PR.
raise NotImplementedError("Hyperbola sample is not implemented yet")


class BezierSample(object):
def __init__(self, p0, p1, p2, p3):
self.p0 = p0
self.p1 = p1
self.p2 = p2
self.p3 = p3

@classmethod
def s_curve(cls):
return cls(p0=(0.0, 0.0), p1=(1.0, 3.0),
p2=(4.0, -1.0), p3=(5.0, 2.0))

@classmethod
def arch(cls):
return cls(p0=(0.0, 0.0), p1=(1.5, 4.0),
p2=(3.5, 4.0), p3=(5.0, 0.0))

@classmethod
def loop(cls):
return cls(p0=(0.0, 0.0), p1=(5.0, 3.0),
p2=(0.0, 3.0), p3=(5.0, 0.0))


class BezierSampler(object):
def __init__(self, world, bezier_sample):
self.world = world
self.bezier_sample = bezier_sample

def draw(self, nsample=50, fac=1.0, off_x=0.0, off_y=0.0,
show_control_polygon=True, show_control_points=True):
point_type = core.Point3dFp64
bezier_sample = self.bezier_sample

def _point(xy_pair):
return point_type(xy_pair[0] * fac + off_x,
xy_pair[1] * fac + off_y, 0)

p0 = _point(bezier_sample.p0)
p1 = _point(bezier_sample.p1)
p2 = _point(bezier_sample.p2)
p3 = _point(bezier_sample.p3)

bezier = self.world.add_bezier(p0=p0, p1=p1, p2=p2, p3=p3)
bezier.sample(nsample)

if show_control_polygon:
self.world.add_segment(p0, p1)
self.world.add_segment(p1, p2)
self.world.add_segment(p2, p3)

if show_control_points:
mark_size = 0.1 * fac
for point in (p0, p1, p2, p3):
self.world.add_segment(
point_type(point.x - mark_size, point.y, 0),
point_type(point.x + mark_size, point.y, 0)
)
self.world.add_segment(
point_type(point.x, point.y - mark_size, 0),
point_type(point.x, point.y + mark_size, 0)
)


# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4 tw=79:
2 changes: 1 addition & 1 deletion modmesh/pilot/_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def launch(self, name="pilot", size=(1000, 600)):
self.eulerone = _euler1d.Euler1DApp(mgr=self._rmgr)
self.burgers = _burgers1d.Burgers1DApp(mgr=self._rmgr)
self.linear_wave = _linear_wave.LinearWave1DApp(mgr=self._rmgr)
self.canvas = _canvas.CanvasMenu(mgr=self._rmgr)
self.canvas = _canvas.Canvas(mgr=self._rmgr)
self.openprofiledata = _profiling.Profiling(mgr=self._rmgr)
self.runprofiling = _profiling.RunProfiling(mgr=self._rmgr)
self.populate_menu()
Expand Down
116 changes: 116 additions & 0 deletions tests/test_pilot_canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright (c) 2026, Anchi Liu <phy.tiger@gmail.com>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

All cases are AI genereated with manual checks and fine-tuning.

#
# 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.


import unittest

import modmesh as mm
import pytest

pytest.importorskip("PySide6")

from modmesh.pilot import _canvas # noqa: E402


class BezierSampleTC(unittest.TestCase):
def test_s_curve(self):
bs = _canvas.BezierSample.s_curve()
self.assertEqual(bs.p0, (0.0, 0.0))
self.assertEqual(bs.p1, (1.0, 3.0))
self.assertEqual(bs.p2, (4.0, -1.0))
self.assertEqual(bs.p3, (5.0, 2.0))

def test_arch(self):
bs = _canvas.BezierSample.arch()
# The arch preset is defined to start at the origin and end at
# x=5, y=0 so that the curve spans a fixed 5-unit horizontal range
self.assertEqual(bs.p0, (0.0, 0.0))
self.assertEqual(bs.p1, (1.5, 4.0))
self.assertEqual(bs.p2, (3.5, 4.0))
self.assertEqual(bs.p3, (5.0, 0.0))

def test_loop(self):
bs = _canvas.BezierSample.loop()
# The loop preset shares the same endpoints as arch so that both
# presets can be compared under identical boundary conditions;
# the difference lies in the control points that create the loop shape
self.assertEqual(bs.p0, (0.0, 0.0))
self.assertEqual(bs.p1, (5.0, 3.0))
self.assertEqual(bs.p2, (0.0, 3.0))
self.assertEqual(bs.p3, (5.0, 0.0))


class BezierSamplerTC(unittest.TestCase):
def test_construction(self):
w = mm.WorldFp64()
bs = _canvas.BezierSample.arch()
_canvas.BezierSampler(w, bs)

def test_draw(self):
w = mm.WorldFp64()
bs = _canvas.BezierSample.arch()
sampler = _canvas.BezierSampler(w, bs)
# nsample=10 is small enough to keep the test fast but large enough
# to exercise the loop body in draw() more than once, catching
# off-by-one errors in the sampling range
sampler.draw(nsample=10)
# draw() adds 1 Bezier curve for the arch itself
self.assertEqual(w.nbezier, 1)
# With default show_control_polygon=True and show_control_points=True:
# 3 control polygon segments + 2 cross-mark segments per control
# point * 4 points = 11 segments total
self.assertEqual(w.nsegment, 11)

def test_draw_no_control_polygon(self):
w = mm.WorldFp64()
bs = _canvas.BezierSample.arch()
sampler = _canvas.BezierSampler(w, bs)
sampler.draw(nsample=10, show_control_polygon=False)
self.assertEqual(w.nbezier, 1)
# Without control polygon: only 2 cross-mark segments per control
# point * 4 points = 8 segments (no polygon edges)
self.assertEqual(w.nsegment, 8)

def test_draw_no_control_points(self):
w = mm.WorldFp64()
bs = _canvas.BezierSample.arch()
sampler = _canvas.BezierSampler(w, bs)
sampler.draw(nsample=10, show_control_points=False)
self.assertEqual(w.nbezier, 1)
# Without control point marks: only 3 polygon edge segments
self.assertEqual(w.nsegment, 3)

def test_draw_curve_only(self):
w = mm.WorldFp64()
bs = _canvas.BezierSample.arch()
sampler = _canvas.BezierSampler(w, bs)
sampler.draw(nsample=10, show_control_polygon=False,
show_control_points=False)
self.assertEqual(w.nbezier, 1)
# No auxiliary segments at all
self.assertEqual(w.nsegment, 0)

# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4:
Loading