Adding Mixin1D.curvature_comb
Some checks failed
benchmarks / benchmarks (macos-13, 3.12) (push) Has been cancelled
benchmarks / benchmarks (macos-14, 3.12) (push) Has been cancelled
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Has been cancelled
benchmarks / benchmarks (windows-latest, 3.12) (push) Has been cancelled
Upload coverage reports to Codecov / run (push) Has been cancelled
pylint / lint (3.10) (push) Has been cancelled
Run type checker / typecheck (3.10) (push) Has been cancelled
Run type checker / typecheck (3.13) (push) Has been cancelled
Wheel building and publishing / Build wheel on ubuntu-latest (push) Has been cancelled
tests / tests (macos-13, 3.10) (push) Has been cancelled
tests / tests (macos-13, 3.13) (push) Has been cancelled
tests / tests (macos-14, 3.10) (push) Has been cancelled
tests / tests (macos-14, 3.13) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.10) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.13) (push) Has been cancelled
tests / tests (windows-latest, 3.10) (push) Has been cancelled
tests / tests (windows-latest, 3.13) (push) Has been cancelled
Wheel building and publishing / upload_pypi (push) Has been cancelled

This commit is contained in:
gumyr 2025-09-03 19:29:46 -04:00
parent 5681bfb905
commit 790f0eaced
2 changed files with 147 additions and 3 deletions

View file

@ -53,10 +53,11 @@ from __future__ import annotations
import copy
import itertools
import numpy as np
import warnings
from collections.abc import Iterable
from itertools import combinations
from math import radians, inf, pi, cos, copysign, ceil, floor
from math import radians, inf, pi, cos, copysign, ceil, floor, isclose
from typing import cast as tcast
from typing import Literal, overload, TYPE_CHECKING
from typing_extensions import Self
@ -493,6 +494,89 @@ class Mixin1D(Shape):
return result
def curvature_comb(
self, count: int = 100, max_tooth_size: float | None = None
) -> ShapeList[Edge]:
"""
Build a *curvature comb* for a planar (XY) 1D curve.
A curvature comb is a set of short line segments (teeth) erected
perpendicular to the curve that visualize the signed curvature κ(u).
Tooth length is proportional to |κ| and the direction encodes the sign
(left normal for κ>0, right normal for κ<0). This is useful for inspecting
fairness and continuity (C0/C1/C2) of edges and wires.
Args:
count (int, optional): Number of uniformly spaced samples over the normalized
parameter. Increase for a denser comb. Defaults to 100.
max_tooth_size (float | None, optional): Maximum tooth height in model units.
If None, set to 10% maximum curve dimension. Defaults to None.
Raises:
ValueError: Empty curve.
ValueError: If the curve is not planar on `Plane.XY`.
Returns:
ShapeList[Edge]: A list of short `Edge` objects (lines) anchored on the curve
and oriented along the left normal `n̂ = normalize(t) × +Z`.
Notes:
- On circles, κ = 1/R so tooth length is constant.
- On straight segments, κ = 0 so no teeth are drawn.
- At inflection points κ0 and the tooth flips direction.
- At C0 corners the tangent is discontinuous; nearby teeth may jump.
C1 yields continuous direction; C2 yields continuous magnitude as well.
Example:
>>> comb = my_wire.curvature_comb(count=200, max_tooth_size=2.0)
>>> show(my_wire, Curve(comb))
"""
if self.wrapped is None:
raise ValueError("Can't create curvature_comb for empty curve")
pln = self.common_plane()
if pln is None or not isclose(abs(pln.z_dir.Z), 1.0, abs_tol=TOLERANCE):
raise ValueError("curvature_comb only works for curves on Plane.XY")
# If periodic the first and last tooth would be the same so skip them
u_values = np.linspace(0, 1, count, endpoint=not self.is_closed)
# first pass: gather kappas for scaling
kappas = []
tangents, curvatures = [], []
for u in u_values:
tangent = self.derivative_at(u, 1)
curvature = self.derivative_at(u, 2)
tangents.append(tangent)
curvatures.append(curvature)
cross = tangent.cross(curvature)
kappa = cross.length / (tangent.length**3 + TOLERANCE)
# signed for XY:
sign = 1.0 if cross.Z >= 0 else -1.0
kappas.append(sign * kappa)
# choose a scale so the tallest tooth is max_tooth_size
max_kappa_size = max(TOLERANCE, max(abs(k) for k in kappas))
curve_size = max(self.bounding_box().size)
max_tooth_size = (
max_tooth_size if max_tooth_size is not None else curve_size / 10
)
scale = max_tooth_size / max_kappa_size
comb_edges = ShapeList[Edge]()
for u, kappa, tangent in zip(u_values, kappas, tangents):
# Avoid tiny teeth
if abs(length := scale * kappa) < TOLERANCE:
continue
pnt_on_curve = self @ u
# left normal in XY (principal normal direction for a planar curve)
kappa_dir = tangent.normalized().cross(Vector(0, 0, 1))
comb_edges.append(
Edge.make_line(pnt_on_curve, pnt_on_curve + length * kappa_dir)
)
return comb_edges
def derivative_at(
self,
position: float | VectorLike,

View file

@ -37,8 +37,8 @@ from build123d.build_enums import (
Side,
SortBy,
)
from build123d.geometry import Axis, Location, Plane, Vector
from build123d.objects_curve import Polyline
from build123d.geometry import Axis, Location, Plane, Rot, Vector, TOLERANCE
from build123d.objects_curve import CenterArc, Line, Polyline
from build123d.objects_part import Box, Cylinder
from build123d.operations_part import extrude
from build123d.operations_generic import fillet
@ -437,5 +437,65 @@ class TestMixin1D(unittest.TestCase):
Edge.extrude(pnt, (0, 0, 1))
class TestCurvatureComb(unittest.TestCase):
def test_raises_if_not_on_XY(self):
line_xz = Polyline((0, 0, 0), (1, 0, 0), (0, 0, 1))
with self.assertRaises(ValueError):
_ = line_xz.curvature_comb()
def test_empty_curve(self):
c = CenterArc((0, 0), 1, 0, 360)
c.wrapped = None
with self.assertRaises(ValueError):
c.curvature_comb()
def test_circle_constant_height_and_count(self):
radius = 5.0
count = 64
max_tooth = 2.0
# A closed circle in the XY plane
c = CenterArc((0, 0), radius, 0, 360)
comb = c.curvature_comb(count=count, max_tooth_size=max_tooth)
# For a closed curve, endpoint is excluded but the method still returns `count` samples.
self.assertEqual(len(comb), count)
# On a circle, kappa = 1/R => all teeth should have the same length = max_tooth
lengths = [edge.length for edge in comb]
self.assertTrue(all(abs(L - max_tooth) <= TOLERANCE for L in lengths))
# Direction check: teeth should be radial (perpendicular to tangent),
# i.e., aligned with (start_point - center). For Circle(...) center is (0,0,0).
center = Vector(0, 0, 0)
for edge in comb[:: max(1, len(comb) // 8)]: # sample a few
p0 = edge.position_at(0.0)
p1 = edge.position_at(1.0)
tooth_dir = (p1 - p0).normalized()
radial = (p0 - center).normalized()
# allow either direction (outward/inward), check colinearity
cross_len = tooth_dir.cross(radial).length
self.assertLessEqual(cross_len, 1e-3)
def test_line_near_zero_teeth_and_count(self):
# Straight segment in XY => curvature = 0 everywhere
line = Line((0, 0), (10, 0))
count = 25
comb = line.curvature_comb(count=count, max_tooth_size=3.0)
self.assertEqual(len(comb), 0) # They are 0 length so skipped
def test_open_arc_count_and_variation(self):
# Open arc: teeth count == requested count; lengths not constant in general
arc = CenterArc((0, 0), 5, 0, 180) # open, CCW half-circle
count = 40
comb = arc.curvature_comb(count=count, max_tooth_size=1.0)
self.assertEqual(len(comb), count)
# For a circular arc, curvature is constant, so lengths should still be constant
lengths = [e.length for e in comb]
self.assertLessEqual(max(lengths) - min(lengths), 1e-6)
if __name__ == "__main__":
unittest.main()