mirror of
https://github.com/gumyr/build123d.git
synced 2026-01-24 21:41:15 -08:00
Merge branch 'dev' into intersections
This commit is contained in:
commit
431cf4c191
10 changed files with 1531 additions and 55 deletions
|
|
@ -55,11 +55,13 @@ __all__ = [
|
|||
"Intrinsic",
|
||||
"Keep",
|
||||
"Kind",
|
||||
"Sagitta",
|
||||
"LengthMode",
|
||||
"MeshType",
|
||||
"Mode",
|
||||
"NumberDisplay",
|
||||
"PageSize",
|
||||
"Tangency",
|
||||
"PositionMode",
|
||||
"PrecisionMode",
|
||||
"Select",
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
648
src/build123d/topology/constrained_lines.py
Normal file
648
src/build123d/topology/constrained_lines.py
Normal 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])
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
517
tests/test_direct_api/test_constrained_arcs.py
Normal file
517
tests/test_direct_api/test_constrained_arcs.py
Normal 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 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)
|
||||
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)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue