diff --git a/modmesh/pilot/__init__.py b/modmesh/pilot/__init__.py index 25842ffd3..59a0156b2 100644 --- a/modmesh/pilot/__init__.py +++ b/modmesh/pilot/__init__.py @@ -48,6 +48,7 @@ launch, ) from . import airfoil # noqa: F401 + from . import _canvas # noqa: F401 # NOTE: intentionally omit __all__ for now diff --git a/modmesh/pilot/_canvas.py b/modmesh/pilot/_canvas.py index df3a09547..3c661332e 100644 --- a/modmesh/pilot/_canvas.py +++ b/modmesh/pilot/_canvas.py @@ -1,4 +1,5 @@ # Copyright (c) 2026, Han-Xuan Huang +# Copyright (c) 2026, Anchi Liu # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -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__ = [ + 'Canvas', + 'BezierSample', + 'BezierSampler', +] + class Canvas(PilotFeature): """ - 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( @@ -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" + 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): + 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") @@ -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): + 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: diff --git a/modmesh/pilot/_gui.py b/modmesh/pilot/_gui.py index 359bd5bb1..70aa02d5f 100644 --- a/modmesh/pilot/_gui.py +++ b/modmesh/pilot/_gui.py @@ -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() diff --git a/tests/test_pilot_canvas.py b/tests/test_pilot_canvas.py new file mode 100644 index 000000000..3f2533797 --- /dev/null +++ b/tests/test_pilot_canvas.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026, Anchi 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. + + +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: