From 2eb3365e1378cec90b0e1755d4fd77327251b8bc Mon Sep 17 00:00:00 2001 From: tigercosmos Date: Fri, 27 Feb 2026 00:35:58 +0900 Subject: [PATCH 1/3] pilot: add canvas samplers for conics and cubic Bezier curves Add pilot canvas drawing utilities for conic curves and cubic Bezier examples, and expose them through the GUI sample menu. - add `modmesh.pilot._canvas` with `Canvas`/`CanvasMenu` - implement `Ellipse`, `Parabola`, and `Hyperbola` samplers - add shared piecewise cubic Bezier rendering with input validation - add `BezierSample` presets and `BezierSampler` with control overlays - wire `_canvas` imports in pilot initialization - add `tests/test_pilot_canvas.py` for geometry generation, draw paths, and expected error cases --- modmesh/pilot/__init__.py | 1 + modmesh/pilot/_canvas.py | 365 ++++++++++++++++++++++++++++++++++--- modmesh/pilot/_gui.py | 2 +- tests/test_pilot_canvas.py | 279 ++++++++++++++++++++++++++++ 4 files changed, 621 insertions(+), 26 deletions(-) create mode 100644 tests/test_pilot_canvas.py 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..d2d4a7896 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,39 +25,78 @@ # 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. +""" + +import numpy as np + from .. import core, plot from ._gui_common import PilotFeature +__all__ = [ + 'Canvas', + 'Ellipse', + 'EllipseSampler', + 'Parabola', + 'ParabolaSampler', + 'Hyperbola', + 'HyperbolaSampler', + 'BezierSample', + 'BezierSampler', +] -class Canvas(PilotFeature): - """ - Create canvas windows for render layer. - """ - def __init__(self): - self.world = core.WorldFp64() +def _populate_sampler_points(curve, npoint=100, fac=1.0, off_x=0.0, + off_y=0.0): + if npoint < 1: + raise ValueError("npoint must be at least 1") - def draw_layer(self, layer): - P = core.Point3dFp64 + points = curve.calc_points(npoint) + points.x.ndarray[:] *= fac + points.y.ndarray[:] *= fac + points.x.ndarray[:] += off_x + points.y.ndarray[:] += off_y + return points - 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]))) +def _draw_piecewise_bezier(world, points, spacing=0.01): + if points is None: + raise RuntimeError( + "populate_points() must be called before draw_cbc()" + ) + if spacing <= 0: + raise ValueError("spacing must be positive") + if len(points) < 2: + return - self.world.add_segments(pad=spad) + point_type = core.Point3dFp64 + point_x = points.x.ndarray + point_y = points.y.ndarray + segment_length = np.hypot( + point_x[:-1] - point_x[1:], + point_y[:-1] - point_y[1:], + ) + nsample = np.maximum((segment_length // spacing).astype(int) - 1, 2) - def get_world(self): - return self.world + for index in range(len(points) - 1): + p0 = np.array(points[index]) + p3 = np.array(points[index + 1]) + p1 = p0 + (1.0 / 3.0) * (p3 - p0) + p2 = p0 + (2.0 / 3.0) * (p3 - p0) + bezier = world.add_bezier( + p0=point_type(p0[0], p0[1], 0.0), + p1=point_type(p1[0], p1[1], 0.0), + p2=point_type(p2[0], p2[1], 0.0), + p3=point_type(p3[0], p3[1], 0.0), + ) + bezier.sample(int(nsample[index])) -class CanvasMenu(PilotFeature): +class Canvas(PilotFeature): """ - Create sample canvas windows. + Canvas feature providing menu items for drawing curves and polygons. """ def populate_menu(self): @@ -67,6 +107,61 @@ 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, + ) + self._add_menu_item( + menu=self._mgr.canvasMenu, + text="Sample: Ellipse", + tip="Draw a sample ellipse (a=2, b=1)", + func=self._ellipse_window, + ) + self._add_menu_item( + menu=self._mgr.canvasMenu, + text="Sample: Parabola", + tip="Draw a sample parabola (y = 0.5*x^2)", + func=self._parabola_window, + ) + self._add_menu_item( + menu=self._mgr.canvasMenu, + text="Sample: Hyperbola", + tip="Draw a sample hyperbola (both branches)", + func=self._hyperbola_window, + ) + + @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 +175,231 @@ def mesh_iccad_2013(self): "70 1140 370 1140 370 1020 70 1020" ) - canvas = Canvas() - canvas.draw_layer(layer) + world = core.WorldFp64() + self._draw_layer(world, layer) + self._show_world(world) + + def _show_world(self, world): + widget = self._mgr.add3DWidget() + widget.updateWorld(world) + widget.showMark() + + def _bezier_s_curve(self): + world = core.WorldFp64() + bezier_sample = BezierSample.s_curve() + sampler = BezierSampler(world, bezier_sample) + sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) + self._show_world(world) + + def _bezier_arch(self): + world = core.WorldFp64() + bezier_sample = BezierSample.arch() + sampler = BezierSampler(world, bezier_sample) + sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) + self._show_world(world) + + def _bezier_loop(self): + world = core.WorldFp64() + bezier_sample = BezierSample.loop() + sampler = BezierSampler(world, bezier_sample) + sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) + self._show_world(world) + + def _ellipse_window(self): + world = core.WorldFp64() + ellipse = Ellipse(a=2.0, b=1.0) + sampler = EllipseSampler(world, ellipse) + sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) + sampler.draw_cbc() + self._show_world(world) + + def _parabola_window(self): + world = core.WorldFp64() + parabola = Parabola(a=0.5, t_min=-3.0, t_max=6.0) + sampler = ParabolaSampler(world, parabola) + sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) + sampler.draw_cbc() + self._show_world(world) + + def _hyperbola_window(self): + world = core.WorldFp64() + hyperbola = Hyperbola(a=1.0, b=1.0, t_min=-2.0, t_max=2.0) + + right_sampler = HyperbolaSampler(world, hyperbola) + right_sampler.populate_points( + npoint=100, + fac=1.0, + off_x=0.0, + off_y=0.0, + ) + right_sampler.draw_cbc() + + left_sampler = HyperbolaSampler(world, hyperbola) + left_sampler.populate_points( + npoint=100, + fac=1.0, + off_x=0.0, + off_y=0.0, + ) + left_sampler.points.x.ndarray[:] *= -1.0 + left_sampler.draw_cbc() + + self._show_world(world) + + +class Ellipse(object): + def __init__(self, a=2.0, b=1.0): + self.a = a + self.b = b + + def calc_points(self, npoint): + t_array = np.linspace(0.0, 2.0 * np.pi, npoint + 1, dtype='float64') + point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) + for index, t_value in enumerate(t_array): + x_value = self.a * np.cos(t_value) + y_value = self.b * np.sin(t_value) + point_pad.set_at(index, x_value, y_value) + return point_pad + + +class EllipseSampler(object): + def __init__(self, world, ellipse): + self.world = world + self.ellipse = ellipse + self.points = None + + def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): + self.points = _populate_sampler_points( + self.ellipse, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) + + def draw_cbc(self, spacing=0.01): + _draw_piecewise_bezier(self.world, self.points, spacing=spacing) + + +class Parabola(object): + def __init__(self, a=0.5, t_min=-3.0, t_max=3.0): + self.a = a + self.t_min = t_min + self.t_max = t_max + + def calc_points(self, npoint): + t_array = np.linspace(self.t_min, self.t_max, npoint + 1, + dtype='float64') + point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) + for index, t_value in enumerate(t_array): + x_value = t_value + y_value = self.a * t_value * t_value + point_pad.set_at(index, x_value, y_value) + return point_pad + + +class ParabolaSampler(object): + def __init__(self, world, parabola): + self.world = world + self.parabola = parabola + self.points = None + + def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): + self.points = _populate_sampler_points( + self.parabola, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) + + def draw_cbc(self, spacing=0.01): + _draw_piecewise_bezier(self.world, self.points, spacing=spacing) + + +class Hyperbola(object): + def __init__(self, a=1.0, b=1.0, t_min=-2.0, t_max=2.0): + self.a = a + self.b = b + self.t_min = t_min + self.t_max = t_max + + def calc_points(self, npoint): + t_array = np.linspace(self.t_min, self.t_max, npoint + 1, + dtype='float64') + point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) + for index, t_value in enumerate(t_array): + x_value = self.a * np.cosh(t_value) + y_value = self.b * np.sinh(t_value) + point_pad.set_at(index, x_value, y_value) + return point_pad + + +class HyperbolaSampler(object): + def __init__(self, world, hyperbola): + self.world = world + self.hyperbola = hyperbola + self.points = None + + def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): + self.points = _populate_sampler_points( + self.hyperbola, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) + + def draw_cbc(self, spacing=0.01): + _draw_piecewise_bezier(self.world, self.points, spacing=spacing) + + +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) + ) - wid = self._mgr.add3DWidget() - wid.updateWorld(canvas.get_world()) - wid.showMark() -# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: +# 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..e48af493c --- /dev/null +++ b/tests/test_pilot_canvas.py @@ -0,0 +1,279 @@ +# 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 EllipseTC(unittest.TestCase): + def test_npoint(self): + ell = _canvas.Ellipse(a=2.0, b=1.0) + points = ell.calc_points(10) + # ndim must be 2 because the ellipse lives in the xy-plane + self.assertEqual(points.ndim, 2) + # calc_points(n) samples n+1 points so that n intervals span [0, 2pi] + self.assertEqual(len(points), 11) + + def test_closure(self): + """First and last point should coincide for a full ellipse.""" + ell = _canvas.Ellipse(a=2.0, b=1.0) + # 100 intervals gives fine angular resolution (~3.6 deg per step) + # while keeping the test fast + points = ell.calc_points(100) + p_first = points.get_at(0) + p_last = points.get_at(len(points) - 1) + # t=0 and t=2*pi map to the same point analytically, so the + # round-trip through float64 trig should agree to near machine + # precision; places=10 allows for ~1e-10 floating-point error + self.assertAlmostEqual(p_first.x, p_last.x, places=10) + self.assertAlmostEqual(p_first.y, p_last.y, places=10) + + def test_axes(self): + """Check that extreme points match semi-axes.""" + ell = _canvas.Ellipse(a=3.0, b=2.0) + # 400 intervals gives an angular step of ~0.9 deg, so the sampled + # maximum deviates from the true semi-axis by at most + # a*(1 - cos(pi/400)) < 3e-4, which is within places=2 (1e-2) + points = ell.calc_points(400) + xs = points.x.ndarray + ys = points.y.ndarray + # x(t) = a*cos(t) has maximum a at t=0; y(t) = b*sin(t) has + # maximum b at t=pi/2 + self.assertAlmostEqual(float(max(xs)), 3.0, places=2) + self.assertAlmostEqual(float(max(ys)), 2.0, places=2) + + +class EllipseSamplerTC(unittest.TestCase): + def test_construction(self): + w = mm.WorldFp64() + ell = _canvas.Ellipse(a=2.0, b=1.0) + _canvas.EllipseSampler(w, ell) + + def test_populate_and_draw(self): + w = mm.WorldFp64() + ell = _canvas.Ellipse(a=2.0, b=1.0) + sampler = _canvas.EllipseSampler(w, ell) + # Use non-trivial fac/off_x/off_y values to exercise the scaling + # and translation paths in populate_points; npoint=20 keeps the + # test fast while still producing multiple line segments + sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) + sampler.draw_cbc() + # 20 intervals produce 20 piecewise Bezier segments + self.assertEqual(w.nbezier, 20) + + def test_draw_without_populate_raises(self): + w = mm.WorldFp64() + ell = _canvas.Ellipse(a=2.0, b=1.0) + sampler = _canvas.EllipseSampler(w, ell) + with self.assertRaisesRegex(RuntimeError, "populate_points"): + sampler.draw_cbc() + + def test_non_positive_spacing_raises(self): + w = mm.WorldFp64() + ell = _canvas.Ellipse(a=2.0, b=1.0) + sampler = _canvas.EllipseSampler(w, ell) + sampler.populate_points(npoint=10) + with self.assertRaisesRegex(ValueError, "spacing"): + sampler.draw_cbc(spacing=0.0) + + def test_npoint_zero_raises(self): + w = mm.WorldFp64() + ell = _canvas.Ellipse(a=2.0, b=1.0) + sampler = _canvas.EllipseSampler(w, ell) + with self.assertRaisesRegex(ValueError, "npoint"): + sampler.populate_points(npoint=0) + + def test_scaling_and_offset(self): + """Verify fac/off_x/off_y transform points correctly.""" + ell = _canvas.Ellipse(a=2.0, b=1.0) + base_points = ell.calc_points(10) + sampler = _canvas.EllipseSampler(mm.WorldFp64(), ell) + sampler.populate_points(npoint=10, fac=2.0, off_x=5.0, off_y=3.0) + for i in range(len(sampler.points)): + pb = base_points.get_at(i) + ps = sampler.points.get_at(i) + # populate_points scales by fac then shifts by off_x/off_y + self.assertAlmostEqual(ps.x, pb.x * 2.0 + 5.0, places=10) + self.assertAlmostEqual(ps.y, pb.y * 2.0 + 3.0, places=10) + + +class ParabolaTC(unittest.TestCase): + def test_npoint(self): + par = _canvas.Parabola(a=0.5, t_min=-3.0, t_max=3.0) + # Same n+1 convention as Ellipse: 20 intervals produce 21 points + points = par.calc_points(20) + self.assertEqual(len(points), 21) + + def test_vertex(self): + """Vertex at t=0 should be at (0,0).""" + par = _canvas.Parabola(a=1.0, t_min=-2.0, t_max=2.0) + # 100 intervals over a symmetric range [-2, 2] produce 101 points; + # np.linspace(-2, 2, 101)[50] == 0.0 exactly, so the middle point + # always falls precisely on t=0 and thus on the vertex (0, 0) + points = par.calc_points(100) + mid = len(points) // 2 + p = points.get_at(mid) + self.assertAlmostEqual(p.x, 0.0, places=5) + self.assertAlmostEqual(p.y, 0.0, places=5) + + +class ParabolaSamplerTC(unittest.TestCase): + def test_construction(self): + w = mm.WorldFp64() + par = _canvas.Parabola(a=0.5) + _canvas.ParabolaSampler(w, par) + + def test_populate_and_draw(self): + w = mm.WorldFp64() + par = _canvas.Parabola(a=0.5) + sampler = _canvas.ParabolaSampler(w, par) + sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) + sampler.draw_cbc() + # 20 intervals produce 20 piecewise Bezier segments + self.assertEqual(w.nbezier, 20) + + +class HyperbolaTC(unittest.TestCase): + def test_npoint(self): + hyp = _canvas.Hyperbola(a=1.0, b=1.0) + # Same n+1 convention: 50 intervals produce 51 points + points = hyp.calc_points(50) + self.assertEqual(len(points), 51) + + def test_right_branch_x_positive(self): + """All x values should be >= a (cosh >= 1).""" + hyp = _canvas.Hyperbola(a=1.0, b=1.0) + points = hyp.calc_points(100) + xs = points.x.ndarray + for x in xs: + # x(t) = a*cosh(t); cosh(t) >= 1 for all real t, so x >= a = 1.0. + # The 1e-10 tolerance accommodates floating-point rounding in + # cosh() near t=0 where cosh(0) == 1.0 exactly in IEEE 754 + self.assertGreaterEqual(float(x), 1.0 - 1e-10) + + +class HyperbolaSamplerTC(unittest.TestCase): + def test_construction(self): + w = mm.WorldFp64() + hyp = _canvas.Hyperbola(a=1.0, b=1.0) + _canvas.HyperbolaSampler(w, hyp) + + def test_populate_and_draw(self): + w = mm.WorldFp64() + hyp = _canvas.Hyperbola(a=1.0, b=1.0) + sampler = _canvas.HyperbolaSampler(w, hyp) + sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) + sampler.draw_cbc() + # 20 intervals produce 20 piecewise Bezier segments + self.assertEqual(w.nbezier, 20) + + +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: From ca900a31cd5c41a38fbc9f553cdc4dd9a047dc94 Mon Sep 17 00:00:00 2001 From: tigercosmos Date: Sat, 7 Mar 2026 00:58:33 +0900 Subject: [PATCH 2/3] draw everything in the same window --- modmesh/pilot/_canvas.py | 63 ++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/modmesh/pilot/_canvas.py b/modmesh/pilot/_canvas.py index d2d4a7896..bf853f87d 100644 --- a/modmesh/pilot/_canvas.py +++ b/modmesh/pilot/_canvas.py @@ -99,6 +99,11 @@ class Canvas(PilotFeature): Canvas feature providing menu items for drawing curves and polygons. """ + 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( menu=self._mgr.canvasMenu, @@ -132,19 +137,19 @@ def populate_menu(self): menu=self._mgr.canvasMenu, text="Sample: Ellipse", tip="Draw a sample ellipse (a=2, b=1)", - func=self._ellipse_window, + 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_window, + func=self._parabola, ) self._add_menu_item( menu=self._mgr.canvasMenu, text="Sample: Hyperbola", tip="Draw a sample hyperbola (both branches)", - func=self._hyperbola_window, + func=self._hyperbola, ) @staticmethod @@ -175,57 +180,51 @@ def mesh_iccad_2013(self): "70 1140 370 1140 370 1020 70 1020" ) - world = core.WorldFp64() - self._draw_layer(world, layer) - self._show_world(world) + self._draw_layer(self._world, layer) + self._update_widget() - def _show_world(self, world): - widget = self._mgr.add3DWidget() - widget.updateWorld(world) - widget.showMark() + 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): - world = core.WorldFp64() bezier_sample = BezierSample.s_curve() - sampler = BezierSampler(world, bezier_sample) + sampler = BezierSampler(self._world, bezier_sample) sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) - self._show_world(world) + self._update_widget() def _bezier_arch(self): - world = core.WorldFp64() bezier_sample = BezierSample.arch() - sampler = BezierSampler(world, bezier_sample) + sampler = BezierSampler(self._world, bezier_sample) sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) - self._show_world(world) + self._update_widget() def _bezier_loop(self): - world = core.WorldFp64() bezier_sample = BezierSample.loop() - sampler = BezierSampler(world, bezier_sample) + sampler = BezierSampler(self._world, bezier_sample) sampler.draw(nsample=50, fac=1.0, off_x=0.0, off_y=0.0) - self._show_world(world) + self._update_widget() - def _ellipse_window(self): - world = core.WorldFp64() + def _ellipse(self): ellipse = Ellipse(a=2.0, b=1.0) - sampler = EllipseSampler(world, ellipse) + sampler = EllipseSampler(self._world, ellipse) sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) sampler.draw_cbc() - self._show_world(world) + self._update_widget() - def _parabola_window(self): - world = core.WorldFp64() + def _parabola(self): parabola = Parabola(a=0.5, t_min=-3.0, t_max=6.0) - sampler = ParabolaSampler(world, parabola) + sampler = ParabolaSampler(self._world, parabola) sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) sampler.draw_cbc() - self._show_world(world) + self._update_widget() - def _hyperbola_window(self): - world = core.WorldFp64() + def _hyperbola(self): hyperbola = Hyperbola(a=1.0, b=1.0, t_min=-2.0, t_max=2.0) - right_sampler = HyperbolaSampler(world, hyperbola) + right_sampler = HyperbolaSampler(self._world, hyperbola) right_sampler.populate_points( npoint=100, fac=1.0, @@ -234,7 +233,7 @@ def _hyperbola_window(self): ) right_sampler.draw_cbc() - left_sampler = HyperbolaSampler(world, hyperbola) + left_sampler = HyperbolaSampler(self._world, hyperbola) left_sampler.populate_points( npoint=100, fac=1.0, @@ -244,7 +243,7 @@ def _hyperbola_window(self): left_sampler.points.x.ndarray[:] *= -1.0 left_sampler.draw_cbc() - self._show_world(world) + self._update_widget() class Ellipse(object): From cca7b9c3c07fb21e276059da6d9b8fe7da17ea2e Mon Sep 17 00:00:00 2001 From: tigercosmos Date: Sat, 7 Mar 2026 01:07:40 +0900 Subject: [PATCH 3/3] remove ellipse, parabola, hyperbola due to the length limit of the PR --- modmesh/pilot/_canvas.py | 186 ++----------------------------------- tests/test_pilot_canvas.py | 163 -------------------------------- 2 files changed, 8 insertions(+), 341 deletions(-) diff --git a/modmesh/pilot/_canvas.py b/modmesh/pilot/_canvas.py index bf853f87d..3c661332e 100644 --- a/modmesh/pilot/_canvas.py +++ b/modmesh/pilot/_canvas.py @@ -29,71 +29,17 @@ Canvas utilities and curve/conic drawing utilities for pilot GUI. """ -import numpy as np - from .. import core, plot from ._gui_common import PilotFeature __all__ = [ 'Canvas', - 'Ellipse', - 'EllipseSampler', - 'Parabola', - 'ParabolaSampler', - 'Hyperbola', - 'HyperbolaSampler', 'BezierSample', 'BezierSampler', ] -def _populate_sampler_points(curve, npoint=100, fac=1.0, off_x=0.0, - off_y=0.0): - if npoint < 1: - raise ValueError("npoint must be at least 1") - - points = curve.calc_points(npoint) - points.x.ndarray[:] *= fac - points.y.ndarray[:] *= fac - points.x.ndarray[:] += off_x - points.y.ndarray[:] += off_y - return points - - -def _draw_piecewise_bezier(world, points, spacing=0.01): - if points is None: - raise RuntimeError( - "populate_points() must be called before draw_cbc()" - ) - if spacing <= 0: - raise ValueError("spacing must be positive") - if len(points) < 2: - return - - point_type = core.Point3dFp64 - point_x = points.x.ndarray - point_y = points.y.ndarray - segment_length = np.hypot( - point_x[:-1] - point_x[1:], - point_y[:-1] - point_y[1:], - ) - nsample = np.maximum((segment_length // spacing).astype(int) - 1, 2) - - for index in range(len(points) - 1): - p0 = np.array(points[index]) - p3 = np.array(points[index + 1]) - p1 = p0 + (1.0 / 3.0) * (p3 - p0) - p2 = p0 + (2.0 / 3.0) * (p3 - p0) - bezier = world.add_bezier( - p0=point_type(p0[0], p0[1], 0.0), - p1=point_type(p1[0], p1[1], 0.0), - p2=point_type(p2[0], p2[1], 0.0), - p3=point_type(p3[0], p3[1], 0.0), - ) - bezier.sample(int(nsample[index])) - - class Canvas(PilotFeature): """ Canvas feature providing menu items for drawing curves and polygons. @@ -133,6 +79,8 @@ def populate_menu(self): "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", @@ -208,134 +156,16 @@ def _bezier_loop(self): self._update_widget() def _ellipse(self): - ellipse = Ellipse(a=2.0, b=1.0) - sampler = EllipseSampler(self._world, ellipse) - sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) - sampler.draw_cbc() - self._update_widget() + # TODO: Make it in the next PR. + raise NotImplementedError("Ellipse sample is not implemented yet") def _parabola(self): - parabola = Parabola(a=0.5, t_min=-3.0, t_max=6.0) - sampler = ParabolaSampler(self._world, parabola) - sampler.populate_points(npoint=100, fac=1.0, off_x=0.0, off_y=0.0) - sampler.draw_cbc() - self._update_widget() + # TODO: Make it in the next PR. + raise NotImplementedError("Parabola sample is not implemented yet") def _hyperbola(self): - hyperbola = Hyperbola(a=1.0, b=1.0, t_min=-2.0, t_max=2.0) - - right_sampler = HyperbolaSampler(self._world, hyperbola) - right_sampler.populate_points( - npoint=100, - fac=1.0, - off_x=0.0, - off_y=0.0, - ) - right_sampler.draw_cbc() - - left_sampler = HyperbolaSampler(self._world, hyperbola) - left_sampler.populate_points( - npoint=100, - fac=1.0, - off_x=0.0, - off_y=0.0, - ) - left_sampler.points.x.ndarray[:] *= -1.0 - left_sampler.draw_cbc() - - self._update_widget() - - -class Ellipse(object): - def __init__(self, a=2.0, b=1.0): - self.a = a - self.b = b - - def calc_points(self, npoint): - t_array = np.linspace(0.0, 2.0 * np.pi, npoint + 1, dtype='float64') - point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) - for index, t_value in enumerate(t_array): - x_value = self.a * np.cos(t_value) - y_value = self.b * np.sin(t_value) - point_pad.set_at(index, x_value, y_value) - return point_pad - - -class EllipseSampler(object): - def __init__(self, world, ellipse): - self.world = world - self.ellipse = ellipse - self.points = None - - def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): - self.points = _populate_sampler_points( - self.ellipse, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) - - def draw_cbc(self, spacing=0.01): - _draw_piecewise_bezier(self.world, self.points, spacing=spacing) - - -class Parabola(object): - def __init__(self, a=0.5, t_min=-3.0, t_max=3.0): - self.a = a - self.t_min = t_min - self.t_max = t_max - - def calc_points(self, npoint): - t_array = np.linspace(self.t_min, self.t_max, npoint + 1, - dtype='float64') - point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) - for index, t_value in enumerate(t_array): - x_value = t_value - y_value = self.a * t_value * t_value - point_pad.set_at(index, x_value, y_value) - return point_pad - - -class ParabolaSampler(object): - def __init__(self, world, parabola): - self.world = world - self.parabola = parabola - self.points = None - - def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): - self.points = _populate_sampler_points( - self.parabola, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) - - def draw_cbc(self, spacing=0.01): - _draw_piecewise_bezier(self.world, self.points, spacing=spacing) - - -class Hyperbola(object): - def __init__(self, a=1.0, b=1.0, t_min=-2.0, t_max=2.0): - self.a = a - self.b = b - self.t_min = t_min - self.t_max = t_max - - def calc_points(self, npoint): - t_array = np.linspace(self.t_min, self.t_max, npoint + 1, - dtype='float64') - point_pad = core.PointPadFp64(ndim=2, nelem=npoint + 1) - for index, t_value in enumerate(t_array): - x_value = self.a * np.cosh(t_value) - y_value = self.b * np.sinh(t_value) - point_pad.set_at(index, x_value, y_value) - return point_pad - - -class HyperbolaSampler(object): - def __init__(self, world, hyperbola): - self.world = world - self.hyperbola = hyperbola - self.points = None - - def populate_points(self, npoint=100, fac=1.0, off_x=0.0, off_y=0.0): - self.points = _populate_sampler_points( - self.hyperbola, npoint=npoint, fac=fac, off_x=off_x, off_y=off_y) - - def draw_cbc(self, spacing=0.01): - _draw_piecewise_bezier(self.world, self.points, spacing=spacing) + # TODO: Make it in the next PR. + raise NotImplementedError("Hyperbola sample is not implemented yet") class BezierSample(object): diff --git a/tests/test_pilot_canvas.py b/tests/test_pilot_canvas.py index e48af493c..3f2533797 100644 --- a/tests/test_pilot_canvas.py +++ b/tests/test_pilot_canvas.py @@ -35,169 +35,6 @@ from modmesh.pilot import _canvas # noqa: E402 -class EllipseTC(unittest.TestCase): - def test_npoint(self): - ell = _canvas.Ellipse(a=2.0, b=1.0) - points = ell.calc_points(10) - # ndim must be 2 because the ellipse lives in the xy-plane - self.assertEqual(points.ndim, 2) - # calc_points(n) samples n+1 points so that n intervals span [0, 2pi] - self.assertEqual(len(points), 11) - - def test_closure(self): - """First and last point should coincide for a full ellipse.""" - ell = _canvas.Ellipse(a=2.0, b=1.0) - # 100 intervals gives fine angular resolution (~3.6 deg per step) - # while keeping the test fast - points = ell.calc_points(100) - p_first = points.get_at(0) - p_last = points.get_at(len(points) - 1) - # t=0 and t=2*pi map to the same point analytically, so the - # round-trip through float64 trig should agree to near machine - # precision; places=10 allows for ~1e-10 floating-point error - self.assertAlmostEqual(p_first.x, p_last.x, places=10) - self.assertAlmostEqual(p_first.y, p_last.y, places=10) - - def test_axes(self): - """Check that extreme points match semi-axes.""" - ell = _canvas.Ellipse(a=3.0, b=2.0) - # 400 intervals gives an angular step of ~0.9 deg, so the sampled - # maximum deviates from the true semi-axis by at most - # a*(1 - cos(pi/400)) < 3e-4, which is within places=2 (1e-2) - points = ell.calc_points(400) - xs = points.x.ndarray - ys = points.y.ndarray - # x(t) = a*cos(t) has maximum a at t=0; y(t) = b*sin(t) has - # maximum b at t=pi/2 - self.assertAlmostEqual(float(max(xs)), 3.0, places=2) - self.assertAlmostEqual(float(max(ys)), 2.0, places=2) - - -class EllipseSamplerTC(unittest.TestCase): - def test_construction(self): - w = mm.WorldFp64() - ell = _canvas.Ellipse(a=2.0, b=1.0) - _canvas.EllipseSampler(w, ell) - - def test_populate_and_draw(self): - w = mm.WorldFp64() - ell = _canvas.Ellipse(a=2.0, b=1.0) - sampler = _canvas.EllipseSampler(w, ell) - # Use non-trivial fac/off_x/off_y values to exercise the scaling - # and translation paths in populate_points; npoint=20 keeps the - # test fast while still producing multiple line segments - sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) - sampler.draw_cbc() - # 20 intervals produce 20 piecewise Bezier segments - self.assertEqual(w.nbezier, 20) - - def test_draw_without_populate_raises(self): - w = mm.WorldFp64() - ell = _canvas.Ellipse(a=2.0, b=1.0) - sampler = _canvas.EllipseSampler(w, ell) - with self.assertRaisesRegex(RuntimeError, "populate_points"): - sampler.draw_cbc() - - def test_non_positive_spacing_raises(self): - w = mm.WorldFp64() - ell = _canvas.Ellipse(a=2.0, b=1.0) - sampler = _canvas.EllipseSampler(w, ell) - sampler.populate_points(npoint=10) - with self.assertRaisesRegex(ValueError, "spacing"): - sampler.draw_cbc(spacing=0.0) - - def test_npoint_zero_raises(self): - w = mm.WorldFp64() - ell = _canvas.Ellipse(a=2.0, b=1.0) - sampler = _canvas.EllipseSampler(w, ell) - with self.assertRaisesRegex(ValueError, "npoint"): - sampler.populate_points(npoint=0) - - def test_scaling_and_offset(self): - """Verify fac/off_x/off_y transform points correctly.""" - ell = _canvas.Ellipse(a=2.0, b=1.0) - base_points = ell.calc_points(10) - sampler = _canvas.EllipseSampler(mm.WorldFp64(), ell) - sampler.populate_points(npoint=10, fac=2.0, off_x=5.0, off_y=3.0) - for i in range(len(sampler.points)): - pb = base_points.get_at(i) - ps = sampler.points.get_at(i) - # populate_points scales by fac then shifts by off_x/off_y - self.assertAlmostEqual(ps.x, pb.x * 2.0 + 5.0, places=10) - self.assertAlmostEqual(ps.y, pb.y * 2.0 + 3.0, places=10) - - -class ParabolaTC(unittest.TestCase): - def test_npoint(self): - par = _canvas.Parabola(a=0.5, t_min=-3.0, t_max=3.0) - # Same n+1 convention as Ellipse: 20 intervals produce 21 points - points = par.calc_points(20) - self.assertEqual(len(points), 21) - - def test_vertex(self): - """Vertex at t=0 should be at (0,0).""" - par = _canvas.Parabola(a=1.0, t_min=-2.0, t_max=2.0) - # 100 intervals over a symmetric range [-2, 2] produce 101 points; - # np.linspace(-2, 2, 101)[50] == 0.0 exactly, so the middle point - # always falls precisely on t=0 and thus on the vertex (0, 0) - points = par.calc_points(100) - mid = len(points) // 2 - p = points.get_at(mid) - self.assertAlmostEqual(p.x, 0.0, places=5) - self.assertAlmostEqual(p.y, 0.0, places=5) - - -class ParabolaSamplerTC(unittest.TestCase): - def test_construction(self): - w = mm.WorldFp64() - par = _canvas.Parabola(a=0.5) - _canvas.ParabolaSampler(w, par) - - def test_populate_and_draw(self): - w = mm.WorldFp64() - par = _canvas.Parabola(a=0.5) - sampler = _canvas.ParabolaSampler(w, par) - sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) - sampler.draw_cbc() - # 20 intervals produce 20 piecewise Bezier segments - self.assertEqual(w.nbezier, 20) - - -class HyperbolaTC(unittest.TestCase): - def test_npoint(self): - hyp = _canvas.Hyperbola(a=1.0, b=1.0) - # Same n+1 convention: 50 intervals produce 51 points - points = hyp.calc_points(50) - self.assertEqual(len(points), 51) - - def test_right_branch_x_positive(self): - """All x values should be >= a (cosh >= 1).""" - hyp = _canvas.Hyperbola(a=1.0, b=1.0) - points = hyp.calc_points(100) - xs = points.x.ndarray - for x in xs: - # x(t) = a*cosh(t); cosh(t) >= 1 for all real t, so x >= a = 1.0. - # The 1e-10 tolerance accommodates floating-point rounding in - # cosh() near t=0 where cosh(0) == 1.0 exactly in IEEE 754 - self.assertGreaterEqual(float(x), 1.0 - 1e-10) - - -class HyperbolaSamplerTC(unittest.TestCase): - def test_construction(self): - w = mm.WorldFp64() - hyp = _canvas.Hyperbola(a=1.0, b=1.0) - _canvas.HyperbolaSampler(w, hyp) - - def test_populate_and_draw(self): - w = mm.WorldFp64() - hyp = _canvas.Hyperbola(a=1.0, b=1.0) - sampler = _canvas.HyperbolaSampler(w, hyp) - sampler.populate_points(npoint=20, fac=2.0, off_x=1.0, off_y=1.0) - sampler.draw_cbc() - # 20 intervals produce 20 piecewise Bezier segments - self.assertEqual(w.nbezier, 20) - - class BezierSampleTC(unittest.TestCase): def test_s_curve(self): bs = _canvas.BezierSample.s_curve()