From 25de6af76b348f8a602dfa0f909239e9e7e9eabc Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Tue, 23 Sep 2025 13:20:58 -0500 Subject: [PATCH 01/28] objects_curve.py -> add deprecations to unreleased arc type objects --- src/build123d/objects_curve.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index e697145..497310a 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -29,6 +29,7 @@ license: from __future__ import annotations import copy as copy_module +import warnings from collections.abc import Iterable from itertools import product from math import copysign, cos, radians, sin, sqrt @@ -1237,6 +1238,12 @@ class PointArcTangentLine(BaseEdgeObject): mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( + "The 'PointArcTangentLine' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( @@ -1316,6 +1323,12 @@ class PointArcTangentArc(BaseEdgeObject): RuntimeError: No tangent arc found """ + warnings.warn( + "The 'PointArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( @@ -1459,6 +1472,11 @@ class ArcArcTangentLine(BaseEdgeObject): Defaults to Keep.INSIDE mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( + "The 'ArcArcTangentLine' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) _applies_to = [BuildLine._tag] @@ -1560,6 +1578,12 @@ class ArcArcTangentArc(BaseEdgeObject): mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( + "The 'ArcArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( From 31a73bacdaf82bc711f1ce676971937f556c32b8 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 1 Oct 2025 11:36:49 -0400 Subject: [PATCH 02/28] Basic make_constrained_lines working --- src/build123d/topology/constrained_lines.py | 141 ++++++++++++++++++- src/build123d/topology/one_d.py | 146 +++++++++++++++++++- 2 files changed, 279 insertions(+), 8 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 624ee37..0d2925a 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -29,7 +29,7 @@ license: from __future__ import annotations -from math import floor, pi +from math import cos, sin from typing import TYPE_CHECKING, Callable, TypeVar from typing import cast as tcast @@ -49,33 +49,34 @@ from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve from OCP.Geom2dGcc import ( Geom2dGcc_Circ2d2TanOn, - Geom2dGcc_Circ2d2TanOnGeo, Geom2dGcc_Circ2d2TanRad, Geom2dGcc_Circ2d3Tan, Geom2dGcc_Circ2dTanCen, Geom2dGcc_Circ2dTanOnRad, - Geom2dGcc_Circ2dTanOnRadGeo, + Geom2dGcc_Lin2dTanObl, + Geom2dGcc_Lin2d2Tan, Geom2dGcc_QualifiedCurve, ) -from OCP.GeomAbs import GeomAbs_CurveType -from OCP.GeomAPI import GeomAPI, GeomAPI_ProjectPointOnCurve +from OCP.GeomAPI import GeomAPI from OCP.gp import ( gp_Ax2d, gp_Ax3, gp_Circ2d, gp_Dir, gp_Dir2d, + gp_Lin2d, gp_Pln, gp_Pnt, gp_Pnt2d, ) +from OCP.IntAna2d import IntAna2d_AnaIntersection from OCP.Standard import Standard_ConstructionError, Standard_Failure from OCP.TopoDS import TopoDS_Edge from build123d.build_enums import Sagitta, Tangency -from build123d.geometry import TOLERANCE, Vector, VectorLike +from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike from .zero_d import Vertex -from .shape_core import ShapeList, downcast +from .shape_core import ShapeList if TYPE_CHECKING: from build123d.topology.one_d import Edge # pragma: no cover @@ -201,6 +202,40 @@ def _two_arc_edges_from_params( return [minor, major] +def _edge_from_line( + p1: gp_Pnt2d, + p2: gp_Pnt2d, +) -> TopoDS_Edge: + """ + Build a finite Edge from two 2D contact points. + + Parameters + ---------- + p1, p2 : gp_Pnt2d + Endpoints of the line segment (in 2D). + edge_factory : type[Edge], optional + Factory for building the Edge subtype (defaults to Edge). + + Returns + ------- + TopoDS_Edge + Finite line segment between the two points. + """ + mk_edge = BRepBuilderAPI_MakeEdge( + Vertex(p1.X(), p1.Y()).wrapped, Vertex(p2.X(), p2.Y()).wrapped + ) + if not mk_edge.IsDone(): + raise RuntimeError("Failed to build edge from line contacts") + return mk_edge.Edge() + + +def _gp_lin2d_from_axis(ax: Axis) -> gp_Lin2d: + """Build a 2D reference line from an Axis (XY plane).""" + p = gp_Pnt2d(ax.position.X, ax.position.Y) + d = gp_Dir2d(ax.direction.X, ax.direction.Y) + return gp_Lin2d(gp_Ax2d(p, d)) + + def _qstr(q) -> str: # pragma: no cover """Debugging facility that works with OCP's GccEnt enum values""" try: @@ -646,3 +681,95 @@ def _make_tan_on_rad_arcs( out_topos.append(_edge_from_circle(h2d, 0.0, per)) return ShapeList([edge_factory(e) for e in out_topos]) + + +# ----------------------------------------------------------------------------- +# Line solvers (siblings of constrained arcs) +# ----------------------------------------------------------------------------- + + +def _make_2tan_lines( + curve1: Edge, + curve2: Edge | Vector, + *, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Construct line(s) tangent to two curves. + + Parameters + ---------- + curve1, curve2 : Edge + Target curves. + + Returns + ------- + ShapeList[Edge] + Finite tangent line(s). + """ + q1, _, _, _, _ = _as_gcc_arg(curve1, Tangency.UNQUALIFIED) + + if isinstance(curve2, Vector): + pnt_2d = gp_Pnt2d(curve2.X, curve2.Y) + gcc = Geom2dGcc_Lin2d2Tan(q1, pnt_2d, TOLERANCE) + else: + q2, _, _, _, _ = _as_gcc_arg(curve2, Tangency.UNQUALIFIED) + gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE) + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find common tangent line(s)") + + out_edges: list[Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + # Two tangency points + p1, p2 = gp_Pnt2d(), gp_Pnt2d() + gcc.Tangency1(i, p1) + gcc.Tangency2(i, p2) + contacts = [p1, p2] + + out_edges.append(_edge_from_line(*contacts)) + return ShapeList([edge_factory(e) for e in out_edges]) + + +def _make_tan_oriented_lines( + curve: Edge, + reference: Axis, + angle: float | None = None, # radians; absolute angle offset from `reference` + *, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Construct line(s) tangent to a curve and forming a given angle with a + reference line (Axis) per Geom2dGcc_Lin2dTanObl. Trimmed between: + - the tangency point on the curve, and + - the intersection with the reference line. + """ + q_curve, _, _, _, _ = _as_gcc_arg(curve, Tangency.UNQUALIFIED) + + dir2d = gp_Dir2d(cos(angle), sin(angle)) + + # Reference axis as gp_Lin2d + ref_lin = _gp_lin2d_from_axis(reference) + + gcc = Geom2dGcc_Lin2dTanObl(q_curve, ref_lin, TOLERANCE, angle) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find tangent line for given orientation") + + out: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + # Tangency on the curve + p_tan = gp_Pnt2d() + gcc.Tangency1(i, p_tan) + + tan_line = gp_Lin2d(p_tan, dir2d) + + # Intersect with reference axis + # Note: Intersection2 doesn't seem reliable + inter = IntAna2d_AnaIntersection(tan_line, ref_lin) + if not inter.IsDone() or inter.NbPoints() == 0: + continue + p_isect = inter.Point(1).Value() + + out.append(_edge_from_line(p_tan, p_isect)) + + return ShapeList([edge_factory(e) for e in out]) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index bac4507..9cbb41f 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -56,7 +56,7 @@ import itertools import warnings from collections.abc import Iterable from itertools import combinations -from math import ceil, copysign, cos, floor, inf, isclose, pi, radians +from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians from typing import TYPE_CHECKING, Literal, TypeAlias, overload from typing import cast as tcast @@ -240,6 +240,8 @@ from .constrained_lines import ( _make_3tan_arcs, _make_tan_cen_arcs, _make_tan_on_rad_arcs, + _make_tan_oriented_lines, + _make_2tan_lines, ) if TYPE_CHECKING: # pragma: no cover @@ -1824,6 +1826,148 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): raise ValueError("Unsupported or ambiguous combination of constraints.") + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to two provided curves. + + Args: + tangency_one, tangency_two + (tuple[Axis | Edge, Tangency] | Axis | Edge): + Geometric entities to be contacted/touched by the line(s). + + Returns: + ShapeList[Edge]: tangent lines + """ + + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, + tangency_two: Vector, + *, + angle: float | None = None, + direction: Vector | None = None, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to one curve and passing + through a fixed point. + + Args: + tangency_one + (tuple[Axis | Edge, Tangency] | Axis | Edge): + Geometric entity to be contacted/touched by the line(s). + tangency_two (Vector): + Fixed point through which the line(s) must pass. + angle : float, optional + Line orientation in degrees (measured CCW from the X-axis). + direction : Vector, optional + Direction vector for the line (only X and Y components are used). + + Returns: + ShapeList[Edge]: tangent lines + """ + + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: Edge, + tangency_two: Axis, + *, + angle: float | None = None, + direction: Vector | None = None, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to one curve and passing + through a fixed point. + + Args: + tangency_one + Fixed point through which the line(s) must pass. + tangency_two (Vector): + (tuple[Axis | Edge, Tangency] | Axis | Edge): + Geometric entity to be contacted/touched by the line(s). + angle : float, optional + Line orientation in degrees (measured CCW from the X-axis). + direction : Vector, optional + Direction vector for the line (only X and Y components are used). + + Returns: + ShapeList[Edge]: tangent lines + """ + + @classmethod + def make_constrained_lines(cls, *args, **kwargs) -> ShapeList[Edge]: + """ + Create planar line(s) on XY subject to tangency/contact constraints. + + Supported cases + --------------- + 1. Tangent to two curves + 2. Tangent to one curve and passing through a given point + """ + tangency_one = args[0] if len(args) > 0 else None + tangency_two = args[1] if len(args) > 1 else None + + tangency_one = kwargs.pop("tangency_one", tangency_one) + tangency_two = kwargs.pop("tangency_two", tangency_two) + + angle = kwargs.pop("angle", None) + direction = kwargs.pop("direction", None) + + # Handle unexpected kwargs + if kwargs: + raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + tangency_args = [t for t in (tangency_one, tangency_two) if t is not None] + if len(tangency_args) != 2: + raise TypeError("Provide exactly 2 tangency targets.") + + tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = [] + for tangency_arg in tangency_args: + if isinstance(tangency_arg, Axis): + tangencies.append(tangency_arg) + continue + elif isinstance(tangency_arg, Edge): + tangencies.append(tangency_arg) + continue + if isinstance(tangency_arg, tuple): + if isinstance(tangency_arg[0], Axis): + tangencies.append((Edge(tangency_arg[0]), tangency_arg[1])) + continue + elif isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue + # Fallback: treat as a point + try: + tangencies.append(Vector(tangency_arg)) + except Exception as exc: + raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc + + # Sort so Vector (point) is always last + tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector))) + + # --- decide problem kind --- + if isinstance(tangencies[1], Axis): + if angle is not None: + ang_rad = radians(angle) + elif direction is not None: + ang_rad = atan2(direction.Y, direction.X) + else: + raise ValueError("Specify exactly one of 'angle' or 'direction'") + return _make_tan_oriented_lines( + tangencies[0], tangencies[1], ang_rad, edge_factory=cls + ) + else: + return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls) + @classmethod def make_ellipse( cls, From 59a6e3623f6e21a2baec8d901d0b05be55a2a795 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 1 Oct 2025 11:55:42 -0400 Subject: [PATCH 03/28] Fixing docstring & angle calculation --- src/build123d/topology/constrained_lines.py | 11 +++++++++-- src/build123d/topology/one_d.py | 15 +++------------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 0d2925a..914eed7 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -29,7 +29,7 @@ license: from __future__ import annotations -from math import cos, sin +from math import atan2, cos, sin from typing import TYPE_CHECKING, Callable, TypeVar from typing import cast as tcast @@ -746,7 +746,14 @@ def _make_tan_oriented_lines( """ q_curve, _, _, _, _ = _as_gcc_arg(curve, Tangency.UNQUALIFIED) - dir2d = gp_Dir2d(cos(angle), sin(angle)) + # reference axis direction (2D angle in radians) + ref_dir = reference.direction + theta_ref = atan2(ref_dir.Y, ref_dir.X) + + # total absolute angle + theta_abs = theta_ref + angle + + dir2d = gp_Dir2d(cos(theta_abs), sin(theta_abs)) # Reference axis as gp_Lin2d ref_lin = _gp_lin2d_from_axis(reference) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 9cbb41f..e5847a4 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1851,9 +1851,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): cls, tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, tangency_two: Vector, - *, - angle: float | None = None, - direction: Vector | None = None, ) -> ShapeList[Edge]: """ Create all planar line(s) on the XY plane tangent to one curve and passing @@ -1865,10 +1862,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Geometric entity to be contacted/touched by the line(s). tangency_two (Vector): Fixed point through which the line(s) must pass. - angle : float, optional - Line orientation in degrees (measured CCW from the X-axis). - direction : Vector, optional - Direction vector for the line (only X and Y components are used). Returns: ShapeList[Edge]: tangent lines @@ -1889,15 +1882,13 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): through a fixed point. Args: - tangency_one - Fixed point through which the line(s) must pass. - tangency_two (Vector): - (tuple[Axis | Edge, Tangency] | Axis | Edge): - Geometric entity to be contacted/touched by the line(s). + tangency_one (Edge): edge that line will be tangent to + tangency_two (Axis): axis that angle will be measured against angle : float, optional Line orientation in degrees (measured CCW from the X-axis). direction : Vector, optional Direction vector for the line (only X and Y components are used). + Note: one of angle or direction must be provided Returns: ShapeList[Edge]: tangent lines From 6ac2e67a2eed74bca1c397bd61332c139cbbbb8f Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 1 Oct 2025 19:13:51 -0400 Subject: [PATCH 04/28] Fixed typing problems --- src/build123d/topology/constrained_lines.py | 57 +++++++++++++++------ src/build123d/topology/one_d.py | 21 ++++++-- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 914eed7..12329da 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -30,12 +30,12 @@ license: from __future__ import annotations from math import atan2, cos, sin -from typing import TYPE_CHECKING, Callable, TypeVar +from typing import overload, TYPE_CHECKING, Callable, TypeVar from typing import cast as tcast from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeVertex from OCP.GCPnts import GCPnts_AbscissaPoint from OCP.Geom import Geom_Curve, Geom_Plane from OCP.Geom2d import ( @@ -71,7 +71,7 @@ from OCP.gp import ( ) from OCP.IntAna2d import IntAna2d_AnaIntersection from OCP.Standard import Standard_ConstructionError, Standard_Failure -from OCP.TopoDS import TopoDS_Edge +from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex from build123d.build_enums import Sagitta, Tangency from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike @@ -154,6 +154,18 @@ def _param_in_trim( return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) +@overload +def _as_gcc_arg( + obj: Edge, constaint: Tangency +) -> tuple[ + Geom2dGcc_QualifiedCurve, Geom2d_Curve | None, float | None, float | None, bool +]: ... +@overload +def _as_gcc_arg( + obj: Vector, constaint: Tangency +) -> tuple[Geom2d_CartesianPoint, None, None, None, bool]: ... + + def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, Geom2d_Curve | None, @@ -221,9 +233,10 @@ def _edge_from_line( TopoDS_Edge Finite line segment between the two points. """ - mk_edge = BRepBuilderAPI_MakeEdge( - Vertex(p1.X(), p1.Y()).wrapped, Vertex(p2.X(), p2.Y()).wrapped - ) + v1 = BRepBuilderAPI_MakeVertex(gp_Pnt(p1.X(), p1.Y(), 0)).Vertex() + v2 = BRepBuilderAPI_MakeVertex(gp_Pnt(p2.X(), p2.Y(), 0)).Vertex() + + mk_edge = BRepBuilderAPI_MakeEdge(v1, v2) if not mk_edge.IsDone(): raise RuntimeError("Failed to build edge from line contacts") return mk_edge.Edge() @@ -689,8 +702,8 @@ def _make_tan_on_rad_arcs( def _make_2tan_lines( - curve1: Edge, - curve2: Edge | Vector, + tangency1: tuple[Edge, Tangency] | Edge, + tangency2: tuple[Edge, Tangency] | Edge | Vector, *, edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: @@ -707,19 +720,27 @@ def _make_2tan_lines( ShapeList[Edge] Finite tangent line(s). """ - q1, _, _, _, _ = _as_gcc_arg(curve1, Tangency.UNQUALIFIED) + if isinstance(tangency1, tuple): + object_one, obj1_qual = tangency1 + else: + object_one, obj1_qual = tangency1, Tangency.UNQUALIFIED + q1, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual) - if isinstance(curve2, Vector): - pnt_2d = gp_Pnt2d(curve2.X, curve2.Y) + if isinstance(tangency2, Vector): + pnt_2d = gp_Pnt2d(tangency2.X, tangency2.Y) gcc = Geom2dGcc_Lin2d2Tan(q1, pnt_2d, TOLERANCE) else: - q2, _, _, _, _ = _as_gcc_arg(curve2, Tangency.UNQUALIFIED) + if isinstance(tangency2, tuple): + object_two, obj2_qual = tangency2 + else: + object_two, obj2_qual = tangency2, Tangency.UNQUALIFIED + q2, _, _, _, _ = _as_gcc_arg(object_two, obj2_qual) gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find common tangent line(s)") - out_edges: list[Edge] = [] + out_edges: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): # Two tangency points p1, p2 = gp_Pnt2d(), gp_Pnt2d() @@ -732,9 +753,9 @@ def _make_2tan_lines( def _make_tan_oriented_lines( - curve: Edge, + tangency: tuple[Edge, Tangency] | Edge, reference: Axis, - angle: float | None = None, # radians; absolute angle offset from `reference` + angle: float, # radians; absolute angle offset from `reference` *, edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: @@ -744,7 +765,11 @@ def _make_tan_oriented_lines( - the tangency point on the curve, and - the intersection with the reference line. """ - q_curve, _, _, _, _ = _as_gcc_arg(curve, Tangency.UNQUALIFIED) + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency + else: + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED + q_curve, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual) # reference axis direction (2D angle in radians) ref_dir = reference.direction diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index e5847a4..2018474 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1871,7 +1871,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_lines( cls, - tangency_one: Edge, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, tangency_two: Axis, *, angle: float | None = None, @@ -1913,6 +1913,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): angle = kwargs.pop("angle", None) direction = kwargs.pop("direction", None) + is_ref = angle is not None or direction is not None # Handle unexpected kwargs if kwargs: raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") @@ -1921,10 +1922,13 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if len(tangency_args) != 2: raise TypeError("Provide exactly 2 tangency targets.") - tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = [] - for tangency_arg in tangency_args: + tangencies: list[tuple[Edge, Tangency] | Axis | Edge | Vector] = [] + for i, tangency_arg in enumerate(tangency_args): if isinstance(tangency_arg, Axis): - tangencies.append(tangency_arg) + if i == 1 and is_ref: + tangencies.append(tangency_arg) + else: + tangencies.append(Edge(tangency_arg)) continue elif isinstance(tangency_arg, Edge): tangencies.append(tangency_arg) @@ -1942,11 +1946,14 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): except Exception as exc: raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc - # Sort so Vector (point) is always last + # Sort so Vector (point) | Axis is always last tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector))) # --- decide problem kind --- if isinstance(tangencies[1], Axis): + assert isinstance( + tangencies[0], Edge + ), "Internal error - 1st tangency must be Edge" if angle is not None: ang_rad = radians(angle) elif direction is not None: @@ -1957,6 +1964,10 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangencies[0], tangencies[1], ang_rad, edge_factory=cls ) else: + assert not isinstance( + tangencies[0], (Axis, Vector) + ), "Internal error - 1st tangency can't be an Axis | Vector" + return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls) @classmethod From 64267ab3f777cf0615e06c205624f78ccd9ae7c0 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Wed, 1 Oct 2025 22:10:19 -0400 Subject: [PATCH 05/28] feat: add Gordon surface implementation and test modified: pyproject.toml modified: src/build123d/topology/two_d.py modified: tests/test_direct_api/test_face.py --- pyproject.toml | 1 + src/build123d/topology/two_d.py | 33 ++++++++++++++ tests/test_direct_api/test_face.py | 72 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0a2c87a..be18ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", + "ocp_gordon >= 0.1.10", "trianglesolver", "sympy", "scipy", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 306c5b8..d265114 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -105,6 +105,7 @@ from OCP.TopExp import TopExp from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape from typing_extensions import Self +from ocp_gordon import interpolate_curve_network from build123d.build_enums import ( CenterOf, @@ -864,6 +865,38 @@ class Face(Mixin2D, Shape[TopoDS_Face]): raise ValueError("Can't extrude empty object") return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction))) + @classmethod + def gordon_surface( + cls, + profiles: Iterable[Edge], + guides: Iterable[Edge], + tolerance: float = 3e-4, + ) -> Face: + """ + Creates a Gordon surface from a network of profile and guide curves. + + Args: + profiles (Iterable[Edge]): Edges representing profile curves. + guides (Iterable[Edge]): Edges representing guide curves. + tolerance (float, optional): Tolerance for surface creation and + intersection calculations. + + Returns: + Face: the interpolated Gordon surface + """ + ocp_profiles = [BRep_Tool.Curve_s(edge.wrapped, 0, 1) for edge in profiles] + ocp_guides = [BRep_Tool.Curve_s(edge.wrapped, 0, 1) for edge in guides] + + gordon_bspline_surface = interpolate_curve_network( + ocp_profiles, ocp_guides, tolerance=tolerance + ) + + return cls( + BRepBuilderAPI_MakeFace( + gordon_bspline_surface, Precision.Confusion_s() + ).Face() + ) + @classmethod def make_bezier_surface( cls, diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 82460c0..3e14740 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -502,6 +502,78 @@ class TestFace(unittest.TestCase): ] ) + def test_gordon_surface(self): + def create_test_curves( + num_profiles: int = 3, + num_guides: int = 4, + u_range: float = 1.0, + v_range: float = 1.0, + ): + profiles: list[Edge] = [] + guides: list[Edge] = [] + + intersection_points = [ + [(0.0, 0.0, 0.0) for _ in range(num_guides)] + for _ in range(num_profiles) + ] + + for i in range(num_profiles): + for j in range(num_guides): + u = i * u_range / (num_profiles - 1) + v = j * v_range / (num_guides - 1) + z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi) + intersection_points[i][j] = (u, v, z) + + for i in range(num_profiles): + points = [intersection_points[i][j] for j in range(num_guides)] + profiles.append(Spline(points)) + + for j in range(num_guides): + points = [intersection_points[i][j] for i in range(num_profiles)] + guides.append(Spline(points)) + + return profiles, guides + + profiles, guides = create_test_curves() + + tolerance = 3e-4 + gordon_surface = Face.gordon_surface(profiles, guides, tolerance=tolerance) + + self.assertIsInstance( + gordon_surface, Face, "The returned object should be a Face." + ) + + def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): + point_at_uv = gordon_surface.position_at(u, v) + self.assertAlmostEqual( + point_at_uv.X, + expected_point.X, + delta=tolerance, + msg=f"X coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Y, + expected_point.Y, + delta=tolerance, + msg=f"Y coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Z, + expected_point.Z, + delta=tolerance, + msg=f"Z coordinate mismatch at ({u},{v})", + ) + + point_at_uv_against_expected( + u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=0.0, v=1.0, expected_point=guides[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0) + ) + # def test_to_arcs(self): # with BuildSketch() as bs: # with BuildLine() as bl: From 9a7c9493d325699225f4d41bfb60d948f51c6eb8 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Thu, 2 Oct 2025 12:16:29 -0500 Subject: [PATCH 06/28] test.yml -> move to macos-15-intel --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b1416d..0f9dabc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: # "3.12", "3.13", ] - os: [macos-13, macos-14, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: From bde1ee08a9529d08543eab49bc6f6a4b358eb19d Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Thu, 2 Oct 2025 12:16:51 -0500 Subject: [PATCH 07/28] benchmark.yml -> macos-15-intel --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 56976c4..ff389dc 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -12,7 +12,7 @@ jobs: # "3.11", "3.12", ] - os: [macos-13, macos-14, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: From 925d12ff7c78298496f3773fa2a0eb85c8f6efac Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 2 Oct 2025 21:01:28 -0400 Subject: [PATCH 08/28] fix: change function name to make_gordon_surface fix: change the test name accordingly fix: corrected the type error for Edge.wrapped fix: change min version of ocp_gordon to 0.1.12 modified: pyproject.toml modified: src/build123d/topology/two_d.py modified: tests/test_direct_api/test_face.py --- pyproject.toml | 2 +- src/build123d/topology/two_d.py | 73 +++++++------- tests/test_direct_api/test_face.py | 147 +++++++++++++++-------------- 3 files changed, 117 insertions(+), 105 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be18ea2..15c5e14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", - "ocp_gordon >= 0.1.10", + "ocp_gordon >= 0.1.12", "trianglesolver", "sympy", "scipy", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index d265114..5d5fe96 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -865,38 +865,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]): raise ValueError("Can't extrude empty object") return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction))) - @classmethod - def gordon_surface( - cls, - profiles: Iterable[Edge], - guides: Iterable[Edge], - tolerance: float = 3e-4, - ) -> Face: - """ - Creates a Gordon surface from a network of profile and guide curves. - - Args: - profiles (Iterable[Edge]): Edges representing profile curves. - guides (Iterable[Edge]): Edges representing guide curves. - tolerance (float, optional): Tolerance for surface creation and - intersection calculations. - - Returns: - Face: the interpolated Gordon surface - """ - ocp_profiles = [BRep_Tool.Curve_s(edge.wrapped, 0, 1) for edge in profiles] - ocp_guides = [BRep_Tool.Curve_s(edge.wrapped, 0, 1) for edge in guides] - - gordon_bspline_surface = interpolate_curve_network( - ocp_profiles, ocp_guides, tolerance=tolerance - ) - - return cls( - BRepBuilderAPI_MakeFace( - gordon_bspline_surface, Precision.Confusion_s() - ).Face() - ) - @classmethod def make_bezier_surface( cls, @@ -946,6 +914,47 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return cls(BRepBuilderAPI_MakeFace(bezier, Precision.Confusion_s()).Face()) + @classmethod + def make_gordon_surface( + cls, + profiles: Iterable[Edge], + guides: Iterable[Edge], + tolerance: float = 3e-4, + ) -> Face: + """ + Creates a Gordon surface from a network of profile and guide curves. + + Args: + profiles (Iterable[Edge]): Edges representing profile curves. + guides (Iterable[Edge]): Edges representing guide curves. + tolerance (float, optional): Tolerance for surface creation and + intersection calculations. + + Raises: + ValueError: Input edge cannot be empty + + Returns: + Face: the interpolated Gordon surface + """ + + def to_geom_curve(edge: Edge): + if edge.wrapped is None: + raise ValueError("input edge cannot be empty") + return BRep_Tool.Curve_s(edge.wrapped, 0, 1) + + ocp_profiles = [to_geom_curve(edge) for edge in profiles] + ocp_guides = [to_geom_curve(edge) for edge in guides] + + gordon_bspline_surface = interpolate_curve_network( + ocp_profiles, ocp_guides, tolerance=tolerance + ) + + return cls( + BRepBuilderAPI_MakeFace( + gordon_bspline_surface, Precision.Confusion_s() + ).Face() + ) + @classmethod def make_plane( cls, diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 3e14740..5006680 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -359,6 +359,81 @@ class TestFace(unittest.TestCase): self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5) self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5) + def test_make_gordon_surface(self): + def create_test_curves( + num_profiles: int = 3, + num_guides: int = 4, + u_range: float = 1.0, + v_range: float = 1.0, + ): + profiles: list[Edge] = [] + guides: list[Edge] = [] + + intersection_points = [ + [(0.0, 0.0, 0.0) for _ in range(num_guides)] + for _ in range(num_profiles) + ] + + for i in range(num_profiles): + for j in range(num_guides): + u = i * u_range / (num_profiles - 1) + v = j * v_range / (num_guides - 1) + z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi) + intersection_points[i][j] = (u, v, z) + + for i in range(num_profiles): + points = [intersection_points[i][j] for j in range(num_guides)] + profiles.append(Spline(points)) + + for j in range(num_guides): + points = [intersection_points[i][j] for i in range(num_profiles)] + guides.append(Spline(points)) + + return profiles, guides + + profiles, guides = create_test_curves() + + tolerance = 3e-4 + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + + self.assertIsInstance( + gordon_surface, Face, "The returned object should be a Face." + ) + + def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): + point_at_uv = gordon_surface.position_at(u, v) + self.assertAlmostEqual( + point_at_uv.X, + expected_point.X, + delta=tolerance, + msg=f"X coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Y, + expected_point.Y, + delta=tolerance, + msg=f"Y coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Z, + expected_point.Z, + delta=tolerance, + msg=f"Z coordinate mismatch at ({u},{v})", + ) + + point_at_uv_against_expected( + u=0.0, v=0.0, expected_point=guides[0].position_at(0.0) + ) + point_at_uv_against_expected( + u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=0.0, v=1.0, expected_point=guides[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0) + ) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( @@ -502,78 +577,6 @@ class TestFace(unittest.TestCase): ] ) - def test_gordon_surface(self): - def create_test_curves( - num_profiles: int = 3, - num_guides: int = 4, - u_range: float = 1.0, - v_range: float = 1.0, - ): - profiles: list[Edge] = [] - guides: list[Edge] = [] - - intersection_points = [ - [(0.0, 0.0, 0.0) for _ in range(num_guides)] - for _ in range(num_profiles) - ] - - for i in range(num_profiles): - for j in range(num_guides): - u = i * u_range / (num_profiles - 1) - v = j * v_range / (num_guides - 1) - z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi) - intersection_points[i][j] = (u, v, z) - - for i in range(num_profiles): - points = [intersection_points[i][j] for j in range(num_guides)] - profiles.append(Spline(points)) - - for j in range(num_guides): - points = [intersection_points[i][j] for i in range(num_profiles)] - guides.append(Spline(points)) - - return profiles, guides - - profiles, guides = create_test_curves() - - tolerance = 3e-4 - gordon_surface = Face.gordon_surface(profiles, guides, tolerance=tolerance) - - self.assertIsInstance( - gordon_surface, Face, "The returned object should be a Face." - ) - - def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): - point_at_uv = gordon_surface.position_at(u, v) - self.assertAlmostEqual( - point_at_uv.X, - expected_point.X, - delta=tolerance, - msg=f"X coordinate mismatch at ({u},{v})", - ) - self.assertAlmostEqual( - point_at_uv.Y, - expected_point.Y, - delta=tolerance, - msg=f"Y coordinate mismatch at ({u},{v})", - ) - self.assertAlmostEqual( - point_at_uv.Z, - expected_point.Z, - delta=tolerance, - msg=f"Z coordinate mismatch at ({u},{v})", - ) - - point_at_uv_against_expected( - u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0) - ) - point_at_uv_against_expected( - u=0.0, v=1.0, expected_point=guides[0].position_at(1.0) - ) - point_at_uv_against_expected( - u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0) - ) - # def test_to_arcs(self): # with BuildSketch() as bs: # with BuildLine() as bl: From b3cec27cfb8333bbb4332fcbdaa0a4503f8fa362 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 2 Oct 2025 22:32:41 -0400 Subject: [PATCH 09/28] fix: add test for ValueError for gordon surface modified: tests/test_direct_api/test_face.py --- tests/test_direct_api/test_face.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 5006680..57a4f8f 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -434,6 +434,22 @@ class TestFace(unittest.TestCase): u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0) ) + temp_curve = profiles[0] + with self.assertRaises(ValueError): + profiles[0] = Edge() + tolerance = 3e-4 + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + + profiles[0] = temp_curve + with self.assertRaises(ValueError): + guides[0] = Edge() + tolerance = 3e-4 + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( From 3bd4b39b0a6f356a9f1fe57f39d6e6ff581ef570 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 2 Oct 2025 22:37:39 -0400 Subject: [PATCH 10/28] fix: minor adjust to test_make_gordon_surface modified: tests/test_direct_api/test_face.py --- tests/test_direct_api/test_face.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 57a4f8f..769ede3 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -435,17 +435,15 @@ class TestFace(unittest.TestCase): ) temp_curve = profiles[0] + profiles[0] = Edge() with self.assertRaises(ValueError): - profiles[0] = Edge() - tolerance = 3e-4 gordon_surface = Face.make_gordon_surface( profiles, guides, tolerance=tolerance ) profiles[0] = temp_curve + guides[0] = Edge() with self.assertRaises(ValueError): - guides[0] = Edge() - tolerance = 3e-4 gordon_surface = Face.make_gordon_surface( profiles, guides, tolerance=tolerance ) From a00ae674aeba8ae79069876d41537f7b5478b91f Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Sun, 5 Oct 2025 08:14:25 -0400 Subject: [PATCH 11/28] fix: make_gordon_surface supports all edge types fix: upgrade ocp_gordon to make intersect stable modified: pyproject.toml modified: src/build123d/topology/two_d.py modified: tests/test_direct_api/test_face.py --- pyproject.toml | 2 +- src/build123d/topology/two_d.py | 24 ++++++-- tests/test_direct_api/test_face.py | 90 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 15c5e14..c90ec65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", - "ocp_gordon >= 0.1.12", + "ocp_gordon >= 0.1.13", "trianglesolver", "sympy", "scipy", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 5d5fe96..0cd9dc8 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -64,7 +64,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, overload import OCP.TopAbs as ta from OCP.BRep import BRep_Builder, BRep_Tool -from OCP.BRepAdaptor import BRepAdaptor_Surface +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCP.BRepAlgo import BRepAlgo from OCP.BRepAlgoAPI import BRepAlgoAPI_Common from OCP.BRepBuilderAPI import ( @@ -81,8 +81,13 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeS from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol from OCP.BRepTools import BRepTools, BRepTools_ReShape from OCP.gce import gce_MakeLin -from OCP.Geom import Geom_BezierSurface, Geom_RectangularTrimmedSurface, Geom_Surface -from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_G2 +from OCP.Geom import ( + Geom_BezierSurface, + Geom_RectangularTrimmedSurface, + Geom_Surface, + Geom_TrimmedCurve, +) +from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_G2, GeomAbs_CurveType from OCP.GeomAPI import ( GeomAPI_ExtremaCurveCurve, GeomAPI_PointsToBSplineSurface, @@ -940,7 +945,18 @@ class Face(Mixin2D, Shape[TopoDS_Face]): def to_geom_curve(edge: Edge): if edge.wrapped is None: raise ValueError("input edge cannot be empty") - return BRep_Tool.Curve_s(edge.wrapped, 0, 1) + + adaptor = BRepAdaptor_Curve(edge.wrapped) + curve = BRep_Tool.Curve_s(edge.wrapped, 0, 1) + if not ( + (adaptor.IsPeriodic() and adaptor.IsClosed()) + or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BSplineCurve + or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BezierCurve + ): + curve = Geom_TrimmedCurve( + curve, adaptor.FirstParameter(), adaptor.LastParameter() + ) + return curve ocp_profiles = [to_geom_curve(edge) for edge in profiles] ocp_guides = [to_geom_curve(edge) for edge in guides] diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 769ede3..043c22c 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -448,6 +448,96 @@ class TestFace(unittest.TestCase): profiles, guides, tolerance=tolerance ) + def test_make_gordon_surface_edge_types(self): + tolerance = 3e-4 + + def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): + point_at_uv = gordon_surface.position_at(u, v) + self.assertAlmostEqual( + point_at_uv.X, + expected_point.X, + delta=tolerance, + msg=f"X coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Y, + expected_point.Y, + delta=tolerance, + msg=f"Y coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Z, + expected_point.Z, + delta=tolerance, + msg=f"Z coordinate mismatch at ({u},{v})", + ) + + points = [ + Vector(0, 0, 0), + Vector(10, 0, 0), + Vector(12, 20, 1), + Vector(4, 22, -1), + ] + + profiles = [Line(points[0], points[1]), Line(points[3], points[2])] + guides = [Line(points[0], points[3]), Line(points[1], points[2])] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected( + u=0.5, + v=0.5, + expected_point=(points[0] + points[1] + points[2] + points[3]) / 4, + ) + + profiles = [ + ThreePointArc( + points[0], (points[0] + points[1]) / 2 + Vector(0, 0, 2), points[1] + ), + ThreePointArc( + points[3], (points[3] + points[2]) / 2 + Vector(0, 0, 3), points[2] + ), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 1, profiles[1] @ 1), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5) + + profiles = [ + Edge.make_bezier( + points[0], + points[0] + Vector(1, 0, 1), + points[1] - Vector(1, 0, 1), + points[1], + ), + Edge.make_bezier( + points[3], + points[3] + Vector(1, 0, 1), + points[2] - Vector(1, 0, 1), + points[2], + ), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 1, profiles[1] @ 1), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5) + + profiles = [ + Edge.make_ellipse(10, 6), + Edge.make_ellipse(8, 7).translate((1, 2, 10)), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 0.5, profiles[1] @ 0.5), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( From f67cc12c34610d1b7c676f6af3c44a891ecaafe4 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 6 Oct 2025 13:42:46 -0400 Subject: [PATCH 12/28] Adding Airfoil 1D object --- docs/assets/example_airfoil.svg | 8 ++ docs/cheat_sheet.rst | 1 + docs/objects.rst | 8 ++ src/build123d/__init__.py | 1 + src/build123d/objects_curve.py | 127 +++++++++++++++++++++++++++++++- src/build123d/topology/one_d.py | 13 +++- 6 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 docs/assets/example_airfoil.svg diff --git a/docs/assets/example_airfoil.svg b/docs/assets/example_airfoil.svg new file mode 100644 index 0000000..47e2fbe --- /dev/null +++ b/docs/assets/example_airfoil.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index d46ccf8..8bd0d86 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -15,6 +15,7 @@ Cheat Sheet .. grid-item-card:: 1D - BuildLine + | :class:`~objects_curve.Airfoil` | :class:`~objects_curve.ArcArcTangentArc` | :class:`~objects_curve.ArcArcTangentLine` | :class:`~objects_curve.Bezier` diff --git a/docs/objects.rst b/docs/objects.rst index 0cff926..26c1fe8 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that .. grid:: 3 + .. grid-item-card:: :class:`~objects_curve.Airfoil` + + .. image:: assets/example_airfoil.svg + + +++ + Airfoil described by 4 digit NACA profile + .. grid-item-card:: :class:`~objects_curve.Bezier` .. image:: assets/bezier_curve_example.svg @@ -228,6 +235,7 @@ Reference .. py:module:: objects_curve .. autoclass:: BaseLineObject +.. autoclass:: Airfoil .. autoclass:: Bezier .. autoclass:: BlendCurve .. autoclass:: CenterArc diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index 6d52b40..2dcf0b0 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -81,6 +81,7 @@ __all__ = [ "BuildSketch", # 1D Curve Objects "BaseLineObject", + "Airfoil", "Bezier", "BlendCurve", "CenterArc", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index e697145..8850cdc 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -29,11 +29,13 @@ license: from __future__ import annotations import copy as copy_module +import numpy as np +import sympy # type: ignore from collections.abc import Iterable from itertools import product from math import copysign, cos, radians, sin, sqrt from scipy.optimize import minimize -import sympy # type: ignore +from typing import overload, Literal from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs from build123d.build_enums import ( @@ -100,6 +102,129 @@ class BaseEdgeObject(Edge): super().__init__(curve.wrapped) +class Airfoil(BaseLineObject): + """ + Create an airfoil described by a 4-digit (or fractional) NACA airfoil + (e.g. '2412' or '2213.323'). + + The NACA four-digit wing sections define the airfoil_code by: + - First digit describing maximum camber as percentage of the chord. + - Second digit describing the distance of maximum camber from the airfoil leading edge + in tenths of the chord. + - Last two digits describing maximum thickness of the airfoil as percent of the chord. + + Args: + airfoil_code : str + The NACA 4-digit (or fractional) airfoil code (e.g. '2213.323'). + n_points : int + Number of points per upper/lower surface. + finite_te : bool + If True, enforces a finite trailing edge (default False). + mode (Mode, optional): combination mode. Defaults to Mode.ADD + + """ + + _applies_to = [BuildLine._tag] + + @staticmethod + def parse_naca4(value: str | float) -> tuple[float, float, float]: + """ + Parse NACA 4-digit (or fractional) airfoil code into parameters. + """ + s = str(value).replace("NACA", "").strip() + if "." in s: + int_part, frac_part = s.split(".", 1) + m = int(int_part[0]) / 100 + p = int(int_part[1]) / 10 + t = float(f"{int(int_part[2:]):02}.{frac_part}") / 100 + else: + m = int(s[0]) / 100 + p = int(s[1]) / 10 + t = int(s[2:]) / 100 + return m, p, t + + def __init__( + self, + airfoil_code: str, + n_points: int = 50, + finite_te: bool = False, + mode: Mode = Mode.ADD, + ): + + # Airfoil thickness distribution equation: + # + # yₜ=5t[0.2969√x-0.1260x-0.3516x²+0.2843x³-0.1015x⁴] + # + # where: + # - x is the distance along the chord (0 at the leading edge, 1 at the trailing edge), + # - t is the maximum thickness as a fraction of the chord (e.g. 0.12 for a NACA 2412), + # - yₜ gives the half-thickness at each chordwise location. + + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + m, p, t = Airfoil.parse_naca4(airfoil_code) + + # Cosine-spaced x values for better nose resolution + beta = np.linspace(0.0, np.pi, n_points) + x = (1 - np.cos(beta)) / 2 + + # Thickness distribution + a0, a1, a2, a3 = 0.2969, -0.1260, -0.3516, 0.2843 + a4 = -0.1015 if finite_te else -0.1036 + yt = 5 * t * (a0 * np.sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4) + + # Camber line and slope + if m == 0 or p == 0 or p == 1: + yc = np.zeros_like(x) + dyc_dx = np.zeros_like(x) + else: + yc = np.empty_like(x) + dyc_dx = np.empty_like(x) + mask = x < p + yc[mask] = m / p**2 * (2 * p * x[mask] - x[mask] ** 2) + yc[~mask] = ( + m / (1 - p) ** 2 * ((1 - 2 * p) + 2 * p * x[~mask] - x[~mask] ** 2) + ) + dyc_dx[mask] = 2 * m / p**2 * (p - x[mask]) + dyc_dx[~mask] = 2 * m / (1 - p) ** 2 * (p - x[~mask]) + + theta = np.arctan(dyc_dx) + self._camber_points = [Vector(xi, yi) for xi, yi in zip(x, yc)] + + # Upper and lower surfaces + xu = x - yt * np.sin(theta) + yu = yc + yt * np.cos(theta) + xl = x + yt * np.sin(theta) + yl = yc - yt * np.cos(theta) + + upper_pnts = [Vector(x, y) for x, y in zip(xu, yu)] + lower_pnts = [Vector(x, y) for x, y in zip(xl, yl)] + unique_points: list[ + Vector | tuple[float, float] | tuple[float, float, float] + ] = list(dict.fromkeys(upper_pnts[::-1] + lower_pnts)) + surface = Edge.make_spline(unique_points, periodic=not finite_te) # type: ignore[arg-type] + if finite_te: + trailing_edge = Edge.make_line(surface @ 0, surface @ 1) + airfoil_profile = Wire([surface, trailing_edge]) + else: + airfoil_profile = Wire([surface]) + + super().__init__(airfoil_profile, mode=mode) + + # Store metadata + self.code: str = airfoil_code #: NACA code string (e.g. "2412") + self.max_camber: float = m #: Maximum camber as fraction of chord + self.camber_pos: float = p #: Chordwise position of max camber (0–1) + self.thickness: float = t #: Maximum thickness as fraction of chord + self.finite_te: bool = finite_te #: If True, trailing edge is finite + + @property + def camber_line(self) -> Edge: + """Camber line of the airfoil as an Edge.""" + return Edge.make_spline(self._camber_points) # type: ignore[arg-type] + + class Bezier(BaseEdgeObject): """Line Object: Bezier Curve diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 31baf33..04ddb3c 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -792,6 +792,8 @@ class Mixin1D(Shape): case Edge() as obj, Plane() as plane: # Find any edge / plane intersection points & edges # Find point intersections + if obj.wrapped is None: + continue geom_line = BRep_Tool.Curve_s( obj.wrapped, obj.param_at(0), obj.param_at(1) ) @@ -818,10 +820,13 @@ class Mixin1D(Shape): vts = common_set.vertices() eds = common_set.edges() if vts and eds: - filtered_vts = ShapeList([ - v for v in vts - if all(v.distance_to(e) > TOLERANCE for e in eds) - ]) + filtered_vts = ShapeList( + [ + v + for v in vts + if all(v.distance_to(e) > TOLERANCE for e in eds) + ] + ) common_set = filtered_vts + eds else: return None From c4ccfb141f5f4a1bc430f30a3b5dd927b189869a Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 6 Oct 2025 13:46:23 -0400 Subject: [PATCH 13/28] Adding missing test --- tests/test_airfoil.py | 106 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/test_airfoil.py diff --git a/tests/test_airfoil.py b/tests/test_airfoil.py new file mode 100644 index 0000000..21c2e06 --- /dev/null +++ b/tests/test_airfoil.py @@ -0,0 +1,106 @@ +import pytest +import numpy as np +from build123d import Airfoil, Vector, Edge, Wire + + +# --- parse_naca4 tests ------------------------------------------------------ + + +@pytest.mark.parametrize( + "code, expected", + [ + ("2412", (0.02, 0.4, 0.12)), # standard NACA 2412 + ("0012", (0.0, 0.0, 0.12)), # symmetric section + ("2213.323", (0.02, 0.2, 0.13323)), # fractional thickness + ("NACA2412", (0.02, 0.4, 0.12)), # with prefix + ], +) +def test_parse_naca4_variants(code, expected): + m, p, t = Airfoil.parse_naca4(code) + np.testing.assert_allclose([m, p, t], expected, rtol=1e-6) + + +# --- basic construction tests ----------------------------------------------- + + +def test_airfoil_basic_construction(): + airfoil = Airfoil("2412", n_points=40) + assert isinstance(airfoil, Airfoil) + assert isinstance(airfoil.camber_line, Edge) + assert isinstance(airfoil._camber_points, list) + assert all(isinstance(p, Vector) for p in airfoil._camber_points) + + # Check metadata + assert airfoil.code == "2412" + assert pytest.approx(airfoil.max_camber, rel=1e-6) == 0.02 + assert pytest.approx(airfoil.camber_pos, rel=1e-6) == 0.4 + assert pytest.approx(airfoil.thickness, rel=1e-6) == 0.12 + assert airfoil.finite_te is False + + +def test_airfoil_finite_te_profile(): + """Finite trailing edge version should have a line closing the profile.""" + airfoil = Airfoil("2412", finite_te=True, n_points=40) + assert isinstance(airfoil, Wire) + assert airfoil.finite_te + assert len(list(airfoil.edges())) == 2 + + +def test_airfoil_infinite_te_profile(): + """Infinite trailing edge (periodic spline).""" + airfoil = Airfoil("2412", finite_te=False, n_points=40) + assert isinstance(airfoil, Wire) + # Should contain a single closed Edge + assert len(airfoil.edges()) == 1 + assert airfoil.edges()[0].is_closed + + +# --- geometric / numerical validity ----------------------------------------- + + +def test_camber_line_geometry_monotonic(): + """Camber x coordinates should increase monotonically along the chord.""" + af = Airfoil("2412", n_points=80) + x_coords = [p.X for p in af._camber_points] + assert np.all(np.diff(x_coords) >= 0) + + +def test_airfoil_chord_limits(): + """Airfoil should be bounded between x=0 and x=1.""" + af = Airfoil("2412", n_points=100) + all_points = af._camber_points + xs = np.array([p.X for p in all_points]) + assert xs.min() >= -1e-9 + assert xs.max() <= 1.0 + 1e-9 + + +def test_airfoil_thickness_scaling(): + """Check that airfoil thickness scales linearly with NACA last two digits.""" + af1 = Airfoil("0010", n_points=120) + af2 = Airfoil("0020", n_points=120) + + # Extract main surface edge (for finite_te=False it's just one edge) + edge1 = af1.edges()[0] + edge2 = af2.edges()[0] + + # Sample many points along each edge + n = 500 + ys1 = [(edge1 @ u).Y for u in np.linspace(0.0, 1.0, n)] + ys2 = [(edge2 @ u).Y for u in np.linspace(0.0, 1.0, n)] + + # Total height (max - min) + h1 = max(ys1) - min(ys1) + h2 = max(ys2) - min(ys2) + + # For symmetric NACA 00xx, thickness is proportional to 't' + assert (h1 / h2) == pytest.approx(0.5, rel=0.05) + + +def test_camber_line_is_centered(): + """Mean of upper and lower surfaces should approximate camber line.""" + af = Airfoil("2412", n_points=50) + # Extract central camber Y near mid-chord + mid_index = len(af._camber_points) // 2 + mid_point = af._camber_points[mid_index] + # Camber line should be roughly symmetric around y=0 for small m + assert abs(mid_point.Y) < 0.05 From 32c1322370d0d8cf1bb6bf9ae5d38e8dcdc0e8f7 Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 9 Oct 2025 11:37:24 -0400 Subject: [PATCH 14/28] 99% coverage on constrained lines --- src/build123d/topology/constrained_lines.py | 45 ++-- .../test_direct_api/test_constrained_lines.py | 201 ++++++++++++++++++ 2 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 tests/test_direct_api/test_constrained_lines.py diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 12329da..58e8b0f 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -29,7 +29,7 @@ license: from __future__ import annotations -from math import atan2, cos, sin +from math import atan2, cos, isnan, sin from typing import overload, TYPE_CHECKING, Callable, TypeVar from typing import cast as tcast @@ -42,11 +42,12 @@ from OCP.Geom2d import ( Geom2d_CartesianPoint, Geom2d_Circle, Geom2d_Curve, + Geom2d_Line, Geom2d_Point, Geom2d_TrimmedCurve, ) from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve -from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve +from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve from OCP.Geom2dGcc import ( Geom2dGcc_Circ2d2TanOn, Geom2dGcc_Circ2d2TanRad, @@ -724,7 +725,7 @@ def _make_2tan_lines( object_one, obj1_qual = tangency1 else: object_one, obj1_qual = tangency1, Tangency.UNQUALIFIED - q1, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual) + q1, c1, _, _, _ = _as_gcc_arg(object_one, obj1_qual) if isinstance(tangency2, Vector): pnt_2d = gp_Pnt2d(tangency2.X, tangency2.Y) @@ -734,7 +735,7 @@ def _make_2tan_lines( object_two, obj2_qual = tangency2 else: object_two, obj2_qual = tangency2, Tangency.UNQUALIFIED - q2, _, _, _, _ = _as_gcc_arg(object_two, obj2_qual) + q2, c2, _, _, _ = _as_gcc_arg(object_two, obj2_qual) gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: @@ -742,13 +743,25 @@ def _make_2tan_lines( out_edges: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): - # Two tangency points - p1, p2 = gp_Pnt2d(), gp_Pnt2d() - gcc.Tangency1(i, p1) - gcc.Tangency2(i, p2) - contacts = [p1, p2] + lin2d = Geom2d_Line(gcc.ThisSolution(i)) - out_edges.append(_edge_from_line(*contacts)) + # Two tangency points - Note Tangency1/Tangency2 can use different + # indices for the same line + inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c1) + pt1 = inter_cc.Point(1) # There will always be one tangent intersection + + if isinstance(tangency2, Vector): + pt2 = gp_Pnt2d(tangency2.X, tangency2.Y) + else: + inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c2) + pt2 = inter_cc.Point(1) + + # Skip degenerate lines + separation = pt1.Distance(pt2) + if isnan(separation) or separation < TOLERANCE: + continue + + out_edges.append(_edge_from_line(pt1, pt2)) return ShapeList([edge_factory(e) for e in out_edges]) @@ -769,6 +782,10 @@ def _make_tan_oriented_lines( object_one, obj1_qual = tangency else: object_one, obj1_qual = tangency, Tangency.UNQUALIFIED + + if abs(abs(reference.direction.Z) - 1) < TOLERANCE: + raise ValueError("reference Axis can't be perpendicular to Plane.XY") + q_curve, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual) # reference axis direction (2D angle in radians) @@ -783,9 +800,8 @@ def _make_tan_oriented_lines( # Reference axis as gp_Lin2d ref_lin = _gp_lin2d_from_axis(reference) + # Note that is seems impossible for Geom2dGcc_Lin2dTanObl to provide no solutions gcc = Geom2dGcc_Lin2dTanObl(q_curve, ref_lin, TOLERANCE, angle) - if not gcc.IsDone() or gcc.NbSolutions() == 0: - raise RuntimeError("Unable to find tangent line for given orientation") out: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): @@ -802,6 +818,11 @@ def _make_tan_oriented_lines( continue p_isect = inter.Point(1).Value() + # Skip degenerate lines + separation = p_tan.Distance(p_isect) + if isnan(separation) or separation < TOLERANCE: + continue + out.append(_edge_from_line(p_tan, p_isect)) return ShapeList([edge_factory(e) for e in out]) diff --git a/tests/test_direct_api/test_constrained_lines.py b/tests/test_direct_api/test_constrained_lines.py new file mode 100644 index 0000000..d74bf90 --- /dev/null +++ b/tests/test_direct_api/test_constrained_lines.py @@ -0,0 +1,201 @@ +""" +build123d tests + +name: test_constrained_lines.py +by: Gumyr +date: October 8, 2025 + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import math +import pytest +from OCP.gp import gp_Pnt2d, gp_Dir2d, gp_Lin2d +from build123d import Edge, Axis, Vector, Tangency, Plane +from build123d.topology.constrained_lines import ( + _make_2tan_lines, + _make_tan_oriented_lines, + _edge_from_line, +) +from build123d.geometry import TOLERANCE + + +@pytest.fixture +def unit_circle() -> Edge: + """A simple unit circle centered at the origin on XY.""" + return Edge.make_circle(1.0, Plane.XY) + + +# --------------------------------------------------------------------------- +# utility tests +# --------------------------------------------------------------------------- + + +def test_edge_from_line(): + line = _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(1, 0)) + assert Edge(line).length == 1 + + with pytest.raises(RuntimeError) as excinfo: + _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(0, 0)) + assert "Failed to build edge from line contacts" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# _make_2tan_lines tests +# --------------------------------------------------------------------------- + + +def test_two_circles_tangents(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines(c1, c2, edge_factory=Edge) + # There should be 4 external/internal tangents + assert len(lines) in (4, 2) + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_two_constrained_circles_tangents1(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines((c1, Tangency.ENCLOSING), c2, edge_factory=Edge) + # There should be 2 external/internal tangents + assert len(lines) == 2 + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_two_constrained_circles_tangents2(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines( + (c1, Tangency.ENCLOSING), (c2, Tangency.ENCLOSING), edge_factory=Edge + ) + # There should be 1 external/external tangents + assert len(lines) == 1 + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_curve_and_point_tangent(unit_circle): + """A line tangent to a circle and passing through a point should exist.""" + pt = Vector(2.0, 0.0) + lines = _make_2tan_lines(unit_circle, pt, edge_factory=Edge) + assert len(lines) == 2 + for ln in lines: + # The line must pass through the given point (approximately) + dist_to_point = ln.distance_to(pt) + assert math.isclose(dist_to_point, 0.0, abs_tol=1e-6) + # It should also touch the circle at exactly one point + dist_to_circle = unit_circle.distance_to(ln) + assert math.isclose(dist_to_circle, 0.0, abs_tol=TOLERANCE) + + +def test_invalid_tangent_raises(unit_circle): + """Non-intersecting degenerate input result in no output.""" + lines = _make_2tan_lines(unit_circle, unit_circle, edge_factory=Edge) + assert len(lines) == 0 + + with pytest.raises(RuntimeError) as excinfo: + _make_2tan_lines(unit_circle, Vector(0, 0), edge_factory=Edge) + assert "Unable to find common tangent line(s)" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# _make_tan_oriented_lines tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("angle_deg", [math.radians(30), -math.radians(30)]) +def test_oriented_tangents_with_x_axis(unit_circle, angle_deg): + """Lines tangent to a circle at ±30° from the X-axis.""" + lines = _make_tan_oriented_lines(unit_circle, Axis.X, angle_deg, edge_factory=Edge) + assert all(isinstance(e, Edge) for e in lines) + # The tangent lines should all intersect the X axis (red line) + for ln in lines: + p = ln.position_at(0.5) + assert abs(p.Z) < 1e-9 + + lines = _make_tan_oriented_lines(unit_circle, Axis.X, 0, edge_factory=Edge) + assert len(lines) == 0 + + lines = _make_tan_oriented_lines( + unit_circle, Axis((0, -2), (1, 0)), 0, edge_factory=Edge + ) + assert len(lines) == 0 + + +def test_oriented_tangents_with_y_axis(unit_circle): + """Lines tangent to a circle and 30° from Y-axis should exist.""" + angle = math.radians(30) + lines = _make_tan_oriented_lines(unit_circle, Axis.Y, angle, edge_factory=Edge) + assert len(lines) >= 1 + # They should roughly touch the circle (tangent distance ≈ 0) + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_oriented_constrained_tangents_with_y_axis(unit_circle): + angle = math.radians(30) + lines = _make_tan_oriented_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, angle, edge_factory=Edge + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_invalid_oriented_tangent_raises(unit_circle): + """Non-intersecting degenerate input result in no output.""" + + with pytest.raises(ValueError) as excinfo: + _make_tan_oriented_lines(unit_circle, Axis.Z, 1, edge_factory=Edge) + assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value) + + with pytest.raises(ValueError) as excinfo: + _make_tan_oriented_lines( + unit_circle, Axis((1, 2, 3), (0, 0, -1)), 1, edge_factory=Edge + ) + assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value) + + +def test_invalid_oriented_tangent(unit_circle): + lines = _make_tan_oriented_lines( + unit_circle, Axis((1, 0), (0, 1)), 0, edge_factory=Edge + ) + assert len(lines) == 0 + + lines = _make_tan_oriented_lines( + unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge + ) + assert len(lines) == 0 From 198dab0ab41508e55b64423eed881294db2e63bb Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Sat, 11 Oct 2025 17:04:46 -0400 Subject: [PATCH 15/28] fix: gradient error in gordon surface intersect modified: pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c90ec65..03e3803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", - "ocp_gordon >= 0.1.13", + "ocp_gordon >= 0.1.14", "trianglesolver", "sympy", "scipy", From 02d7be83b14e2bbf9b16d6855ced86e8554bfba6 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Mon, 13 Oct 2025 11:19:21 -0400 Subject: [PATCH 16/28] feat: allow a single point to be used as either a profile or a guide modified: pyproject.toml modified: src/build123d/topology/two_d.py modified: tests/test_direct_api/test_face.py --- pyproject.toml | 2 +- src/build123d/topology/two_d.py | 75 +++++++++++++++++++++++------- tests/test_direct_api/test_face.py | 32 +++++++++++-- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03e3803..a25bde4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", - "ocp_gordon >= 0.1.14", + "ocp_gordon >= 0.1.15", "trianglesolver", "sympy", "scipy", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 0cd9dc8..50f5aad 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -83,11 +83,12 @@ from OCP.BRepTools import BRepTools, BRepTools_ReShape from OCP.gce import gce_MakeLin from OCP.Geom import ( Geom_BezierSurface, + Geom_BSplineCurve, Geom_RectangularTrimmedSurface, Geom_Surface, Geom_TrimmedCurve, ) -from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_G2, GeomAbs_CurveType +from OCP.GeomAbs import GeomAbs_C0, GeomAbs_CurveType, GeomAbs_G1, GeomAbs_G2 from OCP.GeomAPI import ( GeomAPI_ExtremaCurveCurve, GeomAPI_PointsToBSplineSurface, @@ -104,13 +105,17 @@ from OCP.Standard import ( Standard_NoSuchObject, ) from OCP.StdFail import StdFail_NotDone -from OCP.TColgp import TColgp_HArray2OfPnt -from OCP.TColStd import TColStd_HArray2OfReal +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt +from OCP.TColStd import ( + TColStd_Array1OfInteger, + TColStd_Array1OfReal, + TColStd_HArray2OfReal, +) from OCP.TopExp import TopExp from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape -from typing_extensions import Self from ocp_gordon import interpolate_curve_network +from typing_extensions import Self from build123d.build_enums import ( CenterOf, @@ -922,32 +927,66 @@ class Face(Mixin2D, Shape[TopoDS_Face]): @classmethod def make_gordon_surface( cls, - profiles: Iterable[Edge], - guides: Iterable[Edge], + profiles: Iterable[VectorLike | Edge], + guides: Iterable[VectorLike | Edge], tolerance: float = 3e-4, ) -> Face: """ - Creates a Gordon surface from a network of profile and guide curves. + Constructs a Gordon surface from a network of profile and guide curves. + + Profiles and guides may consist of points or curves, but at least one + profile and one guide must be a non-point curve. Args: - profiles (Iterable[Edge]): Edges representing profile curves. - guides (Iterable[Edge]): Edges representing guide curves. - tolerance (float, optional): Tolerance for surface creation and + profiles (Iterable[VectorLike | Edge]): Profiles defined as points or edges. + guides (Iterable[VectorLike | Edge]): Guides defined as points or edges. + tolerance (float, optional): Tolerance used for surface construction and intersection calculations. Raises: - ValueError: Input edge cannot be empty + ValueError: If the input profiles or guides are empty. Returns: Face: the interpolated Gordon surface """ - def to_geom_curve(edge: Edge): - if edge.wrapped is None: - raise ValueError("input edge cannot be empty") + def create_zero_length_bspline_curve( + point: gp_Pnt, degree: int = 1 + ) -> Geom_BSplineCurve: + """ + Helper to create a simple linear B-spline curve. + """ + control_points = TColgp_Array1OfPnt(1, 2) + control_points.SetValue(1, point) + control_points.SetValue(2, point) - adaptor = BRepAdaptor_Curve(edge.wrapped) - curve = BRep_Tool.Curve_s(edge.wrapped, 0, 1) + knots = TColStd_Array1OfReal(1, 2) + knots.SetValue(1, 0.0) + knots.SetValue(2, 1.0) + + multiplicities = TColStd_Array1OfInteger(1, 2) + multiplicities.SetValue(1, degree + 1) + multiplicities.SetValue(2, degree + 1) + + curve = Geom_BSplineCurve(control_points, knots, multiplicities, degree) + return curve + + def to_geom_curve(shape: VectorLike | Edge): + if isinstance(shape, (Vector, tuple, Sequence)): + _shape = Vector(shape) + if _shape.wrapped is None: + raise ValueError("input VectorLike cannot be empty") + + single_point_curve = create_zero_length_bspline_curve( + gp_Pnt(_shape.wrapped.XYZ()) + ) + return single_point_curve + + if shape.wrapped is None: + raise ValueError("input Edge cannot be empty") + + adaptor = BRepAdaptor_Curve(shape.wrapped) + curve = BRep_Tool.Curve_s(shape.wrapped, 0, 1) if not ( (adaptor.IsPeriodic() and adaptor.IsClosed()) or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BSplineCurve @@ -958,8 +997,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) return curve - ocp_profiles = [to_geom_curve(edge) for edge in profiles] - ocp_guides = [to_geom_curve(edge) for edge in guides] + ocp_profiles = [to_geom_curve(shape) for shape in profiles] + ocp_guides = [to_geom_curve(shape) for shape in guides] gordon_bspline_surface = interpolate_curve_network( ocp_profiles, ocp_guides, tolerance=tolerance diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 043c22c..518e53b 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -31,9 +31,11 @@ import os import platform import random import unittest +from unittest.mock import PropertyMock, patch -from unittest.mock import patch, PropertyMock from OCP.Geom import Geom_RectangularTrimmedSurface +from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve + from build123d.build_common import Locations, PolarLocations from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType from build123d.build_line import BuildLine @@ -57,7 +59,6 @@ from build123d.operations_generic import fillet, offset from build123d.operations_part import extrude from build123d.operations_sketch import make_face from build123d.topology import Edge, Face, Shell, Solid, Wire -from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve class TestFace(unittest.TestCase): @@ -448,7 +449,7 @@ class TestFace(unittest.TestCase): profiles, guides, tolerance=tolerance ) - def test_make_gordon_surface_edge_types(self): + def test_make_gordon_surface_input_types(self): tolerance = 3e-4 def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): @@ -538,6 +539,30 @@ class TestFace(unittest.TestCase): point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5) + profiles = [ + points[0], + ThreePointArc( + points[1], (points[1] + points[3]) / 2 + Vector(0, 0, 2), points[3] + ), + points[2], + ] + guides = [ + Spline( + points[0], + profiles[1] @ 0, + points[2], + ), + Spline( + points[0], + profiles[1] @ 1, + points[2], + ), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=1.0, expected_point=guides[0] @ 1) + point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1) + point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0]) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( @@ -1232,3 +1257,4 @@ class TestAxesOfSymmetrySplitNone(unittest.TestCase): if __name__ == "__main__": unittest.main() + unittest.main() From acfe5fde8a3fe2b2fb6c8008613aa19ae054f4f5 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Mon, 13 Oct 2025 11:44:02 -0400 Subject: [PATCH 17/28] fix: no need to check wrapped for Vector class modified: src/build123d/topology/two_d.py --- src/build123d/topology/two_d.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 50f5aad..d094ac4 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -974,9 +974,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]): def to_geom_curve(shape: VectorLike | Edge): if isinstance(shape, (Vector, tuple, Sequence)): _shape = Vector(shape) - if _shape.wrapped is None: - raise ValueError("input VectorLike cannot be empty") - single_point_curve = create_zero_length_bspline_curve( gp_Pnt(_shape.wrapped.XYZ()) ) From bd03fcbdb4f1ac69f519eb2b0fc4d69f2506af70 Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Mon, 13 Oct 2025 11:53:37 -0400 Subject: [PATCH 18/28] fix: remove minor artifact modified: tests/test_direct_api/test_face.py --- tests/test_direct_api/test_face.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 518e53b..79d3685 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -1257,4 +1257,3 @@ class TestAxesOfSymmetrySplitNone(unittest.TestCase): if __name__ == "__main__": unittest.main() - unittest.main() From b0974555057e1cb3284ab9f22c2db4773572a55e Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 16 Oct 2025 22:25:14 -0400 Subject: [PATCH 19/28] fix: single point only allowed at start and end modified: pyproject.toml modified: src/build123d/topology/two_d.py modified: tests/test_direct_api/test_face.py --- pyproject.toml | 4 ++-- src/build123d/topology/two_d.py | 14 ++++++++------ tests/test_direct_api/test_face.py | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a25bde4..fdb8bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = [ "brep", "cad", "cadquery", - "opencscade", + "opencascade", "python", ] license = {text = "Apache-2.0"} @@ -44,7 +44,7 @@ dependencies = [ "ipython >= 8.0.0, < 10", "lib3mf >= 2.4.1", "ocpsvg >= 0.5, < 0.6", - "ocp_gordon >= 0.1.15", + "ocp_gordon >= 0.1.17", "trianglesolver", "sympy", "scipy", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index d094ac4..8b8f264 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -934,8 +934,13 @@ class Face(Mixin2D, Shape[TopoDS_Face]): """ Constructs a Gordon surface from a network of profile and guide curves. - Profiles and guides may consist of points or curves, but at least one - profile and one guide must be a non-point curve. + Requirements: + 1. Profiles and guides may be defined as points or curves. + 2. Only the first or last profile or guide may be a point. + 3. At least one profile and one guide must be a non-point curve. + 4. Each profile must intersect with every guide. + 5. Both ends of every profile must lie on a guide. + 6. Both ends of every guide must lie on a profile. Args: profiles (Iterable[VectorLike | Edge]): Profiles defined as points or edges. @@ -944,7 +949,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): intersection calculations. Raises: - ValueError: If the input profiles or guides are empty. + ValueError: input Edge cannot be empty. Returns: Face: the interpolated Gordon surface @@ -953,9 +958,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]): def create_zero_length_bspline_curve( point: gp_Pnt, degree: int = 1 ) -> Geom_BSplineCurve: - """ - Helper to create a simple linear B-spline curve. - """ control_points = TColgp_Array1OfPnt(1, 2) control_points.SetValue(1, point) control_points.SetValue(2, point) diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 79d3685..f8619c5 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -563,6 +563,28 @@ class TestFace(unittest.TestCase): point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1) point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0]) + profiles = [ + Line(points[0], points[1]), + (points[0] + points[2]) / 2, + Line(points[3], points[2]), + ] + guides = [ + Spline( + profiles[0] @ 0, + profiles[1], + profiles[2] @ 0, + ), + Spline( + profiles[0] @ 1, + profiles[1], + profiles[2] @ 1, + ), + ] + with self.assertRaises(ValueError): + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( From 13685139563df1eec0528e0ca5844acae456a3ef Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 17 Oct 2025 11:15:08 -0400 Subject: [PATCH 20/28] make_constrained_lines working --- src/build123d/topology/constrained_lines.py | 12 +--- src/build123d/topology/one_d.py | 51 +++++++------- .../test_direct_api/test_constrained_lines.py | 66 +++++++++++++++++++ 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 58e8b0f..9c316b6 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -119,22 +119,16 @@ def _edge_to_qualified_2d( """Convert a TopoDS_Edge into 2d curve & extract properties""" # 1) Underlying curve + range (also retrieve location to be safe) - loc = edge.Location() hcurve3d = BRep_Tool.Curve_s(edge, float(), float()) first, last = BRep_Tool.Range_s(edge) - # 2) Apply location if the edge is positioned by a TopLoc_Location - if not loc.IsIdentity(): - trsf = loc.Transformation() - hcurve3d = tcast(Geom_Curve, hcurve3d.Transformed(trsf)) - - # 3) Convert to 2D on Plane.XY (Z-up frame at origin) + # 2) Convert to 2D on Plane.XY (Z-up frame at origin) hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve - # 4) Wrap in an adaptor using the same parametric range + # 3) Wrap in an adaptor using the same parametric range adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) - # 5) Create the qualified curve (unqualified is fine here) + # 4) Create the qualified curve (unqualified is fine here) qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) return qcurve, hcurve2d, first, last, adapt2d diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 8eeb2d7..744a9ed 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1969,15 +1969,15 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_lines( cls, - tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, - tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge, + tangency_one: tuple[Edge, Tangency] | Axis | Edge, + tangency_two: tuple[Edge, Tangency] | Axis | Edge, ) -> ShapeList[Edge]: """ Create all planar line(s) on the XY plane tangent to two provided curves. Args: tangency_one, tangency_two - (tuple[Axis | Edge, Tangency] | Axis | Edge): + (tuple[Edge, Tangency] | Axis | Edge): Geometric entities to be contacted/touched by the line(s). Returns: @@ -1988,7 +1988,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_lines( cls, - tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, + tangency_one: tuple[Edge, Tangency] | Edge, tangency_two: Vector, ) -> ShapeList[Edge]: """ @@ -1997,7 +1997,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Args: tangency_one - (tuple[Axis | Edge, Tangency] | Axis | Edge): + (tuple[Edge, Tangency] | Edge): Geometric entity to be contacted/touched by the line(s). tangency_two (Vector): Fixed point through which the line(s) must pass. @@ -2010,11 +2010,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_lines( cls, - tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge, + tangency_one: tuple[Edge, Tangency] | Edge, tangency_two: Axis, *, angle: float | None = None, - direction: Vector | None = None, + direction: VectorLike | None = None, ) -> ShapeList[Edge]: """ Create all planar line(s) on the XY plane tangent to one curve and passing @@ -2025,7 +2025,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_two (Axis): axis that angle will be measured against angle : float, optional Line orientation in degrees (measured CCW from the X-axis). - direction : Vector, optional + direction : VectorLike, optional Direction vector for the line (only X and Y components are used). Note: one of angle or direction must be provided @@ -2051,6 +2051,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): angle = kwargs.pop("angle", None) direction = kwargs.pop("direction", None) + direction = Vector(direction) if direction is not None else None is_ref = angle is not None or direction is not None # Handle unexpected kwargs @@ -2072,13 +2073,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): elif isinstance(tangency_arg, Edge): tangencies.append(tangency_arg) continue - if isinstance(tangency_arg, tuple): - if isinstance(tangency_arg[0], Axis): - tangencies.append((Edge(tangency_arg[0]), tangency_arg[1])) - continue - elif isinstance(tangency_arg[0], Edge): - tangencies.append(tangency_arg) - continue + if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue # Fallback: treat as a point try: tangencies.append(Vector(tangency_arg)) @@ -2089,16 +2086,23 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector))) # --- decide problem kind --- - if isinstance(tangencies[1], Axis): - assert isinstance( - tangencies[0], Edge - ), "Internal error - 1st tangency must be Edge" + if angle is not None or direction is not None: + if isinstance(tangencies[0], tuple): + assert isinstance( + tangencies[0][0], Edge + ), "Internal error - 1st tangency must be Edge" + else: + assert isinstance( + tangencies[0], Edge + ), "Internal error - 1st tangency must be Edge" if angle is not None: ang_rad = radians(angle) - elif direction is not None: - ang_rad = atan2(direction.Y, direction.X) else: - raise ValueError("Specify exactly one of 'angle' or 'direction'") + assert direction is not None + ang_rad = atan2(direction.Y, direction.X) + assert isinstance( + tangencies[1], Axis + ), "Internal error - 2nd tangency must be an Axis" return _make_tan_oriented_lines( tangencies[0], tangencies[1], ang_rad, edge_factory=cls ) @@ -2106,6 +2110,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): assert not isinstance( tangencies[0], (Axis, Vector) ), "Internal error - 1st tangency can't be an Axis | Vector" + assert not isinstance( + tangencies[1], Axis + ), "Internal error - 2nd tangency can't be an Axis" return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls) diff --git a/tests/test_direct_api/test_constrained_lines.py b/tests/test_direct_api/test_constrained_lines.py index d74bf90..dc32dff 100644 --- a/tests/test_direct_api/test_constrained_lines.py +++ b/tests/test_direct_api/test_constrained_lines.py @@ -199,3 +199,69 @@ def test_invalid_oriented_tangent(unit_circle): unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge ) assert len(lines) == 0 + + +def test_make_constrained_lines0(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, unit_circle.translate((3, 0, 0))) + assert len(lines) == 4 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines1(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, (3, 0)) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines3(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, Axis.X, angle=30) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + assert abs((ln @ 1).Y) < 1e-6 + + +def test_make_constrained_lines4(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, Axis.Y, angle=30) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + assert abs((ln @ 1).X) < 1e-6 + + +def test_make_constrained_lines5(unit_circle): + lines = Edge.make_constrained_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, angle=30 + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines6(unit_circle): + lines = Edge.make_constrained_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, direction=(1, 1) + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines_raises(unit_circle): + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle, Axis.Z, ref_angle=1) + assert "Unexpected argument(s): ref_angle" in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle) + assert "Provide exactly 2 tangency targets." in str(excinfo.value) + + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_lines(Axis.X, Axis.Y) + assert "Unable to find common tangent line(s)" in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle, ("three", 0)) + assert "Invalid tangency:" in str(excinfo.value) From 283767f69de8d5139e28ca73c112b67d2197befd Mon Sep 17 00:00:00 2001 From: gumyr Date: Sun, 19 Oct 2025 11:29:21 -0400 Subject: [PATCH 21/28] Cached color lookups --- src/build123d/importers.py | 70 ++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/build123d/importers.py b/src/build123d/importers.py index 55d1d42..d53628a 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -38,8 +38,10 @@ from pathlib import Path from typing import Literal, Optional, TextIO, Union import warnings +from OCP.Bnd import Bnd_Box from OCP.BRep import BRep_Builder -from OCP.BRepGProp import BRepGProp +from OCP.BRepBndLib import BRepBndLib +from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepTools import BRepTools from OCP.GProp import GProp_GProps from OCP.Quantity import Quantity_ColorRGBA @@ -145,37 +147,42 @@ def import_step(filename: PathLike | str | bytes) -> Compound: clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C") return clean_name.translate(str.maketrans(" .()", "____")) - def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA: + def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None: + """Get the color of a shape from a cache""" + key = obj.TShape().__hash__() + if key in _color_cache: + return _color_cache[key] + + col = Quantity_ColorRGBA() + has_color = ( + color_tool.GetColor(obj, XCAFDoc_ColorCurv, col) + or color_tool.GetColor(obj, XCAFDoc_ColorGen, col) + or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col) + ) + _color_cache[key] = col if has_color else None + return _color_cache[key] + + def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None: """Get the color - take that of the largest Face if multiple""" + shape_color = get_shape_color_from_cache(shape) + if shape_color is not None: + return shape_color - def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA: - col = Quantity_ColorRGBA() - if ( - color_tool.GetColor(obj, XCAFDoc_ColorCurv, col) - or color_tool.GetColor(obj, XCAFDoc_ColorGen, col) - or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col) - ): - return col - - shape_color = get_col(shape) - - colors = {} - face_explorer = TopExp_Explorer(shape, TopAbs_FACE) - while face_explorer.More(): - current_face = face_explorer.Current() - properties = GProp_GProps() - BRepGProp.SurfaceProperties_s(current_face, properties) - area = properties.Mass() - color = get_col(current_face) - if color is not None: - colors[area] = color - face_explorer.Next() - - # If there are multiple colors, return the one from the largest face - if colors: - shape_color = sorted(colors.items())[-1][1] - - return shape_color + max_extent = -1.0 + winner = None + exp = TopExp_Explorer(shape, TopAbs_FACE) + while exp.More(): + face = exp.Current() + col = get_shape_color_from_cache(face) + if col is not None: + box = Bnd_Box() + BRepBndLib.Add_s(face, box) + extent = box.SquareExtent() + if extent > max_extent: + max_extent = extent + winner = col + exp.Next() + return winner def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]: """Recursively extract object into an assembly""" @@ -211,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound: if not os.path.exists(filename): raise FileNotFoundError(filename) + # Retrieving color info is expensive so cache the lookups + _color_cache: dict[int, Quantity_ColorRGBA | None] = {} + fmt = TCollection_ExtendedString("XCAF") doc = TDocStd_Document(fmt) shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) From 4a32cedcd2fbae2583a439f34a372e739eb7344a Mon Sep 17 00:00:00 2001 From: gumyr Date: Sun, 19 Oct 2025 15:31:47 -0400 Subject: [PATCH 22/28] Updating surface modeling docs --- docs/_static/spitfire_wing.glb | Bin 0 -> 12476 bytes docs/assets/surface_modeling/heart_token.png | Bin 0 -> 27335 bytes .../assets/surface_modeling/spitfire_wing.png | Bin 0 -> 45374 bytes .../spitfire_wing_profiles_guides.svg | 11 ++ .../token_half_surface.png | Bin .../token_heart_perimeter.png | Bin .../token_heart_solid.png | Bin .../{ => surface_modeling}/token_sides.png | Bin docs/heart_token.py | 68 +++++++ docs/spitfire_wing_gordon.py | 77 ++++++++ docs/tutorial_surface_heart_token.rst | 125 ++++++++++++ docs/tutorial_surface_modeling.rst | 181 ++++-------------- examples/tea_cup.py | 12 +- 13 files changed, 327 insertions(+), 147 deletions(-) create mode 100644 docs/_static/spitfire_wing.glb create mode 100644 docs/assets/surface_modeling/heart_token.png create mode 100644 docs/assets/surface_modeling/spitfire_wing.png create mode 100644 docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg rename docs/assets/{ => surface_modeling}/token_half_surface.png (100%) rename docs/assets/{ => surface_modeling}/token_heart_perimeter.png (100%) rename docs/assets/{ => surface_modeling}/token_heart_solid.png (100%) rename docs/assets/{ => surface_modeling}/token_sides.png (100%) create mode 100644 docs/heart_token.py create mode 100644 docs/spitfire_wing_gordon.py create mode 100644 docs/tutorial_surface_heart_token.rst diff --git a/docs/_static/spitfire_wing.glb b/docs/_static/spitfire_wing.glb new file mode 100644 index 0000000000000000000000000000000000000000..93c275b4dc50962012f1e3c0d0ee3c816d71d491 GIT binary patch literal 12476 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 literal 0 HcmV?d00001 diff --git a/docs/assets/surface_modeling/heart_token.png b/docs/assets/surface_modeling/heart_token.png new file mode 100644 index 0000000000000000000000000000000000000000..24cfeb78261372c3e2815720f21a880bed5104f7 GIT binary patch literal 27335 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 literal 0 HcmV?d00001 diff --git a/docs/assets/surface_modeling/spitfire_wing.png b/docs/assets/surface_modeling/spitfire_wing.png new file mode 100644 index 0000000000000000000000000000000000000000..10924261ecb9f5d3e79aa22a3a12bbdd1e3d2d15 GIT binary patch literal 45374 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

yn_?pft7a-#=MUUG1+Ba6xMKqmYypLQnIT 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 X=)L;wtlTID1_lOCS3j3^P6 + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/token_half_surface.png b/docs/assets/surface_modeling/token_half_surface.png similarity index 100% rename from docs/assets/token_half_surface.png rename to docs/assets/surface_modeling/token_half_surface.png diff --git a/docs/assets/token_heart_perimeter.png b/docs/assets/surface_modeling/token_heart_perimeter.png similarity index 100% rename from docs/assets/token_heart_perimeter.png rename to docs/assets/surface_modeling/token_heart_perimeter.png diff --git a/docs/assets/token_heart_solid.png b/docs/assets/surface_modeling/token_heart_solid.png similarity index 100% rename from docs/assets/token_heart_solid.png rename to docs/assets/surface_modeling/token_heart_solid.png diff --git a/docs/assets/token_sides.png b/docs/assets/surface_modeling/token_sides.png similarity index 100% rename from docs/assets/token_sides.png rename to docs/assets/surface_modeling/token_sides.png diff --git a/docs/heart_token.py b/docs/heart_token.py new file mode 100644 index 0000000..da11e68 --- /dev/null +++ b/docs/heart_token.py @@ -0,0 +1,68 @@ +# [Code] +from build123d import * +from ocp_vscode import show + +# Create the edges of one half the heart surface +l1 = JernArc((0, 0), (1, 1.4), 40, -17) +l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175) +l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20))) +l4 = ThreePointArc(l3 @ 1, (0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0) +heart_half = Wire([l1, l2, l3, l4]) +# [SurfaceEdges] + +# Create a point elevated off the center +surface_pnt = l2.arc_center + (0, 0, 1.5) +# [SurfacePoint] + +# Create the surface from the edges and point +top_right_surface = Pos(Z=0.5) * -Face.make_surface(heart_half, [surface_pnt]) +# [Surface] + +# Use the mirror method to create the other top and bottom surfaces +top_left_surface = top_right_surface.mirror(Plane.YZ) +bottom_right_surface = top_right_surface.mirror(Plane.XY) +bottom_left_surface = -top_left_surface.mirror(Plane.XY) +# [Surfaces] + +# Create the left and right sides +left_wire = Wire([l3, l2, l1]) +left_side = Pos(Z=-0.5) * Shell.extrude(left_wire, (0, 0, 1)) +right_side = left_side.mirror(Plane.YZ) +# [Sides] + +# Put all of the faces together into a Shell/Solid +heart = Solid( + Shell( + [ + top_right_surface, + top_left_surface, + bottom_right_surface, + bottom_left_surface, + left_side, + right_side, + ] + ) +) +# [Solid] + +# Build a frame around the heart +with BuildPart() as heart_token: + with BuildSketch() as outline: + with BuildLine(): + add(l1) + add(l2) + add(l3) + Line(l3 @ 1, l1 @ 0) + make_face() + mirror(about=Plane.YZ) + center = outline.sketch + offset(amount=2, kind=Kind.INTERSECTION) + add(center, mode=Mode.SUBTRACT) + extrude(amount=2, both=True) + add(heart) + +heart_token.part.color = "Red" + +show(heart_token) +# [End] +# export_gltf(heart_token.part, "heart_token.glb", binary=True) diff --git a/docs/spitfire_wing_gordon.py b/docs/spitfire_wing_gordon.py new file mode 100644 index 0000000..8b41a0c --- /dev/null +++ b/docs/spitfire_wing_gordon.py @@ -0,0 +1,77 @@ +""" +Supermarine Spitfire Wing +""" + +# [Code] + +from build123d import * +from ocp_vscode import show + +wing_span = 36 * FT + 10 * IN +wing_leading = 2.5 * FT +wing_trailing = wing_span / 4 - wing_leading +wing_leading_fraction = wing_leading / (wing_leading + wing_trailing) +wing_tip_section = wing_span / 2 - 1 * IN # distance from root to last section + +# Create leading and trailing edges +leading_edge = EllipticalCenterArc( + (0, 0), wing_span / 2, wing_leading, start_angle=270, end_angle=360 +) +trailing_edge = EllipticalCenterArc( + (0, 0), wing_span / 2, wing_trailing, start_angle=0, end_angle=90 +) + +# [AirfoilSizes] +# Calculate the airfoil sizes from the leading/trailing edges +airfoil_sizes = [] +for i in [0, 1]: + tip_axis = Axis(i * (wing_tip_section, 0, 0), (0, 1, 0)) + leading_pnt = leading_edge.intersect(tip_axis)[0] + trailing_pnt = trailing_edge.intersect(tip_axis)[0] + airfoil_sizes.append(trailing_pnt.Y - leading_pnt.Y) + +# [Airfoils] +# Create the root and tip airfoils - note that they are different NACA profiles +airfoil_root = Plane.YZ * scale( + Airfoil("2213").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[0] +) +airfoil_tip = ( + Plane.YZ + * Pos(Z=wing_tip_section) + * scale(Airfoil("2205").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[1]) +) + +# [Profiles] +# Create the Gordon surface profiles and guides +profiles = airfoil_root.edges() + airfoil_tip.edges() +profiles.append(leading_edge @ 1) # wing tip +guides = [leading_edge, trailing_edge] +# Create the wing surface as a Gordon Surface +wing_surface = -Face.make_gordon_surface(profiles, guides) +# Create the root of the wing +wing_root = -Face(Wire(wing_surface.edges().filter_by(Edge.is_closed))) + +# [Solid] +# Create the wing Solid +wing = Solid(Shell([wing_surface, wing_root])) +wing.color = 0x99A3B9 # Azure Blue + +show(wing) +# [End] +# Documentation artifact generation +# wing_control_edges = Curve( +# [airfoil_root, airfoil_tip, Vertex(leading_edge @ 1), leading_edge, trailing_edge] +# ) +# visible, _ = wing_control_edges.project_to_viewport((50 * FT, -50 * FT, 50 * FT)) +# max_dimension = max(*Compound(children=visible).bounding_box().size) +# svg = ExportSVG(scale=100 / max_dimension) +# svg.add_shape(visible) +# svg.write("assets/surface_modeling/spitfire_wing_profiles_guides.svg") + +# export_gltf( +# wing, +# "assets/surface_modeling/spitfire_wing.glb", +# binary=True, +# linear_deflection=0.1, +# angular_deflection=1, +# ) diff --git a/docs/tutorial_surface_heart_token.rst b/docs/tutorial_surface_heart_token.rst new file mode 100644 index 0000000..2c45f62 --- /dev/null +++ b/docs/tutorial_surface_heart_token.rst @@ -0,0 +1,125 @@ +################################## +Tutorial: Heart Token (Basics) +################################## + +This hands‑on tutorial introduces the fundamentals of surface modeling by building +a heart‑shaped token from a small set of non‑planar faces. We’ll create +non‑planar surfaces, mirror them, add side faces, and assemble a closed shell +into a solid. + +As described in the `topology_` section, a BREP model consists of vertices, edges, faces, +and other elements that define the boundary of an object. When creating objects with +non-planar faces, it is often more convenient to explicitly create the boundary faces of +the object. To illustrate this process, we will create the following game token: + +.. raw:: html + + + + +Useful :class:`~topology.Face` creation methods include +:meth:`~topology.Face.make_surface`, :meth:`~topology.Face.make_bezier_surface`, +and :meth:`~topology.Face.make_surface_from_array_of_points`. See the +:doc:`surface_modeling` overview for the full list. + +In this case, we'll use the ``make_surface`` method, providing it with the edges that define +the perimeter of the surface and a central point on that surface. + +To create the perimeter, we'll define the perimeter edges. Since the heart is +symmetric, we'll only create half of its surface here: + +.. literalinclude:: heart_token.py + :start-after: [Code] + :end-before: [SurfaceEdges] + +Note that ``l4`` is not in the same plane as the other lines; it defines the center line +of the heart and archs up off ``Plane.XY``. + +.. image:: ./assets/surface_modeling/token_heart_perimeter.png + :align: center + :alt: token perimeter + +In preparation for creating the surface, we'll define a point on the surface: + +.. literalinclude:: heart_token.py + :start-after: [SurfaceEdges] + :end-before: [SurfacePoint] + +We will then use this point to create a non-planar ``Face``: + +.. literalinclude:: heart_token.py + :start-after: [SurfacePoint] + :end-before: [Surface] + +.. image:: ./assets/surface_modeling/token_half_surface.png + :align: center + :alt: token perimeter + +Note that the surface was raised up by 0.5 using an Algebra expression with Pos. Also, +note that the ``-`` in front of ``Face`` simply flips the face normal so that the colored +side is up, which isn't necessary but helps with viewing. + +Now that one half of the top of the heart has been created, the remainder of the top +and bottom can be created by mirroring: + +.. literalinclude:: heart_token.py + :start-after: [Surface] + :end-before: [Surfaces] + +The sides of the heart are going to be created by extruding the outside of the perimeter +as follows: + +.. literalinclude:: heart_token.py + :start-after: [Surfaces] + :end-before: [Sides] + +.. image:: ./assets/surface_modeling/token_sides.png + :align: center + :alt: token sides + +With the top, bottom, and sides, the complete boundary of the object is defined. We can +now put them together, first into a :class:`~topology.Shell` and then into a +:class:`~topology.Solid`: + +.. literalinclude:: heart_token.py + :start-after: [Sides] + :end-before: [Solid] + +.. image:: ./assets/surface_modeling/token_heart_solid.png + :align: center + :alt: token heart solid + +.. note:: + When creating a Solid from a Shell, the Shell must be "water-tight," meaning it + should have no holes. For objects with complex Edges, it's best practice to reuse + Edges in adjoining Faces whenever possible to avoid slight mismatches that can + create openings. + +Finally, we'll create the frame around the heart as a simple extrusion of a planar +shape defined by the perimeter of the heart and merge all of the components together: + +.. literalinclude:: heart_token.py + :start-after: [Solid] + :end-before: [End] + +Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face`` +can be created. The :func:`~operations_generic.offset` function defines the outside of +the frame as a constant distance from the heart itself. + +Summary +------- + +In this tutorial, we've explored surface modeling techniques to create a non-planar +heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face` +class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and +central point of the surface. We then assembled the complete boundary of the object +by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell` +and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart +using the :func:`~operations_generic.offset` function to maintain a constant distance +from the heart. + +Next steps +---------- + +Continue to :doc:`tutorial_heart_token` for an advanced example using +:meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing. diff --git a/docs/tutorial_surface_modeling.rst b/docs/tutorial_surface_modeling.rst index 08ed253..4d8fca0 100644 --- a/docs/tutorial_surface_modeling.rst +++ b/docs/tutorial_surface_modeling.rst @@ -1,156 +1,55 @@ -################ +################# Surface Modeling -################ +################# -Surface modeling is employed to create objects with non-planar surfaces that can't be -generated using functions like :func:`~operations_part.extrude`, -:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no -specific builders designed to assist with the creation of non-planar surfaces or objects, -the following should be considered a more advanced technique. -As described in the `topology_` section, a BREP model consists of vertices, edges, faces, -and other elements that define the boundary of an object. When creating objects with -non-planar faces, it is often more convenient to explicitly create the boundary faces of -the object. To illustrate this process, we will create the following game token: +Surface modeling refers to the direct creation and manipulation of the skin of a 3D +object—its bounding faces—rather than starting from volumetric primitives or solid +operations. -.. raw:: html +Instead of defining a shape by extruding or revolving a 2D profile to fill a volume, +surface modeling focuses on building the individual curved or planar faces that together +define the outer boundary of a part. This approach allows for precise control of complex +freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that +cannot easily be expressed with simple parametric solids. - - +In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling, +all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and +vertices. Each face represents a finite patch of a geometric surface (plane, cylinder, +Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share +edges consistently and close into a continuous boundary, they form a manifold +:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly +oriented and encloses a finite region of space, the model becomes a solid. -There are several methods of the :class:`~topology.Face` class that can be used to create -non-planar surfaces: +Surface modeling therefore operates at the most fundamental level of BREP construction. +Rather than relying on higher-level modeling operations to implicitly generate faces, +it allows you to construct and connect those faces explicitly. This provides a path to +build geometry that blends analytical and freeform shapes seamlessly, with full control +over continuity, tangency, and curvature across boundaries. -* :meth:`~topology.Face.make_bezier_surface`, -* :meth:`~topology.Face.make_surface`, and -* :meth:`~topology.Face.make_surface_from_array_of_points`. +This section provides: +- A concise overview of surface‑building tools in build123d +- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces -In this case, we'll use the ``make_surface`` method, providing it with the edges that define -the perimeter of the surface and a central point on that surface. +.. rubric:: Available surface methods -To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is -symmetric, we'll only create half of its surface here: +Methods on :class:`~topology.Face` for creating non‑planar surfaces: -.. code-block:: python - - with BuildLine() as heart_half: - l1 = JernArc((0, 0), (1, 1.4), 40, -17) - l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175) - l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20))) - l4 = ThreePointArc(l3 @ 1, Vector(0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0) - -Note that ``l4`` is not in the same plane as the other lines; it defines the center line -of the heart and archs up off ``Plane.XY``. - -.. image:: ./assets/token_heart_perimeter.png - :align: center - :alt: token perimeter - -In preparation for creating the surface, we'll define a point on the surface: - -.. code-block:: python - - surface_pnt = l2.edge().arc_center + Vector(0, 0, 1.5) - -We will then use this point to create a non-planar ``Face``: - -.. code-block:: python - - top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate( - Pos(Z=0.5) - ) - -.. image:: ./assets/token_half_surface.png - :align: center - :alt: token perimeter - -Note that the surface was raised up by 0.5 using the locate method. Also, note that -the ``-`` in front of ``Face`` simply flips the face normal so that the colored side -is up, which isn't necessary but helps with viewing. - -Now that one half of the top of the heart has been created, the remainder of the top -and bottom can be created by mirroring: - -.. code-block:: python - - top_left_surface = top_right_surface.mirror(Plane.YZ) - bottom_right_surface = top_right_surface.mirror(Plane.XY) - bottom_left_surface = -top_left_surface.mirror(Plane.XY) - -The sides of the heart are going to be created by extruding the outside of the perimeter -as follows: - -.. code-block:: python - - left_wire = Wire([l3.edge(), l2.edge(), l1.edge()]) - left_side = Face.extrude(left_wire, (0, 0, 1)).locate(Pos(Z=-0.5)) - right_side = left_side.mirror(Plane.YZ) - -.. image:: ./assets/token_sides.png - :align: center - :alt: token sides - -With the top, bottom, and sides, the complete boundary of the object is defined. We can -now put them together, first into a :class:`~topology.Shell` and then into a -:class:`~topology.Solid`: - -.. code-block:: python - - heart = Solid( - Shell( - [ - top_right_surface, - top_left_surface, - bottom_right_surface, - bottom_left_surface, - left_side, - right_side, - ] - ) - ) - -.. image:: ./assets/token_heart_solid.png - :align: center - :alt: token heart solid +* :meth:`~topology.Face.make_bezier_surface` +* :meth:`~topology.Face.make_gordon_surface` +* :meth:`~topology.Face.make_surface` +* :meth:`~topology.Face.make_surface_from_array_of_points` +* :meth:`~topology.Face.make_surface_from_curves` +* :meth:`~topology.Face.make_surface_patch` .. note:: - When creating a Solid from a Shell, the Shell must be "water-tight," meaning it - should have no holes. For objects with complex Edges, it's best practice to reuse - Edges in adjoining Faces whenever possible to avoid slight mismatches that can - create openings. + Surface modeling is an advanced technique. Robust results usually come from + reusing the same :class:`~topology.Edge` objects across adjacent faces and + ensuring the final :class:`~topology.Shell` is *water‑tight* or *manifold* (no gaps). -Finally, we'll create the frame around the heart as a simple extrusion of a planar -shape defined by the perimeter of the heart and merge all of the components together: +.. toctree:: + :maxdepth: 1 - .. code-block:: python + tutorial_surface_heart_token.rst + tutorial_spitfire_wing_gordon.rst - with BuildPart() as heart_token: - with BuildSketch() as outline: - with BuildLine(): - add(l1) - add(l2) - add(l3) - Line(l3 @ 1, l1 @ 0) - make_face() - mirror(about=Plane.YZ) - center = outline.sketch - offset(amount=2, kind=Kind.INTERSECTION) - add(center, mode=Mode.SUBTRACT) - extrude(amount=2, both=True) - add(heart) - -Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face`` -can be created. The :func:`~operations_generic.offset` function defines the outside of -the frame as a constant distance from the heart itself. - -Summary -------- - -In this tutorial, we've explored surface modeling techniques to create a non-planar -heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face` -class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and -central point of the surface. We then assembled the complete boundary of the object -by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell` -and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart -using the :func:`~operations_generic.offset` function to maintain a constant distance -from the heart. \ No newline at end of file diff --git a/examples/tea_cup.py b/examples/tea_cup.py index 866ee1f..8bc8ed6 100644 --- a/examples/tea_cup.py +++ b/examples/tea_cup.py @@ -4,19 +4,19 @@ name: tea_cup.py by: Gumyr date: March 27th 2023 -desc: This example demonstrates the creation a tea cup, which serves as an example of +desc: This example demonstrates the creation a tea cup, which serves as an example of constructing complex, non-flat geometrical shapes programmatically. The tea cup model involves several CAD techniques, such as: - - Revolve Operations: There is 1 occurrence of a revolve operation. This is used - to create the main body of the tea cup by revolving a profile around an axis, + - Revolve Operations: There is 1 occurrence of a revolve operation. This is used + to create the main body of the tea cup by revolving a profile around an axis, a common technique for generating symmetrical objects like cups. - Sweep Operations: There are 2 occurrences of sweep operations. The handle are created by sweeping a profile along a path to generate non-planar surfaces. - Offset/Shell Operations: the bowl of the cup is hollowed out with the offset - operation leaving the top open. - - Fillet Operations: There is 1 occurrence of a fillet operation which is used to - round the edges for aesthetic improvement and to mimic real-world objects more + operation leaving the top open. + - Fillet Operations: There is 1 occurrence of a fillet operation which is used to + round the edges for aesthetic improvement and to mimic real-world objects more closely. license: From d66e22655ea9908d9d39fc15784667d669ed20e5 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sun, 19 Oct 2025 15:37:32 -0400 Subject: [PATCH 23/28] Adding missing spitfile file --- docs/tutorial_spitfire_wing_gordon.rst | 106 +++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/tutorial_spitfire_wing_gordon.rst diff --git a/docs/tutorial_spitfire_wing_gordon.rst b/docs/tutorial_spitfire_wing_gordon.rst new file mode 100644 index 0000000..716f862 --- /dev/null +++ b/docs/tutorial_spitfire_wing_gordon.rst @@ -0,0 +1,106 @@ +############################################# +Tutorial: Spitfire Wing with Gordon Surface +############################################# + +In this advanced tutorial we construct a Supermarine Spitfire wing as a +:meth:`~topology.Face.make_gordon_surface`—a powerful technique for surfacing +from intersecting *profiles* and *guides*. A Gordon surface blends a grid of +curves into a smooth, coherent surface as long as the profiles and guides +intersect consistently. + +.. note:: + Gordon surfaces work best when *each profile intersects each guide exactly + once*, producing a well‑formed curve network. + +Overview +======== + +We will: + +1. Define overall wing dimensions and elliptic leading/trailing edge guide curves +2. Sample the guides to size the root and tip airfoils (different NACA profiles) +3. Build the Gordon surface from the airfoil *profiles* and wing‑edge *guides* +4. Close the root with a planar face and build the final :class:`~topology.Solid` + +.. raw:: html + + + + +Step 1 — Dimensions and guide curves +==================================== + +We model a single wing (half‑span), with an elliptic leading and trailing edge. +These two edges act as the *guides* for the Gordon surface. + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [Code] + :end-before: [AirfoilSizes] + + +Step 2 — Root and tip airfoil sizing +==================================== + +We intersect the guides with planes normal to the span to size the airfoil sections. +The resulting chord lengths define uniform scales for each airfoil curve. + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [AirfoilSizes] + :end-before: [Airfoils] + +Step 3 — Build airfoil profiles (root and tip) +============================================== + +We place two different NACA airfoils on :data:`Plane.YZ`—with the airfoil origins +shifted so the leading edge fraction is aligned—then scale to the chord lengths +from Step 2. + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [Airfoils] + :end-before: [Profiles] + + +Step 4 — Gordon surface construction +==================================== + +A Gordon surface needs *profiles* and *guides*. Here the airfoil edges are the +profiles; the elliptic edges are the guides. We also add the wing tip section +so the profile grid closes at the tip. + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [Profiles] + :end-before: [Solid] + +.. image:: ./assets/surface_modeling/spitfire_wing_profiles_guides.svg + :align: center + :alt: Elliptic leading/trailing guides + + +Step 5 — Cap the root and create the solid +========================================== + +We extract the closed root edge loop, make a planar cap, and form a solid shell. + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [Solid] + :end-before: [End] + +.. image:: ./assets/surface_modeling/spitfire_wing.png + :align: center + :alt: Final wing solid + +Tips for robust Gordon surfaces +------------------------------- + +- Ensure each profile intersects each guide once and only once +- Keep the curve network coherent (no duplicated or missing intersections) +- When possible, reuse the same :class:`~topology.Edge` objects across adjacent faces + +Complete listing +================ + +For convenience, here is the full script in one block: + +.. literalinclude:: spitfire_wing_gordon.py + :start-after: [Code] + :end-before: [End] From 453f676882f612d0b81a0b870128b5e02af74721 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 20 Oct 2025 18:50:14 -0400 Subject: [PATCH 24/28] Adding points to trim --- src/build123d/topology/one_d.py | 77 ++++++++++++++++++++++-------- tests/test_direct_api/test_edge.py | 25 ++++++++-- tests/test_direct_api/test_wire.py | 6 ++- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 744a9ed..25b817a 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -358,6 +358,21 @@ class Mixin1D(Shape): """Unused - only here because Mixin1D is a subclass of Shape""" return NotImplemented + # ---- Static Methods ---- + + @staticmethod + def _to_param(edge_wire: Mixin1D, value: float | VectorLike, name: str) -> float: + """Convert a float or VectorLike into a curve parameter.""" + if isinstance(value, (int, float)): + return float(value) + try: + point = Vector(value) + except TypeError as exc: + raise TypeError( + f"{name} must be a float or VectorLike, not {value!r}" + ) from exc + return edge_wire.param_at_point(point) + # ---- Instance Methods ---- def __add__( @@ -2972,24 +2987,43 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): ) return Wire([self]) - def trim(self, start: float, end: float) -> Edge: + def trim(self, start: float | VectorLike, end: float | VectorLike) -> Edge: + """_summary_ + + Args: + start (float | VectorLike): _description_ + end (float | VectorLike): _description_ + + Raises: + TypeError: _description_ + ValueError: _description_ + + Returns: + Edge: _description_ + """ """trim Create a new edge by keeping only the section between start and end. Args: - start (float): 0.0 <= start < 1.0 - end (float): 0.0 < end <= 1.0 + start (float | VectorLike): 0.0 <= start < 1.0 or point on edge + end (float | VectorLike): 0.0 < end <= 1.0 or point on edge Raises: - ValueError: start >= end + TypeError: invalid input, must be float or VectorLike ValueError: can't trim empty edge Returns: Edge: trimmed edge """ - if start >= end: - raise ValueError(f"start ({start}) must be less than end ({end})") + + start_u = Mixin1D._to_param(self, start, "start") + end_u = Mixin1D._to_param(self, end, "end") + + start_u, end_u = sorted([start_u, end_u]) + + # if start_u >= end_u: + # raise ValueError(f"start ({start_u}) must be less than end ({end_u})") if self.wrapped is None: raise ValueError("Can't trim empty edge") @@ -3000,8 +3034,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): new_curve = BRep_Tool.Curve_s( self_copy.wrapped, self.param_at(0), self.param_at(1) ) - parm_start = self.param_at(start) - parm_end = self.param_at(end) + parm_start = self.param_at(start_u) + parm_end = self.param_at(end_u) trimmed_curve = Geom_TrimmedCurve( new_curve, parm_start, @@ -3010,14 +3044,14 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() return Edge(new_edge) - def trim_to_length(self, start: float, length: float) -> Edge: + def trim_to_length(self, start: float | VectorLike, length: float) -> Edge: """trim_to_length Create a new edge starting at the given normalized parameter of a given length. Args: - start (float): 0.0 <= start < 1.0 + start (float | VectorLike): 0.0 <= start < 1.0 or point on edge length (float): target length Raise: @@ -3029,6 +3063,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if self.wrapped is None: raise ValueError("Can't trim empty edge") + start_u = Mixin1D._to_param(self, start, "start") + self_copy = copy.deepcopy(self) assert self_copy.wrapped is not None @@ -3040,7 +3076,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): adaptor_curve = GeomAdaptor_Curve(new_curve) # Find the parameter corresponding to the desired length - parm_start = self.param_at(start) + parm_start = self.param_at(start_u) abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start) # Get the parameter at the desired length @@ -3550,7 +3586,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): return Wire.make_polygon(corners_world, close=True) # ---- Static Methods ---- - @staticmethod def order_chamfer_edges( reference_edge: Edge | None, edges: tuple[Edge, Edge] @@ -4066,29 +4101,31 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): ) return self - def trim(self: Wire, start: float, end: float) -> Wire: + def trim(self: Wire, start: float | VectorLike, end: float | VectorLike) -> Wire: """Trim a wire between [start, end] normalized over total length. Args: - start (float): normalized start position (0.0 to <1.0) - end (float): normalized end position (>0.0 to 1.0) + start (float | VectorLike): normalized start position (0.0 to <1.0) or point + end (float | VectorLike): normalized end position (>0.0 to 1.0) or point Returns: Wire: trimmed Wire """ - if start >= end: - raise ValueError("start must be less than end") + start_u = Mixin1D._to_param(self, start, "start") + end_u = Mixin1D._to_param(self, end, "end") + + start_u, end_u = sorted([start_u, end_u]) # Extract the edges in order ordered_edges = self.edges().sort_by(self) # If this is really just an edge, skip the complexity of a Wire if len(ordered_edges) == 1: - return Wire([ordered_edges[0].trim(start, end)]) + return Wire([ordered_edges[0].trim(start_u, end_u)]) total_length = self.length - start_len = start * total_length - end_len = end * total_length + start_len = start_u * total_length + end_len = end_u * total_length trimmed_edges = [] cur_length = 0.0 diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py index fb60a7d..6f06f68 100644 --- a/tests/test_direct_api/test_edge.py +++ b/tests/test_direct_api/test_edge.py @@ -37,7 +37,7 @@ from build123d.geometry import Axis, Plane, Vector from build123d.objects_curve import CenterArc, EllipticalCenterArc from build123d.objects_sketch import Circle, Rectangle, RegularPolygon from build123d.operations_generic import sweep -from build123d.topology import Edge, Face, Wire +from build123d.topology import Edge, Face, Wire, Vertex from OCP.GeomProjLib import GeomProjLib @@ -183,8 +183,23 @@ class TestEdge(unittest.TestCase): line = Edge.make_line((-2, 0), (2, 0)) self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5) self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5) - with self.assertRaises(ValueError): - line.trim(0.75, 0.25) + + l1 = CenterArc((0, 0), 1, 0, 180) + l2 = l1.trim(0, l1 @ 0.5) + self.assertAlmostEqual(l2 @ 0, (1, 0, 0), 5) + self.assertAlmostEqual(l2 @ 1, (0, 1, 0), 5) + + l3 = l1.trim((1, 0), (0, 1)) + self.assertAlmostEqual(l3 @ 0, (1, 0, 0), 5) + self.assertAlmostEqual(l3 @ 1, (0, 1, 0), 5) + + l4 = l1.trim(0.5, (-1, 0)) + self.assertAlmostEqual(l4 @ 0, (0, 1, 0), 5) + self.assertAlmostEqual(l4 @ 1, (-1, 0, 0), 5) + + l5 = l1.trim(0.5, Vertex(-1, 0)) + self.assertAlmostEqual(l5 @ 0, (0, 1, 0), 5) + self.assertAlmostEqual(l5 @ 1, (-1, 0, 0), 5) line.wrapped = None with self.assertRaises(ValueError): @@ -213,6 +228,10 @@ class TestEdge(unittest.TestCase): e4_trim = Edge(a4).trim_to_length(0.5, 2) self.assertAlmostEqual(e4_trim.length, 2, 5) + e5 = e1.trim_to_length((5, 5), 1) + self.assertAlmostEqual(e5 @ 0, (5, 5), 5) + self.assertAlmostEqual(e5.length, 1, 5) + e1.wrapped = None with self.assertRaises(ValueError): e1.trim_to_length(0.1, 2) diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py index cbb9449..bbfb6fc 100644 --- a/tests/test_direct_api/test_wire.py +++ b/tests/test_direct_api/test_wire.py @@ -155,8 +155,10 @@ class TestWire(unittest.TestCase): t4 = o.trim(0.5, 0.75) self.assertAlmostEqual(t4.length, o.length * 0.25, 5) - with self.assertRaises(ValueError): - o.trim(0.75, 0.25) + w0 = Polyline((0, 0), (0, 1), (1, 1), (1, 0)) + w2 = w0.trim(0, (0.5, 1)) + self.assertAlmostEqual(w2 @ 1, (0.5, 1), 5) + spline = Spline( (0, 0, 0), (0, 10, 0), From 96ce15a1e101848bc39da0ba2786e251f06ddc0b Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Tue, 21 Oct 2025 09:49:24 -0500 Subject: [PATCH 25/28] .readthedocs.yaml -> fix tab title version on dev version builds --- .readthedocs.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9925d2f..44248cf 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,10 @@ build: python: "3.10" apt_packages: - graphviz + jobs: + post_checkout: + # necessary to ensure that the development builds get a correct version tag + - git fetch --unshallow || true # Build from the docs/ directory with Sphinx sphinx: @@ -21,8 +25,3 @@ python: path: . extra_requirements: - docs - -# Explicitly set the version of Python and its requirements -# python: -# install: -# - requirements: docs/requirements.txt From a649fab27cb7b0cfef8f731dfe153ed4c21b9db8 Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 23 Oct 2025 13:50:50 -0400 Subject: [PATCH 26/28] Improving attribution --- NOTICE | 15 +++++++++++++++ README.md | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 NOTICE diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2082252 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +build123d +Copyright (c) 2022–2025 The build123d Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at: + + http://www.apache.org/licenses/LICENSE-2.0 + +------------------------------------------------------------------------------- + +This project was originally derived from portions of the CadQuery codebase +(https://github.com/CadQuery/cadquery) but has since been extensively +refactored and restructured into an independent system. +CadQuery is licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 818210d..8b17f25 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,17 @@ [![DOI](https://zenodo.org/badge/510925389.svg)](https://doi.org/10.5281/zenodo.14872322) -Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks. +Build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to popular CAD tools such as [FreeCAD] and SolidWorks. -Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc. +Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers: +- Minimal or no internal state depending on mode, +- Explicit 1D, 2D, and 3D geometry classes with well-defined operations, +- Extensibility through subclassing and functional composition—no monkey patching, +- Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints, +- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)), +- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic. + +The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath. The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html). @@ -62,6 +70,10 @@ python3 -m pip install -e . Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html). +Attribution: + +Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system. + [BREP]: https://en.wikipedia.org/wiki/Boundary_representation [CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html [FreeCAD]: https://www.freecad.org/ From 70310ddd4afff0196f33ccc32f4cea81fbb6c7c9 Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 23 Oct 2025 14:34:11 -0400 Subject: [PATCH 27/28] Shortened and removed CQ reference --- docs/index.rst | 78 +++++++++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 0af6014..8c0c70e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,68 +29,54 @@ :align: center :alt: build123d logo -Build123d is a python-based, parametric, boundary representation (BREP) modeling -framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and -allows for the creation of complex models using a simple and intuitive python -syntax. Build123d can be used to create models for 3D printing, CNC machining, -laser cutting, and other manufacturing processes. Models can be exported to a -wide variety of popular CAD tools such as FreeCAD and SolidWorks. - -Build123d could be considered as an evolution of -`CadQuery `_ where the -somewhat restrictive Fluent API (method chaining) is replaced with stateful -context managers - i.e. `with` blocks - thus enabling the full python toolbox: -for loops, references to objects, object sorting and filtering, etc. - -Note that this documentation is available in -`pdf `_ and -`epub `_ formats -for reference while offline. - ######## -Overview +About ######## -build123d uses the standard python context manager - i.e. the ``with`` statement often used when -working with files - as a builder of the object under construction. Once the object is complete -it can be extracted from the builders and used in other ways: for example exported as a STEP -file or used in an Assembly. There are three builders available: +Build123d is a Python-based, parametric (BREP) modeling framework for 2D and 3D CAD. +Built on the Open Cascade geometric kernel, it provides a clean, fully Pythonic interface +for creating precise models suitable for 3D printing, CNC machining, laser cutting, and +other manufacturing processes. Models can be exported to popular CAD tools such as FreeCAD +and SolidWorks. -* **BuildLine**: a builder of one dimensional objects - those with the property - of length but not of area or volume - typically used - to create complex lines used in sketches or paths. -* **BuildSketch**: a builder of planar two dimensional objects - those with the property - of area but not of volume - typically used to create 2D drawings that are extruded into 3D parts. -* **BuildPart**: a builder of three dimensional objects - those with the property of volume - - used to create individual parts. +Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with +expressive, algebraic modeling. It offers: -The three builders work together in a hierarchy as follows: +* Minimal or no internal state depending on mode +* Explicit 1D, 2D, and 3D geometry classes with well-defined operations +* Extensibility through subclassing and functional composition—no monkey patching +* Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints +* Deep Python integration—selectors as lists, locations as iterables, and natural + conversions (``Solid(shell)``, ``tuple(Vector)``) +* Operator-driven modeling (``obj += sub_obj``, ``Plane.XZ * Pos(X=5) * Rectangle(1, 1)``) + for algebraic, readable, and composable design logic -.. code-block:: python +The result is a framework that feels native to Python while providing the full power of +OpenCascade geometry underneath. - with BuildPart() as my_part: - ... - with BuildSketch() as my_sketch: - ... - with BuildLine() as my_line: - ... - ... - ... -where ``my_line`` will be added to ``my_sketch`` once the line is complete and ``my_sketch`` will be -added to ``my_part`` once the sketch is complete. +With build123d, intricate parametric models can be created in just a few lines of readable +Python code—as demonstrated by the tea cup example below. -As an example, consider the design of a tea cup: +.. dropdown:: Teacup Example -.. literalinclude:: ../examples/tea_cup.py - :start-after: [Code] - :end-before: [End] + .. literalinclude:: ../examples/tea_cup.py + :start-after: [Code] + :end-before: [End] .. raw:: html +.. note:: + + + This documentation is available in + `pdf `_ and + `epub `_ formats + for reference while offline. + .. note:: There is a `Discord `_ server (shared with CadQuery) where From 696e99c8891745f54c509c28383b6e3ee6c9b88d Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 24 Oct 2025 18:34:11 -0400 Subject: [PATCH 28/28] Improving Face creation - fix inner Wires --- src/build123d/topology/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index c1bbb1e..dbccc80 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -263,7 +263,10 @@ def _make_topods_face_from_wires( for inner_wire in inner_wires: if not BRep_Tool.IsClosed_s(inner_wire): raise ValueError("Cannot build face(s): inner wire is not closed") - face_builder.Add(inner_wire) + sf_s = ShapeFix_Shape(inner_wire) + sf_s.Perform() + fixed_inner_wire = TopoDS.Wire_s(sf_s.Shape()) + face_builder.Add(fixed_inner_wire) face_builder.Build()