diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index 56976c4..ff389dc 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -12,7 +12,7 @@ jobs:
# "3.11",
"3.12",
]
- os: [macos-13, macos-14, ubuntu-latest, windows-latest]
+ os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6b1416d..0f9dabc 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,7 +13,7 @@ jobs:
# "3.12",
"3.13",
]
- os: [macos-13, macos-14, ubuntu-latest, windows-latest]
+ os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 9925d2f..44248cf 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -10,6 +10,10 @@ build:
python: "3.10"
apt_packages:
- graphviz
+ jobs:
+ post_checkout:
+ # necessary to ensure that the development builds get a correct version tag
+ - git fetch --unshallow || true
# Build from the docs/ directory with Sphinx
sphinx:
@@ -21,8 +25,3 @@ python:
path: .
extra_requirements:
- docs
-
-# Explicitly set the version of Python and its requirements
-# python:
-# install:
-# - requirements: docs/requirements.txt
diff --git a/docs/_static/spitfire_wing.glb b/docs/_static/spitfire_wing.glb
new file mode 100644
index 0000000..93c275b
Binary files /dev/null and b/docs/_static/spitfire_wing.glb differ
diff --git a/docs/assets/example_airfoil.svg b/docs/assets/example_airfoil.svg
new file mode 100644
index 0000000..47e2fbe
--- /dev/null
+++ b/docs/assets/example_airfoil.svg
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/docs/assets/surface_modeling/heart_token.png b/docs/assets/surface_modeling/heart_token.png
new file mode 100644
index 0000000..24cfeb7
Binary files /dev/null and b/docs/assets/surface_modeling/heart_token.png differ
diff --git a/docs/assets/surface_modeling/spitfire_wing.png b/docs/assets/surface_modeling/spitfire_wing.png
new file mode 100644
index 0000000..1092426
Binary files /dev/null and b/docs/assets/surface_modeling/spitfire_wing.png differ
diff --git a/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg b/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg
new file mode 100644
index 0000000..2dfbd4c
--- /dev/null
+++ b/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/docs/assets/token_half_surface.png b/docs/assets/surface_modeling/token_half_surface.png
similarity index 100%
rename from docs/assets/token_half_surface.png
rename to docs/assets/surface_modeling/token_half_surface.png
diff --git a/docs/assets/token_heart_perimeter.png b/docs/assets/surface_modeling/token_heart_perimeter.png
similarity index 100%
rename from docs/assets/token_heart_perimeter.png
rename to docs/assets/surface_modeling/token_heart_perimeter.png
diff --git a/docs/assets/token_heart_solid.png b/docs/assets/surface_modeling/token_heart_solid.png
similarity index 100%
rename from docs/assets/token_heart_solid.png
rename to docs/assets/surface_modeling/token_heart_solid.png
diff --git a/docs/assets/token_sides.png b/docs/assets/surface_modeling/token_sides.png
similarity index 100%
rename from docs/assets/token_sides.png
rename to docs/assets/surface_modeling/token_sides.png
diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst
index d46ccf8..8bd0d86 100644
--- a/docs/cheat_sheet.rst
+++ b/docs/cheat_sheet.rst
@@ -15,6 +15,7 @@ Cheat Sheet
.. grid-item-card:: 1D - BuildLine
+ | :class:`~objects_curve.Airfoil`
| :class:`~objects_curve.ArcArcTangentArc`
| :class:`~objects_curve.ArcArcTangentLine`
| :class:`~objects_curve.Bezier`
diff --git a/docs/heart_token.py b/docs/heart_token.py
new file mode 100644
index 0000000..da11e68
--- /dev/null
+++ b/docs/heart_token.py
@@ -0,0 +1,68 @@
+# [Code]
+from build123d import *
+from ocp_vscode import show
+
+# Create the edges of one half the heart surface
+l1 = JernArc((0, 0), (1, 1.4), 40, -17)
+l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
+l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
+l4 = ThreePointArc(l3 @ 1, (0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
+heart_half = Wire([l1, l2, l3, l4])
+# [SurfaceEdges]
+
+# Create a point elevated off the center
+surface_pnt = l2.arc_center + (0, 0, 1.5)
+# [SurfacePoint]
+
+# Create the surface from the edges and point
+top_right_surface = Pos(Z=0.5) * -Face.make_surface(heart_half, [surface_pnt])
+# [Surface]
+
+# Use the mirror method to create the other top and bottom surfaces
+top_left_surface = top_right_surface.mirror(Plane.YZ)
+bottom_right_surface = top_right_surface.mirror(Plane.XY)
+bottom_left_surface = -top_left_surface.mirror(Plane.XY)
+# [Surfaces]
+
+# Create the left and right sides
+left_wire = Wire([l3, l2, l1])
+left_side = Pos(Z=-0.5) * Shell.extrude(left_wire, (0, 0, 1))
+right_side = left_side.mirror(Plane.YZ)
+# [Sides]
+
+# Put all of the faces together into a Shell/Solid
+heart = Solid(
+ Shell(
+ [
+ top_right_surface,
+ top_left_surface,
+ bottom_right_surface,
+ bottom_left_surface,
+ left_side,
+ right_side,
+ ]
+ )
+)
+# [Solid]
+
+# Build a frame around the heart
+with BuildPart() as heart_token:
+ with BuildSketch() as outline:
+ with BuildLine():
+ add(l1)
+ add(l2)
+ add(l3)
+ Line(l3 @ 1, l1 @ 0)
+ make_face()
+ mirror(about=Plane.YZ)
+ center = outline.sketch
+ offset(amount=2, kind=Kind.INTERSECTION)
+ add(center, mode=Mode.SUBTRACT)
+ extrude(amount=2, both=True)
+ add(heart)
+
+heart_token.part.color = "Red"
+
+show(heart_token)
+# [End]
+# export_gltf(heart_token.part, "heart_token.glb", binary=True)
diff --git a/docs/objects.rst b/docs/objects.rst
index 395d81d..85204bf 100644
--- a/docs/objects.rst
+++ b/docs/objects.rst
@@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that
.. grid:: 3
+ .. grid-item-card:: :class:`~objects_curve.Airfoil`
+
+ .. image:: assets/example_airfoil.svg
+
+ +++
+ Airfoil described by 4 digit NACA profile
+
.. grid-item-card:: :class:`~objects_curve.Bezier`
.. image:: assets/bezier_curve_example.svg
@@ -228,6 +235,7 @@ Reference
.. py:module:: objects_curve
.. autoclass:: BaseLineObject
+.. autoclass:: Airfoil
.. autoclass:: Bezier
.. autoclass:: BlendCurve
.. autoclass:: CenterArc
diff --git a/docs/spitfire_wing_gordon.py b/docs/spitfire_wing_gordon.py
new file mode 100644
index 0000000..8b41a0c
--- /dev/null
+++ b/docs/spitfire_wing_gordon.py
@@ -0,0 +1,77 @@
+"""
+Supermarine Spitfire Wing
+"""
+
+# [Code]
+
+from build123d import *
+from ocp_vscode import show
+
+wing_span = 36 * FT + 10 * IN
+wing_leading = 2.5 * FT
+wing_trailing = wing_span / 4 - wing_leading
+wing_leading_fraction = wing_leading / (wing_leading + wing_trailing)
+wing_tip_section = wing_span / 2 - 1 * IN # distance from root to last section
+
+# Create leading and trailing edges
+leading_edge = EllipticalCenterArc(
+ (0, 0), wing_span / 2, wing_leading, start_angle=270, end_angle=360
+)
+trailing_edge = EllipticalCenterArc(
+ (0, 0), wing_span / 2, wing_trailing, start_angle=0, end_angle=90
+)
+
+# [AirfoilSizes]
+# Calculate the airfoil sizes from the leading/trailing edges
+airfoil_sizes = []
+for i in [0, 1]:
+ tip_axis = Axis(i * (wing_tip_section, 0, 0), (0, 1, 0))
+ leading_pnt = leading_edge.intersect(tip_axis)[0]
+ trailing_pnt = trailing_edge.intersect(tip_axis)[0]
+ airfoil_sizes.append(trailing_pnt.Y - leading_pnt.Y)
+
+# [Airfoils]
+# Create the root and tip airfoils - note that they are different NACA profiles
+airfoil_root = Plane.YZ * scale(
+ Airfoil("2213").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[0]
+)
+airfoil_tip = (
+ Plane.YZ
+ * Pos(Z=wing_tip_section)
+ * scale(Airfoil("2205").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[1])
+)
+
+# [Profiles]
+# Create the Gordon surface profiles and guides
+profiles = airfoil_root.edges() + airfoil_tip.edges()
+profiles.append(leading_edge @ 1) # wing tip
+guides = [leading_edge, trailing_edge]
+# Create the wing surface as a Gordon Surface
+wing_surface = -Face.make_gordon_surface(profiles, guides)
+# Create the root of the wing
+wing_root = -Face(Wire(wing_surface.edges().filter_by(Edge.is_closed)))
+
+# [Solid]
+# Create the wing Solid
+wing = Solid(Shell([wing_surface, wing_root]))
+wing.color = 0x99A3B9 # Azure Blue
+
+show(wing)
+# [End]
+# Documentation artifact generation
+# wing_control_edges = Curve(
+# [airfoil_root, airfoil_tip, Vertex(leading_edge @ 1), leading_edge, trailing_edge]
+# )
+# visible, _ = wing_control_edges.project_to_viewport((50 * FT, -50 * FT, 50 * FT))
+# max_dimension = max(*Compound(children=visible).bounding_box().size)
+# svg = ExportSVG(scale=100 / max_dimension)
+# svg.add_shape(visible)
+# svg.write("assets/surface_modeling/spitfire_wing_profiles_guides.svg")
+
+# export_gltf(
+# wing,
+# "assets/surface_modeling/spitfire_wing.glb",
+# binary=True,
+# linear_deflection=0.1,
+# angular_deflection=1,
+# )
diff --git a/docs/tutorial_spitfire_wing_gordon.rst b/docs/tutorial_spitfire_wing_gordon.rst
new file mode 100644
index 0000000..716f862
--- /dev/null
+++ b/docs/tutorial_spitfire_wing_gordon.rst
@@ -0,0 +1,106 @@
+#############################################
+Tutorial: Spitfire Wing with Gordon Surface
+#############################################
+
+In this advanced tutorial we construct a Supermarine Spitfire wing as a
+:meth:`~topology.Face.make_gordon_surface`—a powerful technique for surfacing
+from intersecting *profiles* and *guides*. A Gordon surface blends a grid of
+curves into a smooth, coherent surface as long as the profiles and guides
+intersect consistently.
+
+.. note::
+ Gordon surfaces work best when *each profile intersects each guide exactly
+ once*, producing a well‑formed curve network.
+
+Overview
+========
+
+We will:
+
+1. Define overall wing dimensions and elliptic leading/trailing edge guide curves
+2. Sample the guides to size the root and tip airfoils (different NACA profiles)
+3. Build the Gordon surface from the airfoil *profiles* and wing‑edge *guides*
+4. Close the root with a planar face and build the final :class:`~topology.Solid`
+
+.. raw:: html
+
+
+
+
+Step 1 — Dimensions and guide curves
+====================================
+
+We model a single wing (half‑span), with an elliptic leading and trailing edge.
+These two edges act as the *guides* for the Gordon surface.
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [Code]
+ :end-before: [AirfoilSizes]
+
+
+Step 2 — Root and tip airfoil sizing
+====================================
+
+We intersect the guides with planes normal to the span to size the airfoil sections.
+The resulting chord lengths define uniform scales for each airfoil curve.
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [AirfoilSizes]
+ :end-before: [Airfoils]
+
+Step 3 — Build airfoil profiles (root and tip)
+==============================================
+
+We place two different NACA airfoils on :data:`Plane.YZ`—with the airfoil origins
+shifted so the leading edge fraction is aligned—then scale to the chord lengths
+from Step 2.
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [Airfoils]
+ :end-before: [Profiles]
+
+
+Step 4 — Gordon surface construction
+====================================
+
+A Gordon surface needs *profiles* and *guides*. Here the airfoil edges are the
+profiles; the elliptic edges are the guides. We also add the wing tip section
+so the profile grid closes at the tip.
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [Profiles]
+ :end-before: [Solid]
+
+.. image:: ./assets/surface_modeling/spitfire_wing_profiles_guides.svg
+ :align: center
+ :alt: Elliptic leading/trailing guides
+
+
+Step 5 — Cap the root and create the solid
+==========================================
+
+We extract the closed root edge loop, make a planar cap, and form a solid shell.
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [Solid]
+ :end-before: [End]
+
+.. image:: ./assets/surface_modeling/spitfire_wing.png
+ :align: center
+ :alt: Final wing solid
+
+Tips for robust Gordon surfaces
+-------------------------------
+
+- Ensure each profile intersects each guide once and only once
+- Keep the curve network coherent (no duplicated or missing intersections)
+- When possible, reuse the same :class:`~topology.Edge` objects across adjacent faces
+
+Complete listing
+================
+
+For convenience, here is the full script in one block:
+
+.. literalinclude:: spitfire_wing_gordon.py
+ :start-after: [Code]
+ :end-before: [End]
diff --git a/docs/tutorial_surface_heart_token.rst b/docs/tutorial_surface_heart_token.rst
new file mode 100644
index 0000000..2c45f62
--- /dev/null
+++ b/docs/tutorial_surface_heart_token.rst
@@ -0,0 +1,125 @@
+##################################
+Tutorial: Heart Token (Basics)
+##################################
+
+This hands‑on tutorial introduces the fundamentals of surface modeling by building
+a heart‑shaped token from a small set of non‑planar faces. We’ll create
+non‑planar surfaces, mirror them, add side faces, and assemble a closed shell
+into a solid.
+
+As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
+and other elements that define the boundary of an object. When creating objects with
+non-planar faces, it is often more convenient to explicitly create the boundary faces of
+the object. To illustrate this process, we will create the following game token:
+
+.. raw:: html
+
+
+
+
+Useful :class:`~topology.Face` creation methods include
+:meth:`~topology.Face.make_surface`, :meth:`~topology.Face.make_bezier_surface`,
+and :meth:`~topology.Face.make_surface_from_array_of_points`. See the
+:doc:`surface_modeling` overview for the full list.
+
+In this case, we'll use the ``make_surface`` method, providing it with the edges that define
+the perimeter of the surface and a central point on that surface.
+
+To create the perimeter, we'll define the perimeter edges. Since the heart is
+symmetric, we'll only create half of its surface here:
+
+.. literalinclude:: heart_token.py
+ :start-after: [Code]
+ :end-before: [SurfaceEdges]
+
+Note that ``l4`` is not in the same plane as the other lines; it defines the center line
+of the heart and archs up off ``Plane.XY``.
+
+.. image:: ./assets/surface_modeling/token_heart_perimeter.png
+ :align: center
+ :alt: token perimeter
+
+In preparation for creating the surface, we'll define a point on the surface:
+
+.. literalinclude:: heart_token.py
+ :start-after: [SurfaceEdges]
+ :end-before: [SurfacePoint]
+
+We will then use this point to create a non-planar ``Face``:
+
+.. literalinclude:: heart_token.py
+ :start-after: [SurfacePoint]
+ :end-before: [Surface]
+
+.. image:: ./assets/surface_modeling/token_half_surface.png
+ :align: center
+ :alt: token perimeter
+
+Note that the surface was raised up by 0.5 using an Algebra expression with Pos. Also,
+note that the ``-`` in front of ``Face`` simply flips the face normal so that the colored
+side is up, which isn't necessary but helps with viewing.
+
+Now that one half of the top of the heart has been created, the remainder of the top
+and bottom can be created by mirroring:
+
+.. literalinclude:: heart_token.py
+ :start-after: [Surface]
+ :end-before: [Surfaces]
+
+The sides of the heart are going to be created by extruding the outside of the perimeter
+as follows:
+
+.. literalinclude:: heart_token.py
+ :start-after: [Surfaces]
+ :end-before: [Sides]
+
+.. image:: ./assets/surface_modeling/token_sides.png
+ :align: center
+ :alt: token sides
+
+With the top, bottom, and sides, the complete boundary of the object is defined. We can
+now put them together, first into a :class:`~topology.Shell` and then into a
+:class:`~topology.Solid`:
+
+.. literalinclude:: heart_token.py
+ :start-after: [Sides]
+ :end-before: [Solid]
+
+.. image:: ./assets/surface_modeling/token_heart_solid.png
+ :align: center
+ :alt: token heart solid
+
+.. note::
+ When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
+ should have no holes. For objects with complex Edges, it's best practice to reuse
+ Edges in adjoining Faces whenever possible to avoid slight mismatches that can
+ create openings.
+
+Finally, we'll create the frame around the heart as a simple extrusion of a planar
+shape defined by the perimeter of the heart and merge all of the components together:
+
+.. literalinclude:: heart_token.py
+ :start-after: [Solid]
+ :end-before: [End]
+
+Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
+can be created. The :func:`~operations_generic.offset` function defines the outside of
+the frame as a constant distance from the heart itself.
+
+Summary
+-------
+
+In this tutorial, we've explored surface modeling techniques to create a non-planar
+heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
+class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
+central point of the surface. We then assembled the complete boundary of the object
+by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
+and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
+using the :func:`~operations_generic.offset` function to maintain a constant distance
+from the heart.
+
+Next steps
+----------
+
+Continue to :doc:`tutorial_heart_token` for an advanced example using
+:meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing.
diff --git a/docs/tutorial_surface_modeling.rst b/docs/tutorial_surface_modeling.rst
index 79aeada..afa7f82 100644
--- a/docs/tutorial_surface_modeling.rst
+++ b/docs/tutorial_surface_modeling.rst
@@ -1,156 +1,55 @@
-################
+#################
Surface Modeling
-################
+#################
-Surface modeling is employed to create objects with non-planar surfaces that can't be
-generated using functions like :func:`~operations_part.extrude`,
-:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no
-specific builders designed to assist with the creation of non-planar surfaces or objects,
-the following should be considered a more advanced technique.
-As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
-and other elements that define the boundary of an object. When creating objects with
-non-planar faces, it is often more convenient to explicitly create the boundary faces of
-the object. To illustrate this process, we will create the following game token:
+Surface modeling refers to the direct creation and manipulation of the skin of a 3D
+object—its bounding faces—rather than starting from volumetric primitives or solid
+operations.
-.. raw:: html
+Instead of defining a shape by extruding or revolving a 2D profile to fill a volume,
+surface modeling focuses on building the individual curved or planar faces that together
+define the outer boundary of a part. This approach allows for precise control of complex
+freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that
+cannot easily be expressed with simple parametric solids.
-
-
+In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling,
+all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and
+vertices. Each face represents a finite patch of a geometric surface (plane, cylinder,
+Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share
+edges consistently and close into a continuous boundary, they form a manifold
+:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly
+oriented and encloses a finite region of space, the model becomes a solid.
-There are several methods of the :class:`~topology.Face` class that can be used to create
-non-planar surfaces:
+Surface modeling therefore operates at the most fundamental level of BREP construction.
+Rather than relying on higher-level modeling operations to implicitly generate faces,
+it allows you to construct and connect those faces explicitly. This provides a path to
+build geometry that blends analytical and freeform shapes seamlessly, with full control
+over continuity, tangency, and curvature across boundaries.
-* :meth:`~topology.Face.make_bezier_surface`,
-* :meth:`~topology.Face.make_surface`, and
-* :meth:`~topology.Face.make_surface_from_array_of_points`.
+This section provides:
+- A concise overview of surface‑building tools in build123d
+- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces
-In this case, we'll use the ``make_surface`` method, providing it with the edges that define
-the perimeter of the surface and a central point on that surface.
+.. rubric:: Available surface methods
-To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is
-symmetric, we'll only create half of its surface here:
+Methods on :class:`~topology.Face` for creating non‑planar surfaces:
-.. code-block:: build123d
-
- with BuildLine() as heart_half:
- l1 = JernArc((0, 0), (1, 1.4), 40, -17)
- l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
- l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
- l4 = ThreePointArc(l3 @ 1, Vector(0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
-
-Note that ``l4`` is not in the same plane as the other lines; it defines the center line
-of the heart and archs up off ``Plane.XY``.
-
-.. image:: ./assets/token_heart_perimeter.png
- :align: center
- :alt: token perimeter
-
-In preparation for creating the surface, we'll define a point on the surface:
-
-.. code-block:: build123d
-
- surface_pnt = l2.edge().arc_center + Vector(0, 0, 1.5)
-
-We will then use this point to create a non-planar ``Face``:
-
-.. code-block:: build123d
-
- top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate(
- Pos(Z=0.5)
- )
-
-.. image:: ./assets/token_half_surface.png
- :align: center
- :alt: token perimeter
-
-Note that the surface was raised up by 0.5 using the locate method. Also, note that
-the ``-`` in front of ``Face`` simply flips the face normal so that the colored side
-is up, which isn't necessary but helps with viewing.
-
-Now that one half of the top of the heart has been created, the remainder of the top
-and bottom can be created by mirroring:
-
-.. code-block:: build123d
-
- top_left_surface = top_right_surface.mirror(Plane.YZ)
- bottom_right_surface = top_right_surface.mirror(Plane.XY)
- bottom_left_surface = -top_left_surface.mirror(Plane.XY)
-
-The sides of the heart are going to be created by extruding the outside of the perimeter
-as follows:
-
-.. code-block:: build123d
-
- left_wire = Wire([l3.edge(), l2.edge(), l1.edge()])
- left_side = Face.extrude(left_wire, (0, 0, 1)).locate(Pos(Z=-0.5))
- right_side = left_side.mirror(Plane.YZ)
-
-.. image:: ./assets/token_sides.png
- :align: center
- :alt: token sides
-
-With the top, bottom, and sides, the complete boundary of the object is defined. We can
-now put them together, first into a :class:`~topology.Shell` and then into a
-:class:`~topology.Solid`:
-
-.. code-block:: build123d
-
- heart = Solid(
- Shell(
- [
- top_right_surface,
- top_left_surface,
- bottom_right_surface,
- bottom_left_surface,
- left_side,
- right_side,
- ]
- )
- )
-
-.. image:: ./assets/token_heart_solid.png
- :align: center
- :alt: token heart solid
+* :meth:`~topology.Face.make_bezier_surface`
+* :meth:`~topology.Face.make_gordon_surface`
+* :meth:`~topology.Face.make_surface`
+* :meth:`~topology.Face.make_surface_from_array_of_points`
+* :meth:`~topology.Face.make_surface_from_curves`
+* :meth:`~topology.Face.make_surface_patch`
.. note::
- When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
- should have no holes. For objects with complex Edges, it's best practice to reuse
- Edges in adjoining Faces whenever possible to avoid slight mismatches that can
- create openings.
+ Surface modeling is an advanced technique. Robust results usually come from
+ reusing the same :class:`~topology.Edge` objects across adjacent faces and
+ ensuring the final :class:`~topology.Shell` is *water‑tight* or *manifold* (no gaps).
-Finally, we'll create the frame around the heart as a simple extrusion of a planar
-shape defined by the perimeter of the heart and merge all of the components together:
+.. toctree::
+ :maxdepth: 1
- .. code-block:: build123d
+ tutorial_surface_heart_token.rst
+ tutorial_spitfire_wing_gordon.rst
- with BuildPart() as heart_token:
- with BuildSketch() as outline:
- with BuildLine():
- add(l1)
- add(l2)
- add(l3)
- Line(l3 @ 1, l1 @ 0)
- make_face()
- mirror(about=Plane.YZ)
- center = outline.sketch
- offset(amount=2, kind=Kind.INTERSECTION)
- add(center, mode=Mode.SUBTRACT)
- extrude(amount=2, both=True)
- add(heart)
-
-Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
-can be created. The :func:`~operations_generic.offset` function defines the outside of
-the frame as a constant distance from the heart itself.
-
-Summary
--------
-
-In this tutorial, we've explored surface modeling techniques to create a non-planar
-heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
-class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
-central point of the surface. We then assembled the complete boundary of the object
-by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
-and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
-using the :func:`~operations_generic.offset` function to maintain a constant distance
-from the heart.
\ No newline at end of file
diff --git a/examples/tea_cup.py b/examples/tea_cup.py
index 866ee1f..8bc8ed6 100644
--- a/examples/tea_cup.py
+++ b/examples/tea_cup.py
@@ -4,19 +4,19 @@ name: tea_cup.py
by: Gumyr
date: March 27th 2023
-desc: This example demonstrates the creation a tea cup, which serves as an example of
+desc: This example demonstrates the creation a tea cup, which serves as an example of
constructing complex, non-flat geometrical shapes programmatically.
The tea cup model involves several CAD techniques, such as:
- - Revolve Operations: There is 1 occurrence of a revolve operation. This is used
- to create the main body of the tea cup by revolving a profile around an axis,
+ - Revolve Operations: There is 1 occurrence of a revolve operation. This is used
+ to create the main body of the tea cup by revolving a profile around an axis,
a common technique for generating symmetrical objects like cups.
- Sweep Operations: There are 2 occurrences of sweep operations. The handle are
created by sweeping a profile along a path to generate non-planar surfaces.
- Offset/Shell Operations: the bowl of the cup is hollowed out with the offset
- operation leaving the top open.
- - Fillet Operations: There is 1 occurrence of a fillet operation which is used to
- round the edges for aesthetic improvement and to mimic real-world objects more
+ operation leaving the top open.
+ - Fillet Operations: There is 1 occurrence of a fillet operation which is used to
+ round the edges for aesthetic improvement and to mimic real-world objects more
closely.
license:
diff --git a/pyproject.toml b/pyproject.toml
index 0a2c87a..fdb8bc0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,7 @@ keywords = [
"brep",
"cad",
"cadquery",
- "opencscade",
+ "opencascade",
"python",
]
license = {text = "Apache-2.0"}
@@ -44,6 +44,7 @@ dependencies = [
"ipython >= 8.0.0, < 10",
"lib3mf >= 2.4.1",
"ocpsvg >= 0.5, < 0.6",
+ "ocp_gordon >= 0.1.17",
"trianglesolver",
"sympy",
"scipy",
diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py
index a2ccbfc..2dcf0b0 100644
--- a/src/build123d/__init__.py
+++ b/src/build123d/__init__.py
@@ -55,11 +55,13 @@ __all__ = [
"Intrinsic",
"Keep",
"Kind",
+ "Sagitta",
"LengthMode",
"MeshType",
"Mode",
"NumberDisplay",
"PageSize",
+ "Tangency",
"PositionMode",
"PrecisionMode",
"Select",
@@ -79,6 +81,7 @@ __all__ = [
"BuildSketch",
# 1D Curve Objects
"BaseLineObject",
+ "Airfoil",
"Bezier",
"BlendCurve",
"CenterArc",
diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py
index 8cca982..44d7c8b 100644
--- a/src/build123d/build_enums.py
+++ b/src/build123d/build_enums.py
@@ -29,9 +29,15 @@ license:
from __future__ import annotations
from enum import Enum, auto, IntEnum, unique
-from typing import Union
+from typing import TypeAlias, Union
-from typing import TypeAlias
+from OCP.GccEnt import (
+ GccEnt_unqualified,
+ GccEnt_enclosing,
+ GccEnt_enclosed,
+ GccEnt_outside,
+ GccEnt_noqualifier,
+)
class Align(Enum):
@@ -248,6 +254,17 @@ class FontStyle(Enum):
return f"<{self.__class__.__name__}.{self.name}>"
+class 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"""
diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py
index ea93fd4..07a5193 100644
--- a/src/build123d/drafting.py
+++ b/src/build123d/drafting.py
@@ -52,7 +52,7 @@ from build123d.objects_curve import Line, TangentArc
from build123d.objects_sketch import BaseSketchObject, Polygon, Text
from build123d.operations_generic import fillet, mirror, sweep
from build123d.operations_sketch import make_face, trace
-from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire
+from build123d.topology import Compound, Curve, Edge, ShapeList, Sketch, Vertex, Wire
class ArrowHead(BaseSketchObject):
@@ -709,7 +709,7 @@ class TechnicalDrawing(BaseSketchObject):
# Text Box Frame
bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5
bf_pnt2 = frame_wire.edges().sort_by(Axis.X)[-1] @ 0.75
- box_frame_curve = Wire.make_polygon(
+ box_frame_curve: Edge | Wire | ShapeList[Edge] = Wire.make_polygon(
[bf_pnt1, (bf_pnt1.X, bf_pnt2.Y), bf_pnt2], close=False
)
bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index fa54fe7..e4c0eeb 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -527,18 +527,22 @@ class Vector:
@overload
def intersect(self, location: Location) -> Vector | None:
- """Find intersection of location and vector"""
+ """Find intersection of vector and location"""
@overload
def intersect(self, axis: Axis) -> Vector | None:
- """Find intersection of axis and vector"""
+ """Find intersection of vector and axis"""
@overload
def intersect(self, plane: Plane) -> Vector | None:
- """Find intersection of plane and vector"""
+ """Find intersection of vector and plane"""
+
+ @overload
+ def intersect(self, shape: Shape) -> Shape | None:
+ """Find intersection of vector and shape"""
def intersect(self, *args, **kwargs):
- """Find intersection of geometric objects and vector"""
+ """Find intersection of vector and geometric object or shape"""
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
if axis is not None:
@@ -906,22 +910,26 @@ class Axis(metaclass=AxisMeta):
@overload
def intersect(self, vector: VectorLike) -> Vector | None:
- """Find intersection of vector and axis"""
+ """Find intersection of axis and vector"""
@overload
- def intersect(self, location: Location) -> Location | None:
- """Find intersection of location and axis"""
+ def intersect(self, location: Location) -> Vector | Location | None:
+ """Find intersection of axis and location"""
@overload
- def intersect(self, axis: Axis) -> Axis | None:
+ def intersect(self, axis: Axis) -> Vector | Axis | None:
"""Find intersection of axis and axis"""
@overload
- def intersect(self, plane: Plane) -> Axis | None:
- """Find intersection of plane and axis"""
+ def intersect(self, plane: Plane) -> Vector | Axis | None:
+ """Find intersection of axis and plane"""
+
+ @overload
+ def intersect(self, shape: Shape) -> Shape | None:
+ """Find intersection of axis and shape"""
def intersect(self, *args, **kwargs):
- """Find intersection of geometric object and axis"""
+ """Find intersection of axis and geometric object or shape"""
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
if axis is not None:
@@ -965,12 +973,12 @@ class Axis(metaclass=AxisMeta):
# Find the "direction" of the location
location_dir = Plane(location).z_dir
- # Is the location on the axis with the same direction?
- if (
- self.intersect(location.position) is not None
- and location_dir == self.direction
- ):
- return location
+ if self.intersect(location.position) is not None:
+ # Is the location on the axis with the same direction?
+ if location_dir == self.direction:
+ return location
+ else:
+ return location.position
if shape is not None:
return shape.intersect(self)
@@ -1929,22 +1937,26 @@ class Location:
@overload
def intersect(self, vector: VectorLike) -> Vector | None:
- """Find intersection of vector and location"""
+ """Find intersection of location and vector"""
@overload
- def intersect(self, location: Location) -> Location | None:
+ def intersect(self, location: Location) -> Vector | Location | None:
"""Find intersection of location and location"""
@overload
- def intersect(self, axis: Axis) -> Location | None:
- """Find intersection of axis and location"""
+ def intersect(self, axis: Axis) -> Vector | Location | None:
+ """Find intersection of location and axis"""
@overload
- def intersect(self, plane: Plane) -> Location | None:
- """Find intersection of plane and location"""
+ def intersect(self, plane: Plane) -> Vector | Location | None:
+ """Find intersection of location and plane"""
+
+ @overload
+ def intersect(self, shape: Shape) -> Shape | None:
+ """Find intersection of location and shape"""
def intersect(self, *args, **kwargs):
- """Find intersection of geometric object and location"""
+ """Find intersection of location and geometric object or shape"""
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
if axis is not None:
@@ -1956,8 +1968,11 @@ class Location:
if vector is not None and self.position == vector:
return vector
- if location is not None and self == location:
- return self
+ if location is not None:
+ if self == location:
+ return self
+ elif self.position == location.position:
+ return self.position
if shape is not None:
return shape.intersect(self)
@@ -3128,18 +3143,18 @@ class Plane(metaclass=PlaneMeta):
@overload
def intersect(self, vector: VectorLike) -> Vector | None:
- """Find intersection of vector and plane"""
+ """Find intersection of plane and vector"""
@overload
- def intersect(self, location: Location) -> Location | None:
- """Find intersection of location and plane"""
+ def intersect(self, location: Location) -> Vector | Location | None:
+ """Find intersection of plane and location"""
@overload
- def intersect(self, axis: Axis) -> Axis | Vector | None:
- """Find intersection of axis and plane"""
+ def intersect(self, axis: Axis) -> Vector | Axis | None:
+ """Find intersection of plane and axis"""
@overload
- def intersect(self, plane: Plane) -> Axis | None:
+ def intersect(self, plane: Plane) -> Axis | Plane | None:
"""Find intersection of plane and plane"""
@overload
@@ -3147,7 +3162,7 @@ class Plane(metaclass=PlaneMeta):
"""Find intersection of plane and shape"""
def intersect(self, *args, **kwargs):
- """Find intersection of geometric object and shape"""
+ """Find intersection of plane and geometric object or shape"""
axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs)
@@ -3172,6 +3187,9 @@ class Plane(metaclass=PlaneMeta):
return intersection_point
if plane is not None:
+ if self.contains(plane.origin) and self.z_dir == plane.z_dir:
+ return self
+
surface1 = Geom_Plane(self.wrapped)
surface2 = Geom_Plane(plane.wrapped)
intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE)
@@ -3187,8 +3205,11 @@ class Plane(metaclass=PlaneMeta):
if location is not None:
pln = Plane(location)
- if pln.origin == self.origin and pln.z_dir == self.z_dir:
- return location
+ if self.contains(pln.origin):
+ if self.z_dir == pln.z_dir:
+ return location
+ else:
+ return pln.origin
if shape is not None:
return shape.intersect(self)
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index 55d1d42..d53628a 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -38,8 +38,10 @@ from pathlib import Path
from typing import Literal, Optional, TextIO, Union
import warnings
+from OCP.Bnd import Bnd_Box
from OCP.BRep import BRep_Builder
-from OCP.BRepGProp import BRepGProp
+from OCP.BRepBndLib import BRepBndLib
+from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepTools import BRepTools
from OCP.GProp import GProp_GProps
from OCP.Quantity import Quantity_ColorRGBA
@@ -145,37 +147,42 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C")
return clean_name.translate(str.maketrans(" .()", "____"))
- def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA:
+ def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None:
+ """Get the color of a shape from a cache"""
+ key = obj.TShape().__hash__()
+ if key in _color_cache:
+ return _color_cache[key]
+
+ col = Quantity_ColorRGBA()
+ has_color = (
+ color_tool.GetColor(obj, XCAFDoc_ColorCurv, col)
+ or color_tool.GetColor(obj, XCAFDoc_ColorGen, col)
+ or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col)
+ )
+ _color_cache[key] = col if has_color else None
+ return _color_cache[key]
+
+ def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None:
"""Get the color - take that of the largest Face if multiple"""
+ shape_color = get_shape_color_from_cache(shape)
+ if shape_color is not None:
+ return shape_color
- def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA:
- col = Quantity_ColorRGBA()
- if (
- color_tool.GetColor(obj, XCAFDoc_ColorCurv, col)
- or color_tool.GetColor(obj, XCAFDoc_ColorGen, col)
- or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col)
- ):
- return col
-
- shape_color = get_col(shape)
-
- colors = {}
- face_explorer = TopExp_Explorer(shape, TopAbs_FACE)
- while face_explorer.More():
- current_face = face_explorer.Current()
- properties = GProp_GProps()
- BRepGProp.SurfaceProperties_s(current_face, properties)
- area = properties.Mass()
- color = get_col(current_face)
- if color is not None:
- colors[area] = color
- face_explorer.Next()
-
- # If there are multiple colors, return the one from the largest face
- if colors:
- shape_color = sorted(colors.items())[-1][1]
-
- return shape_color
+ max_extent = -1.0
+ winner = None
+ exp = TopExp_Explorer(shape, TopAbs_FACE)
+ while exp.More():
+ face = exp.Current()
+ col = get_shape_color_from_cache(face)
+ if col is not None:
+ box = Bnd_Box()
+ BRepBndLib.Add_s(face, box)
+ extent = box.SquareExtent()
+ if extent > max_extent:
+ max_extent = extent
+ winner = col
+ exp.Next()
+ return winner
def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]:
"""Recursively extract object into an assembly"""
@@ -211,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound:
if not os.path.exists(filename):
raise FileNotFoundError(filename)
+ # Retrieving color info is expensive so cache the lookups
+ _color_cache: dict[int, Quantity_ColorRGBA | None] = {}
+
fmt = TCollection_ExtendedString("XCAF")
doc = TDocStd_Document(fmt)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py
index e697145..8850cdc 100644
--- a/src/build123d/objects_curve.py
+++ b/src/build123d/objects_curve.py
@@ -29,11 +29,13 @@ license:
from __future__ import annotations
import copy as copy_module
+import numpy as np
+import sympy # type: ignore
from collections.abc import Iterable
from itertools import product
from math import copysign, cos, radians, sin, sqrt
from scipy.optimize import minimize
-import sympy # type: ignore
+from typing import overload, Literal
from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs
from build123d.build_enums import (
@@ -100,6 +102,129 @@ class BaseEdgeObject(Edge):
super().__init__(curve.wrapped)
+class Airfoil(BaseLineObject):
+ """
+ Create an airfoil described by a 4-digit (or fractional) NACA airfoil
+ (e.g. '2412' or '2213.323').
+
+ The NACA four-digit wing sections define the airfoil_code by:
+ - First digit describing maximum camber as percentage of the chord.
+ - Second digit describing the distance of maximum camber from the airfoil leading edge
+ in tenths of the chord.
+ - Last two digits describing maximum thickness of the airfoil as percent of the chord.
+
+ Args:
+ airfoil_code : str
+ The NACA 4-digit (or fractional) airfoil code (e.g. '2213.323').
+ n_points : int
+ Number of points per upper/lower surface.
+ finite_te : bool
+ If True, enforces a finite trailing edge (default False).
+ mode (Mode, optional): combination mode. Defaults to Mode.ADD
+
+ """
+
+ _applies_to = [BuildLine._tag]
+
+ @staticmethod
+ def parse_naca4(value: str | float) -> tuple[float, float, float]:
+ """
+ Parse NACA 4-digit (or fractional) airfoil code into parameters.
+ """
+ s = str(value).replace("NACA", "").strip()
+ if "." in s:
+ int_part, frac_part = s.split(".", 1)
+ m = int(int_part[0]) / 100
+ p = int(int_part[1]) / 10
+ t = float(f"{int(int_part[2:]):02}.{frac_part}") / 100
+ else:
+ m = int(s[0]) / 100
+ p = int(s[1]) / 10
+ t = int(s[2:]) / 100
+ return m, p, t
+
+ def __init__(
+ self,
+ airfoil_code: str,
+ n_points: int = 50,
+ finite_te: bool = False,
+ mode: Mode = Mode.ADD,
+ ):
+
+ # Airfoil thickness distribution equation:
+ #
+ # yₜ=5t[0.2969√x-0.1260x-0.3516x²+0.2843x³-0.1015x⁴]
+ #
+ # where:
+ # - x is the distance along the chord (0 at the leading edge, 1 at the trailing edge),
+ # - t is the maximum thickness as a fraction of the chord (e.g. 0.12 for a NACA 2412),
+ # - yₜ gives the half-thickness at each chordwise location.
+
+ context: BuildLine | None = BuildLine._get_context(self)
+ validate_inputs(context, self)
+
+ m, p, t = Airfoil.parse_naca4(airfoil_code)
+
+ # Cosine-spaced x values for better nose resolution
+ beta = np.linspace(0.0, np.pi, n_points)
+ x = (1 - np.cos(beta)) / 2
+
+ # Thickness distribution
+ a0, a1, a2, a3 = 0.2969, -0.1260, -0.3516, 0.2843
+ a4 = -0.1015 if finite_te else -0.1036
+ yt = 5 * t * (a0 * np.sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4)
+
+ # Camber line and slope
+ if m == 0 or p == 0 or p == 1:
+ yc = np.zeros_like(x)
+ dyc_dx = np.zeros_like(x)
+ else:
+ yc = np.empty_like(x)
+ dyc_dx = np.empty_like(x)
+ mask = x < p
+ yc[mask] = m / p**2 * (2 * p * x[mask] - x[mask] ** 2)
+ yc[~mask] = (
+ m / (1 - p) ** 2 * ((1 - 2 * p) + 2 * p * x[~mask] - x[~mask] ** 2)
+ )
+ dyc_dx[mask] = 2 * m / p**2 * (p - x[mask])
+ dyc_dx[~mask] = 2 * m / (1 - p) ** 2 * (p - x[~mask])
+
+ theta = np.arctan(dyc_dx)
+ self._camber_points = [Vector(xi, yi) for xi, yi in zip(x, yc)]
+
+ # Upper and lower surfaces
+ xu = x - yt * np.sin(theta)
+ yu = yc + yt * np.cos(theta)
+ xl = x + yt * np.sin(theta)
+ yl = yc - yt * np.cos(theta)
+
+ upper_pnts = [Vector(x, y) for x, y in zip(xu, yu)]
+ lower_pnts = [Vector(x, y) for x, y in zip(xl, yl)]
+ unique_points: list[
+ Vector | tuple[float, float] | tuple[float, float, float]
+ ] = list(dict.fromkeys(upper_pnts[::-1] + lower_pnts))
+ surface = Edge.make_spline(unique_points, periodic=not finite_te) # type: ignore[arg-type]
+ if finite_te:
+ trailing_edge = Edge.make_line(surface @ 0, surface @ 1)
+ airfoil_profile = Wire([surface, trailing_edge])
+ else:
+ airfoil_profile = Wire([surface])
+
+ super().__init__(airfoil_profile, mode=mode)
+
+ # Store metadata
+ self.code: str = airfoil_code #: NACA code string (e.g. "2412")
+ self.max_camber: float = m #: Maximum camber as fraction of chord
+ self.camber_pos: float = p #: Chordwise position of max camber (0–1)
+ self.thickness: float = t #: Maximum thickness as fraction of chord
+ self.finite_te: bool = finite_te #: If True, trailing edge is finite
+
+ @property
+ def camber_line(self) -> Edge:
+ """Camber line of the airfoil as an Edge."""
+ return Edge.make_spline(self._camber_points) # type: ignore[arg-type]
+
+
class Bezier(BaseEdgeObject):
"""Line Object: Bezier Curve
diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py
index 63bdb14..823eece 100644
--- a/src/build123d/topology/composite.py
+++ b/src/build123d/topology/composite.py
@@ -448,7 +448,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
# ---- Instance Methods ----
- def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound:
+ def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound | Wire:
"""Combine other to self `+` operator
Note that if all of the objects are connected Edges/Wires the result
@@ -456,8 +456,15 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""
if self._dim == 1:
curve = Curve() if self.wrapped is None else Curve(self.wrapped)
- self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
- return curve + other
+ sum1d: Edge | Wire | ShapeList[Edge] = curve + other
+ if isinstance(sum1d, ShapeList):
+ 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:
diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py
new file mode 100644
index 0000000..9c316b6
--- /dev/null
+++ b/src/build123d/topology/constrained_lines.py
@@ -0,0 +1,822 @@
+"""
+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 atan2, cos, isnan, sin
+from typing import overload, TYPE_CHECKING, Callable, TypeVar
+from typing import cast as tcast
+
+from OCP.BRep import BRep_Tool
+from OCP.BRepAdaptor import BRepAdaptor_Curve
+from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeVertex
+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_Line,
+ Geom2d_Point,
+ Geom2d_TrimmedCurve,
+)
+from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve
+from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve
+from OCP.Geom2dGcc import (
+ Geom2dGcc_Circ2d2TanOn,
+ Geom2dGcc_Circ2d2TanRad,
+ Geom2dGcc_Circ2d3Tan,
+ Geom2dGcc_Circ2dTanCen,
+ Geom2dGcc_Circ2dTanOnRad,
+ Geom2dGcc_Lin2dTanObl,
+ Geom2dGcc_Lin2d2Tan,
+ Geom2dGcc_QualifiedCurve,
+)
+from OCP.GeomAPI import GeomAPI
+from OCP.gp import (
+ gp_Ax2d,
+ gp_Ax3,
+ gp_Circ2d,
+ gp_Dir,
+ gp_Dir2d,
+ gp_Lin2d,
+ gp_Pln,
+ gp_Pnt,
+ gp_Pnt2d,
+)
+from OCP.IntAna2d import IntAna2d_AnaIntersection
+from OCP.Standard import Standard_ConstructionError, Standard_Failure
+from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex
+
+from build123d.build_enums import Sagitta, Tangency
+from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike
+from .zero_d import Vertex
+from .shape_core import ShapeList
+
+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)
+ hcurve3d = BRep_Tool.Curve_s(edge, float(), float())
+ first, last = BRep_Tool.Range_s(edge)
+
+ # 2) Convert to 2D on Plane.XY (Z-up frame at origin)
+ hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve
+
+ # 3) Wrap in an adaptor using the same parametric range
+ adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last)
+
+ # 4) 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)
+
+
+@overload
+def _as_gcc_arg(
+ obj: Edge, constaint: Tangency
+) -> tuple[
+ Geom2dGcc_QualifiedCurve, Geom2d_Curve | None, float | None, float | None, bool
+]: ...
+@overload
+def _as_gcc_arg(
+ obj: Vector, constaint: Tangency
+) -> tuple[Geom2d_CartesianPoint, None, None, None, bool]: ...
+
+
+def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[
+ Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint,
+ Geom2d_Curve | None,
+ 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 _edge_from_line(
+ p1: gp_Pnt2d,
+ p2: gp_Pnt2d,
+) -> TopoDS_Edge:
+ """
+ Build a finite Edge from two 2D contact points.
+
+ Parameters
+ ----------
+ p1, p2 : gp_Pnt2d
+ Endpoints of the line segment (in 2D).
+ edge_factory : type[Edge], optional
+ Factory for building the Edge subtype (defaults to Edge).
+
+ Returns
+ -------
+ TopoDS_Edge
+ Finite line segment between the two points.
+ """
+ v1 = BRepBuilderAPI_MakeVertex(gp_Pnt(p1.X(), p1.Y(), 0)).Vertex()
+ v2 = BRepBuilderAPI_MakeVertex(gp_Pnt(p2.X(), p2.Y(), 0)).Vertex()
+
+ mk_edge = BRepBuilderAPI_MakeEdge(v1, v2)
+ if not mk_edge.IsDone():
+ raise RuntimeError("Failed to build edge from line contacts")
+ return mk_edge.Edge()
+
+
+def _gp_lin2d_from_axis(ax: Axis) -> gp_Lin2d:
+ """Build a 2D reference line from an Axis (XY plane)."""
+ p = gp_Pnt2d(ax.position.X, ax.position.Y)
+ d = gp_Dir2d(ax.direction.X, ax.direction.Y)
+ return gp_Lin2d(gp_Ax2d(p, d))
+
+
+def _qstr(q) -> str: # pragma: no cover
+ """Debugging facility that works with OCP's GccEnt enum values"""
+ try:
+ 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])
+
+
+# -----------------------------------------------------------------------------
+# Line solvers (siblings of constrained arcs)
+# -----------------------------------------------------------------------------
+
+
+def _make_2tan_lines(
+ tangency1: tuple[Edge, Tangency] | Edge,
+ tangency2: tuple[Edge, Tangency] | Edge | Vector,
+ *,
+ edge_factory: Callable[[TopoDS_Edge], Edge],
+) -> ShapeList[Edge]:
+ """
+ Construct line(s) tangent to two curves.
+
+ Parameters
+ ----------
+ curve1, curve2 : Edge
+ Target curves.
+
+ Returns
+ -------
+ ShapeList[Edge]
+ Finite tangent line(s).
+ """
+ if isinstance(tangency1, tuple):
+ object_one, obj1_qual = tangency1
+ else:
+ object_one, obj1_qual = tangency1, Tangency.UNQUALIFIED
+ q1, c1, _, _, _ = _as_gcc_arg(object_one, obj1_qual)
+
+ if isinstance(tangency2, Vector):
+ pnt_2d = gp_Pnt2d(tangency2.X, tangency2.Y)
+ gcc = Geom2dGcc_Lin2d2Tan(q1, pnt_2d, TOLERANCE)
+ else:
+ if isinstance(tangency2, tuple):
+ object_two, obj2_qual = tangency2
+ else:
+ object_two, obj2_qual = tangency2, Tangency.UNQUALIFIED
+ q2, c2, _, _, _ = _as_gcc_arg(object_two, obj2_qual)
+ gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE)
+
+ if not gcc.IsDone() or gcc.NbSolutions() == 0:
+ raise RuntimeError("Unable to find common tangent line(s)")
+
+ out_edges: list[TopoDS_Edge] = []
+ for i in range(1, gcc.NbSolutions() + 1):
+ lin2d = Geom2d_Line(gcc.ThisSolution(i))
+
+ # Two tangency points - Note Tangency1/Tangency2 can use different
+ # indices for the same line
+ inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c1)
+ pt1 = inter_cc.Point(1) # There will always be one tangent intersection
+
+ if isinstance(tangency2, Vector):
+ pt2 = gp_Pnt2d(tangency2.X, tangency2.Y)
+ else:
+ inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c2)
+ pt2 = inter_cc.Point(1)
+
+ # Skip degenerate lines
+ separation = pt1.Distance(pt2)
+ if isnan(separation) or separation < TOLERANCE:
+ continue
+
+ out_edges.append(_edge_from_line(pt1, pt2))
+ return ShapeList([edge_factory(e) for e in out_edges])
+
+
+def _make_tan_oriented_lines(
+ tangency: tuple[Edge, Tangency] | Edge,
+ reference: Axis,
+ angle: float, # radians; absolute angle offset from `reference`
+ *,
+ edge_factory: Callable[[TopoDS_Edge], Edge],
+) -> ShapeList[Edge]:
+ """
+ Construct line(s) tangent to a curve and forming a given angle with a
+ reference line (Axis) per Geom2dGcc_Lin2dTanObl. Trimmed between:
+ - the tangency point on the curve, and
+ - the intersection with the reference line.
+ """
+ if isinstance(tangency, tuple):
+ object_one, obj1_qual = tangency
+ else:
+ object_one, obj1_qual = tangency, Tangency.UNQUALIFIED
+
+ if abs(abs(reference.direction.Z) - 1) < TOLERANCE:
+ raise ValueError("reference Axis can't be perpendicular to Plane.XY")
+
+ q_curve, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual)
+
+ # reference axis direction (2D angle in radians)
+ ref_dir = reference.direction
+ theta_ref = atan2(ref_dir.Y, ref_dir.X)
+
+ # total absolute angle
+ theta_abs = theta_ref + angle
+
+ dir2d = gp_Dir2d(cos(theta_abs), sin(theta_abs))
+
+ # Reference axis as gp_Lin2d
+ ref_lin = _gp_lin2d_from_axis(reference)
+
+ # Note that is seems impossible for Geom2dGcc_Lin2dTanObl to provide no solutions
+ gcc = Geom2dGcc_Lin2dTanObl(q_curve, ref_lin, TOLERANCE, angle)
+
+ out: list[TopoDS_Edge] = []
+ for i in range(1, gcc.NbSolutions() + 1):
+ # Tangency on the curve
+ p_tan = gp_Pnt2d()
+ gcc.Tangency1(i, p_tan)
+
+ tan_line = gp_Lin2d(p_tan, dir2d)
+
+ # Intersect with reference axis
+ # Note: Intersection2 doesn't seem reliable
+ inter = IntAna2d_AnaIntersection(tan_line, ref_lin)
+ if not inter.IsDone() or inter.NbPoints() == 0:
+ continue
+ p_isect = inter.Point(1).Value()
+
+ # Skip degenerate lines
+ separation = p_tan.Distance(p_isect)
+ if isnan(separation) or separation < TOLERANCE:
+ continue
+
+ out.append(_edge_from_line(p_tan, p_isect))
+
+ return ShapeList([edge_factory(e) for e in out])
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index e019df7..25b817a 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -52,18 +52,15 @@ license:
from __future__ import annotations
import copy
-import itertools
import numpy as np
import warnings
from collections.abc import Iterable
from itertools import combinations
-from math import radians, inf, pi, cos, copysign, ceil, floor, isclose
+from math import atan2, 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
@@ -76,6 +73,7 @@ from OCP.BRepBuilderAPI import (
BRepBuilderAPI_DisconnectedWire,
BRepBuilderAPI_EmptyWire,
BRepBuilderAPI_MakeEdge,
+ BRepBuilderAPI_MakeEdge2d,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeWire,
@@ -92,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,
@@ -122,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,
@@ -156,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,
@@ -206,17 +229,25 @@ 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,
+ _make_tan_oriented_lines,
+ _make_2tan_lines,
)
-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):
@@ -327,6 +358,21 @@ class Mixin1D(Shape):
"""Unused - only here because Mixin1D is a subclass of Shape"""
return NotImplemented
+ # ---- Static Methods ----
+
+ @staticmethod
+ def _to_param(edge_wire: Mixin1D, value: float | VectorLike, name: str) -> float:
+ """Convert a float or VectorLike into a curve parameter."""
+ if isinstance(value, (int, float)):
+ return float(value)
+ try:
+ point = Vector(value)
+ except TypeError as exc:
+ raise TypeError(
+ f"{name} must be a float or VectorLike, not {value!r}"
+ ) from exc
+ return edge_wire.param_at_point(point)
+
# ---- Instance Methods ----
def __add__(
@@ -665,6 +711,145 @@ class Mixin1D(Shape):
return Vector(curve.Value(umax))
+ def intersect(
+ self, *to_intersect: Shape | Vector | Location | Axis | Plane
+ ) -> None | ShapeList[Vertex | Edge]:
+ """Intersect Edge with Shape or geometry object
+
+ Args:
+ to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
+
+ Returns:
+ ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges
+ """
+
+ def to_vector(objs: Iterable) -> ShapeList:
+ return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
+
+ def to_vertex(objs: Iterable) -> ShapeList:
+ return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
+
+ common_set: ShapeList[Vertex | Edge] = ShapeList(self.edges())
+ target: ShapeList | Shape | Plane
+ for other in to_intersect:
+ # Conform target type
+ # Vertices need to be Vector for set()
+ match other:
+ case Axis():
+ target = ShapeList([Edge(other)])
+ case Plane():
+ target = other
+ case Vector():
+ target = Vertex(other)
+ case Location():
+ target = Vertex(other.position)
+ case Edge():
+ target = ShapeList([other])
+ case Wire():
+ target = ShapeList(other.edges())
+ case _ if issubclass(type(other), Shape):
+ target = other
+ case _:
+ raise ValueError(f"Unsupported type to_intersect: {type(other)}")
+
+ # Find common matches
+ common: list[Vector | Edge] = []
+ result: ShapeList | Shape | None
+ for obj in common_set:
+ match (obj, target):
+ case obj, Shape() as target:
+ # Find Shape with Edge/Wire
+ if isinstance(target, Vertex):
+ result = Shape.intersect(obj, target)
+ else:
+ result = target.intersect(obj)
+
+ if result:
+ if not isinstance(result, list):
+ result = ShapeList([result])
+ common.extend(to_vector(result))
+
+ case Vertex() as obj, target:
+ if not isinstance(target, ShapeList):
+ target = ShapeList([target])
+
+ for tar in target:
+ if isinstance(tar, Edge):
+ result = Shape.intersect(obj, tar)
+ else:
+ result = obj.intersect(tar)
+
+ if result:
+ if not isinstance(result, list):
+ result = ShapeList([result])
+ common.extend(to_vector(result))
+
+ case Edge() as obj, ShapeList() as targets:
+ # Find any edge / edge intersection points
+ for tar in targets:
+ # Find crossing points
+ try:
+ intersection_points = obj.find_intersection_points(tar)
+ common.extend(intersection_points)
+ except ValueError:
+ pass
+
+ # Find common end points
+ obj_end_points = set(Vector(v) for v in obj.vertices())
+ tar_end_points = set(Vector(v) for v in tar.vertices())
+ points = set.intersection(obj_end_points, tar_end_points)
+ common.extend(points)
+
+ # Find Edge/Edge overlaps
+ result = obj._bool_op(
+ (obj,), targets, BRepAlgoAPI_Common()
+ ).edges()
+ common.extend(result if isinstance(result, list) else [result])
+
+ case Edge() as obj, Plane() as plane:
+ # Find any edge / plane intersection points & edges
+ # Find point intersections
+ if obj.wrapped is None:
+ continue
+ geom_line = BRep_Tool.Curve_s(
+ obj.wrapped, obj.param_at(0), obj.param_at(1)
+ )
+ geom_plane = Geom_Plane(plane.local_coord_system)
+ intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
+ plane_intersection_points: list[Vector] = []
+ if intersection_calculator.IsDone():
+ plane_intersection_points = [
+ Vector(intersection_calculator.Point(i + 1))
+ for i in range(intersection_calculator.NbPoints())
+ ]
+ common.extend(plane_intersection_points)
+
+ # Find edge intersections
+ if all(
+ plane.contains(v)
+ for v in obj.positions(i / 7 for i in range(8))
+ ): # is a 2D edge
+ common.append(obj)
+
+ if common:
+ common_set = to_vertex(set(common))
+ # Remove Vertex intersections coincident to Edge intersections
+ vts = common_set.vertices()
+ eds = common_set.edges()
+ if vts and eds:
+ filtered_vts = ShapeList(
+ [
+ v
+ for v in vts
+ if all(v.distance_to(e) > TOLERANCE for e in eds)
+ ]
+ )
+ common_set = filtered_vts + eds
+ else:
+ return None
+
+ return ShapeList(common_set)
+
def location_at(
self,
distance: float,
@@ -1555,6 +1740,397 @@ 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.")
+
+ @overload
+ @classmethod
+ def make_constrained_lines(
+ cls,
+ tangency_one: tuple[Edge, Tangency] | Axis | Edge,
+ tangency_two: tuple[Edge, Tangency] | Axis | Edge,
+ ) -> ShapeList[Edge]:
+ """
+ Create all planar line(s) on the XY plane tangent to two provided curves.
+
+ Args:
+ tangency_one, tangency_two
+ (tuple[Edge, Tangency] | Axis | Edge):
+ Geometric entities to be contacted/touched by the line(s).
+
+ Returns:
+ ShapeList[Edge]: tangent lines
+ """
+
+ @overload
+ @classmethod
+ def make_constrained_lines(
+ cls,
+ tangency_one: tuple[Edge, Tangency] | Edge,
+ tangency_two: Vector,
+ ) -> ShapeList[Edge]:
+ """
+ Create all planar line(s) on the XY plane tangent to one curve and passing
+ through a fixed point.
+
+ Args:
+ tangency_one
+ (tuple[Edge, Tangency] | Edge):
+ Geometric entity to be contacted/touched by the line(s).
+ tangency_two (Vector):
+ Fixed point through which the line(s) must pass.
+
+ Returns:
+ ShapeList[Edge]: tangent lines
+ """
+
+ @overload
+ @classmethod
+ def make_constrained_lines(
+ cls,
+ tangency_one: tuple[Edge, Tangency] | Edge,
+ tangency_two: Axis,
+ *,
+ angle: float | None = None,
+ direction: VectorLike | None = None,
+ ) -> ShapeList[Edge]:
+ """
+ Create all planar line(s) on the XY plane tangent to one curve and passing
+ through a fixed point.
+
+ Args:
+ tangency_one (Edge): edge that line will be tangent to
+ tangency_two (Axis): axis that angle will be measured against
+ angle : float, optional
+ Line orientation in degrees (measured CCW from the X-axis).
+ direction : VectorLike, optional
+ Direction vector for the line (only X and Y components are used).
+ Note: one of angle or direction must be provided
+
+ Returns:
+ ShapeList[Edge]: tangent lines
+ """
+
+ @classmethod
+ def make_constrained_lines(cls, *args, **kwargs) -> ShapeList[Edge]:
+ """
+ Create planar line(s) on XY subject to tangency/contact constraints.
+
+ Supported cases
+ ---------------
+ 1. Tangent to two curves
+ 2. Tangent to one curve and passing through a given point
+ """
+ tangency_one = args[0] if len(args) > 0 else None
+ tangency_two = args[1] if len(args) > 1 else None
+
+ tangency_one = kwargs.pop("tangency_one", tangency_one)
+ tangency_two = kwargs.pop("tangency_two", tangency_two)
+
+ angle = kwargs.pop("angle", None)
+ direction = kwargs.pop("direction", None)
+ direction = Vector(direction) if direction is not None else None
+
+ is_ref = angle is not None or direction is not None
+ # Handle unexpected kwargs
+ if kwargs:
+ raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
+
+ tangency_args = [t for t in (tangency_one, tangency_two) if t is not None]
+ if len(tangency_args) != 2:
+ raise TypeError("Provide exactly 2 tangency targets.")
+
+ tangencies: list[tuple[Edge, Tangency] | Axis | Edge | Vector] = []
+ for i, tangency_arg in enumerate(tangency_args):
+ if isinstance(tangency_arg, Axis):
+ if i == 1 and is_ref:
+ tangencies.append(tangency_arg)
+ else:
+ tangencies.append(Edge(tangency_arg))
+ continue
+ elif isinstance(tangency_arg, Edge):
+ tangencies.append(tangency_arg)
+ continue
+ if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge):
+ tangencies.append(tangency_arg)
+ continue
+ # Fallback: treat as a point
+ try:
+ tangencies.append(Vector(tangency_arg))
+ except Exception as exc:
+ raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc
+
+ # Sort so Vector (point) | Axis is always last
+ tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector)))
+
+ # --- decide problem kind ---
+ if angle is not None or direction is not None:
+ if isinstance(tangencies[0], tuple):
+ assert isinstance(
+ tangencies[0][0], Edge
+ ), "Internal error - 1st tangency must be Edge"
+ else:
+ assert isinstance(
+ tangencies[0], Edge
+ ), "Internal error - 1st tangency must be Edge"
+ if angle is not None:
+ ang_rad = radians(angle)
+ else:
+ assert direction is not None
+ ang_rad = atan2(direction.Y, direction.X)
+ assert isinstance(
+ tangencies[1], Axis
+ ), "Internal error - 2nd tangency must be an Axis"
+ return _make_tan_oriented_lines(
+ tangencies[0], tangencies[1], ang_rad, edge_factory=cls
+ )
+ else:
+ assert not isinstance(
+ tangencies[0], (Axis, Vector)
+ ), "Internal error - 1st tangency can't be an Axis | Vector"
+ assert not isinstance(
+ tangencies[1], Axis
+ ), "Internal error - 2nd tangency can't be an Axis"
+
+ return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls)
+
@classmethod
def make_ellipse(
cls,
@@ -2151,90 +2727,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
raise ValueError("Can't find adaptor for empty edge")
return BRepAdaptor_Curve(self.wrapped)
- def intersect(
- self, *to_intersect: Edge | Axis | Plane
- ) -> None | Vertex | Edge | ShapeList[Vertex | Edge]:
- """intersect Edge with Edge or Axis
-
- Args:
- other (Edge | Axis): other object
-
- Returns:
- Shape | None: Compound of vertices and/or edges
- """
- edges: list[Edge] = []
- planes: list[Plane] = []
- edges_common_to_planes: list[Edge] = []
-
- for obj in to_intersect:
- match obj:
- case Axis():
- edges.append(Edge(obj))
- case Edge():
- edges.append(obj)
- case Plane():
- planes.append(obj)
- case _:
- raise ValueError(f"Unknown object type: {type(obj)}")
-
- # Find any edge / edge intersection points
- points_sets: list[set[Vector]] = []
- # Find crossing points
- for edge_pair in combinations([self] + edges, 2):
- intersection_points = edge_pair[0].find_intersection_points(edge_pair[1])
- points_sets.append(set(intersection_points))
-
- # Find common end points
- self_end_points = set(Vector(v) for v in self.vertices())
- edge_end_points = set(Vector(v) for edge in edges for v in edge.vertices())
- common_end_points = set.intersection(self_end_points, edge_end_points)
-
- # Find any edge / plane intersection points & edges
- for edge, plane in itertools.product([self] + edges, planes):
- if edge.wrapped is None:
- continue
- # Find point intersections
- geom_line = BRep_Tool.Curve_s(
- edge.wrapped, edge.param_at(0), edge.param_at(1)
- )
- geom_plane = Geom_Plane(plane.local_coord_system)
- intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
- plane_intersection_points: list[Vector] = []
- if intersection_calculator.IsDone():
- plane_intersection_points = [
- Vector(intersection_calculator.Point(i + 1))
- for i in range(intersection_calculator.NbPoints())
- ]
- points_sets.append(set(plane_intersection_points))
-
- # Find edge intersections
- if all(
- plane.contains(v) for v in edge.positions(i / 7 for i in range(8))
- ): # is a 2D edge
- edges_common_to_planes.append(edge)
-
- edges.extend(edges_common_to_planes)
-
- # Find the intersection of all sets
- common_points = set.intersection(*points_sets)
- common_vertices = [
- Vertex(pnt) for pnt in common_points.union(common_end_points)
- ]
-
- # Find Edge/Edge overlaps
- common_edges: list[Edge] = []
- if edges:
- common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges()
-
- if common_vertices or common_edges:
- # If there is just one vertex or edge return it
- if len(common_vertices) == 1 and len(common_edges) == 0:
- return common_vertices[0]
- if len(common_vertices) == 0 and len(common_edges) == 1:
- return common_edges[0]
- return ShapeList(common_vertices + common_edges)
- return None
-
def _occt_param_at(
self, position: float, position_mode: PositionMode = PositionMode.PARAMETER
) -> tuple[BRepAdaptor_Curve, float, bool]:
@@ -2495,24 +2987,43 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
)
return Wire([self])
- def trim(self, start: float, end: float) -> Edge:
+ def trim(self, start: float | VectorLike, end: float | VectorLike) -> Edge:
+ """_summary_
+
+ Args:
+ start (float | VectorLike): _description_
+ end (float | VectorLike): _description_
+
+ Raises:
+ TypeError: _description_
+ ValueError: _description_
+
+ Returns:
+ Edge: _description_
+ """
"""trim
Create a new edge by keeping only the section between start and end.
Args:
- start (float): 0.0 <= start < 1.0
- end (float): 0.0 < end <= 1.0
+ start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
+ end (float | VectorLike): 0.0 < end <= 1.0 or point on edge
Raises:
- ValueError: start >= end
+ TypeError: invalid input, must be float or VectorLike
ValueError: can't trim empty edge
Returns:
Edge: trimmed edge
"""
- if start >= end:
- raise ValueError(f"start ({start}) must be less than end ({end})")
+
+ start_u = Mixin1D._to_param(self, start, "start")
+ end_u = Mixin1D._to_param(self, end, "end")
+
+ start_u, end_u = sorted([start_u, end_u])
+
+ # if start_u >= end_u:
+ # raise ValueError(f"start ({start_u}) must be less than end ({end_u})")
if self.wrapped is None:
raise ValueError("Can't trim empty edge")
@@ -2523,8 +3034,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
new_curve = BRep_Tool.Curve_s(
self_copy.wrapped, self.param_at(0), self.param_at(1)
)
- parm_start = self.param_at(start)
- parm_end = self.param_at(end)
+ parm_start = self.param_at(start_u)
+ parm_end = self.param_at(end_u)
trimmed_curve = Geom_TrimmedCurve(
new_curve,
parm_start,
@@ -2533,14 +3044,14 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
return Edge(new_edge)
- def trim_to_length(self, start: float, length: float) -> Edge:
+ def trim_to_length(self, start: float | VectorLike, length: float) -> Edge:
"""trim_to_length
Create a new edge starting at the given normalized parameter of a
given length.
Args:
- start (float): 0.0 <= start < 1.0
+ start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
length (float): target length
Raise:
@@ -2552,6 +3063,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
if self.wrapped is None:
raise ValueError("Can't trim empty edge")
+ start_u = Mixin1D._to_param(self, start, "start")
+
self_copy = copy.deepcopy(self)
assert self_copy.wrapped is not None
@@ -2563,7 +3076,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
adaptor_curve = GeomAdaptor_Curve(new_curve)
# Find the parameter corresponding to the desired length
- parm_start = self.param_at(start)
+ parm_start = self.param_at(start_u)
abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start)
# Get the parameter at the desired length
@@ -3073,7 +3586,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
return Wire.make_polygon(corners_world, close=True)
# ---- Static Methods ----
-
@staticmethod
def order_chamfer_edges(
reference_edge: Edge | None, edges: tuple[Edge, Edge]
@@ -3589,29 +4101,31 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
)
return self
- def trim(self: Wire, start: float, end: float) -> Wire:
+ def trim(self: Wire, start: float | VectorLike, end: float | VectorLike) -> Wire:
"""Trim a wire between [start, end] normalized over total length.
Args:
- start (float): normalized start position (0.0 to <1.0)
- end (float): normalized end position (>0.0 to 1.0)
+ start (float | VectorLike): normalized start position (0.0 to <1.0) or point
+ end (float | VectorLike): normalized end position (>0.0 to 1.0) or point
Returns:
Wire: trimmed Wire
"""
- if start >= end:
- raise ValueError("start must be less than end")
+ start_u = Mixin1D._to_param(self, start, "start")
+ end_u = Mixin1D._to_param(self, end, "end")
+
+ start_u, end_u = sorted([start_u, end_u])
# Extract the edges in order
ordered_edges = self.edges().sort_by(self)
# If this is really just an edge, skip the complexity of a Wire
if len(ordered_edges) == 1:
- return Wire([ordered_edges[0].trim(start, end)])
+ return Wire([ordered_edges[0].trim(start_u, end_u)])
total_length = self.length
- start_len = start * total_length
- end_len = end * total_length
+ start_len = start_u * total_length
+ end_len = end_u * total_length
trimmed_edges = []
cur_length = 0.0
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index ec9e2ae..6402c3e 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -472,10 +472,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
return reduce(lambda loc, n: loc * n.location, self.path, Location())
@property
- def location(self) -> Location | None:
+ def location(self) -> Location:
"""Get this Shape's Location"""
if self.wrapped is None:
- return None
+ raise ValueError("Can't find the location of an empty shape")
return Location(self.wrapped.Location())
@location.setter
@@ -529,10 +529,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
return matrix
@property
- def orientation(self) -> Vector | None:
+ def orientation(self) -> Vector:
"""Get the orientation component of this Shape's Location"""
if self.location is None:
- return None
+ raise ValueError("Can't find the orientation of an empty shape")
return self.location.orientation
@orientation.setter
@@ -544,10 +544,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
self.location = loc
@property
- def position(self) -> Vector | None:
+ def position(self) -> Vector:
"""Get the position component of this Shape's Location"""
if self.wrapped is None or self.location is None:
- return None
+ raise ValueError("Can't find the position of an empty shape")
return self.location.position
@position.setter
@@ -1326,7 +1326,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
)
def intersect(
- self, *to_intersect: Shape | Axis | Plane
+ self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | Self | ShapeList[Self]:
"""Intersection of the arguments and this shape
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 91cbc65..8b8f264 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -64,7 +64,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, overload
import OCP.TopAbs as ta
from OCP.BRep import BRep_Builder, BRep_Tool
-from OCP.BRepAdaptor import BRepAdaptor_Surface
+from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCP.BRepAlgo import BRepAlgo
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.BRepBuilderAPI import (
@@ -81,8 +81,14 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeS
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
from OCP.BRepTools import BRepTools, BRepTools_ReShape
from OCP.gce import gce_MakeLin
-from OCP.Geom import Geom_BezierSurface, Geom_RectangularTrimmedSurface, Geom_Surface
-from OCP.GeomAbs import GeomAbs_C0, GeomAbs_G1, GeomAbs_G2
+from OCP.Geom import (
+ Geom_BezierSurface,
+ Geom_BSplineCurve,
+ Geom_RectangularTrimmedSurface,
+ Geom_Surface,
+ Geom_TrimmedCurve,
+)
+from OCP.GeomAbs import GeomAbs_C0, GeomAbs_CurveType, GeomAbs_G1, GeomAbs_G2
from OCP.GeomAPI import (
GeomAPI_ExtremaCurveCurve,
GeomAPI_PointsToBSplineSurface,
@@ -99,11 +105,16 @@ from OCP.Standard import (
Standard_NoSuchObject,
)
from OCP.StdFail import StdFail_NotDone
-from OCP.TColgp import TColgp_HArray2OfPnt
-from OCP.TColStd import TColStd_HArray2OfReal
+from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt
+from OCP.TColStd import (
+ TColStd_Array1OfInteger,
+ TColStd_Array1OfReal,
+ TColStd_HArray2OfReal,
+)
from OCP.TopExp import TopExp
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape
+from ocp_gordon import interpolate_curve_network
from typing_extensions import Self
from build123d.build_enums import (
@@ -649,7 +660,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
@@ -913,6 +924,91 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return cls(BRepBuilderAPI_MakeFace(bezier, Precision.Confusion_s()).Face())
+ @classmethod
+ def make_gordon_surface(
+ cls,
+ profiles: Iterable[VectorLike | Edge],
+ guides: Iterable[VectorLike | Edge],
+ tolerance: float = 3e-4,
+ ) -> Face:
+ """
+ Constructs a Gordon surface from a network of profile and guide curves.
+
+ Requirements:
+ 1. Profiles and guides may be defined as points or curves.
+ 2. Only the first or last profile or guide may be a point.
+ 3. At least one profile and one guide must be a non-point curve.
+ 4. Each profile must intersect with every guide.
+ 5. Both ends of every profile must lie on a guide.
+ 6. Both ends of every guide must lie on a profile.
+
+ Args:
+ profiles (Iterable[VectorLike | Edge]): Profiles defined as points or edges.
+ guides (Iterable[VectorLike | Edge]): Guides defined as points or edges.
+ tolerance (float, optional): Tolerance used for surface construction and
+ intersection calculations.
+
+ Raises:
+ ValueError: input Edge cannot be empty.
+
+ Returns:
+ Face: the interpolated Gordon surface
+ """
+
+ def create_zero_length_bspline_curve(
+ point: gp_Pnt, degree: int = 1
+ ) -> Geom_BSplineCurve:
+ control_points = TColgp_Array1OfPnt(1, 2)
+ control_points.SetValue(1, point)
+ control_points.SetValue(2, point)
+
+ knots = TColStd_Array1OfReal(1, 2)
+ knots.SetValue(1, 0.0)
+ knots.SetValue(2, 1.0)
+
+ multiplicities = TColStd_Array1OfInteger(1, 2)
+ multiplicities.SetValue(1, degree + 1)
+ multiplicities.SetValue(2, degree + 1)
+
+ curve = Geom_BSplineCurve(control_points, knots, multiplicities, degree)
+ return curve
+
+ def to_geom_curve(shape: VectorLike | Edge):
+ if isinstance(shape, (Vector, tuple, Sequence)):
+ _shape = Vector(shape)
+ single_point_curve = create_zero_length_bspline_curve(
+ gp_Pnt(_shape.wrapped.XYZ())
+ )
+ return single_point_curve
+
+ if shape.wrapped is None:
+ raise ValueError("input Edge cannot be empty")
+
+ adaptor = BRepAdaptor_Curve(shape.wrapped)
+ curve = BRep_Tool.Curve_s(shape.wrapped, 0, 1)
+ if not (
+ (adaptor.IsPeriodic() and adaptor.IsClosed())
+ or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BSplineCurve
+ or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BezierCurve
+ ):
+ curve = Geom_TrimmedCurve(
+ curve, adaptor.FirstParameter(), adaptor.LastParameter()
+ )
+ return curve
+
+ ocp_profiles = [to_geom_curve(shape) for shape in profiles]
+ ocp_guides = [to_geom_curve(shape) for shape in guides]
+
+ gordon_bspline_surface = interpolate_curve_network(
+ ocp_profiles, ocp_guides, tolerance=tolerance
+ )
+
+ return cls(
+ BRepBuilderAPI_MakeFace(
+ gordon_bspline_surface, Precision.Confusion_s()
+ ).Face()
+ )
+
@classmethod
def make_plane(
cls,
diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py
index bd19653..cf53676 100644
--- a/src/build123d/topology/zero_d.py
+++ b/src/build123d/topology/zero_d.py
@@ -59,6 +59,7 @@ import warnings
from typing import overload, TYPE_CHECKING
from collections.abc import Iterable
+from typing_extensions import Self
import OCP.TopAbs as ta
from OCP.BRep import BRep_Tool
@@ -66,8 +67,7 @@ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge
from OCP.gp import gp_Pnt
-from build123d.geometry import Matrix, Vector, VectorLike
-from typing_extensions import Self
+from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane
from .shape_core import Shape, ShapeList, downcast, shapetype
@@ -168,6 +168,45 @@ class Vertex(Shape[TopoDS_Vertex]):
"""extrude - invalid operation for Vertex"""
raise NotImplementedError("Vertices can't be created by extrusion")
+ def intersect(
+ self, *to_intersect: Shape | Vector | Location | Axis | Plane
+ ) -> ShapeList[Vertex] | None:
+ """Intersection of vertex and geometric objects or shapes.
+
+ Args:
+ to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]):
+ Objects(s) to intersect with
+
+ Returns:
+ ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None
+ """
+ common = Vector(self)
+ result: Shape | ShapeList[Shape] | Vector | None
+ for obj in to_intersect:
+ # Treat as Vector, otherwise call intersection from Shape
+ match obj:
+ case Vertex():
+ result = common.intersect(Vector(obj))
+ case Vector() | Location() | Axis() | Plane():
+ result = obj.intersect(common)
+ case _ if issubclass(type(obj), Shape):
+ result = obj.intersect(self)
+ case _:
+ raise ValueError(f"Unsupported type to_intersect:: {type(obj)}")
+
+ if isinstance(result, Vector) and result == common:
+ pass
+ elif (
+ isinstance(result, list)
+ and len(result) == 1
+ and Vector(result[0]) == common
+ ):
+ pass
+ else:
+ return None
+
+ return ShapeList([self])
+
# ---- Instance Methods ----
def __add__( # type: ignore
diff --git a/tests/test_airfoil.py b/tests/test_airfoil.py
new file mode 100644
index 0000000..21c2e06
--- /dev/null
+++ b/tests/test_airfoil.py
@@ -0,0 +1,106 @@
+import pytest
+import numpy as np
+from build123d import Airfoil, Vector, Edge, Wire
+
+
+# --- parse_naca4 tests ------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "code, expected",
+ [
+ ("2412", (0.02, 0.4, 0.12)), # standard NACA 2412
+ ("0012", (0.0, 0.0, 0.12)), # symmetric section
+ ("2213.323", (0.02, 0.2, 0.13323)), # fractional thickness
+ ("NACA2412", (0.02, 0.4, 0.12)), # with prefix
+ ],
+)
+def test_parse_naca4_variants(code, expected):
+ m, p, t = Airfoil.parse_naca4(code)
+ np.testing.assert_allclose([m, p, t], expected, rtol=1e-6)
+
+
+# --- basic construction tests -----------------------------------------------
+
+
+def test_airfoil_basic_construction():
+ airfoil = Airfoil("2412", n_points=40)
+ assert isinstance(airfoil, Airfoil)
+ assert isinstance(airfoil.camber_line, Edge)
+ assert isinstance(airfoil._camber_points, list)
+ assert all(isinstance(p, Vector) for p in airfoil._camber_points)
+
+ # Check metadata
+ assert airfoil.code == "2412"
+ assert pytest.approx(airfoil.max_camber, rel=1e-6) == 0.02
+ assert pytest.approx(airfoil.camber_pos, rel=1e-6) == 0.4
+ assert pytest.approx(airfoil.thickness, rel=1e-6) == 0.12
+ assert airfoil.finite_te is False
+
+
+def test_airfoil_finite_te_profile():
+ """Finite trailing edge version should have a line closing the profile."""
+ airfoil = Airfoil("2412", finite_te=True, n_points=40)
+ assert isinstance(airfoil, Wire)
+ assert airfoil.finite_te
+ assert len(list(airfoil.edges())) == 2
+
+
+def test_airfoil_infinite_te_profile():
+ """Infinite trailing edge (periodic spline)."""
+ airfoil = Airfoil("2412", finite_te=False, n_points=40)
+ assert isinstance(airfoil, Wire)
+ # Should contain a single closed Edge
+ assert len(airfoil.edges()) == 1
+ assert airfoil.edges()[0].is_closed
+
+
+# --- geometric / numerical validity -----------------------------------------
+
+
+def test_camber_line_geometry_monotonic():
+ """Camber x coordinates should increase monotonically along the chord."""
+ af = Airfoil("2412", n_points=80)
+ x_coords = [p.X for p in af._camber_points]
+ assert np.all(np.diff(x_coords) >= 0)
+
+
+def test_airfoil_chord_limits():
+ """Airfoil should be bounded between x=0 and x=1."""
+ af = Airfoil("2412", n_points=100)
+ all_points = af._camber_points
+ xs = np.array([p.X for p in all_points])
+ assert xs.min() >= -1e-9
+ assert xs.max() <= 1.0 + 1e-9
+
+
+def test_airfoil_thickness_scaling():
+ """Check that airfoil thickness scales linearly with NACA last two digits."""
+ af1 = Airfoil("0010", n_points=120)
+ af2 = Airfoil("0020", n_points=120)
+
+ # Extract main surface edge (for finite_te=False it's just one edge)
+ edge1 = af1.edges()[0]
+ edge2 = af2.edges()[0]
+
+ # Sample many points along each edge
+ n = 500
+ ys1 = [(edge1 @ u).Y for u in np.linspace(0.0, 1.0, n)]
+ ys2 = [(edge2 @ u).Y for u in np.linspace(0.0, 1.0, n)]
+
+ # Total height (max - min)
+ h1 = max(ys1) - min(ys1)
+ h2 = max(ys2) - min(ys2)
+
+ # For symmetric NACA 00xx, thickness is proportional to 't'
+ assert (h1 / h2) == pytest.approx(0.5, rel=0.05)
+
+
+def test_camber_line_is_centered():
+ """Mean of upper and lower surfaces should approximate camber line."""
+ af = Airfoil("2412", n_points=50)
+ # Extract central camber Y near mid-chord
+ mid_index = len(af._camber_points) // 2
+ mid_point = af._camber_points[mid_index]
+ # Camber line should be roughly symmetric around y=0 for small m
+ assert abs(mid_point.Y) < 0.05
diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py
new file mode 100644
index 0000000..3eaab09
--- /dev/null
+++ b/tests/test_direct_api/test_constrained_arcs.py
@@ -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)
+ )
diff --git a/tests/test_direct_api/test_constrained_lines.py b/tests/test_direct_api/test_constrained_lines.py
new file mode 100644
index 0000000..dc32dff
--- /dev/null
+++ b/tests/test_direct_api/test_constrained_lines.py
@@ -0,0 +1,267 @@
+"""
+build123d tests
+
+name: test_constrained_lines.py
+by: Gumyr
+date: October 8, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 Gumyr
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+"""
+
+import math
+import pytest
+from OCP.gp import gp_Pnt2d, gp_Dir2d, gp_Lin2d
+from build123d import Edge, Axis, Vector, Tangency, Plane
+from build123d.topology.constrained_lines import (
+ _make_2tan_lines,
+ _make_tan_oriented_lines,
+ _edge_from_line,
+)
+from build123d.geometry import TOLERANCE
+
+
+@pytest.fixture
+def unit_circle() -> Edge:
+ """A simple unit circle centered at the origin on XY."""
+ return Edge.make_circle(1.0, Plane.XY)
+
+
+# ---------------------------------------------------------------------------
+# utility tests
+# ---------------------------------------------------------------------------
+
+
+def test_edge_from_line():
+ line = _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(1, 0))
+ assert Edge(line).length == 1
+
+ with pytest.raises(RuntimeError) as excinfo:
+ _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(0, 0))
+ assert "Failed to build edge from line contacts" in str(excinfo.value)
+
+
+# ---------------------------------------------------------------------------
+# _make_2tan_lines tests
+# ---------------------------------------------------------------------------
+
+
+def test_two_circles_tangents(unit_circle):
+ """Tangent lines between two separated circles should yield four results."""
+ c1 = unit_circle
+ c2 = unit_circle.translate((3, 0, 0)) # displaced along X
+ lines = _make_2tan_lines(c1, c2, edge_factory=Edge)
+ # There should be 4 external/internal tangents
+ assert len(lines) in (4, 2)
+ for ln in lines:
+ assert isinstance(ln, Edge)
+ # Tangent lines should not intersect the circle interior
+ dmin = c1.distance_to(ln)
+ assert dmin >= -1e-6
+
+
+def test_two_constrained_circles_tangents1(unit_circle):
+ """Tangent lines between two separated circles should yield four results."""
+ c1 = unit_circle
+ c2 = unit_circle.translate((3, 0, 0)) # displaced along X
+ lines = _make_2tan_lines((c1, Tangency.ENCLOSING), c2, edge_factory=Edge)
+ # There should be 2 external/internal tangents
+ assert len(lines) == 2
+ for ln in lines:
+ assert isinstance(ln, Edge)
+ # Tangent lines should not intersect the circle interior
+ dmin = c1.distance_to(ln)
+ assert dmin >= -1e-6
+
+
+def test_two_constrained_circles_tangents2(unit_circle):
+ """Tangent lines between two separated circles should yield four results."""
+ c1 = unit_circle
+ c2 = unit_circle.translate((3, 0, 0)) # displaced along X
+ lines = _make_2tan_lines(
+ (c1, Tangency.ENCLOSING), (c2, Tangency.ENCLOSING), edge_factory=Edge
+ )
+ # There should be 1 external/external tangents
+ assert len(lines) == 1
+ for ln in lines:
+ assert isinstance(ln, Edge)
+ # Tangent lines should not intersect the circle interior
+ dmin = c1.distance_to(ln)
+ assert dmin >= -1e-6
+
+
+def test_curve_and_point_tangent(unit_circle):
+ """A line tangent to a circle and passing through a point should exist."""
+ pt = Vector(2.0, 0.0)
+ lines = _make_2tan_lines(unit_circle, pt, edge_factory=Edge)
+ assert len(lines) == 2
+ for ln in lines:
+ # The line must pass through the given point (approximately)
+ dist_to_point = ln.distance_to(pt)
+ assert math.isclose(dist_to_point, 0.0, abs_tol=1e-6)
+ # It should also touch the circle at exactly one point
+ dist_to_circle = unit_circle.distance_to(ln)
+ assert math.isclose(dist_to_circle, 0.0, abs_tol=TOLERANCE)
+
+
+def test_invalid_tangent_raises(unit_circle):
+ """Non-intersecting degenerate input result in no output."""
+ lines = _make_2tan_lines(unit_circle, unit_circle, edge_factory=Edge)
+ assert len(lines) == 0
+
+ with pytest.raises(RuntimeError) as excinfo:
+ _make_2tan_lines(unit_circle, Vector(0, 0), edge_factory=Edge)
+ assert "Unable to find common tangent line(s)" in str(excinfo.value)
+
+
+# ---------------------------------------------------------------------------
+# _make_tan_oriented_lines tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("angle_deg", [math.radians(30), -math.radians(30)])
+def test_oriented_tangents_with_x_axis(unit_circle, angle_deg):
+ """Lines tangent to a circle at ±30° from the X-axis."""
+ lines = _make_tan_oriented_lines(unit_circle, Axis.X, angle_deg, edge_factory=Edge)
+ assert all(isinstance(e, Edge) for e in lines)
+ # The tangent lines should all intersect the X axis (red line)
+ for ln in lines:
+ p = ln.position_at(0.5)
+ assert abs(p.Z) < 1e-9
+
+ lines = _make_tan_oriented_lines(unit_circle, Axis.X, 0, edge_factory=Edge)
+ assert len(lines) == 0
+
+ lines = _make_tan_oriented_lines(
+ unit_circle, Axis((0, -2), (1, 0)), 0, edge_factory=Edge
+ )
+ assert len(lines) == 0
+
+
+def test_oriented_tangents_with_y_axis(unit_circle):
+ """Lines tangent to a circle and 30° from Y-axis should exist."""
+ angle = math.radians(30)
+ lines = _make_tan_oriented_lines(unit_circle, Axis.Y, angle, edge_factory=Edge)
+ assert len(lines) >= 1
+ # They should roughly touch the circle (tangent distance ≈ 0)
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_oriented_constrained_tangents_with_y_axis(unit_circle):
+ angle = math.radians(30)
+ lines = _make_tan_oriented_lines(
+ (unit_circle, Tangency.ENCLOSING), Axis.Y, angle, edge_factory=Edge
+ )
+ assert len(lines) == 1
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_invalid_oriented_tangent_raises(unit_circle):
+ """Non-intersecting degenerate input result in no output."""
+
+ with pytest.raises(ValueError) as excinfo:
+ _make_tan_oriented_lines(unit_circle, Axis.Z, 1, edge_factory=Edge)
+ assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
+
+ with pytest.raises(ValueError) as excinfo:
+ _make_tan_oriented_lines(
+ unit_circle, Axis((1, 2, 3), (0, 0, -1)), 1, edge_factory=Edge
+ )
+ assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value)
+
+
+def test_invalid_oriented_tangent(unit_circle):
+ lines = _make_tan_oriented_lines(
+ unit_circle, Axis((1, 0), (0, 1)), 0, edge_factory=Edge
+ )
+ assert len(lines) == 0
+
+ lines = _make_tan_oriented_lines(
+ unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge
+ )
+ assert len(lines) == 0
+
+
+def test_make_constrained_lines0(unit_circle):
+ lines = Edge.make_constrained_lines(unit_circle, unit_circle.translate((3, 0, 0)))
+ assert len(lines) == 4
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_make_constrained_lines1(unit_circle):
+ lines = Edge.make_constrained_lines(unit_circle, (3, 0))
+ assert len(lines) == 2
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_make_constrained_lines3(unit_circle):
+ lines = Edge.make_constrained_lines(unit_circle, Axis.X, angle=30)
+ assert len(lines) == 2
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+ assert abs((ln @ 1).Y) < 1e-6
+
+
+def test_make_constrained_lines4(unit_circle):
+ lines = Edge.make_constrained_lines(unit_circle, Axis.Y, angle=30)
+ assert len(lines) == 2
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+ assert abs((ln @ 1).X) < 1e-6
+
+
+def test_make_constrained_lines5(unit_circle):
+ lines = Edge.make_constrained_lines(
+ (unit_circle, Tangency.ENCLOSING), Axis.Y, angle=30
+ )
+ assert len(lines) == 1
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_make_constrained_lines6(unit_circle):
+ lines = Edge.make_constrained_lines(
+ (unit_circle, Tangency.ENCLOSING), Axis.Y, direction=(1, 1)
+ )
+ assert len(lines) == 1
+ for ln in lines:
+ assert unit_circle.distance_to(ln) < 1e-6
+
+
+def test_make_constrained_lines_raises(unit_circle):
+ with pytest.raises(TypeError) as excinfo:
+ Edge.make_constrained_lines(unit_circle, Axis.Z, ref_angle=1)
+ assert "Unexpected argument(s): ref_angle" in str(excinfo.value)
+
+ with pytest.raises(TypeError) as excinfo:
+ Edge.make_constrained_lines(unit_circle)
+ assert "Provide exactly 2 tangency targets." in str(excinfo.value)
+
+ with pytest.raises(RuntimeError) as excinfo:
+ Edge.make_constrained_lines(Axis.X, Axis.Y)
+ assert "Unable to find common tangent line(s)" in str(excinfo.value)
+
+ with pytest.raises(TypeError) as excinfo:
+ Edge.make_constrained_lines(unit_circle, ("three", 0))
+ assert "Invalid tangency:" in str(excinfo.value)
diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py
index fb60a7d..6f06f68 100644
--- a/tests/test_direct_api/test_edge.py
+++ b/tests/test_direct_api/test_edge.py
@@ -37,7 +37,7 @@ from build123d.geometry import Axis, Plane, Vector
from build123d.objects_curve import CenterArc, EllipticalCenterArc
from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
from build123d.operations_generic import sweep
-from build123d.topology import Edge, Face, Wire
+from build123d.topology import Edge, Face, Wire, Vertex
from OCP.GeomProjLib import GeomProjLib
@@ -183,8 +183,23 @@ class TestEdge(unittest.TestCase):
line = Edge.make_line((-2, 0), (2, 0))
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
- with self.assertRaises(ValueError):
- line.trim(0.75, 0.25)
+
+ l1 = CenterArc((0, 0), 1, 0, 180)
+ l2 = l1.trim(0, l1 @ 0.5)
+ self.assertAlmostEqual(l2 @ 0, (1, 0, 0), 5)
+ self.assertAlmostEqual(l2 @ 1, (0, 1, 0), 5)
+
+ l3 = l1.trim((1, 0), (0, 1))
+ self.assertAlmostEqual(l3 @ 0, (1, 0, 0), 5)
+ self.assertAlmostEqual(l3 @ 1, (0, 1, 0), 5)
+
+ l4 = l1.trim(0.5, (-1, 0))
+ self.assertAlmostEqual(l4 @ 0, (0, 1, 0), 5)
+ self.assertAlmostEqual(l4 @ 1, (-1, 0, 0), 5)
+
+ l5 = l1.trim(0.5, Vertex(-1, 0))
+ self.assertAlmostEqual(l5 @ 0, (0, 1, 0), 5)
+ self.assertAlmostEqual(l5 @ 1, (-1, 0, 0), 5)
line.wrapped = None
with self.assertRaises(ValueError):
@@ -213,6 +228,10 @@ class TestEdge(unittest.TestCase):
e4_trim = Edge(a4).trim_to_length(0.5, 2)
self.assertAlmostEqual(e4_trim.length, 2, 5)
+ e5 = e1.trim_to_length((5, 5), 1)
+ self.assertAlmostEqual(e5 @ 0, (5, 5), 5)
+ self.assertAlmostEqual(e5.length, 1, 5)
+
e1.wrapped = None
with self.assertRaises(ValueError):
e1.trim_to_length(0.1, 2)
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index 82460c0..f8619c5 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -31,9 +31,11 @@ import os
import platform
import random
import unittest
+from unittest.mock import PropertyMock, patch
-from unittest.mock import patch, PropertyMock
from OCP.Geom import Geom_RectangularTrimmedSurface
+from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
+
from build123d.build_common import Locations, PolarLocations
from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType
from build123d.build_line import BuildLine
@@ -57,7 +59,6 @@ from build123d.operations_generic import fillet, offset
from build123d.operations_part import extrude
from build123d.operations_sketch import make_face
from build123d.topology import Edge, Face, Shell, Solid, Wire
-from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
class TestFace(unittest.TestCase):
@@ -359,6 +360,231 @@ class TestFace(unittest.TestCase):
self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
+ def test_make_gordon_surface(self):
+ def create_test_curves(
+ num_profiles: int = 3,
+ num_guides: int = 4,
+ u_range: float = 1.0,
+ v_range: float = 1.0,
+ ):
+ profiles: list[Edge] = []
+ guides: list[Edge] = []
+
+ intersection_points = [
+ [(0.0, 0.0, 0.0) for _ in range(num_guides)]
+ for _ in range(num_profiles)
+ ]
+
+ for i in range(num_profiles):
+ for j in range(num_guides):
+ u = i * u_range / (num_profiles - 1)
+ v = j * v_range / (num_guides - 1)
+ z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi)
+ intersection_points[i][j] = (u, v, z)
+
+ for i in range(num_profiles):
+ points = [intersection_points[i][j] for j in range(num_guides)]
+ profiles.append(Spline(points))
+
+ for j in range(num_guides):
+ points = [intersection_points[i][j] for i in range(num_profiles)]
+ guides.append(Spline(points))
+
+ return profiles, guides
+
+ profiles, guides = create_test_curves()
+
+ tolerance = 3e-4
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+
+ self.assertIsInstance(
+ gordon_surface, Face, "The returned object should be a Face."
+ )
+
+ def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
+ point_at_uv = gordon_surface.position_at(u, v)
+ self.assertAlmostEqual(
+ point_at_uv.X,
+ expected_point.X,
+ delta=tolerance,
+ msg=f"X coordinate mismatch at ({u},{v})",
+ )
+ self.assertAlmostEqual(
+ point_at_uv.Y,
+ expected_point.Y,
+ delta=tolerance,
+ msg=f"Y coordinate mismatch at ({u},{v})",
+ )
+ self.assertAlmostEqual(
+ point_at_uv.Z,
+ expected_point.Z,
+ delta=tolerance,
+ msg=f"Z coordinate mismatch at ({u},{v})",
+ )
+
+ point_at_uv_against_expected(
+ u=0.0, v=0.0, expected_point=guides[0].position_at(0.0)
+ )
+ point_at_uv_against_expected(
+ u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0)
+ )
+ point_at_uv_against_expected(
+ u=0.0, v=1.0, expected_point=guides[0].position_at(1.0)
+ )
+ point_at_uv_against_expected(
+ u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0)
+ )
+
+ temp_curve = profiles[0]
+ profiles[0] = Edge()
+ with self.assertRaises(ValueError):
+ gordon_surface = Face.make_gordon_surface(
+ profiles, guides, tolerance=tolerance
+ )
+
+ profiles[0] = temp_curve
+ guides[0] = Edge()
+ with self.assertRaises(ValueError):
+ gordon_surface = Face.make_gordon_surface(
+ profiles, guides, tolerance=tolerance
+ )
+
+ def test_make_gordon_surface_input_types(self):
+ tolerance = 3e-4
+
+ def point_at_uv_against_expected(u: float, v: float, expected_point: Vector):
+ point_at_uv = gordon_surface.position_at(u, v)
+ self.assertAlmostEqual(
+ point_at_uv.X,
+ expected_point.X,
+ delta=tolerance,
+ msg=f"X coordinate mismatch at ({u},{v})",
+ )
+ self.assertAlmostEqual(
+ point_at_uv.Y,
+ expected_point.Y,
+ delta=tolerance,
+ msg=f"Y coordinate mismatch at ({u},{v})",
+ )
+ self.assertAlmostEqual(
+ point_at_uv.Z,
+ expected_point.Z,
+ delta=tolerance,
+ msg=f"Z coordinate mismatch at ({u},{v})",
+ )
+
+ points = [
+ Vector(0, 0, 0),
+ Vector(10, 0, 0),
+ Vector(12, 20, 1),
+ Vector(4, 22, -1),
+ ]
+
+ profiles = [Line(points[0], points[1]), Line(points[3], points[2])]
+ guides = [Line(points[0], points[3]), Line(points[1], points[2])]
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+ point_at_uv_against_expected(
+ u=0.5,
+ v=0.5,
+ expected_point=(points[0] + points[1] + points[2] + points[3]) / 4,
+ )
+
+ profiles = [
+ ThreePointArc(
+ points[0], (points[0] + points[1]) / 2 + Vector(0, 0, 2), points[1]
+ ),
+ ThreePointArc(
+ points[3], (points[3] + points[2]) / 2 + Vector(0, 0, 3), points[2]
+ ),
+ ]
+ guides = [
+ Line(profiles[0] @ 0, profiles[1] @ 0),
+ Line(profiles[0] @ 1, profiles[1] @ 1),
+ ]
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+ point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
+ point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
+
+ profiles = [
+ Edge.make_bezier(
+ points[0],
+ points[0] + Vector(1, 0, 1),
+ points[1] - Vector(1, 0, 1),
+ points[1],
+ ),
+ Edge.make_bezier(
+ points[3],
+ points[3] + Vector(1, 0, 1),
+ points[2] - Vector(1, 0, 1),
+ points[2],
+ ),
+ ]
+ guides = [
+ Line(profiles[0] @ 0, profiles[1] @ 0),
+ Line(profiles[0] @ 1, profiles[1] @ 1),
+ ]
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+ point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
+ point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5)
+
+ profiles = [
+ Edge.make_ellipse(10, 6),
+ Edge.make_ellipse(8, 7).translate((1, 2, 10)),
+ ]
+ guides = [
+ Line(profiles[0] @ 0, profiles[1] @ 0),
+ Line(profiles[0] @ 0.5, profiles[1] @ 0.5),
+ ]
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+ point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5)
+ point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5)
+
+ profiles = [
+ points[0],
+ ThreePointArc(
+ points[1], (points[1] + points[3]) / 2 + Vector(0, 0, 2), points[3]
+ ),
+ points[2],
+ ]
+ guides = [
+ Spline(
+ points[0],
+ profiles[1] @ 0,
+ points[2],
+ ),
+ Spline(
+ points[0],
+ profiles[1] @ 1,
+ points[2],
+ ),
+ ]
+ gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance)
+ point_at_uv_against_expected(u=0.0, v=1.0, expected_point=guides[0] @ 1)
+ point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1)
+ point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0])
+
+ profiles = [
+ Line(points[0], points[1]),
+ (points[0] + points[2]) / 2,
+ Line(points[3], points[2]),
+ ]
+ guides = [
+ Spline(
+ profiles[0] @ 0,
+ profiles[1],
+ profiles[2] @ 0,
+ ),
+ Spline(
+ profiles[0] @ 1,
+ profiles[1],
+ profiles[2] @ 1,
+ ),
+ ]
+ with self.assertRaises(ValueError):
+ gordon_surface = Face.make_gordon_surface(
+ profiles, guides, tolerance=tolerance
+ )
+
def test_make_surface(self):
corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
net_exterior = Wire(
diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py
new file mode 100644
index 0000000..6eebc41
--- /dev/null
+++ b/tests/test_direct_api/test_intersection.py
@@ -0,0 +1,299 @@
+import pytest
+from collections import Counter
+from dataclasses import dataclass
+from build123d import *
+from build123d.topology.shape_core import Shape
+
+INTERSECT_DEBUG = False
+if INTERSECT_DEBUG:
+ from ocp_vscode import show
+
+
+@dataclass
+class Case:
+ object: Shape | Vector | Location | Axis | Plane
+ target: Shape | Vector | Location | Axis | Plane
+ expected: list | Vector | Location | Axis | Plane
+ name: str
+ xfail: None | str = None
+
+
+@pytest.mark.skip
+def run_test(obj, target, expected):
+ if isinstance(target, list):
+ result = obj.intersect(*target)
+ else:
+ result = obj.intersect(target)
+ if INTERSECT_DEBUG:
+ show([obj, target, result])
+ if expected is None:
+ assert result == expected, f"Expected None, but got {result}"
+ else:
+ e_type = ShapeList if isinstance(expected, list) else expected
+ assert isinstance(result, e_type), f"Expected {e_type}, but got {result}"
+ if e_type == ShapeList:
+ assert len(result) == len(expected), f"Expected {len(expected)} objects, but got {len(result)}"
+
+ actual_counts = Counter(type(obj) for obj in result)
+ expected_counts = Counter(expected)
+ assert all(actual_counts[t] >= count for t, count in expected_counts.items()), f"Expected {expected}, but got {[type(r) for r in result]}"
+
+
+@pytest.mark.skip
+def make_params(matrix):
+ params = []
+ for case in matrix:
+ obj_type = type(case.object).__name__
+ tar_type = type(case.target).__name__
+ i = len(params)
+ if case.xfail and not INTERSECT_DEBUG:
+ marks = [pytest.mark.xfail(reason=case.xfail)]
+ else:
+ marks = []
+ uid = f"{i} {obj_type}, {tar_type}, {case.name}"
+ params.append(pytest.param(case.object, case.target, case.expected, marks=marks, id=uid))
+ if tar_type != obj_type and not isinstance(case.target, list):
+ uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}"
+ params.append(pytest.param(case.target, case.object, case.expected, marks=marks, id=uid))
+
+ return params
+
+
+# Geometric test objects
+ax1 = Axis.X
+ax2 = Axis.Y
+ax3 = Axis((0, 0, 5), (1, 0, 0))
+pl1 = Plane.YZ
+pl2 = Plane.XY
+pl3 = Plane.XY.offset(5)
+pl4 = Plane((0, 5, 0))
+pl5 = Plane.YZ.offset(1)
+vl1 = Vector(2, 0, 0)
+vl2 = Vector(2, 0, 5)
+lc1 = Location((2, 0, 0))
+lc2 = Location((2, 0, 5))
+lc3 = Location((0, 0, 0), (0, 90, 90))
+lc4 = Location((2, 0, 0), (0, 90, 90))
+
+# Geometric test matrix
+geometry_matrix = [
+ Case(ax1, ax3, None, "parallel/skew", None),
+ Case(ax1, ax1, Axis, "collinear", None),
+ Case(ax1, ax2, Vector, "intersecting", None),
+
+ Case(ax1, pl3, None, "parallel", None),
+ Case(ax1, pl2, Axis, "coplanar", None),
+ Case(ax1, pl1, Vector, "intersecting", None),
+
+ Case(ax1, vl2, None, "non-coincident", None),
+ Case(ax1, vl1, Vector, "coincident", None),
+
+ Case(ax1, lc2, None, "non-coincident", None),
+ Case(ax1, lc4, Location, "intersecting, co-z", None),
+ Case(ax1, lc1, Vector, "intersecting", None),
+
+ Case(pl2, pl3, None, "parallel", None),
+ Case(pl2, pl4, Plane, "coplanar", None),
+ Case(pl1, pl2, Axis, "intersecting", None),
+
+ Case(pl3, ax1, None, "parallel", None),
+ Case(pl2, ax1, Axis, "coplanar", None),
+ Case(pl1, ax1, Vector, "intersecting", None),
+
+ Case(pl1, vl2, None, "non-coincident", None),
+ Case(pl2, vl1, Vector, "coincident", None),
+
+ Case(pl1, lc2, None, "non-coincident", None),
+ Case(pl1, lc3, Location, "intersecting, co-z", None),
+ Case(pl2, lc4, Vector, "coincident", None),
+
+ Case(vl1, vl2, None, "non-coincident", None),
+ Case(vl1, vl1, Vector, "coincident", None),
+
+ Case(vl1, lc2, None, "non-coincident", None),
+ Case(vl1, lc1, Vector, "coincident", None),
+
+ Case(lc1, lc2, None, "non-coincident", None),
+ Case(lc1, lc4, Vector, "coincident", None),
+ Case(lc1, lc1, Location, "coincident, co-z", None),
+]
+
+@pytest.mark.parametrize("obj, target, expected", make_params(geometry_matrix))
+def test_geometry(obj, target, expected):
+ run_test(obj, target, expected)
+
+
+# Shape test matrices
+vt1 = Vertex(2, 0, 0)
+vt2 = Vertex(2, 0, 5)
+
+shape_0d_matrix = [
+ Case(vt1, vt2, None, "non-coincident", None),
+ Case(vt1, vt1, [Vertex], "coincident", None),
+
+ Case(vt1, vl2, None, "non-coincident", None),
+ Case(vt1, vl1, [Vertex], "coincident", None),
+
+ Case(vt1, lc2, None, "non-coincident", None),
+ Case(vt1, lc1, [Vertex], "coincident", None),
+
+ Case(vt2, ax1, None, "non-coincident", None),
+ Case(vt1, ax1, [Vertex], "coincident", None),
+
+ Case(vt2, pl1, None, "non-coincident", None),
+ Case(vt1, pl2, [Vertex], "coincident", None),
+
+ Case(vt1, [vt2, lc1], None, "multi to_intersect, non-coincident", None),
+ Case(vt1, [vt1, lc1], [Vertex], "multi to_intersect, coincident", None),
+]
+
+@pytest.mark.parametrize("obj, target, expected", make_params(shape_0d_matrix))
+def test_shape_0d(obj, target, expected):
+ run_test(obj, target, expected)
+
+
+ed1 = Line((0, 0), (5, 0)).edge()
+ed2 = Line((0, -1), (5, 1)).edge()
+ed3 = Line((0, 0, 5), (5, 0, 5)).edge()
+ed4 = CenterArc((3, 1), 2, 0, 360).edge()
+ed5 = CenterArc((3, 1), 5, 0, 360).edge()
+
+ed6 = Edge.make_line((0, -1), (2, 1))
+ed7 = Edge.make_line((0, 1), (2, -1))
+ed8 = Edge.make_line((0, 0), (2, 0))
+
+wi1 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 1.5), 2)]
+wi2 = wi1 + Line((3, 1.5), (3, -1))
+wi3 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 0), 2), Line((3, 0), (5, 0))]
+wi4 = Wire() + [Line((0, 1), (2, -1)) , Line((2, -1), (3, -1))]
+wi5 = wi4 + Line((3, -1), (4, 1))
+wi6 = Wire() + [Line((0, 1, 1), (2, -1, 1)), Line((2, -1, 1), (4, 1, 1))]
+
+shape_1d_matrix = [
+ Case(ed1, vl2, None, "non-coincident", None),
+ Case(ed1, vl1, [Vertex], "coincident", None),
+
+ Case(ed1, lc2, None, "non-coincident", None),
+ Case(ed1, lc1, [Vertex], "coincident", None),
+
+ Case(ed3, ax1, None, "parallel/skew", None),
+ Case(ed2, ax1, [Vertex], "intersecting", None),
+ Case(ed1, ax1, [Edge], "collinear", None),
+ Case(ed4, ax1, [Vertex, Vertex], "multi intersect", None),
+
+ Case(ed1, pl3, None, "parallel/skew", None),
+ Case(ed1, pl1, [Vertex], "intersecting", None),
+ Case(ed1, pl2, [Edge], "collinear", None),
+ Case(ed5, pl1, [Vertex, Vertex], "multi intersect", None),
+
+ Case(ed1, vt2, None, "non-coincident", None),
+ Case(ed1, vt1, [Vertex], "coincident", None),
+
+ Case(ed3, ed1, None, "parallel/skew", None),
+ Case(ed2, ed1, [Vertex], "intersecting", None),
+ Case(ed1, ed1, [Edge], "collinear", None),
+ Case(ed4, ed1, [Vertex, Vertex], "multi intersect", None),
+
+ Case(ed6, [ed7, ed8], [Vertex], "multi to_intersect, intersect", None),
+ Case(ed6, [ed7, pl5], [Vertex], "multi to_intersect, intersect", None),
+ Case(ed6, [ed7, Vector(1, 0)], [Vertex], "multi to_intersect, intersect", None),
+
+ Case(wi6, ax1, None, "parallel/skew", None),
+ Case(wi4, ax1, [Vertex], "intersecting", None),
+ Case(wi1, ax1, [Edge], "collinear", None),
+ Case(wi5, ax1, [Vertex, Vertex], "multi intersect", None),
+ Case(wi2, ax1, [Vertex, Edge], "intersect + collinear", None),
+ Case(wi3, ax1, [Edge, Edge], "2 collinear", None),
+
+ Case(wi6, ed1, None, "parallel/skew", None),
+ Case(wi4, ed1, [Vertex], "intersecting", None),
+ Case(wi1, ed1, [Edge], "collinear", None),
+ Case(wi5, ed1, [Vertex, Vertex], "multi intersect", None),
+ Case(wi2, ed1, [Vertex, Edge], "intersect + collinear", None),
+ Case(wi3, ed1, [Edge, Edge], "2 collinear", None),
+
+ Case(wi5, [ed1, Vector(1, 0)], [Vertex], "multi to_intersect, multi intersect", None),
+]
+
+@pytest.mark.parametrize("obj, target, expected", make_params(shape_1d_matrix))
+def test_shape_1d(obj, target, expected):
+ run_test(obj, target, expected)
+
+
+# FreeCAD issue example
+c1 = CenterArc((0, 0), 10, 0, 360).edge()
+c2 = CenterArc((19, 0), 10, 0, 360).edge()
+skew = Line((-12, 0), (30, 10)).edge()
+vert = Line((10, 0), (10, 20)).edge()
+horz = Line((0, 10), (30, 10)).edge()
+e1 = EllipticalCenterArc((5, 0), 5, 10, 0, 360).edge()
+
+freecad_matrix = [
+ Case(c1, skew, [Vertex, Vertex], "circle, skew, intersect", None),
+ Case(c2, skew, [Vertex, Vertex], "circle, skew, intersect", None),
+ Case(c1, e1, [Vertex, Vertex, Vertex], "circle, ellipse, intersect + tangent", None),
+ Case(c2, e1, [Vertex, Vertex], "circle, ellipse, intersect", None),
+ Case(skew, e1, [Vertex, Vertex], "skew, ellipse, intersect", None),
+ Case(skew, horz, [Vertex], "skew, horizontal, coincident", None),
+ Case(skew, vert, [Vertex], "skew, vertical, intersect", None),
+ Case(horz, vert, [Vertex], "horizontal, vertical, intersect", None),
+ Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None),
+ Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None),
+
+ Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", "Should return 2 Vertices"),
+ Case(c1, horz, [Vertex], "circle, horiz, tangent", None),
+ Case(c2, horz, [Vertex], "circle, horiz, tangent", None),
+ Case(c1, vert, [Vertex], "circle, vert, tangent", None),
+ Case(c2, vert, [Vertex], "circle, vert, intersect", None),
+]
+
+@pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix))
+def test_freecad(obj, target, expected):
+ run_test(obj, target, expected)
+
+
+# Issue tests
+t = Sketch() + GridLocations(5, 0, 2, 1) * Circle(2)
+s = Circle(10).face()
+l = Line(-20, 20).edge()
+a = Rectangle(10,10).face()
+b = (Plane.XZ * a).face()
+e1 = Edge.make_line((-1, 0), (1, 0))
+w1 = Wire.make_circle(0.5)
+f1 = Face(Wire.make_circle(0.5))
+
+issues_matrix = [
+ Case(t, t, [Face, Face], "issue #1015", "Returns Compound"),
+ Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"),
+ Case(a, b, [Edge], "issue #918", "Returns empty Compound"),
+ Case(e1, w1, [Vertex, Vertex], "issue #697"),
+ Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"),
+]
+
+@pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix))
+def test_issues(obj, target, expected):
+ run_test(obj, target, expected)
+
+
+# Exceptions
+exception_matrix = [
+ Case(vt1, Color(), None, "Unsupported type", None),
+ Case(ed1, Color(), None, "Unsupported type", None),
+]
+
+@pytest.mark.skip
+def make_exception_params(matrix):
+ params = []
+ for case in matrix:
+ obj_type = type(case.object).__name__
+ tar_type = type(case.target).__name__
+ i = len(params)
+ uid = f"{i} {obj_type}, {tar_type}, {case.name}"
+ params.append(pytest.param(case.object, case.target, case.expected, id=uid))
+
+ return params
+
+@pytest.mark.parametrize("obj, target, expected", make_exception_params(exception_matrix))
+def test_exceptions(obj, target, expected):
+ with pytest.raises(Exception):
+ obj.intersect(target)
\ No newline at end of file
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
index 40f0e0a..d22cb6c 100644
--- a/tests/test_direct_api/test_location.py
+++ b/tests/test_direct_api/test_location.py
@@ -388,8 +388,8 @@ class TestLocation(unittest.TestCase):
e3 = Edge.make_line((0, 0), (2, 0))
i = e1.intersect(e2, e3)
- self.assertTrue(isinstance(i, Vertex))
- self.assertAlmostEqual(Vector(i), (1, 0, 0), 5)
+ self.assertTrue(isinstance(i, list))
+ self.assertAlmostEqual(Vector(i[0]), (1, 0, 0), 5)
e4 = Edge.make_line((1, -1), (1, 1))
e5 = Edge.make_line((2, -1), (2, 1))
diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py
index 02f9de0..2c0bb3c 100644
--- a/tests/test_direct_api/test_shape.py
+++ b/tests/test_direct_api/test_shape.py
@@ -531,9 +531,12 @@ class TestShape(unittest.TestCase):
def test_empty_shape(self):
empty = Solid()
box = Solid.make_box(1, 1, 1)
- self.assertIsNone(empty.location)
- self.assertIsNone(empty.position)
- self.assertIsNone(empty.orientation)
+ with self.assertRaises(ValueError):
+ empty.location
+ with self.assertRaises(ValueError):
+ empty.position
+ with self.assertRaises(ValueError):
+ empty.orientation
self.assertFalse(empty.is_manifold)
with self.assertRaises(ValueError):
empty.geom_type
diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py
index cbb9449..bbfb6fc 100644
--- a/tests/test_direct_api/test_wire.py
+++ b/tests/test_direct_api/test_wire.py
@@ -155,8 +155,10 @@ class TestWire(unittest.TestCase):
t4 = o.trim(0.5, 0.75)
self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
- with self.assertRaises(ValueError):
- o.trim(0.75, 0.25)
+ w0 = Polyline((0, 0), (0, 1), (1, 1), (1, 0))
+ w2 = w0.trim(0, (0.5, 1))
+ self.assertAlmostEqual(w2 @ 1, (0.5, 1), 5)
+
spline = Spline(
(0, 0, 0),
(0, 10, 0),