Merge branch 'dev' into intersections

This commit is contained in:
Jonathan Wagenet 2025-09-24 20:29:28 -04:00
commit 431cf4c191
10 changed files with 1531 additions and 55 deletions

View file

@ -55,11 +55,13 @@ __all__ = [
"Intrinsic",
"Keep",
"Kind",
"Sagitta",
"LengthMode",
"MeshType",
"Mode",
"NumberDisplay",
"PageSize",
"Tangency",
"PositionMode",
"PrecisionMode",
"Select",

View file

@ -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 Sagitta(Enum):
"""Sagitta 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 Tangency(Enum):
"""Tangency constraint for solvers 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"""

View file

@ -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)

View file

@ -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):
result1d: Curve | Wire = Curve(sum1d)
elif isinstance(sum1d, Edge):
result1d = Curve([sum1d])
else: # Wire
result1d = sum1d
self.copy_attributes_to(result1d, ["wrapped", "_NodeMixin__children"])
return result1d
summands: ShapeList[Shape]
if other is None:

View file

@ -0,0 +1,648 @@
"""
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, pi
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_Curve, Geom_Plane
from OCP.Geom2d import (
Geom2d_CartesianPoint,
Geom2d_Circle,
Geom2d_Curve,
Geom2d_Point,
Geom2d_TrimmedCurve,
)
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_QualifiedCurve,
)
from OCP.GeomAbs import GeomAbs_CurveType
from OCP.GeomAPI import GeomAPI, GeomAPI_ProjectPointOnCurve
from OCP.gp import (
gp_Ax2d,
gp_Ax3,
gp_Circ2d,
gp_Dir,
gp_Dir2d,
gp_Pln,
gp_Pnt,
gp_Pnt2d,
)
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 .zero_d import Vertex
from .shape_core import ShapeList, downcast
if TYPE_CHECKING:
from build123d.topology.one_d import Edge # pragma: no cover
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, period: float) -> float:
"""Map parameter u into [first, first+per)."""
return (u - first) % period + first
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: Tangency
) -> 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)
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)
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, adapt2d
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 | 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 | Vector, constaint: Tangency) -> 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)
- Vector -> (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,)
gp_pnt = gp_Pnt2d(obj.X, obj.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)
period = h2d_circle.Period() # usually 2*pi
# Minor (forward) span
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(period - d) <= TOLERANCE:
return ShapeList()
minor = _edge_from_circle(h2d_circle, u1n, u1n + d)
major = _edge_from_circle(h2d_circle, u2n, u2n + (period - d))
return [minor, major]
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
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(
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2
radius: float,
sagitta: Sagitta = Sagitta.SHORT,
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.
Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported.
Args:
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:
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.
"""
# Unpack optional per-edge qualifiers (default UNQUALIFIED)
tangent_tuples = [
t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies
]
# Build inputs for GCC
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:
raise RuntimeError("Unable to find a tangent arc")
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
# ---------------------------
solutions: list[TopoDS_Edge] = []
for i in range(1, gcc.NbSolutions() + 1):
circ: gp_Circ2d = gcc.ThisSolution(i)
# Tangency on curve 1
p1 = gp_Pnt2d()
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
if not _ok(0, u_arg1):
continue
# Tangency on curve 2
p2 = gp_Pnt2d()
u_circ2, u_arg2 = gcc.Tangency2(i, p2)
if not _ok(1, 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 == 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.value])
return ShapeList([edge_factory(e) for e in solutions])
def _make_2tan_on_arcs(
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2
center_on: Edge,
sagitta: Sagitta = Sagitta.SHORT,
edge_factory: Callable[[TopoDS_Edge], Edge],
) -> 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).
"""
# Unpack optional per-edge qualifiers (default UNQUALIFIED)
tangent_tuples = [
t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED)
for t in list(tangencies) + [center_on]
]
# Build inputs for GCC
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: 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)
else:
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")
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
# ---------------------------
solutions: list[TopoDS_Edge] = []
for i in range(1, gcc.NbSolutions() + 1):
circ: gp_Circ2d = gcc.ThisSolution(i)
# Tangency on curve 1
p1 = gp_Pnt2d()
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
if not _ok(0, u_arg1):
continue
# Tangency on curve 2
p2 = gp_Pnt2d()
u_circ2, u_arg2 = gcc.Tangency2(i, p2)
if not _ok(1, u_arg2):
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)
arcs = sorted(
arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e))
)
solutions.append(arcs[sagitta.value])
return ShapeList([edge_factory(e) for e in solutions])
def _make_3tan_arcs(
*tangencies: tuple[Edge, Tangency] | Edge | Vector, # 3
sagitta: Sagitta = Sagitta.SHORT,
edge_factory: Callable[[TopoDS_Edge], Edge],
) -> 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 `tangencies[0]` and
`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, Tangency.UNQUALIFIED) for t in tangencies
]
# Build inputs for GCC
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: tuple[float, float, float] = tuple(
[(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(3)]
)
# Generate all valid circles tangent to the 3 inputs
msg = "Unable to find a circle tangent to all three objects"
try:
gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses)
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)
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
# ---------------------------
out_topos: list[TopoDS_Edge] = []
for i in range(1, gcc.NbSolutions() + 1):
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()
u_circ1, u_arg1 = gcc.Tangency1(i, p1)
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 _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 _ok(2, u_arg3):
continue
# Build arc(s) between u_circ1 and u_circ2 per LengthConstraint
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)
arcs = sorted(
arcs,
key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)),
)
out_topos.append(arcs[sagitta.value])
return ShapeList([edge_factory(e) for e in out_topos])
def _make_tan_cen_arcs(
tangency: tuple[Edge, Tangency] | Edge | Vector,
*,
center: VectorLike | Vertex,
edge_factory: Callable[[TopoDS_Edge], Edge],
) -> 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)
if isinstance(tangency, tuple):
object_one, obj1_qual = tangency
else:
object_one, obj1_qual = tangency, Tangency.UNQUALIFIED
# ---------------------------
# Build fixed center (gp_Pnt2d)
# ---------------------------
if isinstance(center, Vertex):
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:
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:
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)
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
# 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(
tangency: tuple[Edge, Tangency] | Edge | Vector,
*,
center_on: Edge,
radius: float,
edge_factory: Callable[[TopoDS_Edge], Edge],
) -> 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.
- 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) ---
if isinstance(tangency, tuple):
object_one, obj1_qual = tangency
else:
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)
# --- 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.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, Tangency.UNQUALIFIED
)
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")
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: gp_Circ2d = gcc.ThisSolution(i)
# 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
# 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)
u_on = proj.Parameter(1)
# 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))
return ShapeList([edge_factory(e) for e in out_topos])

View file

@ -56,13 +56,11 @@ 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, TypeAlias, overload
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
import numpy as np
import OCP.TopAbs as ta
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve
@ -75,6 +73,7 @@ from OCP.BRepBuilderAPI import (
BRepBuilderAPI_DisconnectedWire,
BRepBuilderAPI_EmptyWire,
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeEdge2d,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeWire,
@ -91,29 +90,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,
@ -121,30 +136,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,
@ -155,34 +180,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,
Sagitta,
Tangency,
PositionMode,
Side,
)
from build123d.geometry import (
DEG2RAD,
TOLERANCE,
TOL_DIGITS,
TOLERANCE,
Axis,
Color,
Location,
@ -205,17 +229,23 @@ 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 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,
)
from .zero_d import topo_explore_common_vertex, 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):
@ -1688,6 +1718,246 @@ 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[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
radius: float,
sagitta: Sagitta = Sagitta.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, 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
(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[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
center_on: Axis | Edge,
sagitta: Sagitta = Sagitta.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, tangency_two
(tuple[Axus | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entities to be contacted/touched by the circle(s)
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.
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
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]:
"""
Create planar circular arc(s) on XY tangent to three provided objects.
Args:
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
LengthConstraint.SHORT.
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
center: VectorLike,
) -> 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[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entity to be contacted/touched by the circle(s)
center (VectorLike): center position
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
radius: float,
center_on: Edge,
) -> 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[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entity to be contacted/touched by the circle(s)
radius (float): arc radius
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.
Returns:
ShapeList[Edge]: tangent arcs
"""
@classmethod
def make_constrained_arcs(
cls,
*args,
sagitta: Sagitta = Sagitta.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())}")
tangency_args = [
t for t in (tangency_one, tangency_two, tangency_three) if t is not None
]
tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = []
for tangency_arg in tangency_args:
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 Axes, 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
tangencies = sorted(tangencies, key=lambda x: isinstance(x, Vector))
tan_count = len(tangencies)
if not (1 <= tan_count <= 3):
raise TypeError("Provide 1 to 3 tangency targets.")
# Radius sanity
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
and radius is not None
and center is None
and center_on is None
):
return _make_2tan_rad_arcs(
*tangencies,
radius=radius,
sagitta=sagitta,
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=center_on,
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=sagitta, 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=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=center_on, radius=radius, edge_factory=cls
)
raise ValueError("Unsupported or ambiguous combination of constraints.")
@classmethod
def make_ellipse(
cls,

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,517 @@
"""
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.operations_generic import mirror
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 (
_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
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():
"""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), 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)
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_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=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_tan2_rad_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_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
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
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_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)
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
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"""
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(
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_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 xcenter.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 y0 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 y0 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)
tan_center = Edge.make_constrained_arcs((c5, Tangency.UNQUALIFIED), center=(2, 1))
assert len(tan_center) == 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))
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_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)
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_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)
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
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"""
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)
)

View file

@ -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