From bfd7968b8097ba079494a9ee36c74c3e1215d3d5 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sat, 6 Sep 2025 19:30:11 -0400 Subject: [PATCH 01/51] Initial constrained tangent code --- src/build123d/__init__.py | 2 + src/build123d/build_enums.py | 33 ++- src/build123d/topology/one_d.py | 352 ++++++++++++++++++++++++++++---- 3 files changed, 346 insertions(+), 41 deletions(-) diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index a2ccbfc..a7aa36c 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -55,11 +55,13 @@ __all__ = [ "Intrinsic", "Keep", "Kind", + "LengthConstraint", "LengthMode", "MeshType", "Mode", "NumberDisplay", "PageSize", + "PositionConstraint", "PositionMode", "PrecisionMode", "Select", diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index 8cca982..a912381 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -29,9 +29,15 @@ license: from __future__ import annotations from enum import Enum, auto, IntEnum, unique -from typing import Union +from typing import TypeAlias, Union -from typing import TypeAlias +from OCP.GccEnt import ( + GccEnt_unqualified, + GccEnt_enclosing, + GccEnt_enclosed, + GccEnt_outside, + GccEnt_noqualifier, +) class Align(Enum): @@ -248,6 +254,17 @@ class FontStyle(Enum): return f"<{self.__class__.__name__}.{self.name}>" +class LengthConstraint(Enum): + """Length Constraint for sagatti selection""" + + SHORT = 0 + LONG = -1 + BOTH = 1 + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class LengthMode(Enum): """Method of specifying length along PolarLine""" @@ -303,6 +320,18 @@ class PageSize(Enum): return f"<{self.__class__.__name__}.{self.name}>" +class PositionConstraint(Enum): + """Position Constraint for edge selection""" + + UNQUALIFIED = GccEnt_unqualified + ENCLOSING = GccEnt_enclosing + ENCLOSED = GccEnt_enclosed + OUTSIDE = GccEnt_outside + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class PositionMode(Enum): """Position along curve mode""" diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index e019df7..1ed4a7e 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -53,17 +53,15 @@ from __future__ import annotations import copy import itertools -import numpy as np import warnings from collections.abc import Iterable from itertools import combinations -from math import radians, inf, pi, cos, copysign, ceil, floor, isclose +from math import ceil, copysign, cos, floor, inf, isclose, pi, radians +from typing import TYPE_CHECKING, Literal from typing import cast as tcast -from typing import Literal, overload, TYPE_CHECKING -from typing_extensions import Self -from scipy.optimize import minimize_scalar -from scipy.spatial import ConvexHull +from typing import overload +import numpy as np import OCP.TopAbs as ta from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve @@ -76,6 +74,7 @@ from OCP.BRepBuilderAPI import ( BRepBuilderAPI_DisconnectedWire, BRepBuilderAPI_EmptyWire, BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeEdge2d, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeWire, @@ -92,29 +91,45 @@ from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepProj import BRepProj_Projection from OCP.BRepTools import BRepTools, BRepTools_WireExplorer from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse +from OCP.GccEnt import GccEnt_unqualified, GccEnt_Position from OCP.GCPnts import GCPnts_AbscissaPoint -from OCP.GProp import GProp_GProps from OCP.Geom import ( Geom_BezierCurve, Geom_BSplineCurve, Geom_ConicalSurface, Geom_CylindricalSurface, + Geom_Line, Geom_Plane, Geom_Surface, Geom_TrimmedCurve, - Geom_Line, ) -from OCP.Geom2d import Geom2d_Curve, Geom2d_Line, Geom2d_TrimmedCurve +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_InterCurveCurve -from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_C1, GeomAbs_G2, GeomAbs_C2 +from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve +from OCP.GeomAbs import ( + GeomAbs_C0, + GeomAbs_C1, + GeomAbs_C2, + GeomAbs_G1, + GeomAbs_G2, + GeomAbs_JoinType, +) +from OCP.GeomAdaptor import GeomAdaptor_Curve from OCP.GeomAPI import ( + GeomAPI, GeomAPI_IntCS, GeomAPI_Interpolate, GeomAPI_PointsToBSpline, GeomAPI_ProjectPointOnCurve, ) -from OCP.GeomAbs import GeomAbs_JoinType -from OCP.GeomAdaptor import GeomAdaptor_Curve from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve from OCP.GeomFill import ( GeomFill_CorrectedFrenet, @@ -122,30 +137,40 @@ from OCP.GeomFill import ( GeomFill_TrihedronLaw, ) from OCP.GeomProjLib import GeomProjLib +from OCP.gp import ( + gp_Ax1, + gp_Ax2, + gp_Ax3, + gp_Circ, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Elips, + gp_Pln, + gp_Pnt, + gp_Pnt2d, + gp_Trsf, + gp_Vec, +) +from OCP.GProp import GProp_GProps from OCP.HLRAlgo import HLRAlgo_Projector from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe from OCP.Standard import ( + Standard_ConstructionError, Standard_Failure, Standard_NoSuchObject, - Standard_ConstructionError, ) +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt from OCP.TColStd import ( TColStd_Array1OfReal, TColStd_HArray1OfBoolean, TColStd_HArray1OfReal, ) -from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum from OCP.TopExp import TopExp, TopExp_Explorer from OCP.TopLoc import TopLoc_Location -from OCP.TopTools import ( - TopTools_HSequenceOfShape, - TopTools_IndexedDataMapOfShapeListOfShape, - TopTools_IndexedMapOfShape, - TopTools_ListOfShape, -) from OCP.TopoDS import ( TopoDS, TopoDS_Compound, @@ -156,34 +181,33 @@ from OCP.TopoDS import ( TopoDS_Vertex, TopoDS_Wire, ) -from OCP.gp import ( - gp_Ax1, - gp_Ax2, - gp_Ax3, - gp_Circ, - gp_Dir, - gp_Dir2d, - gp_Elips, - gp_Pnt, - gp_Pnt2d, - gp_Trsf, - gp_Vec, +from OCP.TopTools import ( + TopTools_HSequenceOfShape, + TopTools_IndexedDataMapOfShapeListOfShape, + TopTools_IndexedMapOfShape, + TopTools_ListOfShape, ) +from scipy.optimize import minimize_scalar +from scipy.spatial import ConvexHull +from typing_extensions import Self + from build123d.build_enums import ( AngularDirection, - ContinuityLevel, CenterOf, + ContinuityLevel, FrameMethod, GeomType, Keep, Kind, + LengthConstraint, + PositionConstraint, PositionMode, Side, ) from build123d.geometry import ( DEG2RAD, - TOLERANCE, TOL_DIGITS, + TOLERANCE, Axis, Color, Location, @@ -206,17 +230,16 @@ from .shape_core import ( ) from .utils import ( _extrude_topods_shape, - isclose_b, _make_topods_face_from_wires, _topods_bool_op, + isclose_b, ) -from .zero_d import topo_explore_common_vertex, Vertex - +from .zero_d import Vertex, topo_explore_common_vertex if TYPE_CHECKING: # pragma: no cover - from .two_d import Face, Shell # pylint: disable=R0801 + from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801 from .three_d import Solid # pylint: disable=R0801 - from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 + from .two_d import Face, Shell # pylint: disable=R0801 class Mixin1D(Shape): @@ -1885,6 +1908,257 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) + @classmethod + # def make_tangent_arcs( + # cls, + # object_one: tuple[Edge,PositionConstraint] | Vertex | VectorLike, + # object_two: tuple[Edge,PositionConstraint] | Vertex | VectorLike, + # radius: float, + # sagitta_constraint: LengthConstraint = LengthConstraint.SHORT + # ) -> ShapeList[Edge]: + + def make_tangent_arcs( + cls, + object_one: Edge | Vertex | VectorLike, + object_two: Edge | Vertex | VectorLike, + radius: float, + constaints: tuple[PositionConstraint, PositionConstraint, LengthConstraint] = ( + PositionConstraint.UNQUALIFIED, + PositionConstraint.UNQUALIFIED, + LengthConstraint.SHORT, + ), + ) -> ShapeList[Edge]: + """ + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. + + Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. + + Args: + object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + radius (float): Circle radius for all candidate solutions. + + Raises: + ValueError: Invalid input + ValueError: Invalid curve + RuntimeError: no valid circle solutions found + + Returns: + ShapeList[Edge]: A list of planar circular edges (on XY) representing both + the minor and major arcs between the two tangency points for every valid + circle solution. + + """ + # Reuse a single XY plane for 3D->2D projection and for 2D-edge building + _pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) + _surf_xy = Geom_Plane(_pln_xy) + + # --------------------------- + # Normalization utilities + # --------------------------- + def _norm_on_period(u: float, first: float, per: float) -> float: + """Map parameter u into [first, first+per).""" + if per <= 0.0: + return u + k = floor((u - first) / per) + return u - k * per + + def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: + """ + Forward (positive) delta from u1 to u2 on a periodic domain anchored at + 'first'. + """ + u1n = _norm_on_period(u1, first, period) + u2n = _norm_on_period(u2, first, period) + delta = u2n - u1n + if delta < 0.0: + delta += period + return delta + + # --------------------------- + # Core helpers + # --------------------------- + def _edge_to_qualified_2d( + edge: TopoDS_Edge, position_constaint: PositionConstraint + ) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: + """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) + + if hcurve3d is None: + raise ValueError("Edge has no underlying 3D curve.") + + # 2) Apply location if the edge is positioned by a TopLoc_Location + if not loc.IsIdentity(): + trsf = loc.Transformation() + hcurve3d = hcurve3d.Transformed(trsf) + + # 3) 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 + adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) + + # 5) Create the qualified curve (unqualified is fine here) + qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) + return qcurve, hcurve2d, first, last + + def _edge_from_circle( + h2d_circle: Geom2d_Circle, u1: float, u2: float + ) -> TopoDS_Edge: + """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" + arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True + return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() + + def _param_in_trim( + u: float, first: float, last: float, h2d: Geom2d_Curve + ) -> bool: + """Normalize (if periodic) then test [first, last] with tolerance.""" + u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u + return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) + + def _as_gcc_arg( + obj: Edge | Vertex | VectorLike, constaint: PositionConstraint + ) -> tuple[ + Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, + Geom2d_Curve | None, + float | None, + float | None, + bool, + ]: + """ + Normalize input to a GCC argument. + Returns: (q_obj, h2d, first, last, is_edge) + - Edge -> (QualifiedCurve, h2d, first, last, True) + - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) + """ + if isinstance(obj, Edge): + return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) + + loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() + try: + base = Vector(obj) + except (TypeError, ValueError) as exc: + raise ValueError("Expected Edge | Vertex | VectorLike") from exc + + gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + return Geom2d_CartesianPoint(gp_pnt), None, None, None, False + + def _two_arc_edges_from_params( + circ: gp_Circ2d, u1: float, u2: float + ) -> ShapeList[Edge]: + """ + Given two parameters on a circle, return both the forward (minor) + and complementary (major) arcs as TopoDS_Edge(s). + Uses centralized normalization utilities. + """ + h2d_circle = Geom2d_Circle(circ) + per = h2d_circle.Period() # usually 2*pi + + # Minor (forward) span + d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience + u1n = _norm_on_period(u1, 0.0, per) + u2n = _norm_on_period(u2, 0.0, per) + + # Guard degeneracy + if d <= TOLERANCE or abs(per - d) <= TOLERANCE: + return ShapeList() + + minor = _edge_from_circle(h2d_circle, u1n, u1n + d) + major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) + return ShapeList([Edge(minor), Edge(major)]) + + def _qstr(q) -> str: + # Works with OCP's GccEnt enum values + try: + from OCP.GccEnt import ( + GccEnt_enclosed, + GccEnt_enclosing, + GccEnt_outside, + ) + + try: + from OCP.GccEnt import GccEnt_unqualified + except ImportError: + # Some OCCT versions name this 'noqualifier' + from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified + mapping = { + GccEnt_enclosed: "enclosed", + GccEnt_enclosing: "enclosing", + GccEnt_outside: "outside", + GccEnt_unqualified: "unqualified", + } + return mapping.get(q, f"unknown({int(q)})") + except Exception: + # Fallback if enums aren't importable for any reason + return str(int(q)) + + # --------------------------- + # Build inputs and GCC + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, constaints[0]) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, constaints[1]) + + # Put the Edge arg first when exactly one is an Edge (improves robustness) + if is_edge1 ^ is_edge2: + q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) + + gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc") + + def _valid_on_arg1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + def _valid_on_arg2(u: float) -> bool: + return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + + # --------------------------- + # Solutions + # --------------------------- + solutions: list[Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _valid_on_arg1(u_arg1): + continue + + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _valid_on_arg2(u_arg2): + continue + + qual1 = GccEnt_Position(int()) + qual2 = GccEnt_Position(int()) + gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values + print( + f"Solution {i}: " + f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " + f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " + f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" + ) + + # Build BOTH sagitta arcs and select by LengthConstraint + if constaints[2].value == LengthConstraint.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + solutions.append( + _two_arc_edges_from_params(circ, u_circ1, u_circ2).sort_by( + Edge.length + )[constaints[2].value] + ) + return ShapeList(solutions) + @classmethod def make_three_point_arc( cls, point1: VectorLike, point2: VectorLike, point3: VectorLike From f489854425e70ca756ce7216a1bf2730ef716127 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sun, 7 Sep 2025 11:49:16 -0400 Subject: [PATCH 02/51] Restructuring to utils --- src/build123d/topology/one_d.py | 40 +- src/build123d/topology/utils.py | 757 +++++++++++++++++--------------- 2 files changed, 425 insertions(+), 372 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 1ed4a7e..3eea653 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1909,24 +1909,12 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) @classmethod - # def make_tangent_arcs( - # cls, - # object_one: tuple[Edge,PositionConstraint] | Vertex | VectorLike, - # object_two: tuple[Edge,PositionConstraint] | Vertex | VectorLike, - # radius: float, - # sagitta_constraint: LengthConstraint = LengthConstraint.SHORT - # ) -> ShapeList[Edge]: - def make_tangent_arcs( cls, - object_one: Edge | Vertex | VectorLike, - object_two: Edge | Vertex | VectorLike, + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, radius: float, - constaints: tuple[PositionConstraint, PositionConstraint, LengthConstraint] = ( - PositionConstraint.UNQUALIFIED, - PositionConstraint.UNQUALIFIED, - LengthConstraint.SHORT, - ), + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: """ Create all planar circular arcs of a given radius that are tangent/contacting @@ -1952,6 +1940,16 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): circle solution. """ + + if isinstance(object_1, tuple): + object_one, object_one_constraint = object_1 + else: + object_one = object_1 + if isinstance(object_2, tuple): + object_two, object_two_constraint = object_2 + else: + object_two = object_2 + # Reuse a single XY plane for 3D->2D projection and for 2D-edge building _pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) _surf_xy = Geom_Plane(_pln_xy) @@ -2102,8 +2100,12 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): # --------------------------- # Build inputs and GCC # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, constaints[0]) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, constaints[1]) + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( + object_one, object_one_constraint + ) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( + object_two, object_two_constraint + ) # Put the Edge arg first when exactly one is an Edge (improves robustness) if is_edge1 ^ is_edge2: @@ -2149,13 +2151,13 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): ) # Build BOTH sagitta arcs and select by LengthConstraint - if constaints[2].value == LengthConstraint.BOTH: + if sagitta_constraint == LengthConstraint.BOTH: solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: solutions.append( _two_arc_edges_from_params(circ, u_circ1, u_circ2).sort_by( Edge.length - )[constaints[2].value] + )[sagitta_constraint.value] ) return ShapeList(solutions) diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index c1bbb1e..6fd50c5 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -3,35 +3,11 @@ build123d topology name: utils.py by: Gumyr -date: January 07, 2025 +date: September 07, 2025 desc: -This module provides utility functions and helper classes for the build123d CAD library, enabling -advanced geometric operations and facilitating the use of the OpenCascade CAD kernel. It complements -the core library by offering reusable and modular tools for manipulating shapes, performing Boolean -operations, and validating geometry. - -Key Features: -- **Geometric Utilities**: - - `polar`: Converts polar coordinates to Cartesian. - - `tuplify`: Normalizes inputs into consistent tuples. - - `find_max_dimension`: Computes the maximum bounding dimension of shapes. - -- **Shape Creation**: - - `_make_loft`: Creates lofted shapes from wires and vertices. - - `_make_topods_compound_from_shapes`: Constructs compounds from multiple shapes. - - `_make_topods_face_from_wires`: Generates planar faces with optional holes. - -- **Boolean Operations**: - - `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes. - - `new_edges`: Identifies newly created edges from combined shapes. - -- **Enhanced Math**: - - `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance. - -This module is a critical component of build123d, supporting complex CAD workflows and geometric -transformations while maintaining a clean, extensible API. +This module houses utilities used within the topology modules. license: @@ -53,378 +29,453 @@ license: from __future__ import annotations -from math import radians, sin, cos, isclose -from typing import Any, TYPE_CHECKING - +import copy +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 typing import Callable, TypeVar, TYPE_CHECKING, Literal +from typing import cast as tcast +from typing import overload +import numpy as np +import OCP.TopAbs as ta from OCP.BRep import BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve from OCP.BRepAlgoAPI import ( - BRepAlgoAPI_BooleanOperation, - BRepAlgoAPI_Cut, + BRepAlgoAPI_Common, + BRepAlgoAPI_Section, BRepAlgoAPI_Splitter, ) -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace -from OCP.BRepLib import BRepLib_FindSurface -from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections -from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism -from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape -from OCP.TopAbs import TopAbs_ShapeEnum -from OCP.TopExp import TopExp_Explorer -from OCP.TopTools import TopTools_ListOfShape +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_DisconnectedWire, + BRepBuilderAPI_EmptyWire, + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeEdge2d, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakePolygon, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_NonManifoldWire, +) +from OCP.BRepExtrema import BRepExtrema_DistShapeShape, BRepExtrema_SupportType +from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d +from OCP.BRepGProp import BRepGProp, BRepGProp_Face +from OCP.BRepLib import BRepLib, BRepLib_FindSurface +from OCP.BRepLProp import BRepLProp +from OCP.BRepOffset import BRepOffset_MakeOffset +from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace +from OCP.BRepProj import BRepProj_Projection +from OCP.BRepTools import BRepTools, BRepTools_WireExplorer +from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse +from OCP.GccEnt import GccEnt_unqualified, GccEnt_Position +from OCP.GCPnts import GCPnts_AbscissaPoint +from OCP.Geom import ( + Geom_BezierCurve, + Geom_BSplineCurve, + Geom_ConicalSurface, + Geom_CylindricalSurface, + Geom_Line, + Geom_Plane, + Geom_Surface, + Geom_TrimmedCurve, +) +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_InterCurveCurve +from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve +from OCP.GeomAbs import ( + GeomAbs_C0, + GeomAbs_C1, + GeomAbs_C2, + GeomAbs_G1, + GeomAbs_G2, + GeomAbs_JoinType, +) +from OCP.GeomAdaptor import GeomAdaptor_Curve +from OCP.GeomAPI import ( + GeomAPI, + GeomAPI_IntCS, + GeomAPI_Interpolate, + GeomAPI_PointsToBSpline, + GeomAPI_ProjectPointOnCurve, +) +from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve +from OCP.GeomFill import ( + GeomFill_CorrectedFrenet, + GeomFill_Frenet, + GeomFill_TrihedronLaw, +) +from OCP.GeomProjLib import GeomProjLib +from OCP.gp import ( + gp_Ax1, + gp_Ax2, + gp_Ax3, + gp_Circ, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Elips, + gp_Pln, + gp_Pnt, + gp_Pnt2d, + gp_Trsf, + gp_Vec, +) +from OCP.GProp import GProp_GProps +from OCP.HLRAlgo import HLRAlgo_Projector +from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape +from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds +from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe +from OCP.Standard import ( + Standard_ConstructionError, + Standard_Failure, + Standard_NoSuchObject, +) +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt +from OCP.TColStd import ( + TColStd_Array1OfReal, + TColStd_HArray1OfBoolean, + TColStd_HArray1OfReal, +) +from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum +from OCP.TopExp import TopExp, TopExp_Explorer +from OCP.TopLoc import TopLoc_Location from OCP.TopoDS import ( TopoDS, - TopoDS_Builder, TopoDS_Compound, + TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Vertex, - TopoDS_Edge, TopoDS_Wire, ) -from build123d.geometry import TOLERANCE, BoundBox, Vector, VectorLike +from OCP.TopTools import ( + TopTools_HSequenceOfShape, + TopTools_IndexedDataMapOfShapeListOfShape, + TopTools_IndexedMapOfShape, + TopTools_ListOfShape, +) +from scipy.optimize import minimize_scalar +from scipy.spatial import ConvexHull +from typing_extensions import Self -from .shape_core import Shape, ShapeList, downcast, shapetype, unwrap_topods_compound +from build123d.build_enums import ( + AngularDirection, + CenterOf, + ContinuityLevel, + FrameMethod, + GeomType, + Keep, + Kind, + LengthConstraint, + PositionConstraint, + PositionMode, + Side, +) +from build123d.geometry import ( + DEG2RAD, + TOL_DIGITS, + TOLERANCE, + Axis, + Color, + Location, + Plane, + Vector, + VectorLike, + logger, +) + +from .shape_core import ( + Shape, + ShapeList, + SkipClean, + TrimmingTool, + downcast, + get_top_level_topods_shapes, + shapetype, + topods_dim, + unwrap_topods_compound, +) +from .utils import ( + _extrude_topods_shape, + _make_topods_face_from_wires, + _topods_bool_op, + isclose_b, +) +from .zero_d import Vertex, topo_explore_common_vertex + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from build123d.topology.one_d import Edge + +TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass) + +# Reuse a single XY plane for 3D->2D projection and for 2D-edge building +_pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) +_surf_xy = Geom_Plane(_pln_xy) -if TYPE_CHECKING: # pragma: no cover - from .zero_d import Vertex # pylint: disable=R0801 - from .one_d import Edge, Wire # pylint: disable=R0801 +# --------------------------- +# Normalization utilities +# --------------------------- +def _norm_on_period(u: float, first: float, per: float) -> float: + """Map parameter u into [first, first+per).""" + if per <= 0.0: + return u + k = floor((u - first) / per) + return u - k * per -def _extrude_topods_shape(obj: TopoDS_Shape, direction: VectorLike) -> TopoDS_Shape: - """extrude - - Extrude a Shape in the provided direction. - * Vertices generate Edges - * Edges generate Faces - * Wires generate Shells - * Faces generate Solids - * Shells generate Compounds - - Args: - direction (VectorLike): direction and magnitude of extrusion - - Raises: - ValueError: Unsupported class - RuntimeError: Generated invalid result - - Returns: - TopoDS_Shape: extruded shape +def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: """ - direction = Vector(direction) - - if obj is None or not isinstance( - obj, - (TopoDS_Vertex, TopoDS_Edge, TopoDS_Wire, TopoDS_Face, TopoDS_Shell), - ): - raise ValueError(f"extrude not supported for {type(obj)}") - - prism_builder = BRepPrimAPI_MakePrism(obj, direction.wrapped) - extrusion = downcast(prism_builder.Shape()) - shape_type = extrusion.ShapeType() - if shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID: - solids = [] - explorer = TopExp_Explorer(extrusion, TopAbs_ShapeEnum.TopAbs_SOLID) - while explorer.More(): - solids.append(downcast(explorer.Current())) - explorer.Next() - extrusion = _make_topods_compound_from_shapes(solids) - return extrusion - - -def _make_loft( - objs: Iterable[Vertex | Wire], - filled: bool, - ruled: bool = False, -) -> TopoDS_Shape: - """make loft - - Makes a loft from a list of wires and vertices. Vertices can appear only at the - beginning or end of the list, but cannot appear consecutively within the list - nor between wires. - - Args: - wires (list[Wire]): section perimeters - ruled (bool, optional): stepped or smooth. Defaults to False (smooth). - - Raises: - ValueError: Too few wires - - Returns: - TopoDS_Shape: Lofted object + Forward (positive) delta from u1 to u2 on a periodic domain anchored at + 'first'. """ - objs = list(objs) # To determine its length - if len(objs) < 2: - raise ValueError("More than one wire is required") - vertices = [obj for obj in objs if isinstance(obj.wrapped, TopoDS_Vertex)] - vertex_count = len(vertices) + u1n = _norm_on_period(u1, first, period) + u2n = _norm_on_period(u2, first, period) + delta = u2n - u1n + if delta < 0.0: + delta += period + return delta - if vertex_count > 2: - raise ValueError("Only two vertices are allowed") - if vertex_count == 1 and not ( - isinstance(objs[0].wrapped, TopoDS_Vertex) - or isinstance(objs[-1].wrapped, TopoDS_Vertex) - ): - raise ValueError( - "The vertex must be either at the beginning or end of the list" +# --------------------------- +# Core helpers +# --------------------------- +def _edge_to_qualified_2d( + edge: TopoDS_Edge, position_constaint: PositionConstraint +) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: + """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) + + if hcurve3d is None: + raise ValueError("Edge has no underlying 3D curve.") + + # 2) Apply location if the edge is positioned by a TopLoc_Location + if not loc.IsIdentity(): + trsf = loc.Transformation() + hcurve3d = hcurve3d.Transformed(trsf) + + # 3) 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 + adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) + + # 5) Create the qualified curve (unqualified is fine here) + qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) + return qcurve, hcurve2d, first, last + + +def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge: + """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" + arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True + return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() + + +def _param_in_trim(u: float, first: float, last: float, h2d: Geom2d_Curve) -> bool: + """Normalize (if periodic) then test [first, last] with tolerance.""" + u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u + return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) + + +def _as_gcc_arg( + obj: Edge | Vertex | VectorLike, constaint: PositionConstraint +) -> tuple[ + Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, + Geom2d_Curve | None, + float | None, + float | None, + bool, +]: + """ + Normalize input to a GCC argument. + Returns: (q_obj, h2d, first, last, is_edge) + - Edge -> (QualifiedCurve, h2d, first, last, True) + - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) + """ + if isinstance(obj.wrapped, TopoDS_Edge): + return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) + + loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() + try: + base = Vector(obj) + except (TypeError, ValueError) as exc: + raise ValueError("Expected Edge | Vertex | VectorLike") from exc + + gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + return Geom2d_CartesianPoint(gp_pnt), None, None, None, False + + +def _two_arc_edges_from_params( + circ: gp_Circ2d, u1: float, u2: float +) -> ShapeList[Edge]: + """ + Given two parameters on a circle, return both the forward (minor) + and complementary (major) arcs as TopoDS_Edge(s). + Uses centralized normalization utilities. + """ + h2d_circle = Geom2d_Circle(circ) + per = h2d_circle.Period() # usually 2*pi + + # Minor (forward) span + d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience + u1n = _norm_on_period(u1, 0.0, per) + u2n = _norm_on_period(u2, 0.0, per) + + # Guard degeneracy + if d <= TOLERANCE or abs(per - d) <= TOLERANCE: + return ShapeList() + + minor = _edge_from_circle(h2d_circle, u1n, u1n + d) + major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) + return ShapeList([Edge(minor), Edge(major)]) + + +def _qstr(q) -> str: + # Works with OCP's GccEnt enum values + try: + from OCP.GccEnt import ( + GccEnt_enclosed, + GccEnt_enclosing, + GccEnt_outside, ) - if vertex_count == 2: - if len(objs) == 2: - raise ValueError( - "You can't have only 2 vertices to loft; try adding some wires" - ) - if not ( - isinstance(objs[0].wrapped, TopoDS_Vertex) - and isinstance(objs[-1].wrapped, TopoDS_Vertex) - ): - raise ValueError( - "The vertices must be at the beginning and end of the list" - ) - - loft_builder = BRepOffsetAPI_ThruSections(filled, ruled) - - for obj in objs: - if isinstance(obj.wrapped, TopoDS_Vertex): - loft_builder.AddVertex(obj.wrapped) - elif isinstance(obj.wrapped, TopoDS_Wire): - loft_builder.AddWire(obj.wrapped) - - loft_builder.Build() - - return loft_builder.Shape() + try: + from OCP.GccEnt import GccEnt_unqualified + except ImportError: + # Some OCCT versions name this 'noqualifier' + from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified + mapping = { + GccEnt_enclosed: "enclosed", + GccEnt_enclosing: "enclosing", + GccEnt_outside: "outside", + GccEnt_unqualified: "unqualified", + } + return mapping.get(q, f"unknown({int(q)})") + except Exception: + # Fallback if enums aren't importable for any reason + return str(int(q)) -def _make_topods_compound_from_shapes( - occt_shapes: Iterable[TopoDS_Shape | None], -) -> TopoDS_Compound: - """Create an OCCT TopoDS_Compound - - Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects - - Args: - occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes - - Returns: - TopoDS_Compound: OCCT compound +def make_tangent_edges( + cls, + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + radius: float, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> list[TWrap]: """ - comp = TopoDS_Compound() - comp_builder = TopoDS_Builder() - comp_builder.MakeCompound(comp) + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. - for shape in occt_shapes: - if shape is not None: - comp_builder.Add(comp, shape) - - return comp - - -def _make_topods_face_from_wires( - outer_wire: TopoDS_Wire, inner_wires: Iterable[TopoDS_Wire] | None = None -) -> TopoDS_Face: - """_make_topods_face_from_wires - - Makes a planar face from one or more wires + Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. Args: - outer_wire (TopoDS_Wire): closed perimeter wire - inner_wires (Iterable[TopoDS_Wire], optional): holes. Defaults to None. + object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + radius (float): Circle radius for all candidate solutions. Raises: - ValueError: outer wire not closed - ValueError: wires not planar - ValueError: inner wire not closed - ValueError: internal error + ValueError: Invalid input + ValueError: Invalid curve + RuntimeError: no valid circle solutions found Returns: - TopoDS_Face: planar face potentially with holes - """ - if inner_wires and not BRep_Tool.IsClosed_s(outer_wire): - raise ValueError("Cannot build face(s): outer wire is not closed") - inner_wires = list(inner_wires) if inner_wires else [] + ShapeList[Edge]: A list of planar circular edges (on XY) representing both + the minor and major arcs between the two tangency points for every valid + circle solution. - # check if wires are coplanar - verification_compound = _make_topods_compound_from_shapes( - [outer_wire] + inner_wires + """ + + if isinstance(object_1, tuple): + object_one, object_one_constraint = object_1 + else: + object_one = object_1 + if isinstance(object_2, tuple): + object_two, object_two_constraint = object_2 + else: + object_two = object_2 + + # --------------------------- + # Build inputs and GCC + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( + object_one, object_one_constraint + ) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( + object_two, object_two_constraint ) - if not BRepLib_FindSurface(verification_compound, OnlyPlane=True).Found(): - raise ValueError("Cannot build face(s): wires not planar") - # fix outer wire - sf_s = ShapeFix_Shape(outer_wire) - sf_s.Perform() - topo_wire = TopoDS.Wire_s(sf_s.Shape()) + # Put the Edge arg first when exactly one is an Edge (improves robustness) + if is_edge1 ^ is_edge2: + q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) - face_builder = BRepBuilderAPI_MakeFace(topo_wire, True) + gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc") - 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) + def _valid_on_arg1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) - face_builder.Build() + def _valid_on_arg2(u: float) -> bool: + return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) - if not face_builder.IsDone(): - raise ValueError(f"Cannot build face(s): {face_builder.Error()}") + # --------------------------- + # Solutions + # --------------------------- + solutions: list[Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d - face = face_builder.Face() + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _valid_on_arg1(u_arg1): + continue - sf_f = ShapeFix_Face(face) - sf_f.FixOrientation() - sf_f.Perform() + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _valid_on_arg2(u_arg2): + continue - return TopoDS.Face_s(sf_f.Result()) + qual1 = GccEnt_Position(int()) + qual2 = GccEnt_Position(int()) + gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values + print( + f"Solution {i}: " + f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " + f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " + f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" + ) - -def _topods_bool_op( - args: Iterable[TopoDS_Shape], - tools: Iterable[TopoDS_Shape], - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, -) -> TopoDS_Shape: - """Generic boolean operation for TopoDS_Shapes - - Args: - args: Iterable[TopoDS_Shape]: - tools: Iterable[TopoDS_Shape]: - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: - - Returns: TopoDS_Shape - - """ - args = list(args) - tools = list(tools) - arg = TopTools_ListOfShape() - for obj in args: - arg.Append(obj) - - tool = TopTools_ListOfShape() - for obj in tools: - tool.Append(obj) - - operation.SetArguments(arg) - operation.SetTools(tool) - - operation.SetRunParallel(True) - operation.Build() - - result = downcast(operation.Shape()) - # Remove unnecessary TopoDS_Compound around single shape - if isinstance(result, TopoDS_Compound): - result = unwrap_topods_compound(result, True) - - return result - - -def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]: - """Compare the OCCT objects of each list and return the differences""" - shapes_one = list(shapes_one) - shapes_two = list(shapes_two) - occt_one = {shape.wrapped for shape in shapes_one} - occt_two = {shape.wrapped for shape in shapes_two} - occt_delta = list(occt_one - occt_two) - - all_shapes = [] - for shapes in [shapes_one, shapes_two]: - all_shapes.extend(shapes if isinstance(shapes, list) else [*shapes]) - shape_delta = [shape for shape in all_shapes if shape.wrapped in occt_delta] - return shape_delta - - -def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float: - """Return the maximum dimension of one or more shapes""" - shapes = shapes if isinstance(shapes, Iterable) else [shapes] - composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes]) - bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True) - return bbox.diagonal - - -def isclose_b(x: float, y: float, rel_tol=1e-9, abs_tol=1e-14) -> bool: - """Determine whether two floating point numbers are close in value. - Overridden abs_tol default for the math.isclose function. - - Args: - x (float): First value to compare - y (float): Second value to compare - rel_tol (float, optional): Maximum difference for being considered "close", - relative to the magnitude of the input values. Defaults to 1e-9. - abs_tol (float, optional): Maximum difference for being considered "close", - regardless of the magnitude of the input values. Defaults to 1e-14 - (unlike math.isclose which defaults to zero). - - Returns: True if a is close in value to b, and False otherwise. - """ - return isclose(x, y, rel_tol=rel_tol, abs_tol=abs_tol) - - -def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]: - """new_edges - - Given a sequence of shapes and the combination of those shapes, find the newly added edges - - Args: - objects (Shape): sequence of shapes - combined (Shape): result of the combination of objects - - Returns: - ShapeList[Edge]: new edges - """ - # Create a list of combined object edges - combined_topo_edges = TopTools_ListOfShape() - for edge in combined.edges(): - if edge.wrapped is not None: - combined_topo_edges.Append(edge.wrapped) - - # Create a list of original object edges - original_topo_edges = TopTools_ListOfShape() - for edge in [e for obj in objects for e in obj.edges()]: - if edge.wrapped is not None: - original_topo_edges.Append(edge.wrapped) - - # Cut the original edges from the combined edges - operation = BRepAlgoAPI_Cut() - operation.SetArguments(combined_topo_edges) - operation.SetTools(original_topo_edges) - operation.SetRunParallel(True) - operation.Build() - - edges = [] - explorer = TopExp_Explorer(operation.Shape(), TopAbs_ShapeEnum.TopAbs_EDGE) - while explorer.More(): - found_edge = combined.__class__.cast(downcast(explorer.Current())) - found_edge.topo_parent = combined - edges.append(found_edge) - explorer.Next() - - return ShapeList(edges) - - -def polar(length: float, angle: float) -> tuple[float, float]: - """Convert polar coordinates into cartesian coordinates""" - return (length * cos(radians(angle)), length * sin(radians(angle))) - - -def tuplify(obj: Any, dim: int) -> tuple | None: - """Create a size tuple""" - if obj is None: - result = None - elif isinstance(obj, (tuple, list)): - result = tuple(obj) - else: - result = tuple([obj] * dim) - return result - - -def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum: - """Return Shape's TopAbs_ShapeEnum""" - if isinstance(obj.wrapped, TopoDS_Compound): - shapetypes = {shapetype(o.wrapped) for o in obj} - if len(shapetypes) == 1: - result = shapetypes.pop() + # Build BOTH sagitta arcs and select by LengthConstraint + if sagitta_constraint == LengthConstraint.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: - result = shapetype(obj.wrapped) - else: - result = shapetype(obj.wrapped) - return result + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + arcs = sorted( + arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + ) + solutions.append(arcs[sagitta_constraint.value]) + return ShapeList([edge_factory(e) for e in solutions]) From 8b2886144ee3fe93aafc21f1a118d3eeb19410a6 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 8 Sep 2025 12:23:18 -0400 Subject: [PATCH 03/51] Initial commit of make_constrained_arcs --- src/build123d/topology/constrained_lines.py | 720 ++++++++++++++++++ src/build123d/topology/one_d.py | 485 ++++++------ src/build123d/topology/utils.py | 773 +++++++++----------- 3 files changed, 1311 insertions(+), 667 deletions(-) create mode 100644 src/build123d/topology/constrained_lines.py diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py new file mode 100644 index 0000000..d9ac708 --- /dev/null +++ b/src/build123d/topology/constrained_lines.py @@ -0,0 +1,720 @@ +""" +build123d topology + +name: constrained_lines.py +by: Gumyr +date: September 07, 2025 + +desc: + +This module generates lines and arcs that are constrained against other objects. + +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 under the License is 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. + +""" + +from __future__ import annotations + +from math import floor +from typing import 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.GCPnts import GCPnts_AbscissaPoint +from OCP.Geom import Geom_Plane +from OCP.Geom2d import ( + Geom2d_CartesianPoint, + Geom2d_Circle, + Geom2d_Curve, + Geom2d_TrimmedCurve, +) +from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve +from OCP.Geom2dGcc import ( + Geom2dGcc_Circ2d2TanOn, + Geom2dGcc_Circ2d2TanOnGeo, + Geom2dGcc_Circ2d2TanRad, + Geom2dGcc_Circ2d3Tan, + Geom2dGcc_Circ2dTanCen, + Geom2dGcc_Circ2dTanOnRad, + Geom2dGcc_Circ2dTanOnRadGeo, + Geom2dGcc_QualifiedCurve, +) +from OCP.GeomAbs import GeomAbs_CurveType +from OCP.GeomAPI import GeomAPI +from OCP.gp import ( + gp_Ax2d, + gp_Ax3, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Pln, + gp_Pnt, + gp_Pnt2d, +) +from OCP.TopoDS import TopoDS_Edge + +from build123d.build_enums import LengthConstraint, PositionConstraint +from build123d.geometry import TOLERANCE, Vector, VectorLike +from .zero_d import Vertex +from .shape_core import ShapeList + +if TYPE_CHECKING: + from build123d.topology.one_d import Edge + +TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass) + +# Reuse a single XY plane for 3D->2D projection and for 2D-edge building +_pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) +_surf_xy = Geom_Plane(_pln_xy) + + +# --------------------------- +# Normalization utilities +# --------------------------- +def _norm_on_period(u: float, first: float, per: float) -> float: + """Map parameter u into [first, first+per).""" + if per <= 0.0: + return u + k = floor((u - first) / per) + return u - k * per + + +def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: + """ + Forward (positive) delta from u1 to u2 on a periodic domain anchored at + 'first'. + """ + u1n = _norm_on_period(u1, first, period) + u2n = _norm_on_period(u2, first, period) + delta = u2n - u1n + if delta < 0.0: + delta += period + return delta + + +# --------------------------- +# Core helpers +# --------------------------- +def _edge_to_qualified_2d( + edge: TopoDS_Edge, position_constaint: PositionConstraint +) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: + """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) + + if hcurve3d is None: + raise ValueError("Edge has no underlying 3D curve.") + + # 2) Apply location if the edge is positioned by a TopLoc_Location + if not loc.IsIdentity(): + trsf = loc.Transformation() + hcurve3d = hcurve3d.Transformed(trsf) + + # 3) 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 + adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) + + # 5) Create the qualified curve (unqualified is fine here) + qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) + return qcurve, hcurve2d, first, last + + +def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge: + """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" + arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True + return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() + + +def _param_in_trim(u: float, first: float, last: float, h2d: Geom2d_Curve) -> bool: + """Normalize (if periodic) then test [first, last] with tolerance.""" + u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u + return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) + + +def _as_gcc_arg( + obj: Edge | Vertex | VectorLike, constaint: PositionConstraint +) -> tuple[ + Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, + Geom2d_Curve | None, + float | None, + float | None, + bool, +]: + """ + Normalize input to a GCC argument. + Returns: (q_obj, h2d, first, last, is_edge) + - Edge -> (QualifiedCurve, h2d, first, last, True) + - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) + """ + if isinstance(obj.wrapped, TopoDS_Edge): + return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) + + loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() + try: + base = Vector(obj) + except (TypeError, ValueError) as exc: + raise ValueError("Expected Edge | Vertex | VectorLike") from exc + + gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + return Geom2d_CartesianPoint(gp_pnt), None, None, None, False + + +def _two_arc_edges_from_params( + circ: gp_Circ2d, u1: float, u2: float +) -> list[TopoDS_Edge]: + """ + Given two parameters on a circle, return both the forward (minor) + and complementary (major) arcs as TopoDS_Edge(s). + Uses centralized normalization utilities. + """ + h2d_circle = Geom2d_Circle(circ) + per = h2d_circle.Period() # usually 2*pi + + # Minor (forward) span + d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience + u1n = _norm_on_period(u1, 0.0, per) + u2n = _norm_on_period(u2, 0.0, per) + + # Guard degeneracy + if d <= TOLERANCE or abs(per - d) <= TOLERANCE: + return ShapeList() + + minor = _edge_from_circle(h2d_circle, u1n, u1n + d) + major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) + return [minor, major] + + +def _qstr(q) -> str: + # Works with OCP's GccEnt enum values + try: + from OCP.GccEnt import GccEnt_enclosed, GccEnt_enclosing, GccEnt_outside + + try: + from OCP.GccEnt import GccEnt_unqualified + except ImportError: + # Some OCCT versions name this 'noqualifier' + from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified + mapping = { + GccEnt_enclosed: "enclosed", + GccEnt_enclosing: "enclosing", + GccEnt_outside: "outside", + GccEnt_unqualified: "unqualified", + } + return mapping.get(q, f"unknown({int(q)})") + except Exception: + # Fallback if enums aren't importable for any reason + return str(int(q)) + + +def _make_2tan_rad_arcs( + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + radius: float, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> list[Edge]: + """ + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. + + Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. + + Args: + object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched + by the circle(s) + radius (float): Circle radius for all candidate solutions. + + Raises: + ValueError: Invalid input + ValueError: Invalid curve + RuntimeError: no valid circle solutions found + + Returns: + ShapeList[Edge]: A list of planar circular edges (on XY) representing both + the minor and major arcs between the two tangency points for every valid + circle solution. + + """ + + if isinstance(object_1, tuple): + object_one, object_one_constraint = object_1 + else: + object_one = object_1 + if isinstance(object_2, tuple): + object_two, object_two_constraint = object_2 + else: + object_two = object_2 + + # --------------------------- + # Build inputs and GCC + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( + object_one, object_one_constraint + ) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( + object_two, object_two_constraint + ) + + # Put the Edge arg first when exactly one is an Edge (improves robustness) + if is_edge1 ^ is_edge2: + q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) + + gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc") + + def _valid_on_arg1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + def _valid_on_arg2(u: float) -> bool: + return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + + # --------------------------- + # Solutions + # --------------------------- + solutions: list[Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _valid_on_arg1(u_arg1): + continue + + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _valid_on_arg2(u_arg2): + continue + + # qual1 = GccEnt_Position(int()) + # qual2 = GccEnt_Position(int()) + # gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values + # print( + # f"Solution {i}: " + # f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " + # f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " + # f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" + # ) + + # Build BOTH sagitta arcs and select by LengthConstraint + if sagitta_constraint == LengthConstraint.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + arcs = sorted( + arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + ) + solutions.append(arcs[sagitta_constraint.value]) + return ShapeList([edge_factory(e) for e in solutions]) + + +def _make_2tan_on_arcs( + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> ShapeList[Edge]: + """ + Create all planar circular arcs whose circle is tangent to two objects and whose + CENTER lies on a given locus (line/circle/curve) on the XY plane. + + Notes + ----- + - `center_on` is treated as a **center locus** (not a tangency target). For a line + locus this uses Geom2dGcc_Circ2d2TanOn; for other 2D curves it uses the *Geo variant*. + - A point is NOT a valid center locus for the 2TanOn solver; use the TanCen variant + (fixed center) for that case. + """ + + # Unpack optional qualifiers on the two tangency args + object_one_constraint = PositionConstraint.UNQUALIFIED + object_two_constraint = PositionConstraint.UNQUALIFIED + + if isinstance(object_1, tuple): + object_one, object_one_constraint = object_1 + else: + object_one = object_1 + + if isinstance(object_2, tuple): + object_two, object_two_constraint = object_2 + else: + object_two = object_2 + + # --------------------------- + # Build tangency inputs + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( + object_one, object_one_constraint + ) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( + object_two, object_two_constraint + ) + + # Prefer "edge-first" ordering when exactly one arg is an Edge + if is_edge1 ^ is_edge2: + q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) + h_e1, h_e2 = (h_e1, h_e2) if is_edge1 else (h_e2, h_e1) + e1_first, e1_last, e2_first, e2_last = ( + (e1_first, e1_last, e2_first, e2_last) + if is_edge1 + else (e2_first, e2_last, e1_first, e1_last) + ) + is_edge1, is_edge2 = (True, False) if is_edge1 else (False, True) + + # --------------------------- + # Build center locus ("On") input + # --------------------------- + # Allow an (Edge, PositionConstraint) tuple for symmetry, but ignore the qualifier here. + on_obj = center_on[0] if isinstance(center_on, tuple) else center_on + + # 2TanOn expects a 2D locus for the CENTER. Points are not supported here. + if isinstance(on_obj, (Vertex, Vector)): + raise TypeError( + "center_on cannot be a point for 2TanOn; use the 'center=' (TanCen) variant." + ) + + # Project the 'on' Edge to 2D and choose the appropriate solver + if isinstance(on_obj, Edge): + # Reuse your projection utility to get a 2D curve + # (qualifier irrelevant for the center locus) + _, h_on2d, on_first, on_last = _edge_to_qualified_2d(on_obj.wrapped) + adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) + + # Prefer the analytic 'On' constructor when the locus is a line; otherwise use the Geo variant + use_line = adapt_on.GetType() == GeomAbs_CurveType.GeomAbs_Line + if use_line: + gp_lin2d = adapt_on.Line() + gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, gp_lin2d, TOLERANCE) + else: + # Works for circles and general Geom2d curves as the center locus + gcc = Geom2dGcc_Circ2d2TanOnGeo(q_o1, q_o2, h_on2d, TOLERANCE) + else: + # If it's neither Edge/Vertex/VectorLike (shouldn't happen), bail out clearly + raise TypeError("center_on must be an Edge (line/circle/curve) for 2TanOn.") + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc with center_on constraint") + + def _valid_on_arg1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + def _valid_on_arg2(u: float) -> bool: + return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + + # --------------------------- + # Solutions + # --------------------------- + solutions: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _valid_on_arg1(u_arg1): + continue + + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _valid_on_arg2(u_arg2): + continue + + # Build sagitta arc(s) and select by LengthConstraint + if sagitta_constraint == LengthConstraint.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + if not arcs: + continue + arcs = sorted( + arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + ) + solutions.append(arcs[sagitta_constraint.value]) + + return ShapeList([edge_factory(e) for e in solutions]) + + +def _make_3tan_arcs( + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + object_3: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> ShapeList[Edge]: + """ + Create planar circular arc(s) on XY tangent to three provided objects. + + The circle is determined by the three tangency constraints; the returned arc(s) + are trimmed between the two tangency points corresponding to `object_1` and + `object_2`. Use `sagitta_constraint` to select the shorter/longer (or both) arc. + Inputs must be representable on Plane.XY. + """ + + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + obj1_qual = PositionConstraint.UNQUALIFIED + obj2_qual = PositionConstraint.UNQUALIFIED + obj3_qual = PositionConstraint.UNQUALIFIED + + if isinstance(object_1, tuple): + object_one, obj1_qual = object_1 + else: + object_one = object_1 + + if isinstance(object_2, tuple): + object_two, obj2_qual = object_2 + else: + object_two = object_2 + + if isinstance(object_3, tuple): + object_three, obj3_qual = object_3 + else: + object_three = object_3 + + # --------------------------- + # Build inputs for GCC + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) + q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, obj2_qual) + q_o3, h_e3, e3_first, e3_last, is_edge3 = _as_gcc_arg(object_three, obj3_qual) + + # For 3Tan we keep the user-given order so the arc endpoints remain (arg1,arg2) + gcc = Geom2dGcc_Circ2d3Tan(q_o1, q_o2, q_o3, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a circle tangent to all three objects") + + def _ok1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + def _ok2(u: float) -> bool: + return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + + def _ok3(u: float) -> bool: + return True if not is_edge3 else _param_in_trim(u, e3_first, e3_last, h_e3) + + # --------------------------- + # Enumerate solutions + # --------------------------- + out_topos: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Tangency on curve 1 (arc endpoint A) + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _ok1(u_arg1): + continue + + # Tangency on curve 2 (arc endpoint B) + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _ok2(u_arg2): + continue + + # Tangency on curve 3 (validates circle; does not define arc endpoints) + p3 = gp_Pnt2d() + _u_circ3, u_arg3 = gcc.Tangency3(i, p3) + if not _ok3(u_arg3): + continue + + # Build arc(s) between u_circ1 and u_circ2 per LengthConstraint + if sagitta_constraint == LengthConstraint.BOTH: + out_topos.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + if not arcs: + continue + arcs = sorted( + arcs, + key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)), + ) + out_topos.append(arcs[sagitta_constraint.value]) + + return ShapeList([edge_factory(e) for e in out_topos]) + + +def _make_tan_cen_arcs( + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center: VectorLike | Vertex, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, # unused here + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> ShapeList[Edge]: + """ + Create planar circle(s) on XY whose center is fixed and that are tangent/contacting + a single object. + + Notes + ----- + - With a **fixed center** and a single tangency constraint, the natural geometric + result is a full circle; there are no second endpoints to define an arc span. + This routine therefore returns closed circular edges (full 2π trims). + - If the tangency target is a point (Vertex/VectorLike), the circle is the one + centered at `center` and passing through that point (built directly). + """ + + # Unpack optional qualifier on the tangency arg (edges only) + obj1_qual = PositionConstraint.UNQUALIFIED + if isinstance(object_1, tuple): + object_one, obj1_qual = object_1 + else: + object_one = object_1 + + # --------------------------- + # Build fixed center (gp_Pnt2d) + # --------------------------- + if isinstance(center, Vertex): + loc_xyz = center.position + base = Vector(center) + c2d = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + else: + v = Vector(center) + c2d = gp_Pnt2d(v.X, v.Y) + + # --------------------------- + # Tangency input + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) + + solutions_topo: list[TopoDS_Edge] = [] + + # Case A: tangency target is a point -> circle passes through that point + if not is_edge1 and isinstance(q_o1, Geom2d_CartesianPoint): + p = q_o1.Pnt2d() + # radius = distance(center, point) + dx, dy = p.X() - c2d.X(), p.Y() - c2d.Y() + r = (dx * dx + dy * dy) ** 0.5 + if r <= TOLERANCE: + # Center coincides with point: no valid circle + return ShapeList([]) + # Build full circle + circ = gp_Circ2d(gp_Ax2d(c2d, gp_Dir2d(1.0, 0.0)), r) + h2d = Geom2d_Circle(circ) + per = h2d.Period() + solutions_topo.append(_edge_from_circle(h2d, 0.0, per)) + + else: + # Case B: tangency target is a curve/edge (qualified curve) + gcc = Geom2dGcc_Circ2dTanCen(q_o1, c2d, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError( + "Unable to find circle(s) tangent to target with fixed center" + ) + + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Validate tangency lies on trimmed span if the target is an Edge + p1 = gp_Pnt2d() + _u_on_circ, u_on_arg = gcc.Tangency1(i, p1) + if is_edge1 and not _param_in_trim(u_on_arg, e1_first, e1_last, h_e1): + continue + + # Emit full circle (2π trim) + h2d = Geom2d_Circle(circ) + per = h2d.Period() + solutions_topo.append(_edge_from_circle(h2d, 0.0, per)) + + return ShapeList([edge_factory(e) for e in solutions_topo]) + + +def _make_tan_on_rad_arcs( + object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + radius: float, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, # unused here + *, + edge_factory: Callable[[TopoDS_Edge], TWrap], +) -> ShapeList[Edge]: + """ + Create planar circle(s) on XY that: + - are tangent/contacting a single object, and + - have a fixed radius, and + - have their CENTER constrained to lie on a given locus curve. + + Notes + ----- + - The center locus must be a 2D curve (line/circle/any Geom2d curve) — i.e. an Edge + after projection to XY. A point is not a valid 'center_on' locus for this solver. + - With only one tangency, the natural geometric result is a full circle; arc cropping + would require an additional endpoint constraint. This routine therefore returns + closed circular edges (2π trims) for each valid solution. + """ + + # --- unpack optional qualifier on the tangency arg (edges only) --- + obj1_qual = PositionConstraint.UNQUALIFIED + if isinstance(object_1, tuple): + object_one, obj1_qual = object_1 + else: + object_one = object_1 + + # --- build tangency input (point/edge) --- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) + + # --- center locus ('center_on') must be a curve; ignore any qualifier there --- + on_obj = center_on[0] if isinstance(center_on, tuple) else center_on + if not isinstance(on_obj, Edge): + raise TypeError("center_on must be an Edge (line/circle/curve) for TanOnRad.") + + # Project the center locus Edge to 2D (XY) + _, h_on2d, on_first, on_last = _edge_to_qualified_2d(on_obj.wrapped) + adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) + + # Choose the appropriate GCC constructor + if adapt_on.GetType() == GeomAbs_CurveType.GeomAbs_Line: + gp_lin2d = adapt_on.Line() + gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, gp_lin2d, radius, TOLERANCE) + else: + gcc = Geom2dGcc_Circ2dTanOnRadGeo(q_o1, h_on2d, radius, TOLERANCE) + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find circle(s) for TanOnRad constraints") + + def _ok1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + # --- enumerate solutions; emit full circles (2π trims) --- + out_topos: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Validate tangency lies on trimmed span when the target is an Edge + p = gp_Pnt2d() + _u_on_circ, u_on_arg = gcc.Tangency1(i, p) + if not _ok1(u_on_arg): + continue + + h2d = Geom2d_Circle(circ) + per = h2d.Period() + out_topos.append(_edge_from_circle(h2d, 0.0, per)) + + return ShapeList([edge_factory(e) for e in out_topos]) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 3eea653..3b8f5ff 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -57,9 +57,8 @@ import warnings from collections.abc import Iterable from itertools import combinations from math import ceil, copysign, cos, floor, inf, isclose, pi, radians -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, TypeAlias, overload from typing import cast as tcast -from typing import overload import numpy as np import OCP.TopAbs as ta @@ -235,6 +234,13 @@ from .utils import ( isclose_b, ) from .zero_d import Vertex, topo_explore_common_vertex +from .constrained_lines import ( + _make_2tan_rad_arcs, + _make_2tan_on_arcs, + _make_3tan_arcs, + _make_tan_cen_arcs, + _make_tan_on_rad_arcs, +) if TYPE_CHECKING: # pragma: no cover from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801 @@ -1578,6 +1584,228 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) return return_value + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *, + radius: float, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + ) -> ShapeList[Edge]: + """ + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. + Args: + tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + radius (float): arc radius + sagitta_constraint (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *, + center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + ) -> ShapeList[Edge]: + """ + Create all planar circular arcs whose circle is tangent to two objects and whose + CENTER lies on a given locus (line/circle/curve) on the XY plane. + + Args: + tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + center_on (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + the **center locus** (not a tangency target) + sagitta_constraint (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_three: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + ) -> ShapeList[Edge]: + """ + Create planar circular arc(s) on XY tangent to three provided objects. + + Args: + tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_three (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + sagitta_constraint (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *, + center: VectorLike, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + ) -> ShapeList[Edge]: + """make_constrained_arcs + + Create planar circle(s) on XY whose center is fixed and that are tangent/contacting + a single object. + + Args: + tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + Geometric entity to be contacted/touched by the circle(s) + center (VectorLike): center position + sagitta_constraint (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *, + radius: float, + center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + ) -> ShapeList[Edge]: + """make_constrained_arcs + + Create planar circle(s) on XY that: + - are tangent/contacting a single object, and + - have a fixed radius, and + - have their CENTER constrained to lie on a given locus curve. + + Args: + tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + Geometric entity to be contacted/touched by the circle(s) + radius (float): arc radius + center_on (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + the **center locus** (not a tangency target) + sagitta_constraint (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @classmethod + def make_constrained_arcs( + cls, + *args, + sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + **kwargs, + ) -> ShapeList[Edge]: + + tangency_one = args[0] if len(args) > 0 else None + tangency_two = args[1] if len(args) > 1 else None + tangency_three = args[2] if len(args) > 2 else None + + tangency_one = kwargs.pop("tangency_one", tangency_one) + tangency_two = kwargs.pop("tangency_two", tangency_two) + tangency_three = kwargs.pop("tangency_three", tangency_three) + + radius = kwargs.pop("radius", None) + center = kwargs.pop("center", None) + center_on = kwargs.pop("center_on", None) + + # Handle unexpected kwargs + if kwargs: + raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + # --- validate inputs --- + tangencies = [ + t for t in (tangency_one, tangency_two, tangency_three) if t is not None + ] + tan_count = len(tangencies) + if not (1 <= tan_count <= 3): + raise TypeError("Provide 1 to 3 tangency targets.") + if ( + sum(x is not None for x in (radius, center, center_on)) > 1 + and tan_count != 2 + ): + raise TypeError("Ambiguous constraint combination.") + + # Disallow qualifiers on points/vertices (enforce at runtime) + if any(isinstance(t, tuple) and not isinstance(t[0], Edge) for t in tangencies): + raise TypeError("Only Edge targets may be qualified.") + + # Radius sanity + if radius is not None and radius <= 0: + raise ValueError("radius must be > 0.0") + + # --- decide problem kind --- + if ( + tan_count == 2 + and radius is not None + and center is None + and center_on is None + ): + return _make_2tan_rad_arcs( + *tangencies, + radius, + sagitta_constraint, + edge_factory=cls, + ) + if ( + tan_count == 2 + and center_on is not None + and radius is None + and center is None + ): + return _make_2tan_on_arcs( + *tangencies, center_on, sagitta_constraint, edge_factory=cls + ) + if tan_count == 3 and radius is None and center is None and center_on is None: + return _make_3tan_arcs(tangencies, sagitta_constraint, edge_factory=cls) + if ( + tan_count == 1 + and center is not None + and radius is None + and center_on is None + ): + return _make_tan_cen_arcs( + *tangencies, center, sagitta_constraint, edge_factory=cls + ) + if tan_count == 1 and center_on is not None and radius is not None: + return _make_tan_on_rad_arcs( + *tangencies, center_on, radius, sagitta_constraint, edge_factory=cls + ) + + raise ValueError("Unsupported or ambiguous combination of constraints.") + @classmethod def make_ellipse( cls, @@ -1908,259 +2136,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) - @classmethod - def make_tangent_arcs( - cls, - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - radius: float, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, - ) -> ShapeList[Edge]: - """ - Create all planar circular arcs of a given radius that are tangent/contacting - the two provided objects on the XY plane. - - Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. - - Args: - object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) - object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) - radius (float): Circle radius for all candidate solutions. - - Raises: - ValueError: Invalid input - ValueError: Invalid curve - RuntimeError: no valid circle solutions found - - Returns: - ShapeList[Edge]: A list of planar circular edges (on XY) representing both - the minor and major arcs between the two tangency points for every valid - circle solution. - - """ - - if isinstance(object_1, tuple): - object_one, object_one_constraint = object_1 - else: - object_one = object_1 - if isinstance(object_2, tuple): - object_two, object_two_constraint = object_2 - else: - object_two = object_2 - - # Reuse a single XY plane for 3D->2D projection and for 2D-edge building - _pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) - _surf_xy = Geom_Plane(_pln_xy) - - # --------------------------- - # Normalization utilities - # --------------------------- - def _norm_on_period(u: float, first: float, per: float) -> float: - """Map parameter u into [first, first+per).""" - if per <= 0.0: - return u - k = floor((u - first) / per) - return u - k * per - - def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: - """ - Forward (positive) delta from u1 to u2 on a periodic domain anchored at - 'first'. - """ - u1n = _norm_on_period(u1, first, period) - u2n = _norm_on_period(u2, first, period) - delta = u2n - u1n - if delta < 0.0: - delta += period - return delta - - # --------------------------- - # Core helpers - # --------------------------- - def _edge_to_qualified_2d( - edge: TopoDS_Edge, position_constaint: PositionConstraint - ) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: - """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) - - if hcurve3d is None: - raise ValueError("Edge has no underlying 3D curve.") - - # 2) Apply location if the edge is positioned by a TopLoc_Location - if not loc.IsIdentity(): - trsf = loc.Transformation() - hcurve3d = hcurve3d.Transformed(trsf) - - # 3) 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 - adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) - - # 5) Create the qualified curve (unqualified is fine here) - qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) - return qcurve, hcurve2d, first, last - - def _edge_from_circle( - h2d_circle: Geom2d_Circle, u1: float, u2: float - ) -> TopoDS_Edge: - """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" - arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True - return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() - - def _param_in_trim( - u: float, first: float, last: float, h2d: Geom2d_Curve - ) -> bool: - """Normalize (if periodic) then test [first, last] with tolerance.""" - u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u - return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) - - def _as_gcc_arg( - obj: Edge | Vertex | VectorLike, constaint: PositionConstraint - ) -> tuple[ - Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, - Geom2d_Curve | None, - float | None, - float | None, - bool, - ]: - """ - Normalize input to a GCC argument. - Returns: (q_obj, h2d, first, last, is_edge) - - Edge -> (QualifiedCurve, h2d, first, last, True) - - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) - """ - if isinstance(obj, Edge): - return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) - - loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() - try: - base = Vector(obj) - except (TypeError, ValueError) as exc: - raise ValueError("Expected Edge | Vertex | VectorLike") from exc - - gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) - return Geom2d_CartesianPoint(gp_pnt), None, None, None, False - - def _two_arc_edges_from_params( - circ: gp_Circ2d, u1: float, u2: float - ) -> ShapeList[Edge]: - """ - Given two parameters on a circle, return both the forward (minor) - and complementary (major) arcs as TopoDS_Edge(s). - Uses centralized normalization utilities. - """ - h2d_circle = Geom2d_Circle(circ) - per = h2d_circle.Period() # usually 2*pi - - # Minor (forward) span - d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience - u1n = _norm_on_period(u1, 0.0, per) - u2n = _norm_on_period(u2, 0.0, per) - - # Guard degeneracy - if d <= TOLERANCE or abs(per - d) <= TOLERANCE: - return ShapeList() - - minor = _edge_from_circle(h2d_circle, u1n, u1n + d) - major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) - return ShapeList([Edge(minor), Edge(major)]) - - def _qstr(q) -> str: - # Works with OCP's GccEnt enum values - try: - from OCP.GccEnt import ( - GccEnt_enclosed, - GccEnt_enclosing, - GccEnt_outside, - ) - - try: - from OCP.GccEnt import GccEnt_unqualified - except ImportError: - # Some OCCT versions name this 'noqualifier' - from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified - mapping = { - GccEnt_enclosed: "enclosed", - GccEnt_enclosing: "enclosing", - GccEnt_outside: "outside", - GccEnt_unqualified: "unqualified", - } - return mapping.get(q, f"unknown({int(q)})") - except Exception: - # Fallback if enums aren't importable for any reason - return str(int(q)) - - # --------------------------- - # Build inputs and GCC - # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( - object_one, object_one_constraint - ) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( - object_two, object_two_constraint - ) - - # Put the Edge arg first when exactly one is an Edge (improves robustness) - if is_edge1 ^ is_edge2: - q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) - - gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) - if not gcc.IsDone() or gcc.NbSolutions() == 0: - raise RuntimeError("Unable to find a tangent arc") - - def _valid_on_arg1(u: float) -> bool: - return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) - - def _valid_on_arg2(u: float) -> bool: - return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) - - # --------------------------- - # Solutions - # --------------------------- - solutions: list[Edge] = [] - for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d - - # Tangency on curve 1 - p1 = gp_Pnt2d() - u_circ1, u_arg1 = gcc.Tangency1(i, p1) - if not _valid_on_arg1(u_arg1): - continue - - # Tangency on curve 2 - p2 = gp_Pnt2d() - u_circ2, u_arg2 = gcc.Tangency2(i, p2) - if not _valid_on_arg2(u_arg2): - continue - - qual1 = GccEnt_Position(int()) - qual2 = GccEnt_Position(int()) - gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values - print( - f"Solution {i}: " - f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " - f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " - f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" - ) - - # Build BOTH sagitta arcs and select by LengthConstraint - if sagitta_constraint == LengthConstraint.BOTH: - solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) - else: - solutions.append( - _two_arc_edges_from_params(circ, u_circ1, u_circ2).sort_by( - Edge.length - )[sagitta_constraint.value] - ) - return ShapeList(solutions) - @classmethod def make_three_point_arc( cls, point1: VectorLike, point2: VectorLike, point3: VectorLike diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index 6fd50c5..c1bbb1e 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -3,11 +3,35 @@ build123d topology name: utils.py by: Gumyr -date: September 07, 2025 +date: January 07, 2025 desc: -This module houses utilities used within the topology modules. +This module provides utility functions and helper classes for the build123d CAD library, enabling +advanced geometric operations and facilitating the use of the OpenCascade CAD kernel. It complements +the core library by offering reusable and modular tools for manipulating shapes, performing Boolean +operations, and validating geometry. + +Key Features: +- **Geometric Utilities**: + - `polar`: Converts polar coordinates to Cartesian. + - `tuplify`: Normalizes inputs into consistent tuples. + - `find_max_dimension`: Computes the maximum bounding dimension of shapes. + +- **Shape Creation**: + - `_make_loft`: Creates lofted shapes from wires and vertices. + - `_make_topods_compound_from_shapes`: Constructs compounds from multiple shapes. + - `_make_topods_face_from_wires`: Generates planar faces with optional holes. + +- **Boolean Operations**: + - `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes. + - `new_edges`: Identifies newly created edges from combined shapes. + +- **Enhanced Math**: + - `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance. + +This module is a critical component of build123d, supporting complex CAD workflows and geometric +transformations while maintaining a clean, extensible API. license: @@ -29,453 +53,378 @@ license: from __future__ import annotations -import copy -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 typing import Callable, TypeVar, TYPE_CHECKING, Literal -from typing import cast as tcast -from typing import overload +from math import radians, sin, cos, isclose +from typing import Any, TYPE_CHECKING + +from collections.abc import Iterable -import numpy as np -import OCP.TopAbs as ta from OCP.BRep import BRep_Tool -from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve from OCP.BRepAlgoAPI import ( - BRepAlgoAPI_Common, - BRepAlgoAPI_Section, + BRepAlgoAPI_BooleanOperation, + BRepAlgoAPI_Cut, BRepAlgoAPI_Splitter, ) -from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_DisconnectedWire, - BRepBuilderAPI_EmptyWire, - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeEdge2d, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakePolygon, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_NonManifoldWire, -) -from OCP.BRepExtrema import BRepExtrema_DistShapeShape, BRepExtrema_SupportType -from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d -from OCP.BRepGProp import BRepGProp, BRepGProp_Face -from OCP.BRepLib import BRepLib, BRepLib_FindSurface -from OCP.BRepLProp import BRepLProp -from OCP.BRepOffset import BRepOffset_MakeOffset -from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset -from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace -from OCP.BRepProj import BRepProj_Projection -from OCP.BRepTools import BRepTools, BRepTools_WireExplorer -from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse -from OCP.GccEnt import GccEnt_unqualified, GccEnt_Position -from OCP.GCPnts import GCPnts_AbscissaPoint -from OCP.Geom import ( - Geom_BezierCurve, - Geom_BSplineCurve, - Geom_ConicalSurface, - Geom_CylindricalSurface, - Geom_Line, - Geom_Plane, - Geom_Surface, - Geom_TrimmedCurve, -) -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_InterCurveCurve -from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve -from OCP.GeomAbs import ( - GeomAbs_C0, - GeomAbs_C1, - GeomAbs_C2, - GeomAbs_G1, - GeomAbs_G2, - GeomAbs_JoinType, -) -from OCP.GeomAdaptor import GeomAdaptor_Curve -from OCP.GeomAPI import ( - GeomAPI, - GeomAPI_IntCS, - GeomAPI_Interpolate, - GeomAPI_PointsToBSpline, - GeomAPI_ProjectPointOnCurve, -) -from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve -from OCP.GeomFill import ( - GeomFill_CorrectedFrenet, - GeomFill_Frenet, - GeomFill_TrihedronLaw, -) -from OCP.GeomProjLib import GeomProjLib -from OCP.gp import ( - gp_Ax1, - gp_Ax2, - gp_Ax3, - gp_Circ, - gp_Circ2d, - gp_Dir, - gp_Dir2d, - gp_Elips, - gp_Pln, - gp_Pnt, - gp_Pnt2d, - gp_Trsf, - gp_Vec, -) -from OCP.GProp import GProp_GProps -from OCP.HLRAlgo import HLRAlgo_Projector -from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape -from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds -from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe -from OCP.Standard import ( - Standard_ConstructionError, - Standard_Failure, - Standard_NoSuchObject, -) -from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt -from OCP.TColStd import ( - TColStd_Array1OfReal, - TColStd_HArray1OfBoolean, - TColStd_HArray1OfReal, -) -from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum -from OCP.TopExp import TopExp, TopExp_Explorer -from OCP.TopLoc import TopLoc_Location +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace +from OCP.BRepLib import BRepLib_FindSurface +from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections +from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism +from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape +from OCP.TopAbs import TopAbs_ShapeEnum +from OCP.TopExp import TopExp_Explorer +from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import ( TopoDS, + TopoDS_Builder, TopoDS_Compound, - TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Vertex, + TopoDS_Edge, TopoDS_Wire, ) -from OCP.TopTools import ( - TopTools_HSequenceOfShape, - TopTools_IndexedDataMapOfShapeListOfShape, - TopTools_IndexedMapOfShape, - TopTools_ListOfShape, -) -from scipy.optimize import minimize_scalar -from scipy.spatial import ConvexHull -from typing_extensions import Self +from build123d.geometry import TOLERANCE, BoundBox, Vector, VectorLike -from build123d.build_enums import ( - AngularDirection, - CenterOf, - ContinuityLevel, - FrameMethod, - GeomType, - Keep, - Kind, - LengthConstraint, - PositionConstraint, - PositionMode, - Side, -) -from build123d.geometry import ( - DEG2RAD, - TOL_DIGITS, - TOLERANCE, - Axis, - Color, - Location, - Plane, - Vector, - VectorLike, - logger, -) - -from .shape_core import ( - Shape, - ShapeList, - SkipClean, - TrimmingTool, - downcast, - get_top_level_topods_shapes, - shapetype, - topods_dim, - unwrap_topods_compound, -) -from .utils import ( - _extrude_topods_shape, - _make_topods_face_from_wires, - _topods_bool_op, - isclose_b, -) -from .zero_d import Vertex, topo_explore_common_vertex - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from build123d.topology.one_d import Edge - -TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass) - -# Reuse a single XY plane for 3D->2D projection and for 2D-edge building -_pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) -_surf_xy = Geom_Plane(_pln_xy) +from .shape_core import Shape, ShapeList, downcast, shapetype, unwrap_topods_compound -# --------------------------- -# Normalization utilities -# --------------------------- -def _norm_on_period(u: float, first: float, per: float) -> float: - """Map parameter u into [first, first+per).""" - if per <= 0.0: - return u - k = floor((u - first) / per) - return u - k * per +if TYPE_CHECKING: # pragma: no cover + from .zero_d import Vertex # pylint: disable=R0801 + from .one_d import Edge, Wire # pylint: disable=R0801 -def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: - """ - Forward (positive) delta from u1 to u2 on a periodic domain anchored at - 'first'. - """ - u1n = _norm_on_period(u1, first, period) - u2n = _norm_on_period(u2, first, period) - delta = u2n - u1n - if delta < 0.0: - delta += period - return delta +def _extrude_topods_shape(obj: TopoDS_Shape, direction: VectorLike) -> TopoDS_Shape: + """extrude - -# --------------------------- -# Core helpers -# --------------------------- -def _edge_to_qualified_2d( - edge: TopoDS_Edge, position_constaint: PositionConstraint -) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: - """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) - - if hcurve3d is None: - raise ValueError("Edge has no underlying 3D curve.") - - # 2) Apply location if the edge is positioned by a TopLoc_Location - if not loc.IsIdentity(): - trsf = loc.Transformation() - hcurve3d = hcurve3d.Transformed(trsf) - - # 3) 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 - adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) - - # 5) Create the qualified curve (unqualified is fine here) - qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) - return qcurve, hcurve2d, first, last - - -def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge: - """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" - arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True - return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() - - -def _param_in_trim(u: float, first: float, last: float, h2d: Geom2d_Curve) -> bool: - """Normalize (if periodic) then test [first, last] with tolerance.""" - u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u - return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) - - -def _as_gcc_arg( - obj: Edge | Vertex | VectorLike, constaint: PositionConstraint -) -> tuple[ - Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, - Geom2d_Curve | None, - float | None, - float | None, - bool, -]: - """ - Normalize input to a GCC argument. - Returns: (q_obj, h2d, first, last, is_edge) - - Edge -> (QualifiedCurve, h2d, first, last, True) - - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) - """ - if isinstance(obj.wrapped, TopoDS_Edge): - return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) - - loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() - try: - base = Vector(obj) - except (TypeError, ValueError) as exc: - raise ValueError("Expected Edge | Vertex | VectorLike") from exc - - gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) - return Geom2d_CartesianPoint(gp_pnt), None, None, None, False - - -def _two_arc_edges_from_params( - circ: gp_Circ2d, u1: float, u2: float -) -> ShapeList[Edge]: - """ - Given two parameters on a circle, return both the forward (minor) - and complementary (major) arcs as TopoDS_Edge(s). - Uses centralized normalization utilities. - """ - h2d_circle = Geom2d_Circle(circ) - per = h2d_circle.Period() # usually 2*pi - - # Minor (forward) span - d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience - u1n = _norm_on_period(u1, 0.0, per) - u2n = _norm_on_period(u2, 0.0, per) - - # Guard degeneracy - if d <= TOLERANCE or abs(per - d) <= TOLERANCE: - return ShapeList() - - minor = _edge_from_circle(h2d_circle, u1n, u1n + d) - major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) - return ShapeList([Edge(minor), Edge(major)]) - - -def _qstr(q) -> str: - # Works with OCP's GccEnt enum values - try: - from OCP.GccEnt import ( - GccEnt_enclosed, - GccEnt_enclosing, - GccEnt_outside, - ) - - try: - from OCP.GccEnt import GccEnt_unqualified - except ImportError: - # Some OCCT versions name this 'noqualifier' - from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified - mapping = { - GccEnt_enclosed: "enclosed", - GccEnt_enclosing: "enclosing", - GccEnt_outside: "outside", - GccEnt_unqualified: "unqualified", - } - return mapping.get(q, f"unknown({int(q)})") - except Exception: - # Fallback if enums aren't importable for any reason - return str(int(q)) - - -def make_tangent_edges( - cls, - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - radius: float, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, - *, - edge_factory: Callable[[TopoDS_Edge], TWrap], -) -> list[TWrap]: - """ - Create all planar circular arcs of a given radius that are tangent/contacting - the two provided objects on the XY plane. - - Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. + Extrude a Shape in the provided direction. + * Vertices generate Edges + * Edges generate Faces + * Wires generate Shells + * Faces generate Solids + * Shells generate Compounds Args: - object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) - object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) - radius (float): Circle radius for all candidate solutions. + direction (VectorLike): direction and magnitude of extrusion Raises: - ValueError: Invalid input - ValueError: Invalid curve - RuntimeError: no valid circle solutions found + ValueError: Unsupported class + RuntimeError: Generated invalid result Returns: - ShapeList[Edge]: A list of planar circular edges (on XY) representing both - the minor and major arcs between the two tangency points for every valid - circle solution. - + TopoDS_Shape: extruded shape """ + direction = Vector(direction) - if isinstance(object_1, tuple): - object_one, object_one_constraint = object_1 - else: - object_one = object_1 - if isinstance(object_2, tuple): - object_two, object_two_constraint = object_2 - else: - object_two = object_2 + if obj is None or not isinstance( + obj, + (TopoDS_Vertex, TopoDS_Edge, TopoDS_Wire, TopoDS_Face, TopoDS_Shell), + ): + raise ValueError(f"extrude not supported for {type(obj)}") - # --------------------------- - # Build inputs and GCC - # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( - object_one, object_one_constraint - ) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( - object_two, object_two_constraint - ) + prism_builder = BRepPrimAPI_MakePrism(obj, direction.wrapped) + extrusion = downcast(prism_builder.Shape()) + shape_type = extrusion.ShapeType() + if shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID: + solids = [] + explorer = TopExp_Explorer(extrusion, TopAbs_ShapeEnum.TopAbs_SOLID) + while explorer.More(): + solids.append(downcast(explorer.Current())) + explorer.Next() + extrusion = _make_topods_compound_from_shapes(solids) + return extrusion - # Put the Edge arg first when exactly one is an Edge (improves robustness) - if is_edge1 ^ is_edge2: - q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) - gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) - if not gcc.IsDone() or gcc.NbSolutions() == 0: - raise RuntimeError("Unable to find a tangent arc") +def _make_loft( + objs: Iterable[Vertex | Wire], + filled: bool, + ruled: bool = False, +) -> TopoDS_Shape: + """make loft - def _valid_on_arg1(u: float) -> bool: - return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + Makes a loft from a list of wires and vertices. Vertices can appear only at the + beginning or end of the list, but cannot appear consecutively within the list + nor between wires. - def _valid_on_arg2(u: float) -> bool: - return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + Args: + wires (list[Wire]): section perimeters + ruled (bool, optional): stepped or smooth. Defaults to False (smooth). - # --------------------------- - # Solutions - # --------------------------- - solutions: list[Edge] = [] - for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d + Raises: + ValueError: Too few wires - # Tangency on curve 1 - p1 = gp_Pnt2d() - u_circ1, u_arg1 = gcc.Tangency1(i, p1) - if not _valid_on_arg1(u_arg1): - continue + Returns: + TopoDS_Shape: Lofted object + """ + objs = list(objs) # To determine its length + if len(objs) < 2: + raise ValueError("More than one wire is required") + vertices = [obj for obj in objs if isinstance(obj.wrapped, TopoDS_Vertex)] + vertex_count = len(vertices) - # Tangency on curve 2 - p2 = gp_Pnt2d() - u_circ2, u_arg2 = gcc.Tangency2(i, p2) - if not _valid_on_arg2(u_arg2): - continue + if vertex_count > 2: + raise ValueError("Only two vertices are allowed") - qual1 = GccEnt_Position(int()) - qual2 = GccEnt_Position(int()) - gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values - print( - f"Solution {i}: " - f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " - f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " - f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" + if vertex_count == 1 and not ( + isinstance(objs[0].wrapped, TopoDS_Vertex) + or isinstance(objs[-1].wrapped, TopoDS_Vertex) + ): + raise ValueError( + "The vertex must be either at the beginning or end of the list" ) - # Build BOTH sagitta arcs and select by LengthConstraint - if sagitta_constraint == LengthConstraint.BOTH: - solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) - else: - arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) - arcs = sorted( - arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + if vertex_count == 2: + if len(objs) == 2: + raise ValueError( + "You can't have only 2 vertices to loft; try adding some wires" ) - solutions.append(arcs[sagitta_constraint.value]) - return ShapeList([edge_factory(e) for e in solutions]) + if not ( + isinstance(objs[0].wrapped, TopoDS_Vertex) + and isinstance(objs[-1].wrapped, TopoDS_Vertex) + ): + raise ValueError( + "The vertices must be at the beginning and end of the list" + ) + + loft_builder = BRepOffsetAPI_ThruSections(filled, ruled) + + for obj in objs: + if isinstance(obj.wrapped, TopoDS_Vertex): + loft_builder.AddVertex(obj.wrapped) + elif isinstance(obj.wrapped, TopoDS_Wire): + loft_builder.AddWire(obj.wrapped) + + loft_builder.Build() + + return loft_builder.Shape() + + +def _make_topods_compound_from_shapes( + occt_shapes: Iterable[TopoDS_Shape | None], +) -> TopoDS_Compound: + """Create an OCCT TopoDS_Compound + + Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects + + Args: + occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes + + Returns: + TopoDS_Compound: OCCT compound + """ + comp = TopoDS_Compound() + comp_builder = TopoDS_Builder() + comp_builder.MakeCompound(comp) + + for shape in occt_shapes: + if shape is not None: + comp_builder.Add(comp, shape) + + return comp + + +def _make_topods_face_from_wires( + outer_wire: TopoDS_Wire, inner_wires: Iterable[TopoDS_Wire] | None = None +) -> TopoDS_Face: + """_make_topods_face_from_wires + + Makes a planar face from one or more wires + + Args: + outer_wire (TopoDS_Wire): closed perimeter wire + inner_wires (Iterable[TopoDS_Wire], optional): holes. Defaults to None. + + Raises: + ValueError: outer wire not closed + ValueError: wires not planar + ValueError: inner wire not closed + ValueError: internal error + + Returns: + TopoDS_Face: planar face potentially with holes + """ + if inner_wires and not BRep_Tool.IsClosed_s(outer_wire): + raise ValueError("Cannot build face(s): outer wire is not closed") + inner_wires = list(inner_wires) if inner_wires else [] + + # check if wires are coplanar + verification_compound = _make_topods_compound_from_shapes( + [outer_wire] + inner_wires + ) + if not BRepLib_FindSurface(verification_compound, OnlyPlane=True).Found(): + raise ValueError("Cannot build face(s): wires not planar") + + # fix outer wire + sf_s = ShapeFix_Shape(outer_wire) + sf_s.Perform() + topo_wire = TopoDS.Wire_s(sf_s.Shape()) + + face_builder = BRepBuilderAPI_MakeFace(topo_wire, True) + + 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) + + face_builder.Build() + + if not face_builder.IsDone(): + raise ValueError(f"Cannot build face(s): {face_builder.Error()}") + + face = face_builder.Face() + + sf_f = ShapeFix_Face(face) + sf_f.FixOrientation() + sf_f.Perform() + + return TopoDS.Face_s(sf_f.Result()) + + +def _topods_bool_op( + args: Iterable[TopoDS_Shape], + tools: Iterable[TopoDS_Shape], + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, +) -> TopoDS_Shape: + """Generic boolean operation for TopoDS_Shapes + + Args: + args: Iterable[TopoDS_Shape]: + tools: Iterable[TopoDS_Shape]: + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: + + Returns: TopoDS_Shape + + """ + args = list(args) + tools = list(tools) + arg = TopTools_ListOfShape() + for obj in args: + arg.Append(obj) + + tool = TopTools_ListOfShape() + for obj in tools: + tool.Append(obj) + + operation.SetArguments(arg) + operation.SetTools(tool) + + operation.SetRunParallel(True) + operation.Build() + + result = downcast(operation.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(result, TopoDS_Compound): + result = unwrap_topods_compound(result, True) + + return result + + +def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]: + """Compare the OCCT objects of each list and return the differences""" + shapes_one = list(shapes_one) + shapes_two = list(shapes_two) + occt_one = {shape.wrapped for shape in shapes_one} + occt_two = {shape.wrapped for shape in shapes_two} + occt_delta = list(occt_one - occt_two) + + all_shapes = [] + for shapes in [shapes_one, shapes_two]: + all_shapes.extend(shapes if isinstance(shapes, list) else [*shapes]) + shape_delta = [shape for shape in all_shapes if shape.wrapped in occt_delta] + return shape_delta + + +def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float: + """Return the maximum dimension of one or more shapes""" + shapes = shapes if isinstance(shapes, Iterable) else [shapes] + composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes]) + bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True) + return bbox.diagonal + + +def isclose_b(x: float, y: float, rel_tol=1e-9, abs_tol=1e-14) -> bool: + """Determine whether two floating point numbers are close in value. + Overridden abs_tol default for the math.isclose function. + + Args: + x (float): First value to compare + y (float): Second value to compare + rel_tol (float, optional): Maximum difference for being considered "close", + relative to the magnitude of the input values. Defaults to 1e-9. + abs_tol (float, optional): Maximum difference for being considered "close", + regardless of the magnitude of the input values. Defaults to 1e-14 + (unlike math.isclose which defaults to zero). + + Returns: True if a is close in value to b, and False otherwise. + """ + return isclose(x, y, rel_tol=rel_tol, abs_tol=abs_tol) + + +def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]: + """new_edges + + Given a sequence of shapes and the combination of those shapes, find the newly added edges + + Args: + objects (Shape): sequence of shapes + combined (Shape): result of the combination of objects + + Returns: + ShapeList[Edge]: new edges + """ + # Create a list of combined object edges + combined_topo_edges = TopTools_ListOfShape() + for edge in combined.edges(): + if edge.wrapped is not None: + combined_topo_edges.Append(edge.wrapped) + + # Create a list of original object edges + original_topo_edges = TopTools_ListOfShape() + for edge in [e for obj in objects for e in obj.edges()]: + if edge.wrapped is not None: + original_topo_edges.Append(edge.wrapped) + + # Cut the original edges from the combined edges + operation = BRepAlgoAPI_Cut() + operation.SetArguments(combined_topo_edges) + operation.SetTools(original_topo_edges) + operation.SetRunParallel(True) + operation.Build() + + edges = [] + explorer = TopExp_Explorer(operation.Shape(), TopAbs_ShapeEnum.TopAbs_EDGE) + while explorer.More(): + found_edge = combined.__class__.cast(downcast(explorer.Current())) + found_edge.topo_parent = combined + edges.append(found_edge) + explorer.Next() + + return ShapeList(edges) + + +def polar(length: float, angle: float) -> tuple[float, float]: + """Convert polar coordinates into cartesian coordinates""" + return (length * cos(radians(angle)), length * sin(radians(angle))) + + +def tuplify(obj: Any, dim: int) -> tuple | None: + """Create a size tuple""" + if obj is None: + result = None + elif isinstance(obj, (tuple, list)): + result = tuple(obj) + else: + result = tuple([obj] * dim) + return result + + +def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum: + """Return Shape's TopAbs_ShapeEnum""" + if isinstance(obj.wrapped, TopoDS_Compound): + shapetypes = {shapetype(o.wrapped) for o in obj} + if len(shapetypes) == 1: + result = shapetypes.pop() + else: + result = shapetype(obj.wrapped) + else: + result = shapetype(obj.wrapped) + return result From 2d280a0deba47fcc10497cc4ed79c2548afded3f Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 9 Sep 2025 10:56:02 -0400 Subject: [PATCH 04/51] Fixed tan2 with points and on_curve --- src/build123d/build_enums.py | 2 +- src/build123d/topology/constrained_lines.py | 61 ++++++++------------- src/build123d/topology/one_d.py | 10 ++-- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index a912381..65ef0f7 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -255,7 +255,7 @@ class FontStyle(Enum): class LengthConstraint(Enum): - """Length Constraint for sagatti selection""" + """Length Constraint for sagitta selection""" SHORT = 0 LONG = -1 diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index d9ac708..1e47a5a 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -137,7 +137,7 @@ def _edge_to_qualified_2d( # 5) Create the qualified curve (unqualified is fine here) qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) - return qcurve, hcurve2d, first, last + return qcurve, hcurve2d, first, last, adapt2d def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge: @@ -168,7 +168,7 @@ def _as_gcc_arg( - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) """ if isinstance(obj.wrapped, TopoDS_Edge): - return _edge_to_qualified_2d(obj.wrapped, constaint) + (True,) + return _edge_to_qualified_2d(obj.wrapped, constaint)[0:4] + (True,) loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() try: @@ -263,11 +263,11 @@ def _make_2tan_rad_arcs( if isinstance(object_1, tuple): object_one, object_one_constraint = object_1 else: - object_one = object_1 + object_one, object_one_constraint = object_1, None if isinstance(object_2, tuple): object_two, object_two_constraint = object_2 else: - object_two = object_2 + object_two, object_two_constraint = object_2, None # --------------------------- # Build inputs and GCC @@ -337,7 +337,7 @@ def _make_2tan_rad_arcs( def _make_2tan_on_arcs( object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, *, edge_factory: Callable[[TopoDS_Edge], TWrap], @@ -392,33 +392,18 @@ def _make_2tan_on_arcs( # --------------------------- # Build center locus ("On") input # --------------------------- - # Allow an (Edge, PositionConstraint) tuple for symmetry, but ignore the qualifier here. - on_obj = center_on[0] if isinstance(center_on, tuple) else center_on + _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( + center_on.wrapped, PositionConstraint.UNQUALIFIED + ) + # Provide initial guess parameters for all of the lines + guesses = [] + if is_edge1: + guesses.append((e1_last - e1_first) / 2 + e1_first) + if is_edge2: + guesses.append((e2_last - e2_first) / 2 + e2_first) + guesses.append((on_last - on_first) / 2 + on_first) - # 2TanOn expects a 2D locus for the CENTER. Points are not supported here. - if isinstance(on_obj, (Vertex, Vector)): - raise TypeError( - "center_on cannot be a point for 2TanOn; use the 'center=' (TanCen) variant." - ) - - # Project the 'on' Edge to 2D and choose the appropriate solver - if isinstance(on_obj, Edge): - # Reuse your projection utility to get a 2D curve - # (qualifier irrelevant for the center locus) - _, h_on2d, on_first, on_last = _edge_to_qualified_2d(on_obj.wrapped) - adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) - - # Prefer the analytic 'On' constructor when the locus is a line; otherwise use the Geo variant - use_line = adapt_on.GetType() == GeomAbs_CurveType.GeomAbs_Line - if use_line: - gp_lin2d = adapt_on.Line() - gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, gp_lin2d, TOLERANCE) - else: - # Works for circles and general Geom2d curves as the center locus - gcc = Geom2dGcc_Circ2d2TanOnGeo(q_o1, q_o2, h_on2d, TOLERANCE) - else: - # If it's neither Edge/Vertex/VectorLike (shouldn't happen), bail out clearly - raise TypeError("center_on must be an Edge (line/circle/curve) for 2TanOn.") + gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE, *guesses) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc with center_on constraint") @@ -649,7 +634,7 @@ def _make_tan_cen_arcs( def _make_tan_on_rad_arcs( object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: Edge, radius: float, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, # unused here *, @@ -686,15 +671,17 @@ def _make_tan_on_rad_arcs( raise TypeError("center_on must be an Edge (line/circle/curve) for TanOnRad.") # Project the center locus Edge to 2D (XY) - _, h_on2d, on_first, on_last = _edge_to_qualified_2d(on_obj.wrapped) - adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) + _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( + on_obj.wrapped, PositionConstraint.UNQUALIFIED + ) + # adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) # Choose the appropriate GCC constructor if adapt_on.GetType() == GeomAbs_CurveType.GeomAbs_Line: - gp_lin2d = adapt_on.Line() - gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, gp_lin2d, radius, TOLERANCE) + # gp_lin2d = adapt_on.Line() + gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE) else: - gcc = Geom2dGcc_Circ2dTanOnRadGeo(q_o1, h_on2d, radius, TOLERANCE) + gcc = Geom2dGcc_Circ2dTanOnRadGeo(q_o1, adapt_on, radius, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find circle(s) for TanOnRad constraints") diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 3b8f5ff..8a8f3cd 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1617,7 +1617,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, *, - center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: """ @@ -1628,8 +1628,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) - center_on (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - the **center locus** (not a tangency target) + center_on (Edge): center must lie on this edge sagitta_constraint (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1697,7 +1696,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, *, radius: float, - center_on: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: """make_constrained_arcs @@ -1711,8 +1710,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): Geometric entity to be contacted/touched by the circle(s) radius (float): arc radius - center_on (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - the **center locus** (not a tangency target) + center_on (Edge): center must lie on this edge sagitta_constraint (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. From 32fb6c4ed6ebeb66fb9b94b2d7c3a13ab6846a18 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 9 Sep 2025 11:25:51 -0400 Subject: [PATCH 05/51] Fixed 1 tangent/pnt and center --- src/build123d/topology/constrained_lines.py | 5 ++--- src/build123d/topology/one_d.py | 8 +------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 1e47a5a..1ae51b0 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -550,7 +550,6 @@ def _make_3tan_arcs( def _make_tan_cen_arcs( object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, center: VectorLike | Vertex, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, # unused here *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: @@ -572,7 +571,7 @@ def _make_tan_cen_arcs( if isinstance(object_1, tuple): object_one, obj1_qual = object_1 else: - object_one = object_1 + object_one, obj1_qual = object_1, None # --------------------------- # Build fixed center (gp_Pnt2d) @@ -609,7 +608,7 @@ def _make_tan_cen_arcs( else: # Case B: tangency target is a curve/edge (qualified curve) - gcc = Geom2dGcc_Circ2dTanCen(q_o1, c2d, TOLERANCE) + gcc = Geom2dGcc_Circ2dTanCen(q_o1, Geom2d_CartesianPoint(c2d), TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError( "Unable to find circle(s) tangent to target with fixed center" diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 8a8f3cd..12dfdff 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1670,7 +1670,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, *, center: VectorLike, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: """make_constrained_arcs @@ -1681,9 +1680,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): Geometric entity to be contacted/touched by the circle(s) center (VectorLike): center position - sagitta_constraint (LengthConstraint, optional): returned arc selector - (i.e. either the short, long or both arcs). Defaults to - LengthConstraint.SHORT. Returns: ShapeList[Edge]: tangent arcs @@ -1794,9 +1790,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): and radius is None and center_on is None ): - return _make_tan_cen_arcs( - *tangencies, center, sagitta_constraint, edge_factory=cls - ) + return _make_tan_cen_arcs(*tangencies, center, edge_factory=cls) if tan_count == 1 and center_on is not None and radius is not None: return _make_tan_on_rad_arcs( *tangencies, center_on, radius, sagitta_constraint, edge_factory=cls From 76ec798d2127825c65c9bfc90b824a977dc007f6 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 9 Sep 2025 14:22:41 -0400 Subject: [PATCH 06/51] Basic sanity of all options --- src/build123d/topology/constrained_lines.py | 60 +++++++++++++++------ src/build123d/topology/one_d.py | 12 ++--- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 1ae51b0..cb25f72 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -45,6 +45,7 @@ from OCP.Geom2d import ( Geom2d_TrimmedCurve, ) from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve +from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve from OCP.Geom2dGcc import ( Geom2dGcc_Circ2d2TanOn, Geom2dGcc_Circ2d2TanOnGeo, @@ -56,7 +57,7 @@ from OCP.Geom2dGcc import ( Geom2dGcc_QualifiedCurve, ) from OCP.GeomAbs import GeomAbs_CurveType -from OCP.GeomAPI import GeomAPI +from OCP.GeomAPI import GeomAPI, GeomAPI_ProjectPointOnCurve from OCP.gp import ( gp_Ax2d, gp_Ax3, @@ -433,6 +434,23 @@ def _make_2tan_on_arcs( if not _valid_on_arg2(u_arg2): continue + # Center must lie on the trimmed center_on curve segment + center2d = circ.Location() # gp_Pnt2d + + # Project center onto the (trimmed) 2D locus + proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d) + if proj.NbPoints() == 0: + continue # no projection -> reject + + u_on = proj.Parameter(1) + # Optional: make sure it's actually on the curve (not just near) + if proj.Distance(1) > TOLERANCE: + continue + + # Respect the trimmed interval (handles periodic curves too) + if not _param_in_trim(u_on, on_first, on_last, h_on2d): + continue + # Build sagitta arc(s) and select by LengthConstraint if sagitta_constraint == LengthConstraint.BOTH: solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) @@ -492,8 +510,12 @@ def _make_3tan_arcs( q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, obj2_qual) q_o3, h_e3, e3_first, e3_last, is_edge3 = _as_gcc_arg(object_three, obj3_qual) + guesses = [ + (l - f) / 2 + f + for f, l in [(e1_first, e1_last), (e2_first, e2_last), (e3_first, e3_last)] + ] # For 3Tan we keep the user-given order so the arc endpoints remain (arg1,arg2) - gcc = Geom2dGcc_Circ2d3Tan(q_o1, q_o2, q_o3, TOLERANCE) + gcc = Geom2dGcc_Circ2d3Tan(q_o1, q_o2, q_o3, TOLERANCE, *guesses) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a circle tangent to all three objects") @@ -635,7 +657,6 @@ def _make_tan_on_rad_arcs( object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, center_on: Edge, radius: float, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, # unused here *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: @@ -648,39 +669,31 @@ def _make_tan_on_rad_arcs( Notes ----- - The center locus must be a 2D curve (line/circle/any Geom2d curve) — i.e. an Edge - after projection to XY. A point is not a valid 'center_on' locus for this solver. + after projection to XY. - With only one tangency, the natural geometric result is a full circle; arc cropping would require an additional endpoint constraint. This routine therefore returns closed circular edges (2π trims) for each valid solution. """ # --- unpack optional qualifier on the tangency arg (edges only) --- - obj1_qual = PositionConstraint.UNQUALIFIED if isinstance(object_1, tuple): object_one, obj1_qual = object_1 else: - object_one = object_1 + object_one, obj1_qual = object_1, PositionConstraint.UNQUALIFIED # --- build tangency input (point/edge) --- q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) # --- center locus ('center_on') must be a curve; ignore any qualifier there --- on_obj = center_on[0] if isinstance(center_on, tuple) else center_on - if not isinstance(on_obj, Edge): + if not isinstance(on_obj.wrapped, TopoDS_Edge): raise TypeError("center_on must be an Edge (line/circle/curve) for TanOnRad.") # Project the center locus Edge to 2D (XY) _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( on_obj.wrapped, PositionConstraint.UNQUALIFIED ) - # adapt_on = Geom2dAdaptor_Curve(h_on2d, on_first, on_last) - - # Choose the appropriate GCC constructor - if adapt_on.GetType() == GeomAbs_CurveType.GeomAbs_Line: - # gp_lin2d = adapt_on.Line() - gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE) - else: - gcc = Geom2dGcc_Circ2dTanOnRadGeo(q_o1, adapt_on, radius, TOLERANCE) + gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find circle(s) for TanOnRad constraints") @@ -699,6 +712,23 @@ def _make_tan_on_rad_arcs( if not _ok1(u_on_arg): continue + # Center must lie on the trimmed center_on curve segment + center2d = circ.Location() # gp_Pnt2d + + # Project center onto the (trimmed) 2D locus + proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d) + if proj.NbPoints() == 0: + continue # no projection -> reject + + u_on = proj.Parameter(1) + # Optional: make sure it's actually on the curve (not just near) + if proj.Distance(1) > TOLERANCE: + continue + + # Respect the trimmed interval (handles periodic curves too) + if not _param_in_trim(u_on, on_first, on_last, h_on2d): + continue + h2d = Geom2d_Circle(circ) per = h2d.Period() out_topos.append(_edge_from_circle(h2d, 0.0, per)) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 12dfdff..bf076ef 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1693,7 +1693,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): *, radius: float, center_on: Edge, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: """make_constrained_arcs @@ -1746,10 +1745,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tan_count = len(tangencies) if not (1 <= tan_count <= 3): raise TypeError("Provide 1 to 3 tangency targets.") - if ( - sum(x is not None for x in (radius, center, center_on)) > 1 - and tan_count != 2 - ): + if sum( + x is not None for x in (radius, center, center_on) + ) > 1 and tan_count not in [1, 2]: raise TypeError("Ambiguous constraint combination.") # Disallow qualifiers on points/vertices (enforce at runtime) @@ -1783,7 +1781,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): *tangencies, center_on, sagitta_constraint, edge_factory=cls ) if tan_count == 3 and radius is None and center is None and center_on is None: - return _make_3tan_arcs(tangencies, sagitta_constraint, edge_factory=cls) + return _make_3tan_arcs(*tangencies, sagitta_constraint, edge_factory=cls) if ( tan_count == 1 and center is not None @@ -1793,7 +1791,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return _make_tan_cen_arcs(*tangencies, center, edge_factory=cls) if tan_count == 1 and center_on is not None and radius is not None: return _make_tan_on_rad_arcs( - *tangencies, center_on, radius, sagitta_constraint, edge_factory=cls + *tangencies, center_on, radius, edge_factory=cls ) raise ValueError("Unsupported or ambiguous combination of constraints.") From 3b11f40d9debc629b692f3e99b24b62ecd21db9a Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 11 Sep 2025 10:09:56 -0400 Subject: [PATCH 07/51] Moved edge/point ordering to make_constrained_arcs --- src/build123d/topology/constrained_lines.py | 50 ++++++++------------- src/build123d/topology/one_d.py | 42 ++++++++++------- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index cb25f72..39cc35f 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -264,11 +264,11 @@ def _make_2tan_rad_arcs( if isinstance(object_1, tuple): object_one, object_one_constraint = object_1 else: - object_one, object_one_constraint = object_1, None + object_one, object_one_constraint = object_1, PositionConstraint.UNQUALIFIED if isinstance(object_2, tuple): object_two, object_two_constraint = object_2 else: - object_two, object_two_constraint = object_2, None + object_two, object_two_constraint = object_2, PositionConstraint.UNQUALIFIED # --------------------------- # Build inputs and GCC @@ -280,10 +280,6 @@ def _make_2tan_rad_arcs( object_two, object_two_constraint ) - # Put the Edge arg first when exactly one is an Edge (improves robustness) - if is_edge1 ^ is_edge2: - q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) - gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc") @@ -349,25 +345,18 @@ def _make_2tan_on_arcs( Notes ----- - - `center_on` is treated as a **center locus** (not a tangency target). For a line - locus this uses Geom2dGcc_Circ2d2TanOn; for other 2D curves it uses the *Geo variant*. - - A point is NOT a valid center locus for the 2TanOn solver; use the TanCen variant - (fixed center) for that case. + - `center_on` is treated as a **center locus** (not a tangency target). """ - # Unpack optional qualifiers on the two tangency args - object_one_constraint = PositionConstraint.UNQUALIFIED - object_two_constraint = PositionConstraint.UNQUALIFIED - if isinstance(object_1, tuple): object_one, object_one_constraint = object_1 else: - object_one = object_1 + object_one, object_one_constraint = object_1, PositionConstraint.UNQUALIFIED if isinstance(object_2, tuple): object_two, object_two_constraint = object_2 else: - object_two = object_2 + object_two, object_two_constraint = object_2, PositionConstraint.UNQUALIFIED # --------------------------- # Build tangency inputs @@ -379,17 +368,6 @@ def _make_2tan_on_arcs( object_two, object_two_constraint ) - # Prefer "edge-first" ordering when exactly one arg is an Edge - if is_edge1 ^ is_edge2: - q_o1, q_o2 = (q_o1, q_o2) if is_edge1 else (q_o2, q_o1) - h_e1, h_e2 = (h_e1, h_e2) if is_edge1 else (h_e2, h_e1) - e1_first, e1_last, e2_first, e2_last = ( - (e1_first, e1_last, e2_first, e2_last) - if is_edge1 - else (e2_first, e2_last, e1_first, e1_last) - ) - is_edge1, is_edge2 = (True, False) if is_edge1 else (False, True) - # --------------------------- # Build center locus ("On") input # --------------------------- @@ -404,7 +382,10 @@ def _make_2tan_on_arcs( guesses.append((e2_last - e2_first) / 2 + e2_first) guesses.append((on_last - on_first) / 2 + on_first) - gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE, *guesses) + if is_edge1 or is_edge2: + gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE, *guesses) + else: + gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc with center_on constraint") @@ -510,10 +491,15 @@ def _make_3tan_arcs( q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, obj2_qual) q_o3, h_e3, e3_first, e3_last, is_edge3 = _as_gcc_arg(object_three, obj3_qual) - guesses = [ - (l - f) / 2 + f - for f, l in [(e1_first, e1_last), (e2_first, e2_last), (e3_first, e3_last)] - ] + # Provide initial guess parameters for all of the lines + guesses = [] + if is_edge1: + guesses.append((e1_last - e1_first) / 2 + e1_first) + if is_edge2: + guesses.append((e2_last - e2_first) / 2 + e2_first) + if is_edge3: + guesses.append((e3_last - e3_first) / 2 + e3_first) + # For 3Tan we keep the user-given order so the arc endpoints remain (arg1,arg2) gcc = Geom2dGcc_Circ2d3Tan(q_o1, q_o2, q_o3, TOLERANCE, *guesses) if not gcc.IsDone() or gcc.NbSolutions() == 0: diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index bf076ef..ac47101 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1588,8 +1588,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, radius: float, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, @@ -1598,8 +1598,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Create all planar circular arcs of a given radius that are tangent/contacting the two provided objects on the XY plane. Args: - tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) radius (float): arc radius sagitta_constraint (LengthConstraint, optional): returned arc selector @@ -1614,8 +1614,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, @@ -1625,8 +1625,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): CENTER lies on a given locus (line/circle/curve) on the XY plane. Args: - tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) center_on (Edge): center must lie on this edge sagitta_constraint (LengthConstraint, optional): returned arc selector @@ -1641,9 +1641,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - tangency_three: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_three: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, ) -> ShapeList[Edge]: @@ -1651,9 +1651,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Create planar circular arc(s) on XY tangent to three provided objects. Args: - tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Vertex | VectorLike): - tangency_three (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_three (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) sagitta_constraint (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to @@ -1667,7 +1667,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, center: VectorLike, ) -> ShapeList[Edge]: @@ -1677,7 +1677,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): a single object. Args: - tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entity to be contacted/touched by the circle(s) center (VectorLike): center position @@ -1689,7 +1689,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, radius: float, center_on: Edge, @@ -1742,6 +1742,14 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangencies = [ t for t in (tangency_one, tangency_two, tangency_three) if t is not None ] + + # Sort the tangency inputs so points are always last + tangent_tuples = [t if isinstance(t, tuple) else (t, None) for t in tangencies] + tangent_tuples = sorted( + tangent_tuples, key=lambda t: not issubclass(type(t[0]), Edge) + ) + tangencies = [t[0] if t[1] is None else t for t in tangent_tuples] + tan_count = len(tangencies) if not (1 <= tan_count <= 3): raise TypeError("Provide 1 to 3 tangency targets.") From 1bcbde29bce8a8463975bd9a2f385feaa3806369 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 11 Sep 2025 11:38:50 -0400 Subject: [PATCH 08/51] Add intersection test framework with tests from issues --- tests/test_direct_api/test_intersection.py | 114 +++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/test_direct_api/test_intersection.py diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py new file mode 100644 index 0000000..d39c4df --- /dev/null +++ b/tests/test_direct_api/test_intersection.py @@ -0,0 +1,114 @@ +import pytest +from collections import Counter +from dataclasses import dataclass +from build123d import * +from build123d.topology.shape_core import Shape + +INTERSECT_DEBUG = False +if INTERSECT_DEBUG: + from ocp_vscode import show + + +@dataclass +class Case: + object: Shape | Vector | Location | Axis | Plane + target: Shape | Vector | Location | Axis | Plane + expected: list | Vector | Location | Axis | Plane + name: str + xfail: None | str = None + + +@pytest.mark.skip +def run_test(obj, target, expected): + if isinstance(target, list): + result = obj.intersect(*target) + else: + result = obj.intersect(target) + if INTERSECT_DEBUG: + show([obj, target, result]) + if expected is None: + assert result == expected, f"Expected None, but got {result}" + else: + e_type = ShapeList if isinstance(expected, list) else expected + assert isinstance(result, e_type), f"Expected {e_type}, but got {result}" + if e_type == ShapeList: + assert len(result) >= len(expected), f"Expected {len(expected)} objects, but got {len(result)}" + + actual_counts = Counter(type(obj) for obj in result) + expected_counts = Counter(expected) + assert all(actual_counts[t] >= count for t, count in expected_counts.items()), f"Expected {expected}, but got {[type(r) for r in result]}" + + +@pytest.mark.skip +def make_params(matrix): + params = [] + for case in matrix: + obj_type = type(case.object).__name__ + tar_type = type(case.target).__name__ + i = len(params) + if case.xfail and not INTERSECT_DEBUG: + marks = [pytest.mark.xfail(reason=case.xfail)] + else: + marks = [] + uid = f"{i} {obj_type}, {tar_type}, {case.name}" + params.append(pytest.param(case.object, case.target, case.expected, marks=marks, id=uid)) + if tar_type != obj_type and not isinstance(case.target, list): + uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}" + params.append(pytest.param(case.target, case.object, case.expected, marks=marks, id=uid)) + + return params + + +# FreeCAD issue example +c1 = CenterArc((0, 0), 10, 0, 360).edge() +c2 = CenterArc((19, 0), 10, 0, 360).edge() +skew = Line((-12, 0), (30, 10)).edge() +vert = Line((10, 0), (10, 20)).edge() +horz = Line((0, 10), (30, 10)).edge() +e1 = EllipticalCenterArc((5, 0), 5, 10, 0, 360).edge() + +freecad_matrix = [ + Case(c1, skew, [Vertex, Vertex], "circle, skew, intersect", None), + Case(c2, skew, [Vertex, Vertex], "circle, skew, intersect", None), + Case(c1, e1, [Vertex, Vertex, Vertex], "circle, ellipse, intersect + tangent", None), + Case(c2, e1, [Vertex, Vertex], "circle, ellipse, intersect", None), + Case(skew, e1, [Vertex, Vertex], "skew, ellipse, intersect", None), + Case(skew, horz, [Vertex], "skew, horizontal, coincident", None), + Case(skew, vert, [Vertex], "skew, vertical, intersect", None), + Case(horz, vert, [Vertex], "horizontal, vertical, intersect", None), + Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None), + Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None), + + Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", "Should return 2 Vertices"), + Case(c1, horz, [Vertex], "circle, horiz, tangent", None), + Case(c2, horz, [Vertex], "circle, horiz, tangent", None), + Case(c1, vert, [Vertex], "circle, vert, tangent", None), + Case(c2, vert, [Vertex], "circle, vert, intersect", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix)) +def test_freecad(obj, target, expected): + run_test(obj, target, expected) + + +# Issue tests +t = Sketch() + GridLocations(5, 0, 2, 1) * Circle(2) +s = Circle(10).face() +l = Line(-20, 20).edge() +a = Rectangle(10,10).face() +b = (Plane.XZ * a).face() +e1 = Edge.make_line((-1, 0), (1, 0)) +w1 = Wire.make_circle(0.5) +f1 = Face(Wire.make_circle(0.5)) + +issues_matrix = [ + Case(t, t, [Face, Face], "issue #1015", "Returns Compound"), + Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"), + Case(a, b, [Edge], "issue #918", "Returns empty Compound"), + Case(e1, w1, [Vertex, Vertex], "issue #697", "Returns None"), + Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) +def test_issues(obj, target, expected): + run_test(obj, target, expected) \ No newline at end of file From a291a942a17e56c8f0c7f0e7f8d0f87137a6e72b Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 11 Sep 2025 12:30:32 -0400 Subject: [PATCH 09/51] Mark test_freecad xfail due to type missmatches --- tests/test_direct_api/test_intersection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index d39c4df..135e0a3 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -86,6 +86,7 @@ freecad_matrix = [ Case(c2, vert, [Vertex], "circle, vert, intersect", None), ] +@pytest.mark.xfail @pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix)) def test_freecad(obj, target, expected): run_test(obj, target, expected) From da1294a390aac28b8741dbd37165a9e8a3979d32 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 11 Sep 2025 12:32:23 -0400 Subject: [PATCH 10/51] Add geometry intersection tests. Tighten intersection with Vector from Location and coplanar Planes. --- src/build123d/geometry.py | 47 +++++++++------- tests/test_direct_api/test_intersection.py | 63 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index fa54fe7..69fae4f 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -909,15 +909,15 @@ class Axis(metaclass=AxisMeta): """Find intersection of vector and axis""" @overload - def intersect(self, location: Location) -> Location | None: + def intersect(self, location: Location) -> Vector | Location | None: """Find intersection of location and axis""" @overload - def intersect(self, axis: Axis) -> Axis | None: + def intersect(self, axis: Axis) -> Vector | Axis | None: """Find intersection of axis and axis""" @overload - def intersect(self, plane: Plane) -> Axis | None: + def intersect(self, plane: Plane) -> Vector | Axis | None: """Find intersection of plane and axis""" def intersect(self, *args, **kwargs): @@ -965,12 +965,12 @@ class Axis(metaclass=AxisMeta): # Find the "direction" of the location location_dir = Plane(location).z_dir - # Is the location on the axis with the same direction? - if ( - self.intersect(location.position) is not None - and location_dir == self.direction - ): - return location + if self.intersect(location.position) is not None: + # Is the location on the axis with the same direction? + if location_dir == self.direction: + return location + else: + return location.position if shape is not None: return shape.intersect(self) @@ -1932,15 +1932,15 @@ class Location: """Find intersection of vector and location""" @overload - def intersect(self, location: Location) -> Location | None: + def intersect(self, location: Location) -> Vector | Location | None: """Find intersection of location and location""" @overload - def intersect(self, axis: Axis) -> Location | None: + def intersect(self, axis: Axis) -> Vector | Location | None: """Find intersection of axis and location""" @overload - def intersect(self, plane: Plane) -> Location | None: + def intersect(self, plane: Plane) -> Vector | Location | None: """Find intersection of plane and location""" def intersect(self, *args, **kwargs): @@ -1956,8 +1956,11 @@ class Location: if vector is not None and self.position == vector: return vector - if location is not None and self == location: - return self + if location is not None: + if self == location: + return self + elif self.position == location.position: + return self.position if shape is not None: return shape.intersect(self) @@ -3131,15 +3134,15 @@ class Plane(metaclass=PlaneMeta): """Find intersection of vector and plane""" @overload - def intersect(self, location: Location) -> Location | None: + def intersect(self, location: Location) -> Vector | Location | None: """Find intersection of location and plane""" @overload - def intersect(self, axis: Axis) -> Axis | Vector | None: + def intersect(self, axis: Axis) -> Vector | Axis | None: """Find intersection of axis and plane""" @overload - def intersect(self, plane: Plane) -> Axis | None: + def intersect(self, plane: Plane) -> Axis | Plane | None: """Find intersection of plane and plane""" @overload @@ -3172,6 +3175,9 @@ class Plane(metaclass=PlaneMeta): return intersection_point if plane is not None: + if self.contains(plane.origin) and self.z_dir == plane.z_dir: + return self + surface1 = Geom_Plane(self.wrapped) surface2 = Geom_Plane(plane.wrapped) intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE) @@ -3187,8 +3193,11 @@ class Plane(metaclass=PlaneMeta): if location is not None: pln = Plane(location) - if pln.origin == self.origin and pln.z_dir == self.z_dir: - return location + if self.contains(pln.origin): + if self.z_dir == pln.z_dir: + return location + else: + return pln.origin if shape is not None: return shape.intersect(self) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 135e0a3..b885cd0 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -59,6 +59,69 @@ def make_params(matrix): return params +# Geometric test objects +ax1 = Axis.X +ax2 = Axis.Y +ax3 = Axis((0, 0, 5), (1, 0, 0)) +pl1 = Plane.YZ +pl2 = Plane.XY +pl3 = Plane.XY.offset(5) +pl4 = Plane((0, 5, 0)) +vl1 = Vector(2, 0, 0) +vl2 = Vector(2, 0, 5) +lc1 = Location((2, 0, 0)) +lc2 = Location((2, 0, 5)) +lc3 = Location((0, 0, 0), (0, 90, 90)) +lc4 = Location((2, 0, 0), (0, 90, 90)) + +# Geometric test matrix +geometry_matrix = [ + Case(ax1, ax3, None, "parallel/skew", None), + Case(ax1, ax1, Axis, "collinear", None), + Case(ax1, ax2, Vector, "intersecting", None), + + Case(ax1, pl3, None, "parallel", None), + Case(ax1, pl2, Axis, "coplanar", None), + Case(ax1, pl1, Vector, "intersecting", None), + + Case(ax1, vl2, None, "non-coincident", None), + Case(ax1, vl1, Vector, "coincident", None), + + Case(ax1, lc2, None, "non-coincident", None), + Case(ax1, lc4, Location, "intersecting, co-z", None), + Case(ax1, lc1, Vector, "intersecting", None), + + Case(pl2, pl3, None, "parallel", None), + Case(pl2, pl4, Plane, "coplanar", None), + Case(pl1, pl2, Axis, "intersecting", None), + + Case(pl3, ax1, None, "parallel", None), + Case(pl2, ax1, Axis, "coplanar", None), + Case(pl1, ax1, Vector, "intersecting", None), + + Case(pl1, vl2, None, "non-coincident", None), + Case(pl2, vl1, Vector, "coincident", None), + + Case(pl1, lc2, None, "non-coincident", None), + Case(pl1, lc3, Location, "intersecting, co-z", None), + Case(pl2, lc4, Vector, "coincident", None), + + Case(vl1, vl2, None, "non-coincident", None), + Case(vl1, vl1, Vector, "coincident", None), + + Case(vl1, lc2, None, "non-coincident", None), + Case(vl1, lc1, Vector, "coincident", None), + + Case(lc1, lc2, None, "non-coincident", None), + Case(lc1, lc4, Vector, "coincident", None), + Case(lc1, lc1, Location, "coincident, co-z", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(geometry_matrix)) +def test_geometry(obj, target, expected): + run_test(obj, target, expected) + + # FreeCAD issue example c1 = CenterArc((0, 0), 10, 0, 360).edge() c2 = CenterArc((19, 0), 10, 0, 360).edge() From d313ebda60613cebb04627881cfb373538b7807c Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 11 Sep 2025 12:52:43 -0400 Subject: [PATCH 11/51] Add Vertex.intersect Vertex is always treated as Vector with point-like objects --- src/build123d/topology/zero_d.py | 39 +++++++++++++++++++++- tests/test_direct_api/test_intersection.py | 29 ++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index bd19653..7d52245 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -66,7 +66,7 @@ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge from OCP.gp import gp_Pnt -from build123d.geometry import Matrix, Vector, VectorLike +from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane from typing_extensions import Self from .shape_core import Shape, ShapeList, downcast, shapetype @@ -168,6 +168,43 @@ class Vertex(Shape[TopoDS_Vertex]): """extrude - invalid operation for Vertex""" raise NotImplementedError("Vertices can't be created by extrusion") + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex]: + """Intersection of the arguments and this shape + + Args: + to_intersect (sequence of Union[Shape, Axis, Plane]): Shape(s) to + intersect with + + Returns: + ShapeList[Shape]: Resulting object may be of a ShapeList of multiple + non-Compound object created + """ + points_sets: list[set] = [] + for obj in to_intersect: + # Treat as Vector, otherwise call intersection from Shape + match obj: + case Vertex(): + result = Vector(self).intersect(Vector(obj)) + case Vector() | Location() | Axis() | Plane(): + result = obj.intersect(Vector(self)) + case _ if issubclass(type(obj), Shape): + result = obj.intersect(self) + case _: + raise ValueError(f"Unknown object type: {type(obj)}") + + if isinstance(result, Vector): + points_sets.append(set([result])) + else: + points_sets.append(set()) + + common_points = set.intersection(*points_sets) + if common_points: + return ShapeList([Vertex(p) for p in common_points]) + else: + return None + # ---- Instance Methods ---- def __add__( # type: ignore diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index b885cd0..e3f9495 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -122,6 +122,35 @@ def test_geometry(obj, target, expected): run_test(obj, target, expected) +# Shape test matrices +vt1 = Vertex(2, 0, 0) +vt2 = Vertex(2, 0, 5) + +shape_0d_matrix = [ + Case(vt1, vt2, None, "non-coincident", None), + Case(vt1, vt1, [Vertex], "coincident", None), + + Case(vt1, vl2, None, "non-coincident", None), + Case(vt1, vl1, [Vertex], "coincident", None), + + Case(vt1, lc2, None, "non-coincident", None), + Case(vt1, lc1, [Vertex], "coincident", None), + + Case(vt2, ax1, None, "non-coincident", None), + Case(vt1, ax1, [Vertex], "coincident", None), + + Case(vt2, pl1, None, "non-coincident", None), + Case(vt1, pl2, [Vertex], "coincident", None), + + Case(vt1, [vt2, lc1], None, "multi to_intersect, non-coincident", None), + Case(vt1, [vt1, lc1], [Vertex], "multi to_intersect, coincident", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_0d_matrix)) +def test_shape_0d(obj, target, expected): + run_test(obj, target, expected) + + # FreeCAD issue example c1 = CenterArc((0, 0), 10, 0, 360).edge() c2 = CenterArc((19, 0), 10, 0, 360).edge() From 9e679046b15cc12e0c69f107d7120f6c48def18e Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 12 Sep 2025 10:40:54 -0400 Subject: [PATCH 12/51] Cleaning up code --- src/build123d/topology/constrained_lines.py | 220 ++++++++------------ src/build123d/topology/one_d.py | 17 +- 2 files changed, 96 insertions(+), 141 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 39cc35f..020133f 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -229,11 +229,9 @@ def _qstr(q) -> str: def _make_2tan_rad_arcs( - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 2 radius: float, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, - *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> list[Edge]: """ @@ -243,10 +241,8 @@ def _make_2tan_rad_arcs( Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. Args: - object_one (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) - object_two (Edge | Vertex | VectorLike): Geometric entity to be contacted/touched - by the circle(s) + tangencies (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike: + Geometric entity to be contacted/touched by the circle(s) radius (float): Circle radius for all candidate solutions. Raises: @@ -261,34 +257,28 @@ def _make_2tan_rad_arcs( """ - if isinstance(object_1, tuple): - object_one, object_one_constraint = object_1 - else: - object_one, object_one_constraint = object_1, PositionConstraint.UNQUALIFIED - if isinstance(object_2, tuple): - object_two, object_two_constraint = object_2 - else: - object_two, object_two_constraint = object_2, PositionConstraint.UNQUALIFIED + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + tangent_tuples = [ + t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) + for t in tangencies + ] - # --------------------------- - # Build inputs and GCC - # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( - object_one, object_one_constraint - ) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( - object_two, object_two_constraint - ) + # Build inputs for GCC + q_o, h_e, e_first, e_last, is_edge = [[None] * 2 for _ in range(5)] + for i in range(len(tangent_tuples)): + q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( + *tangent_tuples[i] + ) - gcc = Geom2dGcc_Circ2d2TanRad(q_o1, q_o2, radius, TOLERANCE) + gcc = Geom2dGcc_Circ2d2TanRad(*q_o, radius, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc") - def _valid_on_arg1(u: float) -> bool: - return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) - - def _valid_on_arg2(u: float) -> bool: - return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) # --------------------------- # Solutions @@ -300,13 +290,13 @@ def _make_2tan_rad_arcs( # Tangency on curve 1 p1 = gp_Pnt2d() u_circ1, u_arg1 = gcc.Tangency1(i, p1) - if not _valid_on_arg1(u_arg1): + if not _ok(0, u_arg1): continue # Tangency on curve 2 p2 = gp_Pnt2d() u_circ2, u_arg2 = gcc.Tangency2(i, p2) - if not _valid_on_arg2(u_arg2): + if not _ok(1, u_arg2): continue # qual1 = GccEnt_Position(int()) @@ -332,11 +322,9 @@ def _make_2tan_rad_arcs( def _make_2tan_on_arcs( - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 2 center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, - *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ @@ -348,53 +336,41 @@ def _make_2tan_on_arcs( - `center_on` is treated as a **center locus** (not a tangency target). """ - if isinstance(object_1, tuple): - object_one, object_one_constraint = object_1 - else: - object_one, object_one_constraint = object_1, PositionConstraint.UNQUALIFIED + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + tangent_tuples = [ + t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) + for t in tangencies + ] - if isinstance(object_2, tuple): - object_two, object_two_constraint = object_2 - else: - object_two, object_two_constraint = object_2, PositionConstraint.UNQUALIFIED + # Build inputs for GCC + q_o, h_e, e_first, e_last, is_edge = [[None] * 3 for _ in range(5)] + for i in range(len(tangent_tuples)): + q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( + *tangent_tuples[i] + ) - # --------------------------- - # Build tangency inputs - # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg( - object_one, object_one_constraint - ) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg( - object_two, object_two_constraint - ) - - # --------------------------- # Build center locus ("On") input - # --------------------------- - _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( + _, h_on2d, e_first[2], e_last[2], adapt_on = _edge_to_qualified_2d( center_on.wrapped, PositionConstraint.UNQUALIFIED ) - # Provide initial guess parameters for all of the lines - guesses = [] - if is_edge1: - guesses.append((e1_last - e1_first) / 2 + e1_first) - if is_edge2: - guesses.append((e2_last - e2_first) / 2 + e2_first) - guesses.append((on_last - on_first) / 2 + on_first) + is_edge[2] = True - if is_edge1 or is_edge2: - gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE, *guesses) + # Provide initial middle guess parameters for all of the edges + guesses = [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + + if sum(is_edge) > 1: + gcc = Geom2dGcc_Circ2d2TanOn(*q_o[0:2], adapt_on, TOLERANCE, *guesses) else: - gcc = Geom2dGcc_Circ2d2TanOn(q_o1, q_o2, adapt_on, TOLERANCE) + gcc = Geom2dGcc_Circ2d2TanOn(*q_o[0:2], adapt_on, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc with center_on constraint") - def _valid_on_arg1(u: float) -> bool: - return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) - - def _valid_on_arg2(u: float) -> bool: - return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) # --------------------------- # Solutions @@ -406,13 +382,13 @@ def _make_2tan_on_arcs( # Tangency on curve 1 p1 = gp_Pnt2d() u_circ1, u_arg1 = gcc.Tangency1(i, p1) - if not _valid_on_arg1(u_arg1): + if not _ok(0, u_arg1): continue # Tangency on curve 2 p2 = gp_Pnt2d() u_circ2, u_arg2 = gcc.Tangency2(i, p2) - if not _valid_on_arg2(u_arg2): + if not _ok(1, u_arg2): continue # Center must lie on the trimmed center_on curve segment @@ -429,7 +405,7 @@ def _make_2tan_on_arcs( continue # Respect the trimmed interval (handles periodic curves too) - if not _param_in_trim(u_on, on_first, on_last, h_on2d): + if not _param_in_trim(u_on, e_first[2], e_last[2], h_on2d): continue # Build sagitta arc(s) and select by LengthConstraint @@ -448,71 +424,46 @@ def _make_2tan_on_arcs( def _make_3tan_arcs( - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_2: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - object_3: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 3 sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, - *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ Create planar circular arc(s) on XY tangent to three provided objects. The circle is determined by the three tangency constraints; the returned arc(s) - are trimmed between the two tangency points corresponding to `object_1` and - `object_2`. Use `sagitta_constraint` to select the shorter/longer (or both) arc. + are trimmed between the two tangency points corresponding to `tangencies[0]` and + `tangencies[1]`. Use `sagitta_constraint` to select the shorter/longer (or both) arc. Inputs must be representable on Plane.XY. """ # Unpack optional per-edge qualifiers (default UNQUALIFIED) - obj1_qual = PositionConstraint.UNQUALIFIED - obj2_qual = PositionConstraint.UNQUALIFIED - obj3_qual = PositionConstraint.UNQUALIFIED + tangent_tuples = [ + t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) + for t in tangencies + ] - if isinstance(object_1, tuple): - object_one, obj1_qual = object_1 - else: - object_one = object_1 - - if isinstance(object_2, tuple): - object_two, obj2_qual = object_2 - else: - object_two = object_2 - - if isinstance(object_3, tuple): - object_three, obj3_qual = object_3 - else: - object_three = object_3 - - # --------------------------- # Build inputs for GCC - # --------------------------- - q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) - q_o2, h_e2, e2_first, e2_last, is_edge2 = _as_gcc_arg(object_two, obj2_qual) - q_o3, h_e3, e3_first, e3_last, is_edge3 = _as_gcc_arg(object_three, obj3_qual) + q_o, h_e, e_first, e_last, is_edge = [[None] * 3 for _ in range(5)] + for i in range(len(tangent_tuples)): + q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( + *tangent_tuples[i] + ) - # Provide initial guess parameters for all of the lines - guesses = [] - if is_edge1: - guesses.append((e1_last - e1_first) / 2 + e1_first) - if is_edge2: - guesses.append((e2_last - e2_first) / 2 + e2_first) - if is_edge3: - guesses.append((e3_last - e3_first) / 2 + e3_first) + # Provide initial middle guess parameters for all of the edges + guesses = [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + + # Generate all valid circles tangent to the 3 inputs + gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) - # For 3Tan we keep the user-given order so the arc endpoints remain (arg1,arg2) - gcc = Geom2dGcc_Circ2d3Tan(q_o1, q_o2, q_o3, TOLERANCE, *guesses) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a circle tangent to all three objects") - def _ok1(u: float) -> bool: - return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) - - def _ok2(u: float) -> bool: - return True if not is_edge2 else _param_in_trim(u, e2_first, e2_last, h_e2) - - def _ok3(u: float) -> bool: - return True if not is_edge3 else _param_in_trim(u, e3_first, e3_last, h_e3) + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) # --------------------------- # Enumerate solutions @@ -524,19 +475,19 @@ def _make_3tan_arcs( # Tangency on curve 1 (arc endpoint A) p1 = gp_Pnt2d() u_circ1, u_arg1 = gcc.Tangency1(i, p1) - if not _ok1(u_arg1): + if not _ok(0, u_arg1): continue # Tangency on curve 2 (arc endpoint B) p2 = gp_Pnt2d() u_circ2, u_arg2 = gcc.Tangency2(i, p2) - if not _ok2(u_arg2): + if not _ok(1, u_arg2): continue # Tangency on curve 3 (validates circle; does not define arc endpoints) p3 = gp_Pnt2d() _u_circ3, u_arg3 = gcc.Tangency3(i, p3) - if not _ok3(u_arg3): + if not _ok(2, u_arg3): continue # Build arc(s) between u_circ1 and u_circ2 per LengthConstraint @@ -556,9 +507,9 @@ def _make_3tan_arcs( def _make_tan_cen_arcs( - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, - center: VectorLike | Vertex, + tangency: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, *, + center: VectorLike | Vertex, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ @@ -575,11 +526,10 @@ def _make_tan_cen_arcs( """ # Unpack optional qualifier on the tangency arg (edges only) - obj1_qual = PositionConstraint.UNQUALIFIED - if isinstance(object_1, tuple): - object_one, obj1_qual = object_1 + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency else: - object_one, obj1_qual = object_1, None + object_one, obj1_qual = tangency, PositionConstraint.UNQUALIFIED # --------------------------- # Build fixed center (gp_Pnt2d) @@ -640,10 +590,10 @@ def _make_tan_cen_arcs( def _make_tan_on_rad_arcs( - object_1: tuple[Edge, PositionConstraint] | Vertex | VectorLike, + tangency: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + *, center_on: Edge, radius: float, - *, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ @@ -662,10 +612,10 @@ def _make_tan_on_rad_arcs( """ # --- unpack optional qualifier on the tangency arg (edges only) --- - if isinstance(object_1, tuple): - object_one, obj1_qual = object_1 + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency else: - object_one, obj1_qual = object_1, PositionConstraint.UNQUALIFIED + object_one, obj1_qual = tangency, PositionConstraint.UNQUALIFIED # --- build tangency input (point/edge) --- q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index ac47101..1f1f1c7 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1775,8 +1775,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): ): return _make_2tan_rad_arcs( *tangencies, - radius, - sagitta_constraint, + radius=radius, + sagitta_constraint=sagitta_constraint, edge_factory=cls, ) if ( @@ -1786,20 +1786,25 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): and center is None ): return _make_2tan_on_arcs( - *tangencies, center_on, sagitta_constraint, edge_factory=cls + *tangencies, + center_on=center_on, + sagitta_constraint=sagitta_constraint, + edge_factory=cls, ) if tan_count == 3 and radius is None and center is None and center_on is None: - return _make_3tan_arcs(*tangencies, sagitta_constraint, edge_factory=cls) + return _make_3tan_arcs( + *tangencies, sagitta_constraint=sagitta_constraint, edge_factory=cls + ) if ( tan_count == 1 and center is not None and radius is None and center_on is None ): - return _make_tan_cen_arcs(*tangencies, center, edge_factory=cls) + return _make_tan_cen_arcs(*tangencies, center=center, edge_factory=cls) if tan_count == 1 and center_on is not None and radius is not None: return _make_tan_on_rad_arcs( - *tangencies, center_on, radius, edge_factory=cls + *tangencies, center_on=center_on, radius=radius, edge_factory=cls ) raise ValueError("Unsupported or ambiguous combination of constraints.") From 872c62c645bc66ef55e163c10af40619454c9e3d Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 12 Sep 2025 13:42:44 -0400 Subject: [PATCH 13/51] Added support for point inputs & some tests --- src/build123d/topology/constrained_lines.py | 10 +- src/build123d/topology/one_d.py | 24 +- .../test_direct_api/test_constrained_arcs.py | 239 ++++++++++++++++++ 3 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 tests/test_direct_api/test_constrained_arcs.py diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 020133f..d9df8f0 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -229,7 +229,7 @@ def _qstr(q) -> str: def _make_2tan_rad_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 2 + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 2 radius: float, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], @@ -322,7 +322,7 @@ def _make_2tan_rad_arcs( def _make_2tan_on_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 2 + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 2 center_on: Edge, sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], @@ -424,7 +424,7 @@ def _make_2tan_on_arcs( def _make_3tan_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, # 3 + *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 3 sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: @@ -507,7 +507,7 @@ def _make_3tan_arcs( def _make_tan_cen_arcs( - tangency: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency: tuple[Edge, PositionConstraint] | Edge | Vector, *, center: VectorLike | Vertex, edge_factory: Callable[[TopoDS_Edge], TWrap], @@ -590,7 +590,7 @@ def _make_tan_cen_arcs( def _make_tan_on_rad_arcs( - tangency: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency: tuple[Edge, PositionConstraint] | Edge | Vector, *, center_on: Edge, radius: float, diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 1f1f1c7..abccdd1 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1738,10 +1738,22 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if kwargs: raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") - # --- validate inputs --- - tangencies = [ + tangencies_raw = [ t for t in (tangency_one, tangency_two, tangency_three) if t is not None ] + tangencies = [] + for tangency_raw in tangencies_raw: + if ( + isinstance(tangency_raw, tuple) + and not isinstance(tangency_raw[0], Edge) + ) or not isinstance(tangency_raw, Edge): + try: + tangency = Vector(tangency_raw) + except: + raise TypeError("Invalid tangency") + else: + tangency = tangency_raw + tangencies.append(tangency) # Sort the tangency inputs so points are always last tangent_tuples = [t if isinstance(t, tuple) else (t, None) for t in tangencies] @@ -1753,14 +1765,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tan_count = len(tangencies) if not (1 <= tan_count <= 3): raise TypeError("Provide 1 to 3 tangency targets.") - if sum( - x is not None for x in (radius, center, center_on) - ) > 1 and tan_count not in [1, 2]: - raise TypeError("Ambiguous constraint combination.") - - # Disallow qualifiers on points/vertices (enforce at runtime) - if any(isinstance(t, tuple) and not isinstance(t[0], Edge) for t in tangencies): - raise TypeError("Only Edge targets may be qualified.") # Radius sanity if radius is not None and radius <= 0: diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py new file mode 100644 index 0000000..cb36217 --- /dev/null +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -0,0 +1,239 @@ +""" +build123d tests + +name: test_constrained_arcs.py +by: Gumyr +date: September 12, 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 pytest +from build123d.objects_curve import ( + CenterArc, + Line, + PolarLine, + JernArc, + IntersectingLine, + ThreePointArc, +) +from build123d.objects_sketch import Rectangle +from build123d.topology import Edge, Solid, Vertex +from build123d.geometry import Axis, Vector +from build123d.build_enums import PositionConstraint, LengthConstraint, LengthMode + + +radius = 0.5 +e1 = Line((-2, 0), (2, 0)) +# e2 = (1, 1) +e2 = Line((0, -2), (0, 2)) +e1 = CenterArc((0, 0), 1, 0, 90) +e2 = Line((1, 0), (2, 0)) +e1.color = "Grey" +e2.color = "Red" + + +def test_constrained_arcs_0(): + """Test input error handling""" + with pytest.raises(TypeError): + Edge.make_constrained_arcs(Solid.make_box(1, 1, 1), (1, 0), radius=0.5) + with pytest.raises(TypeError): + Edge.make_constrained_arcs( + (Vector(0, 0), PositionConstraint.UNQUALIFIED), (1, 0), radius=0.5 + ) + with pytest.raises(TypeError): + Edge.make_constrained_arcs(pnt1=(1, 1, 1), pnt2=(1, 0), radius=0.5) + with pytest.raises(TypeError): + Edge.make_constrained_arcs(radius=0.1) + with pytest.raises(ValueError): + Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5, center=(0, 0.25)) + with pytest.raises(ValueError): + Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=-0.5) + + +def test_constrained_arcs_1(): + """2 edges & radius""" + e1 = Line((-2, 0), (2, 0)) + e2 = Line((0, -2), (0, 2)) + + tan2_rad_edges = Edge.make_constrained_arcs( + e1, + e2, + radius=0.5, + sagitta_constraint=LengthConstraint.BOTH, + ) + assert len(tan2_rad_edges) == 8 + + tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5) + assert len(tan2_rad_edges) == 4 + + +def test_constrained_arcs_2(): + """2 edges & radius""" + e1 = CenterArc((0, 0), 1, 0, 90) + e2 = Line((1, 0), (2, 0)) + + tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5) + assert len(tan2_rad_edges) == 1 + + +def test_constrained_arcs_3(): + """2 points & radius""" + tan2_rad_edges = Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5) + assert len(tan2_rad_edges) == 2 + + tan2_rad_edges = Edge.make_constrained_arcs( + Vertex(0, 0), Vertex(0, 0.5), radius=0.5 + ) + assert len(tan2_rad_edges) == 2 + + tan2_rad_edges = Edge.make_constrained_arcs( + Vector(0, 0), Vector(0, 0.5), radius=0.5 + ) + assert len(tan2_rad_edges) == 2 + + +# tan2_rad_edges = Edge.make_constrained_arcs( +# (e1, PositionConstraint.OUTSIDE), +# (e2, PositionConstraint.UNQUALIFIED), +# radius=radius, +# sagitta_constraint=LengthConstraint.SHORT, +# ) + + +# # 2 lines & radius + +# # 2 points & radius +# p1 = Vector(0, 0, 0) +# p2 = Vector(3, 0, 0) +# tan2_rad_pnts = Edge().make_constrained_arcs(p1, p2, radius=3) + +# # +# # 2 tangents & center on +# c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) +# c2 = Line((4, -2), (4, 2)) +# c3_center_on_this_line = Line((3, -2), (3, 2)) +# c4 = Line((0, 0), (0, 10)) +# for c in (c1, c2, c3_center_on_this_line, c4): +# c.color = "LightGrey" +# tan2_on_edge = Edge.make_constrained_arcs( +# (c1, PositionConstraint.UNQUALIFIED), +# (c2, PositionConstraint.UNQUALIFIED), +# center_on=c3_center_on_this_line, +# )[0] +# l1 = Line(tan2_on_edge @ 0, (0, 0)) +# l2 = JernArc(tan2_on_edge @ 1, tan2_on_edge % 1, tan2_on_edge.radius, 45) +# l3 = IntersectingLine(l2 @ 1, l2 % 1, c4) + +# # +# # tangent & center +# c5 = PolarLine((0, 0), 4, 60) +# center1 = Vector(2, 1) +# tan_center = Edge.make_constrained_arcs( +# (c5, PositionConstraint.UNQUALIFIED), center=center1 +# ) +# # +# # point & center +# p3 = Vector(-2.5, 1.5) +# center2 = Vector(-2, 1) +# pnt_center = Edge.make_constrained_arcs(p3, center=center2) + +# # +# # tangent, radius, center on +# # tan_rad_on = Edge.make_constrained_arcs( +# # (c1, PositionConstraint.UNQUALIFIED), radius=1, center_on=c3_center_on_this_line +# # ) +# tan_rad_on = Edge.make_constrained_arcs(c1, radius=1, center_on=c3_center_on_this_line) + +# print(f"{len(tan_rad_on)=}") + +# objects = [ +# (c1, PositionConstraint.ENCLOSED), +# (Vector(1, 2, 3), None), +# (Edge.make_line((0, 0), (1, 0)), PositionConstraint.UNQUALIFIED), +# ] +# s = sorted(objects, key=lambda t: not issubclass(type(t[0]), Edge)) +# print(f"{objects=},{s=}") +# # +# # 3 tangents +# c6 = PolarLine((0, 0), 4, 40) +# c7 = CenterArc((0, 0), 4, 0, 90) +# tan3 = Edge.make_constrained_arcs( +# (c5, PositionConstraint.UNQUALIFIED), +# (c6, PositionConstraint.UNQUALIFIED), +# (c7, PositionConstraint.UNQUALIFIED), +# ) +# tan3 = Edge.make_constrained_arcs(c5, c6, c7) + +# # v = Vertex(1, 2, 0) +# # v.color = "Teal" +# # show(e1, e2, tan2_rad, v) + +# r_left, r_right = 0.75, 1.0 +# r_bottom, r_top = 6, 8 +# con_circle_left = CenterArc((-2, 0), r_left, 0, 360) +# con_circle_right = CenterArc((2, 0), r_right, 0, 360) +# for c in [con_circle_left, con_circle_right]: +# c.color = "LightGrey" +# # for con1, con2 in itertools.product(PositionConstraint, PositionConstraint): +# # try: +# # egg1 = Edge.make_constrained_arcs( +# # (c8, con1), +# # (c9, con2), +# # radius=10, +# # ) +# # except: +# # print(f"{con1},{con2} failed") +# # else: +# # print(f"{con1},{con2} {len(egg1)=}") +# egg_bottom = Edge.make_constrained_arcs( +# (con_circle_right, PositionConstraint.OUTSIDE), +# (con_circle_left, PositionConstraint.OUTSIDE), +# radius=r_bottom, +# ).sort_by(Axis.Y)[0] +# egg_top = Edge.make_constrained_arcs( +# (con_circle_right, PositionConstraint.ENCLOSING), +# (con_circle_left, PositionConstraint.ENCLOSING), +# radius=r_top, +# ).sort_by(Axis.Y)[-1] +# egg_right = ThreePointArc( +# egg_bottom.vertices().sort_by(Axis.X)[-1], +# con_circle_right @ 0, +# egg_top.vertices().sort_by(Axis.X)[-1], +# ) +# egg_left = ThreePointArc( +# egg_bottom.vertices().sort_by(Axis.X)[0], +# con_circle_left @ 0.5, +# egg_top.vertices().sort_by(Axis.X)[0], +# ) + +# egg_plant = Wire([egg_left, egg_top, egg_right, egg_bottom]) + + +# make_constrained_arcs + + +# class TestConstrainedArcs(unittest.TestCase): +# def test_close(self): +# self.assertAlmostEqual( +# Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5 +# ) +# self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5) From f0f79fccd463730d4ed214128db51a35ae986445 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sat, 13 Sep 2025 14:17:04 -0400 Subject: [PATCH 14/51] Refining code and adding tests --- src/build123d/__init__.py | 4 +- src/build123d/build_enums.py | 8 +- src/build123d/topology/constrained_lines.py | 55 ++-- src/build123d/topology/one_d.py | 72 +++--- .../test_direct_api/test_constrained_arcs.py | 240 ++++++++---------- 5 files changed, 172 insertions(+), 207 deletions(-) diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index a7aa36c..6d52b40 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -55,13 +55,13 @@ __all__ = [ "Intrinsic", "Keep", "Kind", - "LengthConstraint", + "Sagitta", "LengthMode", "MeshType", "Mode", "NumberDisplay", "PageSize", - "PositionConstraint", + "Tangency", "PositionMode", "PrecisionMode", "Select", diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index 65ef0f7..44d7c8b 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -254,8 +254,8 @@ class FontStyle(Enum): return f"<{self.__class__.__name__}.{self.name}>" -class LengthConstraint(Enum): - """Length Constraint for sagitta selection""" +class Sagitta(Enum): + """Sagitta selection""" SHORT = 0 LONG = -1 @@ -320,8 +320,8 @@ class PageSize(Enum): return f"<{self.__class__.__name__}.{self.name}>" -class PositionConstraint(Enum): - """Position Constraint for edge selection""" +class Tangency(Enum): + """Tangency constraint for solvers edge selection""" UNQUALIFIED = GccEnt_unqualified ENCLOSING = GccEnt_enclosing diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index d9df8f0..6fa87ff 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -70,7 +70,7 @@ from OCP.gp import ( ) from OCP.TopoDS import TopoDS_Edge -from build123d.build_enums import LengthConstraint, PositionConstraint +from build123d.build_enums import Sagitta, Tangency from build123d.geometry import TOLERANCE, Vector, VectorLike from .zero_d import Vertex from .shape_core import ShapeList @@ -113,7 +113,7 @@ def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: # Core helpers # --------------------------- def _edge_to_qualified_2d( - edge: TopoDS_Edge, position_constaint: PositionConstraint + edge: TopoDS_Edge, position_constaint: Tangency ) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: """Convert a TopoDS_Edge into 2d curve & extract properties""" @@ -153,9 +153,7 @@ def _param_in_trim(u: float, first: float, last: float, h2d: Geom2d_Curve) -> bo return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) -def _as_gcc_arg( - obj: Edge | Vertex | VectorLike, constaint: PositionConstraint -) -> tuple[ +def _as_gcc_arg(obj: Edge | Vertex | VectorLike, constaint: Tangency) -> tuple[ Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, Geom2d_Curve | None, float | None, @@ -229,9 +227,9 @@ def _qstr(q) -> str: def _make_2tan_rad_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 2 + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 radius: float, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> list[Edge]: """ @@ -259,8 +257,7 @@ def _make_2tan_rad_arcs( # Unpack optional per-edge qualifiers (default UNQUALIFIED) tangent_tuples = [ - t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) - for t in tangencies + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies ] # Build inputs for GCC @@ -310,21 +307,21 @@ def _make_2tan_rad_arcs( # ) # Build BOTH sagitta arcs and select by LengthConstraint - if sagitta_constraint == LengthConstraint.BOTH: + if sagitta == Sagitta.BOTH: solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) arcs = sorted( arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) ) - solutions.append(arcs[sagitta_constraint.value]) + solutions.append(arcs[sagitta.value]) return ShapeList([edge_factory(e) for e in solutions]) def _make_2tan_on_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 2 + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 center_on: Edge, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ @@ -338,8 +335,7 @@ def _make_2tan_on_arcs( # Unpack optional per-edge qualifiers (default UNQUALIFIED) tangent_tuples = [ - t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) - for t in tangencies + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies ] # Build inputs for GCC @@ -351,7 +347,7 @@ def _make_2tan_on_arcs( # Build center locus ("On") input _, h_on2d, e_first[2], e_last[2], adapt_on = _edge_to_qualified_2d( - center_on.wrapped, PositionConstraint.UNQUALIFIED + center_on.wrapped, Tangency.UNQUALIFIED ) is_edge[2] = True @@ -409,7 +405,7 @@ def _make_2tan_on_arcs( continue # Build sagitta arc(s) and select by LengthConstraint - if sagitta_constraint == LengthConstraint.BOTH: + if sagitta == Sagitta.BOTH: solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) @@ -418,14 +414,14 @@ def _make_2tan_on_arcs( arcs = sorted( arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) ) - solutions.append(arcs[sagitta_constraint.value]) + solutions.append(arcs[sagitta.value]) return ShapeList([edge_factory(e) for e in solutions]) def _make_3tan_arcs( - *tangencies: tuple[Edge, PositionConstraint] | Edge | Vector, # 3 - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 3 + sagitta: Sagitta = Sagitta.SHORT, edge_factory: Callable[[TopoDS_Edge], TWrap], ) -> ShapeList[Edge]: """ @@ -433,14 +429,13 @@ def _make_3tan_arcs( The circle is determined by the three tangency constraints; the returned arc(s) are trimmed between the two tangency points corresponding to `tangencies[0]` and - `tangencies[1]`. Use `sagitta_constraint` to select the shorter/longer (or both) arc. + `tangencies[1]`. Use `sagitta` to select the shorter/longer (or both) arc. Inputs must be representable on Plane.XY. """ # Unpack optional per-edge qualifiers (default UNQUALIFIED) tangent_tuples = [ - t if isinstance(t, tuple) else (t, PositionConstraint.UNQUALIFIED) - for t in tangencies + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies ] # Build inputs for GCC @@ -491,7 +486,7 @@ def _make_3tan_arcs( continue # Build arc(s) between u_circ1 and u_circ2 per LengthConstraint - if sagitta_constraint == LengthConstraint.BOTH: + if sagitta == Sagitta.BOTH: out_topos.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) @@ -501,13 +496,13 @@ def _make_3tan_arcs( arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)), ) - out_topos.append(arcs[sagitta_constraint.value]) + out_topos.append(arcs[sagitta.value]) return ShapeList([edge_factory(e) for e in out_topos]) def _make_tan_cen_arcs( - tangency: tuple[Edge, PositionConstraint] | Edge | Vector, + tangency: tuple[Edge, Tangency] | Edge | Vector, *, center: VectorLike | Vertex, edge_factory: Callable[[TopoDS_Edge], TWrap], @@ -529,7 +524,7 @@ def _make_tan_cen_arcs( if isinstance(tangency, tuple): object_one, obj1_qual = tangency else: - object_one, obj1_qual = tangency, PositionConstraint.UNQUALIFIED + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED # --------------------------- # Build fixed center (gp_Pnt2d) @@ -590,7 +585,7 @@ def _make_tan_cen_arcs( def _make_tan_on_rad_arcs( - tangency: tuple[Edge, PositionConstraint] | Edge | Vector, + tangency: tuple[Edge, Tangency] | Edge | Vector, *, center_on: Edge, radius: float, @@ -615,7 +610,7 @@ def _make_tan_on_rad_arcs( if isinstance(tangency, tuple): object_one, obj1_qual = tangency else: - object_one, obj1_qual = tangency, PositionConstraint.UNQUALIFIED + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED # --- build tangency input (point/edge) --- q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) @@ -627,7 +622,7 @@ def _make_tan_on_rad_arcs( # Project the center locus Edge to 2D (XY) _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( - on_obj.wrapped, PositionConstraint.UNQUALIFIED + on_obj.wrapped, Tangency.UNQUALIFIED ) gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index abccdd1..9ae7556 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -198,8 +198,8 @@ from build123d.build_enums import ( GeomType, Keep, Kind, - LengthConstraint, - PositionConstraint, + Sagitta, + Tangency, PositionMode, Side, ) @@ -1588,11 +1588,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, *, radius: float, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, ) -> ShapeList[Edge]: """ Create all planar circular arcs of a given radius that are tangent/contacting @@ -1602,7 +1602,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) radius (float): arc radius - sagitta_constraint (LengthConstraint, optional): returned arc selector + sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1614,11 +1614,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, *, center_on: Edge, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, ) -> ShapeList[Edge]: """ Create all planar circular arcs whose circle is tangent to two objects and whose @@ -1629,7 +1629,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) center_on (Edge): center must lie on this edge - sagitta_constraint (LengthConstraint, optional): returned arc selector + sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1641,11 +1641,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, - tangency_three: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_three: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, *, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, ) -> ShapeList[Edge]: """ Create planar circular arc(s) on XY tangent to three provided objects. @@ -1655,7 +1655,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): tangency_three (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) - sagitta_constraint (LengthConstraint, optional): returned arc selector + sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1667,7 +1667,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, *, center: VectorLike, ) -> ShapeList[Edge]: @@ -1689,7 +1689,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike, + tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, *, radius: float, center_on: Edge, @@ -1706,7 +1706,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Geometric entity to be contacted/touched by the circle(s) radius (float): arc radius center_on (Edge): center must lie on this edge - sagitta_constraint (LengthConstraint, optional): returned arc selector + sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1718,7 +1718,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): def make_constrained_arcs( cls, *args, - sagitta_constraint: LengthConstraint = LengthConstraint.SHORT, + sagitta: Sagitta = Sagitta.SHORT, **kwargs, ) -> ShapeList[Edge]: @@ -1738,22 +1738,22 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if kwargs: raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") - tangencies_raw = [ + tangency_args = [ t for t in (tangency_one, tangency_two, tangency_three) if t is not None ] tangencies = [] - for tangency_raw in tangencies_raw: - if ( - isinstance(tangency_raw, tuple) - and not isinstance(tangency_raw[0], Edge) - ) or not isinstance(tangency_raw, Edge): - try: - tangency = Vector(tangency_raw) - except: - raise TypeError("Invalid tangency") - else: - tangency = tangency_raw - tangencies.append(tangency) + for tangency_arg in tangency_args: + if isinstance(tangency_arg, Edge): + tangencies.append(tangency_arg) + continue + if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue + # if not Edges or constrained Edges convert to Vectors + try: + tangencies.append(Vector(tangency_arg)) + except Exception as exc: + raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc # Sort the tangency inputs so points are always last tangent_tuples = [t if isinstance(t, tuple) else (t, None) for t in tangencies] @@ -1780,7 +1780,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return _make_2tan_rad_arcs( *tangencies, radius=radius, - sagitta_constraint=sagitta_constraint, + sagitta=sagitta, edge_factory=cls, ) if ( @@ -1792,13 +1792,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return _make_2tan_on_arcs( *tangencies, center_on=center_on, - sagitta_constraint=sagitta_constraint, + sagitta=sagitta, edge_factory=cls, ) if tan_count == 3 and radius is None and center is None and center_on is None: - return _make_3tan_arcs( - *tangencies, sagitta_constraint=sagitta_constraint, edge_factory=cls - ) + return _make_3tan_arcs(*tangencies, sagitta=sagitta, edge_factory=cls) if ( tan_count == 1 and center is not None diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py index cb36217..c98e3ed 100644 --- a/tests/test_direct_api/test_constrained_arcs.py +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -35,11 +35,12 @@ from build123d.objects_curve import ( IntersectingLine, ThreePointArc, ) -from build123d.objects_sketch import Rectangle -from build123d.topology import Edge, Solid, Vertex -from build123d.geometry import Axis, Vector -from build123d.build_enums import PositionConstraint, LengthConstraint, LengthMode - +from build123d.topology import Edge, Solid, Vertex, Wire, topo_explore_common_vertex +from build123d.geometry import Axis, Vector, TOLERANCE +from build123d.build_enums import Tangency, Sagitta, LengthMode +from OCP.BRep import BRep_Tool +from OCP.GeomAbs import GeomAbs_C1 +from OCP.LocalAnalysis import LocalAnalysis_CurveContinuity radius = 0.5 e1 = Line((-2, 0), (2, 0)) @@ -51,13 +52,13 @@ e1.color = "Grey" e2.color = "Red" -def test_constrained_arcs_0(): +def test_constrained_arcs_arg_processing(): """Test input error handling""" with pytest.raises(TypeError): Edge.make_constrained_arcs(Solid.make_box(1, 1, 1), (1, 0), radius=0.5) with pytest.raises(TypeError): Edge.make_constrained_arcs( - (Vector(0, 0), PositionConstraint.UNQUALIFIED), (1, 0), radius=0.5 + (Vector(0, 0), Tangency.UNQUALIFIED), (1, 0), radius=0.5 ) with pytest.raises(TypeError): Edge.make_constrained_arcs(pnt1=(1, 1, 1), pnt2=(1, 0), radius=0.5) @@ -69,24 +70,26 @@ def test_constrained_arcs_0(): Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=-0.5) -def test_constrained_arcs_1(): +def test_tan2_rad_arcs_1(): """2 edges & radius""" e1 = Line((-2, 0), (2, 0)) e2 = Line((0, -2), (0, 2)) tan2_rad_edges = Edge.make_constrained_arcs( - e1, - e2, - radius=0.5, - sagitta_constraint=LengthConstraint.BOTH, + e1, e2, radius=0.5, sagitta=Sagitta.BOTH ) assert len(tan2_rad_edges) == 8 tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5) assert len(tan2_rad_edges) == 4 + tan2_rad_edges = Edge.make_constrained_arcs( + (e1, Tangency.UNQUALIFIED), (e2, Tangency.UNQUALIFIED), radius=0.5 + ) + assert len(tan2_rad_edges) == 4 -def test_constrained_arcs_2(): + +def test_tan2_rad_arcs_2(): """2 edges & radius""" e1 = CenterArc((0, 0), 1, 0, 90) e2 = Line((1, 0), (2, 0)) @@ -95,7 +98,7 @@ def test_constrained_arcs_2(): assert len(tan2_rad_edges) == 1 -def test_constrained_arcs_3(): +def test_tan2_rad_arcs_3(): """2 points & radius""" tan2_rad_edges = Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5) assert len(tan2_rad_edges) == 2 @@ -111,129 +114,98 @@ def test_constrained_arcs_3(): assert len(tan2_rad_edges) == 2 -# tan2_rad_edges = Edge.make_constrained_arcs( -# (e1, PositionConstraint.OUTSIDE), -# (e2, PositionConstraint.UNQUALIFIED), -# radius=radius, -# sagitta_constraint=LengthConstraint.SHORT, -# ) +def test_tan2_center_on_1(): + """2 tangents & center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + c2 = Line((4, -2), (4, 2)) + c3_center_on = Line((3, -2), (3, 2)) + tan2_on_edge = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=c3_center_on, + ) + assert len(tan2_on_edge) == 1 -# # 2 lines & radius - -# # 2 points & radius -# p1 = Vector(0, 0, 0) -# p2 = Vector(3, 0, 0) -# tan2_rad_pnts = Edge().make_constrained_arcs(p1, p2, radius=3) - -# # -# # 2 tangents & center on -# c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) -# c2 = Line((4, -2), (4, 2)) -# c3_center_on_this_line = Line((3, -2), (3, 2)) -# c4 = Line((0, 0), (0, 10)) -# for c in (c1, c2, c3_center_on_this_line, c4): -# c.color = "LightGrey" -# tan2_on_edge = Edge.make_constrained_arcs( -# (c1, PositionConstraint.UNQUALIFIED), -# (c2, PositionConstraint.UNQUALIFIED), -# center_on=c3_center_on_this_line, -# )[0] -# l1 = Line(tan2_on_edge @ 0, (0, 0)) -# l2 = JernArc(tan2_on_edge @ 1, tan2_on_edge % 1, tan2_on_edge.radius, 45) -# l3 = IntersectingLine(l2 @ 1, l2 % 1, c4) - -# # -# # tangent & center -# c5 = PolarLine((0, 0), 4, 60) -# center1 = Vector(2, 1) -# tan_center = Edge.make_constrained_arcs( -# (c5, PositionConstraint.UNQUALIFIED), center=center1 -# ) -# # -# # point & center -# p3 = Vector(-2.5, 1.5) -# center2 = Vector(-2, 1) -# pnt_center = Edge.make_constrained_arcs(p3, center=center2) - -# # -# # tangent, radius, center on -# # tan_rad_on = Edge.make_constrained_arcs( -# # (c1, PositionConstraint.UNQUALIFIED), radius=1, center_on=c3_center_on_this_line -# # ) -# tan_rad_on = Edge.make_constrained_arcs(c1, radius=1, center_on=c3_center_on_this_line) - -# print(f"{len(tan_rad_on)=}") - -# objects = [ -# (c1, PositionConstraint.ENCLOSED), -# (Vector(1, 2, 3), None), -# (Edge.make_line((0, 0), (1, 0)), PositionConstraint.UNQUALIFIED), -# ] -# s = sorted(objects, key=lambda t: not issubclass(type(t[0]), Edge)) -# print(f"{objects=},{s=}") -# # -# # 3 tangents -# c6 = PolarLine((0, 0), 4, 40) -# c7 = CenterArc((0, 0), 4, 0, 90) -# tan3 = Edge.make_constrained_arcs( -# (c5, PositionConstraint.UNQUALIFIED), -# (c6, PositionConstraint.UNQUALIFIED), -# (c7, PositionConstraint.UNQUALIFIED), -# ) -# tan3 = Edge.make_constrained_arcs(c5, c6, c7) - -# # v = Vertex(1, 2, 0) -# # v.color = "Teal" -# # show(e1, e2, tan2_rad, v) - -# r_left, r_right = 0.75, 1.0 -# r_bottom, r_top = 6, 8 -# con_circle_left = CenterArc((-2, 0), r_left, 0, 360) -# con_circle_right = CenterArc((2, 0), r_right, 0, 360) -# for c in [con_circle_left, con_circle_right]: -# c.color = "LightGrey" -# # for con1, con2 in itertools.product(PositionConstraint, PositionConstraint): -# # try: -# # egg1 = Edge.make_constrained_arcs( -# # (c8, con1), -# # (c9, con2), -# # radius=10, -# # ) -# # except: -# # print(f"{con1},{con2} failed") -# # else: -# # print(f"{con1},{con2} {len(egg1)=}") -# egg_bottom = Edge.make_constrained_arcs( -# (con_circle_right, PositionConstraint.OUTSIDE), -# (con_circle_left, PositionConstraint.OUTSIDE), -# radius=r_bottom, -# ).sort_by(Axis.Y)[0] -# egg_top = Edge.make_constrained_arcs( -# (con_circle_right, PositionConstraint.ENCLOSING), -# (con_circle_left, PositionConstraint.ENCLOSING), -# radius=r_top, -# ).sort_by(Axis.Y)[-1] -# egg_right = ThreePointArc( -# egg_bottom.vertices().sort_by(Axis.X)[-1], -# con_circle_right @ 0, -# egg_top.vertices().sort_by(Axis.X)[-1], -# ) -# egg_left = ThreePointArc( -# egg_bottom.vertices().sort_by(Axis.X)[0], -# con_circle_left @ 0.5, -# egg_top.vertices().sort_by(Axis.X)[0], -# ) - -# egg_plant = Wire([egg_left, egg_top, egg_right, egg_bottom]) +def test_tan_center_on_1(): + """1 tangent & center on""" + c5 = PolarLine((0, 0), 4, 60) + tan_center = Edge.make_constrained_arcs((c5, Tangency.UNQUALIFIED), center=(2, 1)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed -# make_constrained_arcs +def test_pnt_center_1(): + """pnt & center""" + pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=(-2, 1)) + assert len(pnt_center) == 1 + assert pnt_center[0].is_closed -# class TestConstrainedArcs(unittest.TestCase): -# def test_close(self): -# self.assertAlmostEqual( -# Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5 -# ) -# self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5) +def test_tan_rad_center_on_1(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + c3_center_on = Line((3, -2), (3, 2)) + tan_rad_on = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), radius=1, center_on=c3_center_on + ) + assert len(tan_rad_on) == 1 + assert tan_rad_on[0].is_closed + + +def test_tan3_1(): + """3 tangents""" + c5 = PolarLine((0, 0), 4, 60) + c6 = PolarLine((0, 0), 4, 40) + c7 = CenterArc((0, 0), 4, 0, 90) + tan3 = Edge.make_constrained_arcs( + (c5, Tangency.UNQUALIFIED), + (c6, Tangency.UNQUALIFIED), + (c7, Tangency.UNQUALIFIED), + ) + assert len(tan3) == 1 + assert not tan3[0].is_closed + + +def test_eggplant(): + """complex set of 4 arcs""" + r_left, r_right = 0.75, 1.0 + r_bottom, r_top = 6, 8 + con_circle_left = CenterArc((-2, 0), r_left, 0, 360) + con_circle_right = CenterArc((2, 0), r_right, 0, 360) + egg_bottom = Edge.make_constrained_arcs( + (con_circle_right, Tangency.OUTSIDE), + (con_circle_left, Tangency.OUTSIDE), + radius=r_bottom, + ).sort_by(Axis.Y)[0] + egg_top = Edge.make_constrained_arcs( + (con_circle_right, Tangency.ENCLOSING), + (con_circle_left, Tangency.ENCLOSING), + radius=r_top, + ).sort_by(Axis.Y)[-1] + egg_right = ThreePointArc( + egg_bottom.vertices().sort_by(Axis.X)[-1], + con_circle_right @ 0, + egg_top.vertices().sort_by(Axis.X)[-1], + ) + egg_left = ThreePointArc( + egg_bottom.vertices().sort_by(Axis.X)[0], + con_circle_left @ 0.5, + egg_top.vertices().sort_by(Axis.X)[0], + ) + + egg_plant = Wire([egg_left, egg_top, egg_right, egg_bottom]) + assert egg_plant.is_closed + egg_plant_edges = egg_plant.edges().sort_by(egg_plant) + common_vertex_cnt = sum( + topo_explore_common_vertex(egg_plant_edges[i], egg_plant_edges[(i + 1) % 4]) + is not None + for i in range(4) + ) + assert common_vertex_cnt == 4 + + # C1 continuity + assert all( + (egg_plant_edges[i] % 1 - egg_plant_edges[(i + 1) % 4] % 0).length < TOLERANCE + for i in range(4) + ) From d8f7da348c1bfe28df5842c273a6b100eee8cf7d Mon Sep 17 00:00:00 2001 From: gumyr Date: Sun, 14 Sep 2025 19:09:29 -0400 Subject: [PATCH 15/51] Fixing typing --- src/build123d/topology/constrained_lines.py | 96 ++++++++++--------- src/build123d/topology/one_d.py | 16 ++-- .../test_direct_api/test_constrained_arcs.py | 8 ++ 3 files changed, 68 insertions(+), 52 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 6fa87ff..25e0f53 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -37,11 +37,12 @@ from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge from OCP.GCPnts import GCPnts_AbscissaPoint -from OCP.Geom import Geom_Plane +from OCP.Geom import Geom_Curve, Geom_Plane from OCP.Geom2d import ( Geom2d_CartesianPoint, Geom2d_Circle, Geom2d_Curve, + Geom2d_Point, Geom2d_TrimmedCurve, ) from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve @@ -73,7 +74,7 @@ from OCP.TopoDS import TopoDS_Edge from build123d.build_enums import Sagitta, Tangency from build123d.geometry import TOLERANCE, Vector, VectorLike from .zero_d import Vertex -from .shape_core import ShapeList +from .shape_core import ShapeList, downcast if TYPE_CHECKING: from build123d.topology.one_d import Edge @@ -114,7 +115,7 @@ def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: # --------------------------- def _edge_to_qualified_2d( edge: TopoDS_Edge, position_constaint: Tangency -) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float]: +) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float, Geom2dAdaptor_Curve]: """Convert a TopoDS_Edge into 2d curve & extract properties""" # 1) Underlying curve + range (also retrieve location to be safe) @@ -128,7 +129,7 @@ def _edge_to_qualified_2d( # 2) Apply location if the edge is positioned by a TopLoc_Location if not loc.IsIdentity(): trsf = loc.Transformation() - hcurve3d = hcurve3d.Transformed(trsf) + hcurve3d = tcast(Geom_Curve, hcurve3d.Transformed(trsf)) # 3) Convert to 2D on Plane.XY (Z-up frame at origin) hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve @@ -147,13 +148,17 @@ def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() -def _param_in_trim(u: float, first: float, last: float, h2d: Geom2d_Curve) -> bool: +def _param_in_trim( + u: float | None, first: float | None, last: float | None, h2d: Geom2d_Curve | None +) -> bool: """Normalize (if periodic) then test [first, last] with tolerance.""" + if u is None or first is None or last is None or h2d is None: # for typing + raise TypeError("Invalid parameters to _param_in_trim") u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) -def _as_gcc_arg(obj: Edge | Vertex | VectorLike, constaint: Tangency) -> tuple[ +def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, Geom2d_Curve | None, float | None, @@ -166,16 +171,18 @@ def _as_gcc_arg(obj: Edge | Vertex | VectorLike, constaint: Tangency) -> tuple[ - Edge -> (QualifiedCurve, h2d, first, last, True) - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) """ + if obj.wrapped is None: + raise TypeError("Can't create a qualified curve from empty edge") + if isinstance(obj.wrapped, TopoDS_Edge): return _edge_to_qualified_2d(obj.wrapped, constaint)[0:4] + (True,) - loc_xyz = obj.position if isinstance(obj, Vertex) else Vector() try: base = Vector(obj) except (TypeError, ValueError) as exc: raise ValueError("Expected Edge | Vertex | VectorLike") from exc - gp_pnt = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + gp_pnt = gp_Pnt2d(base.X, base.Y) return Geom2d_CartesianPoint(gp_pnt), None, None, None, False @@ -230,8 +237,8 @@ def _make_2tan_rad_arcs( *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 radius: float, sagitta: Sagitta = Sagitta.SHORT, - edge_factory: Callable[[TopoDS_Edge], TWrap], -) -> list[Edge]: + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: """ Create all planar circular arcs of a given radius that are tangent/contacting the two provided objects on the XY plane. @@ -261,11 +268,9 @@ def _make_2tan_rad_arcs( ] # Build inputs for GCC - q_o, h_e, e_first, e_last, is_edge = [[None] * 2 for _ in range(5)] - for i in range(len(tangent_tuples)): - q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( - *tangent_tuples[i] - ) + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) gcc = Geom2dGcc_Circ2d2TanRad(*q_o, radius, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: @@ -280,7 +285,7 @@ def _make_2tan_rad_arcs( # --------------------------- # Solutions # --------------------------- - solutions: list[Edge] = [] + solutions: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): circ = gcc.ThisSolution(i) # gp_Circ2d @@ -322,7 +327,7 @@ def _make_2tan_on_arcs( *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 center_on: Edge, sagitta: Sagitta = Sagitta.SHORT, - edge_factory: Callable[[TopoDS_Edge], TWrap], + edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: """ Create all planar circular arcs whose circle is tangent to two objects and whose @@ -335,29 +340,29 @@ def _make_2tan_on_arcs( # Unpack optional per-edge qualifiers (default UNQUALIFIED) tangent_tuples = [ - t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) + for t in list(tangencies) + [center_on] ] # Build inputs for GCC - q_o, h_e, e_first, e_last, is_edge = [[None] * 3 for _ in range(5)] - for i in range(len(tangent_tuples)): - q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( - *tangent_tuples[i] - ) - - # Build center locus ("On") input - _, h_on2d, e_first[2], e_last[2], adapt_on = _edge_to_qualified_2d( - center_on.wrapped, Tangency.UNQUALIFIED - ) - is_edge[2] = True + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[ + Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve + ] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) + adapt_on = Geom2dAdaptor_Curve(h_e[2], e_first[2], e_last[2]) # Provide initial middle guess parameters for all of the edges - guesses = [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + guesses: tuple[float, float, float] = tuple( + [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + ) if sum(is_edge) > 1: - gcc = Geom2dGcc_Circ2d2TanOn(*q_o[0:2], adapt_on, TOLERANCE, *guesses) + gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE, *guesses) else: - gcc = Geom2dGcc_Circ2d2TanOn(*q_o[0:2], adapt_on, TOLERANCE) + assert isinstance(q_o[0], Geom2d_Point) + assert isinstance(q_o[1], Geom2d_Point) + gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError("Unable to find a tangent arc with center_on constraint") @@ -391,7 +396,7 @@ def _make_2tan_on_arcs( center2d = circ.Location() # gp_Pnt2d # Project center onto the (trimmed) 2D locus - proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d) + proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_e[2]) if proj.NbPoints() == 0: continue # no projection -> reject @@ -401,7 +406,7 @@ def _make_2tan_on_arcs( continue # Respect the trimmed interval (handles periodic curves too) - if not _param_in_trim(u_on, e_first[2], e_last[2], h_on2d): + if not _param_in_trim(u_on, e_first[2], e_last[2], h_e[2]): continue # Build sagitta arc(s) and select by LengthConstraint @@ -422,7 +427,7 @@ def _make_2tan_on_arcs( def _make_3tan_arcs( *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 3 sagitta: Sagitta = Sagitta.SHORT, - edge_factory: Callable[[TopoDS_Edge], TWrap], + edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: """ Create planar circular arc(s) on XY tangent to three provided objects. @@ -439,14 +444,16 @@ def _make_3tan_arcs( ] # Build inputs for GCC - q_o, h_e, e_first, e_last, is_edge = [[None] * 3 for _ in range(5)] - for i in range(len(tangent_tuples)): - q_o[i], h_e[i], e_first[i], e_last[i], is_edge[i] = _as_gcc_arg( - *tangent_tuples[i] - ) + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[ + Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve + ] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) # Provide initial middle guess parameters for all of the edges - guesses = [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + guesses: tuple[float, float, float] = tuple( + [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + ) # Generate all valid circles tangent to the 3 inputs gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) @@ -505,7 +512,7 @@ def _make_tan_cen_arcs( tangency: tuple[Edge, Tangency] | Edge | Vector, *, center: VectorLike | Vertex, - edge_factory: Callable[[TopoDS_Edge], TWrap], + edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: """ Create planar circle(s) on XY whose center is fixed and that are tangent/contacting @@ -530,7 +537,7 @@ def _make_tan_cen_arcs( # Build fixed center (gp_Pnt2d) # --------------------------- if isinstance(center, Vertex): - loc_xyz = center.position + loc_xyz = center.position if center.position is not None else Vector(0, 0) base = Vector(center) c2d = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) else: @@ -560,6 +567,7 @@ def _make_tan_cen_arcs( solutions_topo.append(_edge_from_circle(h2d, 0.0, per)) else: + assert isinstance(q_o1, Geom2dGcc_QualifiedCurve) # Case B: tangency target is a curve/edge (qualified curve) gcc = Geom2dGcc_Circ2dTanCen(q_o1, Geom2d_CartesianPoint(c2d), TOLERANCE) if not gcc.IsDone() or gcc.NbSolutions() == 0: @@ -589,7 +597,7 @@ def _make_tan_on_rad_arcs( *, center_on: Edge, radius: float, - edge_factory: Callable[[TopoDS_Edge], TWrap], + edge_factory: Callable[[TopoDS_Edge], Edge], ) -> ShapeList[Edge]: """ Create planar circle(s) on XY that: diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 9ae7556..0f0069d 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1741,7 +1741,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tangency_args = [ t for t in (tangency_one, tangency_two, tangency_three) if t is not None ] - tangencies = [] + tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = [] for tangency_arg in tangency_args: if isinstance(tangency_arg, Edge): tangencies.append(tangency_arg) @@ -1749,18 +1749,18 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge): tangencies.append(tangency_arg) continue - # if not Edges or constrained Edges convert to Vectors + if isinstance(tangency_arg, Vertex): + tangencies.append(Vector(tangency_arg) + tangency_arg.position) + continue + + # if not Edges, constrained Edges or Vertex convert to Vectors try: tangencies.append(Vector(tangency_arg)) except Exception as exc: raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc - # Sort the tangency inputs so points are always last - tangent_tuples = [t if isinstance(t, tuple) else (t, None) for t in tangencies] - tangent_tuples = sorted( - tangent_tuples, key=lambda t: not issubclass(type(t[0]), Edge) - ) - tangencies = [t[0] if t[1] is None else t for t in tangent_tuples] + # # Sort the tangency inputs so points are always last + tangencies = sorted(tangencies, key=lambda x: isinstance(x, Vector)) tan_count = len(tangencies) if not (1 <= tan_count <= 3): diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py index c98e3ed..f016876 100644 --- a/tests/test_direct_api/test_constrained_arcs.py +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -114,6 +114,14 @@ def test_tan2_rad_arcs_3(): assert len(tan2_rad_edges) == 2 +def test_tan2_rad_arcs_4(): + """edge & 1 points & radius""" + # the point should be automatically moved after the edge + e1 = Line((0, 0), (1, 0)) + tan2_rad_edges = Edge.make_constrained_arcs((0, 0.5), e1, radius=0.5) + assert len(tan2_rad_edges) == 1 + + def test_tan2_center_on_1(): """2 tangents & center on""" c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) From e215a120dfa17ce7bed970ba768d99e32b5b730d Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 15 Sep 2025 11:36:27 -0400 Subject: [PATCH 16/51] Fixing typing after mypy upgrade --- src/build123d/drafting.py | 4 ++-- src/build123d/topology/composite.py | 13 ++++++++++--- src/build123d/topology/shape_core.py | 12 ++++++------ src/build123d/topology/two_d.py | 2 +- tests/test_direct_api/test_shape.py | 9 ++++++--- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index ea93fd4..07a5193 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -52,7 +52,7 @@ from build123d.objects_curve import Line, TangentArc from build123d.objects_sketch import BaseSketchObject, Polygon, Text from build123d.operations_generic import fillet, mirror, sweep from build123d.operations_sketch import make_face, trace -from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire +from build123d.topology import Compound, Curve, Edge, ShapeList, Sketch, Vertex, Wire class ArrowHead(BaseSketchObject): @@ -709,7 +709,7 @@ class TechnicalDrawing(BaseSketchObject): # Text Box Frame bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5 bf_pnt2 = frame_wire.edges().sort_by(Axis.X)[-1] @ 0.75 - box_frame_curve = Wire.make_polygon( + box_frame_curve: Edge | Wire | ShapeList[Edge] = Wire.make_polygon( [bf_pnt1, (bf_pnt1.X, bf_pnt2.Y), bf_pnt2], close=False ) bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 63bdb14..cc08e59 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -448,7 +448,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): # ---- Instance Methods ---- - def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound: + def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound | Wire: """Combine other to self `+` operator Note that if all of the objects are connected Edges/Wires the result @@ -456,8 +456,15 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): """ if self._dim == 1: curve = Curve() if self.wrapped is None else Curve(self.wrapped) - self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"]) - return curve + other + sum1d: Edge | Wire | ShapeList[Edge] = curve + other + if isinstance(sum1d, ShapeList): + result: Curve | Wire = Curve(sum1d) + elif isinstance(sum1d, Edge): + result = Curve([sum1d]) + else: # Wire + result = sum1d + self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) + return result summands: ShapeList[Shape] if other is None: diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index ec9e2ae..65fe6f9 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -472,10 +472,10 @@ class Shape(NodeMixin, Generic[TOPODS]): return reduce(lambda loc, n: loc * n.location, self.path, Location()) @property - def location(self) -> Location | None: + def location(self) -> Location: """Get this Shape's Location""" if self.wrapped is None: - return None + raise ValueError("Can't find the location of an empty shape") return Location(self.wrapped.Location()) @location.setter @@ -529,10 +529,10 @@ class Shape(NodeMixin, Generic[TOPODS]): return matrix @property - def orientation(self) -> Vector | None: + def orientation(self) -> Vector: """Get the orientation component of this Shape's Location""" if self.location is None: - return None + raise ValueError("Can't find the orientation of an empty shape") return self.location.orientation @orientation.setter @@ -544,10 +544,10 @@ class Shape(NodeMixin, Generic[TOPODS]): self.location = loc @property - def position(self) -> Vector | None: + def position(self) -> Vector: """Get the position component of this Shape's Location""" if self.wrapped is None or self.location is None: - return None + raise ValueError("Can't find the position of an empty shape") return self.location.position @position.setter diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 91cbc65..306c5b8 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -649,7 +649,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): continue top_list = ShapeList(top if isinstance(top, list) else [top]) - bottom_list = ShapeList(bottom if isinstance(top, list) else [bottom]) + bottom_list = ShapeList(bottom if isinstance(bottom, list) else [bottom]) if len(top_list) != len(bottom_list): # exit early unequal length continue diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 02f9de0..2c0bb3c 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -531,9 +531,12 @@ class TestShape(unittest.TestCase): def test_empty_shape(self): empty = Solid() box = Solid.make_box(1, 1, 1) - self.assertIsNone(empty.location) - self.assertIsNone(empty.position) - self.assertIsNone(empty.orientation) + with self.assertRaises(ValueError): + empty.location + with self.assertRaises(ValueError): + empty.position + with self.assertRaises(ValueError): + empty.orientation self.assertFalse(empty.is_manifold) with self.assertRaises(ValueError): empty.geom_type From bc8fd456251ea5497be65eedf3d8b91f5e6d752f Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 15 Sep 2025 11:40:26 -0400 Subject: [PATCH 17/51] Another typing fix --- src/build123d/topology/composite.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index cc08e59..823eece 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -458,13 +458,13 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): curve = Curve() if self.wrapped is None else Curve(self.wrapped) sum1d: Edge | Wire | ShapeList[Edge] = curve + other if isinstance(sum1d, ShapeList): - result: Curve | Wire = Curve(sum1d) + result1d: Curve | Wire = Curve(sum1d) elif isinstance(sum1d, Edge): - result = Curve([sum1d]) + result1d = Curve([sum1d]) else: # Wire - result = sum1d - self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) - return result + result1d = sum1d + self.copy_attributes_to(result1d, ["wrapped", "_NodeMixin__children"]) + return result1d summands: ShapeList[Shape] if other is None: From 4b8a4e92c11964aeacec7f55d0bcab98084de92a Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 16 Sep 2025 12:42:47 -0400 Subject: [PATCH 18/51] Tidy geometry and zero_d intersection typing and docstrings. --- src/build123d/geometry.py | 44 +++++++++++++------- src/build123d/topology/shape_core.py | 2 +- src/build123d/topology/zero_d.py | 62 ++++++++++++++-------------- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 69fae4f..e4c0eeb 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -527,18 +527,22 @@ class Vector: @overload def intersect(self, location: Location) -> Vector | None: - """Find intersection of location and vector""" + """Find intersection of vector and location""" @overload def intersect(self, axis: Axis) -> Vector | None: - """Find intersection of axis and vector""" + """Find intersection of vector and axis""" @overload def intersect(self, plane: Plane) -> Vector | None: - """Find intersection of plane and vector""" + """Find intersection of vector and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of vector and shape""" def intersect(self, *args, **kwargs): - """Find intersection of geometric objects and vector""" + """Find intersection of vector and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -906,11 +910,11 @@ class Axis(metaclass=AxisMeta): @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and axis""" + """Find intersection of axis and vector""" @overload def intersect(self, location: Location) -> Vector | Location | None: - """Find intersection of location and axis""" + """Find intersection of axis and location""" @overload def intersect(self, axis: Axis) -> Vector | Axis | None: @@ -918,10 +922,14 @@ class Axis(metaclass=AxisMeta): @overload def intersect(self, plane: Plane) -> Vector | Axis | None: - """Find intersection of plane and axis""" + """Find intersection of axis and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of axis and shape""" def intersect(self, *args, **kwargs): - """Find intersection of geometric object and axis""" + """Find intersection of axis and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -1929,7 +1937,7 @@ class Location: @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and location""" + """Find intersection of location and vector""" @overload def intersect(self, location: Location) -> Vector | Location | None: @@ -1937,14 +1945,18 @@ class Location: @overload def intersect(self, axis: Axis) -> Vector | Location | None: - """Find intersection of axis and location""" + """Find intersection of location and axis""" @overload def intersect(self, plane: Plane) -> Vector | Location | None: - """Find intersection of plane and location""" + """Find intersection of location and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of location and shape""" def intersect(self, *args, **kwargs): - """Find intersection of geometric object and location""" + """Find intersection of location and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -3131,15 +3143,15 @@ class Plane(metaclass=PlaneMeta): @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and plane""" + """Find intersection of plane and vector""" @overload def intersect(self, location: Location) -> Vector | Location | None: - """Find intersection of location and plane""" + """Find intersection of plane and location""" @overload def intersect(self, axis: Axis) -> Vector | Axis | None: - """Find intersection of axis and plane""" + """Find intersection of plane and axis""" @overload def intersect(self, plane: Plane) -> Axis | Plane | None: @@ -3150,7 +3162,7 @@ class Plane(metaclass=PlaneMeta): """Find intersection of plane and shape""" def intersect(self, *args, **kwargs): - """Find intersection of geometric object and shape""" + """Find intersection of plane and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index ec9e2ae..4d06397 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1326,7 +1326,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ) def intersect( - self, *to_intersect: Shape | Axis | Plane + self, *to_intersect: Shape | Vector | Location | Axis | Plane ) -> None | Self | ShapeList[Self]: """Intersection of the arguments and this shape diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index 7d52245..bb36513 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -169,41 +169,41 @@ class Vertex(Shape[TopoDS_Vertex]): raise NotImplementedError("Vertices can't be created by extrusion") def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex]: - """Intersection of the arguments and this shape + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> ShapeList[Vertex] | None: + """Intersection of vertex and geometric objects or shapes. - Args: - to_intersect (sequence of Union[Shape, Axis, Plane]): Shape(s) to - intersect with + Args: + to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]): + Objects(s) to intersect with - Returns: - ShapeList[Shape]: Resulting object may be of a ShapeList of multiple - non-Compound object created - """ - points_sets: list[set] = [] - for obj in to_intersect: - # Treat as Vector, otherwise call intersection from Shape - match obj: - case Vertex(): - result = Vector(self).intersect(Vector(obj)) - case Vector() | Location() | Axis() | Plane(): - result = obj.intersect(Vector(self)) - case _ if issubclass(type(obj), Shape): - result = obj.intersect(self) - case _: - raise ValueError(f"Unknown object type: {type(obj)}") + Returns: + ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None + """ + points_sets: list[set] = [] + result: Shape | ShapeList[Shape] | Vector | None + for obj in to_intersect: + # Treat as Vector, otherwise call intersection from Shape + match obj: + case Vertex(): + result = Vector(self).intersect(Vector(obj)) + case Vector() | Location() | Axis() | Plane(): + result = obj.intersect(Vector(self)) + case _ if issubclass(type(obj), Shape): + result = obj.intersect(self) + case _: + raise ValueError(f"Unknown object type: {type(obj)}") - if isinstance(result, Vector): - points_sets.append(set([result])) - else: - points_sets.append(set()) - - common_points = set.intersection(*points_sets) - if common_points: - return ShapeList([Vertex(p) for p in common_points]) + if isinstance(result, Vector): + points_sets.append(set([result])) else: - return None + points_sets.append(set()) + + common_points = set.intersection(*points_sets) + if common_points: + return ShapeList([Vertex(p) for p in common_points]) + + return None # ---- Instance Methods ---- From 6f41cd851cec90ddc35e240ef5d3d6f02c75fdec Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 16 Sep 2025 19:36:41 -0400 Subject: [PATCH 19/51] Improving test coverage --- src/build123d/topology/constrained_lines.py | 33 +++++++++-------- .../test_direct_api/test_constrained_arcs.py | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 25e0f53..bbfdf9c 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -89,12 +89,9 @@ _surf_xy = Geom_Plane(_pln_xy) # --------------------------- # Normalization utilities # --------------------------- -def _norm_on_period(u: float, first: float, per: float) -> float: +def _norm_on_period(u: float, first: float, period: float) -> float: """Map parameter u into [first, first+per).""" - if per <= 0.0: - return u - k = floor((u - first) / per) - return u - k * per + return (u - first) % period + first def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: @@ -195,24 +192,24 @@ def _two_arc_edges_from_params( Uses centralized normalization utilities. """ h2d_circle = Geom2d_Circle(circ) - per = h2d_circle.Period() # usually 2*pi + period = h2d_circle.Period() # usually 2*pi # Minor (forward) span - d = _forward_delta(u1, u2, 0.0, per) # anchor at 0 for circle convenience - u1n = _norm_on_period(u1, 0.0, per) - u2n = _norm_on_period(u2, 0.0, per) + d = _forward_delta(u1, u2, 0.0, period) # anchor at 0 for circle convenience + u1n = _norm_on_period(u1, 0.0, period) + u2n = _norm_on_period(u2, 0.0, period) # Guard degeneracy - if d <= TOLERANCE or abs(per - d) <= TOLERANCE: + if d <= TOLERANCE or abs(period - d) <= TOLERANCE: return ShapeList() minor = _edge_from_circle(h2d_circle, u1n, u1n + d) - major = _edge_from_circle(h2d_circle, u2n, u2n + (per - d)) + major = _edge_from_circle(h2d_circle, u2n, u2n + (period - d)) return [minor, major] -def _qstr(q) -> str: - # Works with OCP's GccEnt enum values +def _qstr(q) -> str: # pragma: no cover + """Debugging facility that works with OCP's GccEnt enum values""" try: from OCP.GccEnt import GccEnt_enclosed, GccEnt_enclosing, GccEnt_outside @@ -353,9 +350,11 @@ def _make_2tan_on_arcs( adapt_on = Geom2dAdaptor_Curve(h_e[2], e_first[2], e_last[2]) # Provide initial middle guess parameters for all of the edges - guesses: tuple[float, float, float] = tuple( - [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] - ) + guesses: list[float] = [ + (e_last[i] - e_first[i]) / 2 + e_first[i] + for i in range(len(tangent_tuples)) + if is_edge[i] + ] if sum(is_edge) > 1: gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE, *guesses) @@ -452,7 +451,7 @@ def _make_3tan_arcs( # Provide initial middle guess parameters for all of the edges guesses: tuple[float, float, float] = tuple( - [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(len(is_edge))] + [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(3)] ) # Generate all valid circles tangent to the 3 inputs diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py index f016876..b076011 100644 --- a/tests/test_direct_api/test_constrained_arcs.py +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -122,6 +122,13 @@ def test_tan2_rad_arcs_4(): assert len(tan2_rad_edges) == 1 +def test_tan2_rad_arcs_5(): + """no solution""" + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs((0, 0), (10, 0), radius=2) + assert "Unable to find a tangent arc" in str(excinfo.value) + + def test_tan2_center_on_1(): """2 tangents & center on""" c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) @@ -135,6 +142,35 @@ def test_tan2_center_on_1(): assert len(tan2_on_edge) == 1 +def test_tan2_center_on_2(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + (0, 3), (5, 0), center_on=Line((0, -5), (0, 5)) + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_3(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), (5, 0), center_on=Line((0, -5), (0, 5)) + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_4(): + """2 tangents & center on""" + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), + Line((-5, 0), (5, 0)), + center_on=Line((-5, -1), (5, -1)), + ) + assert "Unable to find a tangent arc with center_on constraint" in str( + excinfo.value + ) + + def test_tan_center_on_1(): """1 tangent & center on""" c5 = PolarLine((0, 0), 4, 60) From 71534e3e9fa0007d37f0dbda04d0bca7f834773a Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 17 Sep 2025 11:43:45 -0400 Subject: [PATCH 20/51] Improving test coverage --- src/build123d/topology/constrained_lines.py | 40 ++++----- .../test_direct_api/test_constrained_arcs.py | 83 ++++++++++++++++--- 2 files changed, 92 insertions(+), 31 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index bbfdf9c..66cbd73 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 +from math import floor, pi from typing import TYPE_CHECKING, Callable, TypeVar from typing import cast as tcast @@ -69,6 +69,7 @@ from OCP.gp import ( gp_Pnt, gp_Pnt2d, ) +from OCP.Standard import Standard_ConstructionError from OCP.TopoDS import TopoDS_Edge from build123d.build_enums import Sagitta, Tangency @@ -77,7 +78,7 @@ from .zero_d import Vertex from .shape_core import ShapeList, downcast if TYPE_CHECKING: - from build123d.topology.one_d import Edge + from build123d.topology.one_d import Edge # pragma: no cover TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass) @@ -120,9 +121,6 @@ def _edge_to_qualified_2d( hcurve3d = BRep_Tool.Curve_s(edge, float(), float()) first, last = BRep_Tool.Range_s(edge) - if hcurve3d is None: - raise ValueError("Edge has no underlying 3D curve.") - # 2) Apply location if the edge is positioned by a TopLoc_Location if not loc.IsIdentity(): trsf = loc.Transformation() @@ -166,7 +164,7 @@ def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ Normalize input to a GCC argument. Returns: (q_obj, h2d, first, last, is_edge) - Edge -> (QualifiedCurve, h2d, first, last, True) - - Vertex/VectorLike -> (CartesianPoint, None, None, None, False) + - Vector -> (CartesianPoint, None, None, None, False) """ if obj.wrapped is None: raise TypeError("Can't create a qualified curve from empty edge") @@ -174,12 +172,7 @@ def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ if isinstance(obj.wrapped, TopoDS_Edge): return _edge_to_qualified_2d(obj.wrapped, constaint)[0:4] + (True,) - try: - base = Vector(obj) - except (TypeError, ValueError) as exc: - raise ValueError("Expected Edge | Vertex | VectorLike") from exc - - gp_pnt = gp_Pnt2d(base.X, base.Y) + gp_pnt = gp_Pnt2d(obj.X, obj.Y) return Geom2d_CartesianPoint(gp_pnt), None, None, None, False @@ -284,7 +277,7 @@ def _make_2tan_rad_arcs( # --------------------------- solutions: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d + circ: gp_Circ2d = gcc.ThisSolution(i) # Tangency on curve 1 p1 = gp_Pnt2d() @@ -377,7 +370,7 @@ def _make_2tan_on_arcs( # --------------------------- solutions: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d + circ: gp_Circ2d = gcc.ThisSolution(i) # Tangency on curve 1 p1 = gp_Pnt2d() @@ -455,10 +448,13 @@ def _make_3tan_arcs( ) # Generate all valid circles tangent to the 3 inputs - gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) - + msg = "Unable to find a circle tangent to all three objects" + try: + gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) + except Standard_ConstructionError as con_err: + raise RuntimeError(msg) from con_err if not gcc.IsDone() or gcc.NbSolutions() == 0: - raise RuntimeError("Unable to find a circle tangent to all three objects") + raise RuntimeError(msg) def _ok(i: int, u: float) -> bool: """Does the given parameter value lie within the edge range?""" @@ -471,7 +467,13 @@ def _make_3tan_arcs( # --------------------------- out_topos: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d + circ: gp_Circ2d = gcc.ThisSolution(i) + + # Look at all of the solutions + # h2d_circle = Geom2d_Circle(circ) + # arc2d = Geom2d_TrimmedCurve(h2d_circle, 0, 2 * pi, True) + # out_topos.append(BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge()) + # continue # Tangency on curve 1 (arc endpoint A) p1 = gp_Pnt2d() @@ -642,7 +644,7 @@ def _make_tan_on_rad_arcs( # --- enumerate solutions; emit full circles (2π trims) --- out_topos: list[TopoDS_Edge] = [] for i in range(1, gcc.NbSolutions() + 1): - circ = gcc.ThisSolution(i) # gp_Circ2d + circ: gp_Circ2d = gcc.ThisSolution(i) # Validate tangency lies on trimmed span when the target is an Edge p = gp_Pnt2d() diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py index b076011..5cd8a97 100644 --- a/tests/test_direct_api/test_constrained_arcs.py +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -35,21 +35,46 @@ from build123d.objects_curve import ( IntersectingLine, ThreePointArc, ) +from build123d.operations_generic import mirror from build123d.topology import Edge, Solid, Vertex, Wire, topo_explore_common_vertex -from build123d.geometry import Axis, Vector, TOLERANCE +from build123d.geometry import Axis, Plane, Vector, TOLERANCE from build123d.build_enums import Tangency, Sagitta, LengthMode -from OCP.BRep import BRep_Tool -from OCP.GeomAbs import GeomAbs_C1 -from OCP.LocalAnalysis import LocalAnalysis_CurveContinuity +from build123d.topology.constrained_lines import ( + _as_gcc_arg, + _param_in_trim, + _edge_to_qualified_2d, + _two_arc_edges_from_params, +) +from OCP.gp import gp_Ax2d, gp_Dir2d, gp_Circ2d, gp_Pnt2d -radius = 0.5 -e1 = Line((-2, 0), (2, 0)) -# e2 = (1, 1) -e2 = Line((0, -2), (0, 2)) -e1 = CenterArc((0, 0), 1, 0, 90) -e2 = Line((1, 0), (2, 0)) -e1.color = "Grey" -e2.color = "Red" + +def test_edge_to_qualified_2d(): + e = Line((0, 0), (1, 0)) + e.position += (1, 1, 1) + qc, curve_2d, first, last, adaptor = _edge_to_qualified_2d( + e.wrapped, Tangency.UNQUALIFIED + ) + assert first < last + + +def test_two_arc_edges_from_params(): + circle = gp_Circ2d(gp_Ax2d(gp_Pnt2d(0, 0), gp_Dir2d(1.0, 0.0)), 1) + arcs = _two_arc_edges_from_params(circle, 0, TOLERANCE / 10) + assert len(arcs) == 0 + + +def test_param_in_trim(): + with pytest.raises(TypeError) as excinfo: + _param_in_trim(None, 0.0, 1.0, None) + assert "Invalid parameters to _param_in_trim" in str(excinfo.value) + + +def test_as_gcc_arg(): + e = Line((0, 0), (1, 0)) + e.wrapped = None + with pytest.raises(TypeError) as excinfo: + _as_gcc_arg(e, Tangency.UNQUALIFIED) + assert "Can't create a qualified curve from empty edge" in str(excinfo.value) def test_constrained_arcs_arg_processing(): @@ -185,6 +210,10 @@ def test_pnt_center_1(): assert len(pnt_center) == 1 assert pnt_center[0].is_closed + pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=Vertex(-2, 1)) + assert len(pnt_center) == 1 + assert pnt_center[0].is_closed + def test_tan_rad_center_on_1(): """tangent, radius, center on""" @@ -210,6 +239,36 @@ def test_tan3_1(): assert len(tan3) == 1 assert not tan3[0].is_closed + tan3b = Edge.make_constrained_arcs(c5, c6, c7, sagitta=Sagitta.BOTH) + assert len(tan3b) == 2 + + +def test_tan3_2(): + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs( + Line((0, 0), (0, 1)), + Line((0, 0), (1, 0)), + Line((0, 0), (0, -1)), + ) + assert "Unable to find a circle tangent to all three objects" in str(excinfo.value) + + +def test_tan3_3(): + l1 = Line((0, 0), (10, 0)) + l2 = Line((0, 2), (10, 2)) + l3 = Line((0, 5), (10, 5)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(l1, l2, l3) + assert "Unable to find a circle tangent to all three objects" in str(excinfo.value) + + +def test_tan3_4(): + l1 = Line((-1, 0), (-1, 2)) + l2 = Line((1, 0), (1, 2)) + l3 = Line((-1, 0), (-0.75, 0)) + tan3 = Edge.make_constrained_arcs(l1, l2, l3) + assert len(tan3) == 0 + def test_eggplant(): """complex set of 4 arcs""" From d8a2a3b0891e46732a696d1222d3fd1f4feeaaaa Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 18 Sep 2025 17:20:48 -0400 Subject: [PATCH 21/51] Support results of Vertex or list[Vertex] for set intersection --- src/build123d/topology/zero_d.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index bb36513..87b40db 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -174,7 +174,7 @@ class Vertex(Shape[TopoDS_Vertex]): """Intersection of vertex and geometric objects or shapes. Args: - to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]): + to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]): Objects(s) to intersect with Returns: @@ -196,14 +196,15 @@ class Vertex(Shape[TopoDS_Vertex]): if isinstance(result, Vector): points_sets.append(set([result])) + elif isinstance(result, Vertex): + points_sets.append(set([Vector(result)])) + elif isinstance(result, list): + points_sets.append(set(Vector(r) for r in result)) else: points_sets.append(set()) common_points = set.intersection(*points_sets) - if common_points: - return ShapeList([Vertex(p) for p in common_points]) - - return None + return ShapeList([Vertex(p) for p in common_points]) if common_points else None # ---- Instance Methods ---- From ca748f0f2e86556cb604f9f80ca4adba297f2399 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 18 Sep 2025 18:58:19 -0400 Subject: [PATCH 22/51] Move intersect from Edge to Mixin1D, support Wire, tidy up logic --- src/build123d/topology/one_d.py | 230 +++++++++++++-------- tests/test_direct_api/test_intersection.py | 74 ++++++- tests/test_direct_api/test_location.py | 4 +- 3 files changed, 218 insertions(+), 90 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index e019df7..d4cb000 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -52,7 +52,6 @@ license: from __future__ import annotations import copy -import itertools import numpy as np import warnings from collections.abc import Iterable @@ -665,6 +664,151 @@ class Mixin1D(Shape): return Vector(curve.Value(umax)) + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge]: + """Intersect Edge with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges + """ + # targets takes ShapeLists of edges from Edge/Wire + targets: list[ShapeList] = [ShapeList(self.edges())] + points: list[Vertex] = [] + shapes: list[Shape] = [] + planes: list[Plane] = [] + for obj in to_intersect: + match obj: + case Axis(): + targets.append(ShapeList([Edge(obj)])) + case Plane(): + planes.append(obj) + case Vector(): + points.append(Vertex(obj)) + case Location(): + points.append(Vertex(obj.position)) + case Vertex(): + points.append(obj) + case Edge(): + targets.append(ShapeList([obj])) + case Wire(): + targets.append(ShapeList(obj.edges())) + case _ if issubclass(type(obj), Shape): + shapes.append(obj) + case _: + raise ValueError(f"Unknown object type: {type(obj)}") + + # Find intersections of all combinations + # Pool order biases combination order + pool = targets + points + shapes + planes + common_sets = [] + for pair in combinations(pool, 2): + common = [] + match pair: + case (ShapeList() as objs, ShapeList() as tars): + # Find any edge / edge intersection points + for obj in objs: + for tar in tars: + # Find crossing points + try: + intersection_points = obj.find_intersection_points(tar) + common.extend(intersection_points) + except ValueError: + pass + + # Find common end points + obj_end_points = set(Vector(v) for v in obj.vertices()) + tar_end_points = set(Vector(v) for v in tar.vertices()) + common.extend( + set.intersection(obj_end_points, tar_end_points) + ) + + # Find Edge/Edge overlaps + result = obj._bool_op( + (obj,), tars, BRepAlgoAPI_Common() + ).edges() + common.extend(result if isinstance(result, list) else [result]) + + case (ShapeList() as objs, Vertex() as tar): + for obj in objs: + result = Shape.intersect(obj, tar) + if result: + common.append(Vector(result)) + + case (ShapeList() as objs, Plane() as plane): + # Find any edge / plane intersection points & edges + for obj in objs: + # Find point intersections + geom_line = BRep_Tool.Curve_s( + obj.wrapped, obj.param_at(0), obj.param_at(1) + ) + geom_plane = Geom_Plane(plane.local_coord_system) + intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) + plane_intersection_points: list[Vector] = [] + if intersection_calculator.IsDone(): + plane_intersection_points = [ + Vector(intersection_calculator.Point(i + 1)) + for i in range(intersection_calculator.NbPoints()) + ] + common.extend(plane_intersection_points) + + # Find edge intersections + if all( + plane.contains(v) + for v in obj.positions(i / 7 for i in range(8)) + ): # is a 2D edge + common.append(obj) + + case (ShapeList() as objs, tar): + # Find Shape with Edge/Wire + if not isinstance(tar, ShapeList): + for obj in objs: + common.append(tar.intersect(obj)) + else: + raise RuntimeError("Unexpected target of type Shapelist") + + case (Vertex() as obj, Vertex() as tar): + common.append(tar.intersect(obj)) + + case (Plane() as obj, Plane() as tar): + result = tar.intersect(obj) + if isinstance(result, Axis): + common.append(Edge(result)) + else: + common.append(None) + + case _: + obj, tar = pair + # Always run Shape first in a pair + if isinstance(tar, Shape): + common.append(tar.intersect(obj)) + elif isinstance(obj, Shape) and not isinstance(tar, ShapeList): + common.append(obj.intersect(tar)) + else: + raise RuntimeError(f"Invalid intersection {pair}.") + + common_sets.append(set(common)) + + result = set.intersection(*common_sets) + result = ShapeList([Vertex(r) if isinstance(r, Vector) else r for r in result]) + + # Remove Vertices if part of Edges + if result: + vts = result.vertices() + eds = result.edges() + if vts and eds: + filtered_vts = list( + filter( + lambda v: all(v.distance_to(e) > TOLERANCE for e in eds), vts + ) + ) + result = filtered_vts + eds + + return ShapeList(result) if result else None + def location_at( self, distance: float, @@ -2151,90 +2295,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): raise ValueError("Can't find adaptor for empty edge") return BRepAdaptor_Curve(self.wrapped) - def intersect( - self, *to_intersect: Edge | Axis | Plane - ) -> None | Vertex | Edge | ShapeList[Vertex | Edge]: - """intersect Edge with Edge or Axis - - Args: - other (Edge | Axis): other object - - Returns: - Shape | None: Compound of vertices and/or edges - """ - edges: list[Edge] = [] - planes: list[Plane] = [] - edges_common_to_planes: list[Edge] = [] - - for obj in to_intersect: - match obj: - case Axis(): - edges.append(Edge(obj)) - case Edge(): - edges.append(obj) - case Plane(): - planes.append(obj) - case _: - raise ValueError(f"Unknown object type: {type(obj)}") - - # Find any edge / edge intersection points - points_sets: list[set[Vector]] = [] - # Find crossing points - for edge_pair in combinations([self] + edges, 2): - intersection_points = edge_pair[0].find_intersection_points(edge_pair[1]) - points_sets.append(set(intersection_points)) - - # Find common end points - self_end_points = set(Vector(v) for v in self.vertices()) - edge_end_points = set(Vector(v) for edge in edges for v in edge.vertices()) - common_end_points = set.intersection(self_end_points, edge_end_points) - - # Find any edge / plane intersection points & edges - for edge, plane in itertools.product([self] + edges, planes): - if edge.wrapped is None: - continue - # Find point intersections - geom_line = BRep_Tool.Curve_s( - edge.wrapped, edge.param_at(0), edge.param_at(1) - ) - geom_plane = Geom_Plane(plane.local_coord_system) - intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) - plane_intersection_points: list[Vector] = [] - if intersection_calculator.IsDone(): - plane_intersection_points = [ - Vector(intersection_calculator.Point(i + 1)) - for i in range(intersection_calculator.NbPoints()) - ] - points_sets.append(set(plane_intersection_points)) - - # Find edge intersections - if all( - plane.contains(v) for v in edge.positions(i / 7 for i in range(8)) - ): # is a 2D edge - edges_common_to_planes.append(edge) - - edges.extend(edges_common_to_planes) - - # Find the intersection of all sets - common_points = set.intersection(*points_sets) - common_vertices = [ - Vertex(pnt) for pnt in common_points.union(common_end_points) - ] - - # Find Edge/Edge overlaps - common_edges: list[Edge] = [] - if edges: - common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges() - - if common_vertices or common_edges: - # If there is just one vertex or edge return it - if len(common_vertices) == 1 and len(common_edges) == 0: - return common_vertices[0] - if len(common_vertices) == 0 and len(common_edges) == 1: - return common_edges[0] - return ShapeList(common_vertices + common_edges) - return None - def _occt_param_at( self, position: float, position_mode: PositionMode = PositionMode.PARAMETER ) -> tuple[BRepAdaptor_Curve, float, bool]: diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index e3f9495..c388fa5 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -32,7 +32,7 @@ def run_test(obj, target, expected): e_type = ShapeList if isinstance(expected, list) else expected assert isinstance(result, e_type), f"Expected {e_type}, but got {result}" if e_type == ShapeList: - assert len(result) >= len(expected), f"Expected {len(expected)} objects, but got {len(result)}" + assert len(result) == len(expected), f"Expected {len(expected)} objects, but got {len(result)}" actual_counts = Counter(type(obj) for obj in result) expected_counts = Counter(expected) @@ -67,6 +67,7 @@ pl1 = Plane.YZ pl2 = Plane.XY pl3 = Plane.XY.offset(5) pl4 = Plane((0, 5, 0)) +pl5 = Plane.YZ.offset(1) vl1 = Vector(2, 0, 0) vl2 = Vector(2, 0, 5) lc1 = Location((2, 0, 0)) @@ -151,6 +152,74 @@ def test_shape_0d(obj, target, expected): run_test(obj, target, expected) +ed1 = Line((0, 0), (5, 0)).edge() +ed2 = Line((0, -1), (5, 1)).edge() +ed3 = Line((0, 0, 5), (5, 0, 5)).edge() +ed4 = CenterArc((3, 1), 2, 0, 360).edge() +ed5 = CenterArc((3, 1), 5, 0, 360).edge() + +ed6 = Edge.make_line((0, -1), (2, 1)) +ed7 = Edge.make_line((0, 1), (2, -1)) +ed8 = Edge.make_line((0, 0), (2, 0)) + +wi1 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 1.5), 2)] +wi2 = wi1 + Line((3, 1.5), (3, -1)) +wi3 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 0), 2), Line((3, 0), (5, 0))] +wi4 = Wire() + [Line((0, 1), (2, -1)) , Line((2, -1), (3, -1))] +wi5 = wi4 + Line((3, -1), (4, 1)) +wi6 = Wire() + [Line((0, 1, 1), (2, -1, 1)), Line((2, -1, 1), (4, 1, 1))] + +shape_1d_matrix = [ + Case(ed1, vl2, None, "non-coincident", None), + Case(ed1, vl1, [Vertex], "coincident", None), + + Case(ed1, lc2, None, "non-coincident", None), + Case(ed1, lc1, [Vertex], "coincident", None), + + Case(ed3, ax1, None, "parallel/skew", None), + Case(ed2, ax1, [Vertex], "intersecting", None), + Case(ed1, ax1, [Edge], "collinear", None), + Case(ed4, ax1, [Vertex, Vertex], "multi intersect", None), + + Case(ed1, pl3, None, "parallel/skew", None), + Case(ed1, pl1, [Vertex], "intersecting", None), + Case(ed1, pl2, [Edge], "collinear", None), + Case(ed5, pl1, [Vertex, Vertex], "multi intersect", None), + + Case(ed1, vt2, None, "non-coincident", None), + Case(ed1, vt1, [Vertex], "coincident", None), + + Case(ed3, ed1, None, "parallel/skew", None), + Case(ed2, ed1, [Vertex], "intersecting", None), + Case(ed1, ed1, [Edge], "collinear", None), + Case(ed4, ed1, [Vertex, Vertex], "multi intersect", None), + + Case(ed6, [ed7, ed8], [Vertex], "multi to_intersect, intersect", None), + Case(ed6, [ed7, pl5], [Vertex], "multi to_intersect, intersect", None), + Case(ed6, [ed7, Vector(1, 0)], [Vertex], "multi to_intersect, intersect", None), + + Case(wi6, ax1, None, "parallel/skew", None), + Case(wi4, ax1, [Vertex], "intersecting", None), + Case(wi1, ax1, [Edge], "collinear", None), + Case(wi5, ax1, [Vertex, Vertex], "multi intersect", None), + Case(wi2, ax1, [Vertex, Edge], "intersect + collinear", None), + Case(wi3, ax1, [Edge, Edge], "2 collinear", None), + + Case(wi6, ed1, None, "parallel/skew", None), + Case(wi4, ed1, [Vertex], "intersecting", None), + Case(wi1, ed1, [Edge], "collinear", None), + Case(wi5, ed1, [Vertex, Vertex], "multi intersect", None), + Case(wi2, ed1, [Vertex, Edge], "intersect + collinear", None), + Case(wi3, ed1, [Edge, Edge], "2 collinear", None), + + Case(wi5, [ed1, Vector(1, 0)], [Vertex], "multi to_intersect, multi intersect", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_1d_matrix)) +def test_shape_1d(obj, target, expected): + run_test(obj, target, expected) + + # FreeCAD issue example c1 = CenterArc((0, 0), 10, 0, 360).edge() c2 = CenterArc((19, 0), 10, 0, 360).edge() @@ -178,7 +247,6 @@ freecad_matrix = [ Case(c2, vert, [Vertex], "circle, vert, intersect", None), ] -@pytest.mark.xfail @pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix)) def test_freecad(obj, target, expected): run_test(obj, target, expected) @@ -198,7 +266,7 @@ issues_matrix = [ Case(t, t, [Face, Face], "issue #1015", "Returns Compound"), Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"), Case(a, b, [Edge], "issue #918", "Returns empty Compound"), - Case(e1, w1, [Vertex, Vertex], "issue #697", "Returns None"), + Case(e1, w1, [Vertex, Vertex], "issue #697"), Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"), ] diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index 40f0e0a..d22cb6c 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -388,8 +388,8 @@ class TestLocation(unittest.TestCase): e3 = Edge.make_line((0, 0), (2, 0)) i = e1.intersect(e2, e3) - self.assertTrue(isinstance(i, Vertex)) - self.assertAlmostEqual(Vector(i), (1, 0, 0), 5) + self.assertTrue(isinstance(i, list)) + self.assertAlmostEqual(Vector(i[0]), (1, 0, 0), 5) e4 = Edge.make_line((1, -1), (1, 1)) e5 = Edge.make_line((2, -1), (2, 1)) From 5bf505341cee5f4a56290ed99f1b74ed9eda7081 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 22 Sep 2025 14:17:04 -0400 Subject: [PATCH 23/51] Handling MAC error codes --- src/build123d/topology/constrained_lines.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 66cbd73..3738f12 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -69,7 +69,7 @@ from OCP.gp import ( gp_Pnt, gp_Pnt2d, ) -from OCP.Standard import Standard_ConstructionError +from OCP.Standard import Standard_ConstructionError, Standard_Failure from OCP.TopoDS import TopoDS_Edge from build123d.build_enums import Sagitta, Tangency @@ -451,7 +451,7 @@ def _make_3tan_arcs( msg = "Unable to find a circle tangent to all three objects" try: gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) - except Standard_ConstructionError as con_err: + except (Standard_ConstructionError, Standard_Failure) as con_err: raise RuntimeError(msg) from con_err if not gcc.IsDone() or gcc.NbSolutions() == 0: raise RuntimeError(msg) From 26c723ccb6d8e6375b34f46d51b21c04461faedf Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Mon, 22 Sep 2025 14:48:36 -0400 Subject: [PATCH 24/51] Add exception tests --- tests/test_direct_api/test_intersection.py | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index c388fa5..6eebc41 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -272,4 +272,28 @@ issues_matrix = [ @pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) def test_issues(obj, target, expected): - run_test(obj, target, expected) \ No newline at end of file + run_test(obj, target, expected) + + +# Exceptions +exception_matrix = [ + Case(vt1, Color(), None, "Unsupported type", None), + Case(ed1, Color(), None, "Unsupported type", None), +] + +@pytest.mark.skip +def make_exception_params(matrix): + params = [] + for case in matrix: + obj_type = type(case.object).__name__ + tar_type = type(case.target).__name__ + i = len(params) + uid = f"{i} {obj_type}, {tar_type}, {case.name}" + params.append(pytest.param(case.object, case.target, case.expected, id=uid)) + + return params + +@pytest.mark.parametrize("obj, target, expected", make_exception_params(exception_matrix)) +def test_exceptions(obj, target, expected): + with pytest.raises(Exception): + obj.intersect(target) \ No newline at end of file From 1754da47fa2b65cfe7334dda75cfeac1ac31704a Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Mon, 22 Sep 2025 14:50:44 -0400 Subject: [PATCH 25/51] Restructure intersection loops to intersect next in to_intersect with the previous intersect result, exit early if None --- src/build123d/topology/one_d.py | 177 +++++++++++++++---------------- src/build123d/topology/zero_d.py | 29 ++--- 2 files changed, 98 insertions(+), 108 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index d4cb000..a287f86 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -675,43 +675,71 @@ class Mixin1D(Shape): Returns: ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges """ - # targets takes ShapeLists of edges from Edge/Wire - targets: list[ShapeList] = [ShapeList(self.edges())] - points: list[Vertex] = [] - shapes: list[Shape] = [] - planes: list[Plane] = [] - for obj in to_intersect: - match obj: - case Axis(): - targets.append(ShapeList([Edge(obj)])) - case Plane(): - planes.append(obj) - case Vector(): - points.append(Vertex(obj)) - case Location(): - points.append(Vertex(obj.position)) - case Vertex(): - points.append(obj) - case Edge(): - targets.append(ShapeList([obj])) - case Wire(): - targets.append(ShapeList(obj.edges())) - case _ if issubclass(type(obj), Shape): - shapes.append(obj) - case _: - raise ValueError(f"Unknown object type: {type(obj)}") - # Find intersections of all combinations - # Pool order biases combination order - pool = targets + points + shapes + planes - common_sets = [] - for pair in combinations(pool, 2): - common = [] - match pair: - case (ShapeList() as objs, ShapeList() as tars): - # Find any edge / edge intersection points - for obj in objs: - for tar in tars: + def to_vector(objs: Iterable) -> ShapeList: + return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + + def to_vertex(objs: Iterable) -> ShapeList: + return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + + common_set: ShapeList[Vertex | Edge] = ShapeList(self.edges()) + target: ShapeList | Shape | Plane + for other in to_intersect: + # Conform target type + # Vertices need to be Vector for set() + match other: + case Axis(): + target = ShapeList([Edge(other)]) + case Plane(): + target = other + case Vector(): + target = Vertex(other) + case Location(): + target = Vertex(other.position) + case Edge(): + target = ShapeList([other]) + case Wire(): + target = ShapeList(other.edges()) + case _ if issubclass(type(other), Shape): + target = other + case _: + raise ValueError(f"Unsupported type to_intersect: {type(other)}") + + # Find common matches + common: list[Vector | Edge] = [] + result: ShapeList | Shape | None + for obj in common_set: + match (obj, target): + case obj, Shape() as target: + # Find Shape with Edge/Wire + if isinstance(target, Vertex): + result = Shape.intersect(obj, target) + else: + result = target.intersect(obj) + + if result: + if not isinstance(result, list): + result = ShapeList([result]) + common.extend(to_vector(result)) + + case Vertex() as obj, target: + if not isinstance(target, ShapeList): + target = ShapeList([target]) + + for tar in target: + if isinstance(tar, Edge): + result = Shape.intersect(obj, tar) + else: + result = obj.intersect(tar) + + if result: + if not isinstance(result, list): + result = ShapeList([result]) + common.extend(to_vector(result)) + + case Edge() as obj, ShapeList() as targets: + # Find any edge / edge intersection points + for tar in targets: # Find crossing points try: intersection_points = obj.find_intersection_points(tar) @@ -722,25 +750,17 @@ class Mixin1D(Shape): # Find common end points obj_end_points = set(Vector(v) for v in obj.vertices()) tar_end_points = set(Vector(v) for v in tar.vertices()) - common.extend( - set.intersection(obj_end_points, tar_end_points) - ) + points = set.intersection(obj_end_points, tar_end_points) + common.extend(points) # Find Edge/Edge overlaps result = obj._bool_op( - (obj,), tars, BRepAlgoAPI_Common() + (obj,), targets, BRepAlgoAPI_Common() ).edges() common.extend(result if isinstance(result, list) else [result]) - case (ShapeList() as objs, Vertex() as tar): - for obj in objs: - result = Shape.intersect(obj, tar) - if result: - common.append(Vector(result)) - - case (ShapeList() as objs, Plane() as plane): - # Find any edge / plane intersection points & edges - for obj in objs: + case Edge() as obj, Plane() as plane: + # Find any edge / plane intersection points & edges # Find point intersections geom_line = BRep_Tool.Curve_s( obj.wrapped, obj.param_at(0), obj.param_at(1) @@ -762,52 +782,21 @@ class Mixin1D(Shape): ): # is a 2D edge common.append(obj) - case (ShapeList() as objs, tar): - # Find Shape with Edge/Wire - if not isinstance(tar, ShapeList): - for obj in objs: - common.append(tar.intersect(obj)) - else: - raise RuntimeError("Unexpected target of type Shapelist") + if common: + common_set = to_vertex(set(common)) + # Remove Vertex intersections coincident to Edge intersections + 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) + ]) + common_set = filtered_vts + eds + else: + return None - case (Vertex() as obj, Vertex() as tar): - common.append(tar.intersect(obj)) - - case (Plane() as obj, Plane() as tar): - result = tar.intersect(obj) - if isinstance(result, Axis): - common.append(Edge(result)) - else: - common.append(None) - - case _: - obj, tar = pair - # Always run Shape first in a pair - if isinstance(tar, Shape): - common.append(tar.intersect(obj)) - elif isinstance(obj, Shape) and not isinstance(tar, ShapeList): - common.append(obj.intersect(tar)) - else: - raise RuntimeError(f"Invalid intersection {pair}.") - - common_sets.append(set(common)) - - result = set.intersection(*common_sets) - result = ShapeList([Vertex(r) if isinstance(r, Vector) else r for r in result]) - - # Remove Vertices if part of Edges - if result: - vts = result.vertices() - eds = result.edges() - if vts and eds: - filtered_vts = list( - filter( - lambda v: all(v.distance_to(e) > TOLERANCE for e in eds), vts - ) - ) - result = filtered_vts + eds - - return ShapeList(result) if result else None + return ShapeList(common_set) def location_at( self, diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index 87b40db..cf53676 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -59,6 +59,7 @@ import warnings from typing import overload, TYPE_CHECKING from collections.abc import Iterable +from typing_extensions import Self import OCP.TopAbs as ta from OCP.BRep import BRep_Tool @@ -67,7 +68,6 @@ from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge from OCP.gp import gp_Pnt from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane -from typing_extensions import Self from .shape_core import Shape, ShapeList, downcast, shapetype @@ -180,31 +180,32 @@ class Vertex(Shape[TopoDS_Vertex]): Returns: ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None """ - points_sets: list[set] = [] + common = Vector(self) result: Shape | ShapeList[Shape] | Vector | None for obj in to_intersect: # Treat as Vector, otherwise call intersection from Shape match obj: case Vertex(): - result = Vector(self).intersect(Vector(obj)) + result = common.intersect(Vector(obj)) case Vector() | Location() | Axis() | Plane(): - result = obj.intersect(Vector(self)) + result = obj.intersect(common) case _ if issubclass(type(obj), Shape): result = obj.intersect(self) case _: - raise ValueError(f"Unknown object type: {type(obj)}") + raise ValueError(f"Unsupported type to_intersect:: {type(obj)}") - if isinstance(result, Vector): - points_sets.append(set([result])) - elif isinstance(result, Vertex): - points_sets.append(set([Vector(result)])) - elif isinstance(result, list): - points_sets.append(set(Vector(r) for r in result)) + if isinstance(result, Vector) and result == common: + pass + elif ( + isinstance(result, list) + and len(result) == 1 + and Vector(result[0]) == common + ): + pass else: - points_sets.append(set()) + return None - common_points = set.intersection(*points_sets) - return ShapeList([Vertex(p) for p in common_points]) if common_points else None + return ShapeList([self]) # ---- Instance Methods ---- From 404aed73d6d497fea7f208474ce1fc3d7249728d Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 23 Sep 2025 10:48:06 -0400 Subject: [PATCH 26/51] Added Axis as tangent/center_on types --- src/build123d/topology/one_d.py | 65 ++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 0f0069d..bac4507 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -1588,8 +1588,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, *, radius: float, sagitta: Sagitta = Sagitta.SHORT, @@ -1598,8 +1598,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Create all planar circular arcs of a given radius that are tangent/contacting the two provided objects on the XY plane. Args: - tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_one, tangency_two + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) radius (float): arc radius sagitta (LengthConstraint, optional): returned arc selector @@ -1614,10 +1614,10 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, *, - center_on: Edge, + center_on: Axis | Edge, sagitta: Sagitta = Sagitta.SHORT, ) -> ShapeList[Edge]: """ @@ -1625,10 +1625,10 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): CENTER lies on a given locus (line/circle/curve) on the XY plane. Args: - tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_one, tangency_two + (tuple[Axus | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) - center_on (Edge): center must lie on this edge + center_on (Axis | Edge): center must lie on this object sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1641,9 +1641,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, - tangency_two: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, - tangency_three: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_three: ( + tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike + ), *, sagitta: Sagitta = Sagitta.SHORT, ) -> ShapeList[Edge]: @@ -1651,9 +1653,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Create planar circular arc(s) on XY tangent to three provided objects. Args: - tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): - tangency_two (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): - tangency_three (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_one, tangency_two, tangency_three + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): Geometric entities to be contacted/touched by the circle(s) sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to @@ -1667,7 +1668,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, *, center: VectorLike, ) -> ShapeList[Edge]: @@ -1677,7 +1678,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): a single object. Args: - tangency_one (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike): + tangency_one + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): Geometric entity to be contacted/touched by the circle(s) center (VectorLike): center position @@ -1689,7 +1691,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): @classmethod def make_constrained_arcs( cls, - tangency_one: tuple[Edge, Tangency] | Edge | Vertex | VectorLike, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, *, radius: float, center_on: Edge, @@ -1702,10 +1704,11 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): - have their CENTER constrained to lie on a given locus curve. Args: - tangency_one (tuple[Edge, PositionConstraint] | Vertex | VectorLike): + tangency_one + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): Geometric entity to be contacted/touched by the circle(s) radius (float): arc radius - center_on (Edge): center must lie on this edge + center_on (Axis | Edge): center must lie on this object sagitta (LengthConstraint, optional): returned arc selector (i.e. either the short, long or both arcs). Defaults to LengthConstraint.SHORT. @@ -1743,17 +1746,24 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): ] tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = [] for tangency_arg in tangency_args: - if isinstance(tangency_arg, Edge): - tangencies.append(tangency_arg) - continue - if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge): + if isinstance(tangency_arg, Axis): + tangencies.append(Edge(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(tuple(Edge(tangency_arg[0], tangency_arg[1]))) + continue + elif isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue if isinstance(tangency_arg, Vertex): tangencies.append(Vector(tangency_arg) + tangency_arg.position) continue - # if not Edges, constrained Edges or Vertex convert to Vectors + # if not Axes, Edges, constrained Edges or Vertex convert to Vectors try: tangencies.append(Vector(tangency_arg)) except Exception as exc: @@ -1770,6 +1780,9 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): if radius is not None and radius <= 0: raise ValueError("radius must be > 0.0") + if center_on is not None and isinstance(center_on, Axis): + center_on = Edge(center_on) + # --- decide problem kind --- if ( tan_count == 2 From fed77612c0d68663dffd7eac305ae6bc2456067a Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 23 Sep 2025 13:46:12 -0400 Subject: [PATCH 27/51] Coverage at 100% --- src/build123d/topology/constrained_lines.py | 34 +-- .../test_direct_api/test_constrained_arcs.py | 205 +++++++++++++++++- 2 files changed, 207 insertions(+), 32 deletions(-) diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 3738f12..624ee37 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -384,30 +384,11 @@ def _make_2tan_on_arcs( if not _ok(1, u_arg2): continue - # Center must lie on the trimmed center_on curve segment - center2d = circ.Location() # gp_Pnt2d - - # Project center onto the (trimmed) 2D locus - proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_e[2]) - if proj.NbPoints() == 0: - continue # no projection -> reject - - u_on = proj.Parameter(1) - # Optional: make sure it's actually on the curve (not just near) - if proj.Distance(1) > TOLERANCE: - continue - - # Respect the trimmed interval (handles periodic curves too) - if not _param_in_trim(u_on, e_first[2], e_last[2], h_e[2]): - continue - # Build sagitta arc(s) and select by LengthConstraint if sagitta == Sagitta.BOTH: solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) - if not arcs: - continue arcs = sorted( arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) ) @@ -498,8 +479,6 @@ def _make_3tan_arcs( out_topos.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) else: arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) - if not arcs: - continue arcs = sorted( arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)), @@ -571,10 +550,9 @@ def _make_tan_cen_arcs( assert isinstance(q_o1, Geom2dGcc_QualifiedCurve) # Case B: tangency target is a curve/edge (qualified curve) gcc = Geom2dGcc_Circ2dTanCen(q_o1, Geom2d_CartesianPoint(c2d), TOLERANCE) - if not gcc.IsDone() or gcc.NbSolutions() == 0: - raise RuntimeError( - "Unable to find circle(s) tangent to target with fixed center" - ) + assert ( + gcc.IsDone() and gcc.NbSolutions() > 0 + ), "Unexpected: GCC failed to return a tangent circle" for i in range(1, gcc.NbSolutions() + 1): circ = gcc.ThisSolution(i) # gp_Circ2d @@ -657,13 +635,7 @@ def _make_tan_on_rad_arcs( # Project center onto the (trimmed) 2D locus proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d) - if proj.NbPoints() == 0: - continue # no projection -> reject - u_on = proj.Parameter(1) - # Optional: make sure it's actually on the curve (not just near) - if proj.Distance(1) > TOLERANCE: - continue # Respect the trimmed interval (handles periodic curves too) if not _param_in_trim(u_on, on_first, on_last, h_on2d): diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py index 5cd8a97..3eaab09 100644 --- a/tests/test_direct_api/test_constrained_arcs.py +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -36,7 +36,14 @@ from build123d.objects_curve import ( ThreePointArc, ) from build123d.operations_generic import mirror -from build123d.topology import Edge, Solid, Vertex, Wire, topo_explore_common_vertex +from build123d.topology import ( + Edge, + Face, + Solid, + Vertex, + Wire, + topo_explore_common_vertex, +) from build123d.geometry import Axis, Plane, Vector, TOLERANCE from build123d.build_enums import Tangency, Sagitta, LengthMode from build123d.topology.constrained_lines import ( @@ -184,6 +191,14 @@ def test_tan2_center_on_3(): def test_tan2_center_on_4(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), (5, 0), center_on=Axis.Y + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_5(): """2 tangents & center on""" with pytest.raises(RuntimeError) as excinfo: Edge.make_constrained_arcs( @@ -196,6 +211,142 @@ def test_tan2_center_on_4(): ) +def test_tan2_center_on_6(): + """2 tangents & center on""" + l1 = Line((0, 0), (5, 0)) + l2 = Line((0, 0), (0, 5)) + l3 = Line((20, 20), (22, 22)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(l1, l2, center_on=l3) + assert "Unable to find a tangent arc with center_on constraint" in str( + excinfo.value + ) + + +# --- Sagitta selection branches --- + + +def test_tan2_center_on_sagitta_both_returns_two_arcs(): + """ + TWO lines, center_on a line that crosses *both* angle bisectors → multiple + circle solutions; with Sagitta.BOTH we should get 2 arcs per solution. + Setup: x-axis & y-axis; center_on y=1. + """ + c1 = Line((-10, 0), (10, 0)) # y = 0 + c2 = Line((0, -10), (0, 10)) # x = 0 + center_on = Line((-10, 1), (10, 1)) # y = 1 + + arcs = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.BOTH, + ) + # Expect 2 solutions (centers at (1,1) and (-1,1)), each yielding 2 arcs → 4 + assert len(arcs) >= 2 # be permissive across kernels; typically 4 + # At least confirms BOTH path is covered and multiple solutions iterate + + +def test_tan2_center_on_sagitta_long_is_longer_than_short(): + """ + Verify LONG branch by comparing lengths against SHORT for the same geometry. + """ + c1 = Line((-10, 0), (10, 0)) # y = 0 + c2 = Line((0, -10), (0, 10)) # x = 0 + center_on = Line((3, -10), (3, 10)) # x = 3 (unique center) + + short_arc = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + long_arc = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.LONG, + ) + assert len(short_arc) == 2 + assert len(long_arc) == 2 + assert long_arc[0].length > short_arc[0].length + + +# --- Filtering branches inside the Solutions loop --- + + +def test_tan2_center_on_filters_outside_first_tangent_segment(): + """ + Cause _ok(0, u_arg1) to fail: + - First tangency is a *very short* horizontal segment near x∈[0, 0.01]. + - Second tangency is a vertical line far away. + - Center_on is x=5 (vertical). + The resulting tangency on the infinite horizontal line occurs near x≈center.x (≈5), + which lies *outside* the trimmed first segment → filtered out, no arcs. + """ + tiny_first = Line((0.0, 0.0), (0.01, 0.0)) # very short horizontal + c2 = Line((10.0, -10.0), (10.0, 10.0)) # vertical line + center_on = Line((5.0, -10.0), (5.0, 10.0)) # x = 5 + + arcs = Edge.make_constrained_arcs( + (tiny_first, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + # GCC likely finds solutions, but they should be filtered out by _ok(0) + assert len(arcs) == 0 + + +def test_tan2_center_on_filters_outside_second_tangent_segment(): + """ + Cause _ok(1, u_arg2) to fail: + - First tangency is a *point* (so _ok(0) is trivially True). + - Second tangency is a *very short* vertical segment around y≈0 on x=10. + - Center_on is y=2 (horizontal), and first point is at (0,2). + For a circle through (0,2) and tangent to x=10 with center_on y=2, + the center is at (5,2), radius=5, so tangency on x=10 occurs at y=2, + which is *outside* the tiny segment around y≈0 → filtered by _ok(1). + """ + first_point = (0.0, 2.0) # acts as a "point object" + tiny_second = Line((10.0, -0.005), (10.0, 0.005)) # very short vertical near y=0 + center_on = Line((-10.0, 2.0), (10.0, 2.0)) # y = 2 + + arcs = Edge.make_constrained_arcs( + first_point, + (tiny_second, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + assert len(arcs) == 0 + + +# --- Multiple-solution loop coverage with BOTH again (robust geometry) --- + + +def test_tan2_center_on_multiple_solutions_both_counts(): + """ + Another geometry with 2+ GCC solutions: + c1: y=0, c2: y=4 (two non-intersecting parallels), center_on x=0. + Any circle tangent to both has radius=2 and center on y=2; with center_on x=0, + the center fixes at (0,2) — single center → two arcs (BOTH). + Use intersecting lines instead to guarantee >1 solutions: c1: y=0, c2: x=0, + center_on y=-2 (intersects both angle bisectors at (-2,-2) and (2,-2)). + """ + c1 = Line((-20, 0), (20, 0)) # y = 0 + c2 = Line((0, -20), (0, 20)) # x = 0 + center_on = Line((-20, -2), (20, -2)) # y = -2 + + arcs = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.BOTH, + ) + # Expect at least 2 arcs (often 4); asserts loop over multiple i values + assert len(arcs) >= 2 + + def test_tan_center_on_1(): """1 tangent & center on""" c5 = PolarLine((0, 0), 4, 60) @@ -204,6 +355,21 @@ def test_tan_center_on_1(): assert tan_center[0].is_closed +def test_tan_center_on_2(): + """1 tangent & center on""" + tan_center = Edge.make_constrained_arcs(Axis.X, center=(2, 1, 5)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed + + +def test_tan_center_on_3(): + """1 tangent & center on""" + l1 = CenterArc((0, 0), 1, 180, 5) + tan_center = Edge.make_constrained_arcs(l1, center=(2, 0)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed + + def test_pnt_center_1(): """pnt & center""" pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=(-2, 1)) @@ -215,6 +381,21 @@ def test_pnt_center_1(): assert pnt_center[0].is_closed +def test_tan_cen_arcs_center_equals_point_returns_empty(): + """ + If the fixed center coincides with the tangency point, + the computed radius is zero and no valid circle exists. + Function should return an empty ShapeList. + """ + center = (0, 0) + tangency_point = (0, 0) # same as center + + arcs = Edge.make_constrained_arcs(tangency_point, center=center) + + assert isinstance(arcs, list) # ShapeList subclass + assert len(arcs) == 0 + + def test_tan_rad_center_on_1(): """tangent, radius, center on""" c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) @@ -226,6 +407,28 @@ def test_tan_rad_center_on_1(): assert tan_rad_on[0].is_closed +def test_tan_rad_center_on_2(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + tan_rad_on = Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X) + assert len(tan_rad_on) == 1 + assert tan_rad_on[0].is_closed + + +def test_tan_rad_center_on_3(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_arcs(c1, radius=1, center_on=Face.make_rect(1, 1)) + + +def test_tan_rad_center_on_4(): + """tangent, radius, center on""" + c1 = Line((0, 10), (10, 10)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X) + + def test_tan3_1(): """3 tangents""" c5 = PolarLine((0, 0), 4, 60) From 31a73bacdaf82bc711f1ce676971937f556c32b8 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 1 Oct 2025 11:36:49 -0400 Subject: [PATCH 28/51] 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 29/51] 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 30/51] 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 31/51] 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 32/51] 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 33/51] 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 34/51] 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 35/51] 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 36/51] 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 37/51] 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 38/51] 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 39/51] 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 40/51] 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 41/51] 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 42/51] 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 43/51] 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 44/51] 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 45/51] 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 46/51] 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 47/51] 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 48/51] 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 zcmeAS@N?(olHy`uVBq!ia0y~yU=CnlV2a^jV_;zTzRvX@0|Ns~v6E*A2L}g74M$1` z0|SF(iEBhjaDG}zd16s2Lwa6*ZmMo^a#3n(UU5c#$$RGgb_@&*x*$c)MX8A;nfZAN zA(^?U48e&d3WgR6{>dc@Mg~Tv3I;}2riNCgCJF(*3a&08@;XK~3=9eko-U3d6?5L~ zjop0b*xcnm*;l=i+4th8?WUBSsk`4!y*BObJjwG_=}BAn3f{hZS@zOVmp4!Ij=ZU3 z`gH96g8hy8OV)4LTx+=5_OkFs_se@POHYfQ;<9(b6>is;7i_FVYE`_Js7z|FQq9&6 zSmCr#$U$ww%o8m0)K%tcDNOWWD1G(dqQ3FOGtWQoWB>yP<;?rMU=~9MD+?%?7#JLc zM;TNJ1O+)&Dzde(&x-`8N9c)5rJUpE}H6>pidPm1gCe zjmLlGXfZG_h&U`@V6X|Cwv7G%hy9|06Z7lCbv@+e-<)#aUmq@4`z6qYi-AEwKtO|m zq5VnntW%f1Jdn3j3;Ojfv{v$Y=9PzMvrAqxGca&4uy8RPV6?WjmcIMhsB+bhQ@ImX zF7lXbQP^a{#lWyY9b|y_n>TMxynDAUW=}<+%cd=w{&TL(I4yGb7Xt&sFV;pTh8GQ4 zF~RdJW+tiy|9-zmrQLk}$;JJ4TI&AuOzwy?FfeQZDKc1+Wz_L&%Sjg7nN}$^A`A=- zvVt!o|SmUoqM7%R!lm zObiSgKyGMG%{A)yEjQuuce@R0zBjku|FMtB{@35#U~4ltnOGT`7u|7vztFwz&J2^a z`&f^EE&Xw?Rs89PXQrSSH~ZkEVWQ=nCT{EbNV-q%+2?DhXn zK2WqAxZt>eVS(@|PDQyukICQ6dE-4T4g05qy~88Q!o?7^&}+6_P{-wC;yg-MyCio1 z0Nc~X!NkgNYsE3Ml}kKZKXtRs%yP{!n%aHkFn?_~C>1XPdD*S&?w41W)M6)SS|7P~ zv_G$wfx&=9K!ah$k~_Zdf7J4y__%fEmtD@{_SQ#~z`@dR!*Kya0`Ju&0u zXg~~`;jn_))l&#ARw_vHyWSoTDOLss z$3_Q6o}RCHRrZ>;yEA3>zdJD{_Ltw%uDied=2$p_RP1eXVB~2yn{NJhiDItQ@%6S! zsnhobYpTs;U}z9ru~z3=Xqy^pmSW^VG1H#({!%)oHLoI}K+Wy;Z$=WZ-3 ztPu^7Q#HCe%Wl_?iEfMx4)F>B3==o}d$TP6^vB;`=kqpwn{6|1Jxh@0?jL?jK@MKP zFQCzIAkusGzU3Ev&%et5Qn62Ng68fYK1*kOl3`$|X>wp>@u&>ycwdvhclNw%`npP2 zyYiG$V=6xU@Bur1p3TdedIkmqKLL$~292)E6=H&QO zZ1EP*{64|!=Fx3FPw)Pct9uc>B#?pO0v8L{0l}wvRma~~JM`WE?QnkWsRh;Zt38&! zNe*5H_VBGH2gZnF$A75Jp5V2!J>-;~SxT4Yc}9i>yc{A8s%A%Ds<&=^_g4GQBwP?%ldK@7`VO2%h<&{_cx=&)HK>@#dy~N(KeM1=dEUjK->@ z4y!^f;p6LNS5B!G&@A6F5jpKF5Mtpv&~Ua>ao^p-qz;R^zXF=}3c-09myh;?UBJb{ z<=f;xV9$RlEn3(iV z2Ycv*;{paT4fQsS?qErmEY6iv>^+v=2~vkx#39l%uQ#vC_tFxkiBV2kPx*s1w}+H| zRPJSBkTB;E>6y04qIS88-`YAuft9L%JEv^+YHkZTrMoHwoT-j+GO;dR>^bL@LeSk8 z-{tJ)eNO2u8bpYm!!(e#+1vFT!hmS3vC%6Av`p4Tt=SbV(ZuRTagnW?eq z;N?)T?uFI!<3Y~y0Vm`(ekRuDquVT|IxgAO?D3O*X3^;yy`taGOmFV~z-0WDk%32q zg^TU@E(=?|`#+3HSncWzmUPh) z6Il6gLHGPvP=0*G*4UJ>K^x@v84If8>nC{KY&79wP>2)I;E>z?DCEfb^-M~s{r{LJ z{5Ut$ch~7FJI>GN$P{64=urp|(D5$(_3yD#@$^R_Y!kbB|GmHY*gD;8VRyVa*l4E4 zrUwzi>YAOCulv8g;d<(I-;0UYZ6T**E@?0{6gVzeAP#R& zO>O6XB(lxs$KA*q&z1JA?=^UL{jqhvnc`F7bQWpuhn&mtH@4RC50B{b|+jes6P{(>0^^&m#f$dh4FYHAjAmHv6

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 49/51] 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 50/51] 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 51/51] .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