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/NOTICE b/NOTICE
new file mode 100644
index 0000000..2082252
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,15 @@
+build123d
+Copyright (c) 2022–2025 The build123d Contributors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at:
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+-------------------------------------------------------------------------------
+
+This project was originally derived from portions of the CadQuery codebase
+(https://github.com/CadQuery/cadquery) but has since been extensively
+refactored and restructured into an independent system.
+CadQuery is licensed under the Apache License, Version 2.0.
diff --git a/README.md b/README.md
index 818210d..8b17f25 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,17 @@
[](https://doi.org/10.5281/zenodo.14872322)
-Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks.
+Build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to popular CAD tools such as [FreeCAD] and SolidWorks.
-Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc.
+Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers:
+- Minimal or no internal state depending on mode,
+- Explicit 1D, 2D, and 3D geometry classes with well-defined operations,
+- Extensibility through subclassing and functional composition—no monkey patching,
+- Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints,
+- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)),
+- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic.
+
+The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath.
The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html).
@@ -62,6 +70,10 @@ python3 -m pip install -e .
Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html).
+Attribution:
+
+Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system.
+
[BREP]: https://en.wikipedia.org/wiki/Boundary_representation
[CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html
[FreeCAD]: https://www.freecad.org/
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/index.rst b/docs/index.rst
index 0af6014..8c0c70e 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -29,68 +29,54 @@
:align: center
:alt: build123d logo
-Build123d is a python-based, parametric, boundary representation (BREP) modeling
-framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and
-allows for the creation of complex models using a simple and intuitive python
-syntax. Build123d can be used to create models for 3D printing, CNC machining,
-laser cutting, and other manufacturing processes. Models can be exported to a
-wide variety of popular CAD tools such as FreeCAD and SolidWorks.
-
-Build123d could be considered as an evolution of
-`CadQuery `_ where the
-somewhat restrictive Fluent API (method chaining) is replaced with stateful
-context managers - i.e. `with` blocks - thus enabling the full python toolbox:
-for loops, references to objects, object sorting and filtering, etc.
-
-Note that this documentation is available in
-`pdf `_ and
-`epub `_ formats
-for reference while offline.
-
########
-Overview
+About
########
-build123d uses the standard python context manager - i.e. the ``with`` statement often used when
-working with files - as a builder of the object under construction. Once the object is complete
-it can be extracted from the builders and used in other ways: for example exported as a STEP
-file or used in an Assembly. There are three builders available:
+Build123d is a Python-based, parametric (BREP) modeling framework for 2D and 3D CAD.
+Built on the Open Cascade geometric kernel, it provides a clean, fully Pythonic interface
+for creating precise models suitable for 3D printing, CNC machining, laser cutting, and
+other manufacturing processes. Models can be exported to popular CAD tools such as FreeCAD
+and SolidWorks.
-* **BuildLine**: a builder of one dimensional objects - those with the property
- of length but not of area or volume - typically used
- to create complex lines used in sketches or paths.
-* **BuildSketch**: a builder of planar two dimensional objects - those with the property
- of area but not of volume - typically used to create 2D drawings that are extruded into 3D parts.
-* **BuildPart**: a builder of three dimensional objects - those with the property of volume -
- used to create individual parts.
+Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with
+expressive, algebraic modeling. It offers:
-The three builders work together in a hierarchy as follows:
+* Minimal or no internal state depending on mode
+* Explicit 1D, 2D, and 3D geometry classes with well-defined operations
+* Extensibility through subclassing and functional composition—no monkey patching
+* Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints
+* Deep Python integration—selectors as lists, locations as iterables, and natural
+ conversions (``Solid(shell)``, ``tuple(Vector)``)
+* Operator-driven modeling (``obj += sub_obj``, ``Plane.XZ * Pos(X=5) * Rectangle(1, 1)``)
+ for algebraic, readable, and composable design logic
-.. code-block:: python
+The result is a framework that feels native to Python while providing the full power of
+OpenCascade geometry underneath.
- with BuildPart() as my_part:
- ...
- with BuildSketch() as my_sketch:
- ...
- with BuildLine() as my_line:
- ...
- ...
- ...
-where ``my_line`` will be added to ``my_sketch`` once the line is complete and ``my_sketch`` will be
-added to ``my_part`` once the sketch is complete.
+With build123d, intricate parametric models can be created in just a few lines of readable
+Python code—as demonstrated by the tea cup example below.
-As an example, consider the design of a tea cup:
+.. dropdown:: Teacup Example
-.. literalinclude:: ../examples/tea_cup.py
- :start-after: [Code]
- :end-before: [End]
+ .. literalinclude:: ../examples/tea_cup.py
+ :start-after: [Code]
+ :end-before: [End]
.. raw:: html
+.. note::
+
+
+ This documentation is available in
+ `pdf `_ and
+ `epub `_ formats
+ for reference while offline.
+
.. note::
There is a `Discord `_ server (shared with CadQuery) where
diff --git a/docs/objects.rst b/docs/objects.rst
index 0cff926..26c1fe8 100644
--- a/docs/objects.rst
+++ b/docs/objects.rst
@@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that
.. grid:: 3
+ .. grid-item-card:: :class:`~objects_curve.Airfoil`
+
+ .. image:: assets/example_airfoil.svg
+
+ +++
+ Airfoil described by 4 digit NACA profile
+
.. grid-item-card:: :class:`~objects_curve.Bezier`
.. image:: assets/bezier_curve_example.svg
@@ -228,6 +235,7 @@ Reference
.. py:module:: objects_curve
.. autoclass:: BaseLineObject
+.. autoclass:: Airfoil
.. autoclass:: Bezier
.. autoclass:: BlendCurve
.. autoclass:: CenterArc
diff --git a/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 08ed253..4d8fca0 100644
--- a/docs/tutorial_surface_modeling.rst
+++ b/docs/tutorial_surface_modeling.rst
@@ -1,156 +1,55 @@
-################
+#################
Surface Modeling
-################
+#################
-Surface modeling is employed to create objects with non-planar surfaces that can't be
-generated using functions like :func:`~operations_part.extrude`,
-:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no
-specific builders designed to assist with the creation of non-planar surfaces or objects,
-the following should be considered a more advanced technique.
-As described in the `topology_` section, a BREP model consists of vertices, edges, faces,
-and other elements that define the boundary of an object. When creating objects with
-non-planar faces, it is often more convenient to explicitly create the boundary faces of
-the object. To illustrate this process, we will create the following game token:
+Surface modeling refers to the direct creation and manipulation of the skin of a 3D
+object—its bounding faces—rather than starting from volumetric primitives or solid
+operations.
-.. raw:: html
+Instead of defining a shape by extruding or revolving a 2D profile to fill a volume,
+surface modeling focuses on building the individual curved or planar faces that together
+define the outer boundary of a part. This approach allows for precise control of complex
+freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that
+cannot easily be expressed with simple parametric solids.
-
-
+In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling,
+all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and
+vertices. Each face represents a finite patch of a geometric surface (plane, cylinder,
+Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share
+edges consistently and close into a continuous boundary, they form a manifold
+:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly
+oriented and encloses a finite region of space, the model becomes a solid.
-There are several methods of the :class:`~topology.Face` class that can be used to create
-non-planar surfaces:
+Surface modeling therefore operates at the most fundamental level of BREP construction.
+Rather than relying on higher-level modeling operations to implicitly generate faces,
+it allows you to construct and connect those faces explicitly. This provides a path to
+build geometry that blends analytical and freeform shapes seamlessly, with full control
+over continuity, tangency, and curvature across boundaries.
-* :meth:`~topology.Face.make_bezier_surface`,
-* :meth:`~topology.Face.make_surface`, and
-* :meth:`~topology.Face.make_surface_from_array_of_points`.
+This section provides:
+- A concise overview of surface‑building tools in build123d
+- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces
-In this case, we'll use the ``make_surface`` method, providing it with the edges that define
-the perimeter of the surface and a central point on that surface.
+.. rubric:: Available surface methods
-To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is
-symmetric, we'll only create half of its surface here:
+Methods on :class:`~topology.Face` for creating non‑planar surfaces:
-.. code-block:: python
-
- with BuildLine() as heart_half:
- l1 = JernArc((0, 0), (1, 1.4), 40, -17)
- l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175)
- l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20)))
- l4 = ThreePointArc(l3 @ 1, Vector(0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0)
-
-Note that ``l4`` is not in the same plane as the other lines; it defines the center line
-of the heart and archs up off ``Plane.XY``.
-
-.. image:: ./assets/token_heart_perimeter.png
- :align: center
- :alt: token perimeter
-
-In preparation for creating the surface, we'll define a point on the surface:
-
-.. code-block:: python
-
- surface_pnt = l2.edge().arc_center + Vector(0, 0, 1.5)
-
-We will then use this point to create a non-planar ``Face``:
-
-.. code-block:: python
-
- top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate(
- Pos(Z=0.5)
- )
-
-.. image:: ./assets/token_half_surface.png
- :align: center
- :alt: token perimeter
-
-Note that the surface was raised up by 0.5 using the locate method. Also, note that
-the ``-`` in front of ``Face`` simply flips the face normal so that the colored side
-is up, which isn't necessary but helps with viewing.
-
-Now that one half of the top of the heart has been created, the remainder of the top
-and bottom can be created by mirroring:
-
-.. code-block:: python
-
- top_left_surface = top_right_surface.mirror(Plane.YZ)
- bottom_right_surface = top_right_surface.mirror(Plane.XY)
- bottom_left_surface = -top_left_surface.mirror(Plane.XY)
-
-The sides of the heart are going to be created by extruding the outside of the perimeter
-as follows:
-
-.. code-block:: python
-
- left_wire = Wire([l3.edge(), l2.edge(), l1.edge()])
- left_side = Face.extrude(left_wire, (0, 0, 1)).locate(Pos(Z=-0.5))
- right_side = left_side.mirror(Plane.YZ)
-
-.. image:: ./assets/token_sides.png
- :align: center
- :alt: token sides
-
-With the top, bottom, and sides, the complete boundary of the object is defined. We can
-now put them together, first into a :class:`~topology.Shell` and then into a
-:class:`~topology.Solid`:
-
-.. code-block:: python
-
- heart = Solid(
- Shell(
- [
- top_right_surface,
- top_left_surface,
- bottom_right_surface,
- bottom_left_surface,
- left_side,
- right_side,
- ]
- )
- )
-
-.. image:: ./assets/token_heart_solid.png
- :align: center
- :alt: token heart solid
+* :meth:`~topology.Face.make_bezier_surface`
+* :meth:`~topology.Face.make_gordon_surface`
+* :meth:`~topology.Face.make_surface`
+* :meth:`~topology.Face.make_surface_from_array_of_points`
+* :meth:`~topology.Face.make_surface_from_curves`
+* :meth:`~topology.Face.make_surface_patch`
.. note::
- When creating a Solid from a Shell, the Shell must be "water-tight," meaning it
- should have no holes. For objects with complex Edges, it's best practice to reuse
- Edges in adjoining Faces whenever possible to avoid slight mismatches that can
- create openings.
+ Surface modeling is an advanced technique. Robust results usually come from
+ reusing the same :class:`~topology.Edge` objects across adjacent faces and
+ ensuring the final :class:`~topology.Shell` is *water‑tight* or *manifold* (no gaps).
-Finally, we'll create the frame around the heart as a simple extrusion of a planar
-shape defined by the perimeter of the heart and merge all of the components together:
+.. toctree::
+ :maxdepth: 1
- .. code-block:: python
+ tutorial_surface_heart_token.rst
+ tutorial_spitfire_wing_gordon.rst
- with BuildPart() as heart_token:
- with BuildSketch() as outline:
- with BuildLine():
- add(l1)
- add(l2)
- add(l3)
- Line(l3 @ 1, l1 @ 0)
- make_face()
- mirror(about=Plane.YZ)
- center = outline.sketch
- offset(amount=2, kind=Kind.INTERSECTION)
- add(center, mode=Mode.SUBTRACT)
- extrude(amount=2, both=True)
- add(heart)
-
-Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face``
-can be created. The :func:`~operations_generic.offset` function defines the outside of
-the frame as a constant distance from the heart itself.
-
-Summary
--------
-
-In this tutorial, we've explored surface modeling techniques to create a non-planar
-heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face`
-class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and
-central point of the surface. We then assembled the complete boundary of the object
-by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell`
-and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart
-using the :func:`~operations_generic.offset` function to maintain a constant distance
-from the heart.
\ No newline at end of file
diff --git a/examples/tea_cup.py b/examples/tea_cup.py
index 866ee1f..8bc8ed6 100644
--- a/examples/tea_cup.py
+++ b/examples/tea_cup.py
@@ -4,19 +4,19 @@ name: tea_cup.py
by: Gumyr
date: March 27th 2023
-desc: This example demonstrates the creation a tea cup, which serves as an example of
+desc: This example demonstrates the creation a tea cup, which serves as an example of
constructing complex, non-flat geometrical shapes programmatically.
The tea cup model involves several CAD techniques, such as:
- - Revolve Operations: There is 1 occurrence of a revolve operation. This is used
- to create the main body of the tea cup by revolving a profile around an axis,
+ - Revolve Operations: There is 1 occurrence of a revolve operation. This is used
+ to create the main body of the tea cup by revolving a profile around an axis,
a common technique for generating symmetrical objects like cups.
- Sweep Operations: There are 2 occurrences of sweep operations. The handle are
created by sweeping a profile along a path to generate non-planar surfaces.
- Offset/Shell Operations: the bowl of the cup is hollowed out with the offset
- operation leaving the top open.
- - Fillet Operations: There is 1 occurrence of a fillet operation which is used to
- round the edges for aesthetic improvement and to mimic real-world objects more
+ operation leaving the top open.
+ - Fillet Operations: There is 1 occurrence of a fillet operation which is used to
+ round the edges for aesthetic improvement and to mimic real-world objects more
closely.
license:
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 6d52b40..2dcf0b0 100644
--- a/src/build123d/__init__.py
+++ b/src/build123d/__init__.py
@@ -81,6 +81,7 @@ __all__ = [
"BuildSketch",
# 1D Curve Objects
"BaseLineObject",
+ "Airfoil",
"Bezier",
"BlendCurve",
"CenterArc",
diff --git a/src/build123d/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..262f9cf 100644
--- a/src/build123d/objects_curve.py
+++ b/src/build123d/objects_curve.py
@@ -29,11 +29,14 @@ license:
from __future__ import annotations
import copy as copy_module
+import warnings
+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 +103,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
@@ -1237,6 +1363,12 @@ class PointArcTangentLine(BaseEdgeObject):
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
+ warnings.warn(
+ "The 'PointArcTangentLine' object is deprecated and will be removed in a future version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
_applies_to = [BuildLine._tag]
def __init__(
@@ -1316,6 +1448,12 @@ class PointArcTangentArc(BaseEdgeObject):
RuntimeError: No tangent arc found
"""
+ warnings.warn(
+ "The 'PointArcTangentArc' object is deprecated and will be removed in a future version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
_applies_to = [BuildLine._tag]
def __init__(
@@ -1459,6 +1597,11 @@ class ArcArcTangentLine(BaseEdgeObject):
Defaults to Keep.INSIDE
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
+ warnings.warn(
+ "The 'ArcArcTangentLine' object is deprecated and will be removed in a future version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
_applies_to = [BuildLine._tag]
@@ -1560,6 +1703,12 @@ class ArcArcTangentArc(BaseEdgeObject):
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
+ warnings.warn(
+ "The 'ArcArcTangentArc' object is deprecated and will be removed in a future version.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
_applies_to = [BuildLine._tag]
def __init__(
diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py
index 624ee37..9c316b6 100644
--- a/src/build123d/topology/constrained_lines.py
+++ b/src/build123d/topology/constrained_lines.py
@@ -29,53 +29,55 @@ license:
from __future__ import annotations
-from math import floor, pi
-from typing import TYPE_CHECKING, Callable, TypeVar
+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
+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
+from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve
from OCP.Geom2dGcc import (
Geom2dGcc_Circ2d2TanOn,
- Geom2dGcc_Circ2d2TanOnGeo,
Geom2dGcc_Circ2d2TanRad,
Geom2dGcc_Circ2d3Tan,
Geom2dGcc_Circ2dTanCen,
Geom2dGcc_Circ2dTanOnRad,
- Geom2dGcc_Circ2dTanOnRadGeo,
+ Geom2dGcc_Lin2dTanObl,
+ Geom2dGcc_Lin2d2Tan,
Geom2dGcc_QualifiedCurve,
)
-from OCP.GeomAbs import GeomAbs_CurveType
-from OCP.GeomAPI import GeomAPI, GeomAPI_ProjectPointOnCurve
+from OCP.GeomAPI import GeomAPI
from OCP.gp import (
gp_Ax2d,
gp_Ax3,
gp_Circ2d,
gp_Dir,
gp_Dir2d,
+ gp_Lin2d,
gp_Pln,
gp_Pnt,
gp_Pnt2d,
)
+from OCP.IntAna2d import IntAna2d_AnaIntersection
from OCP.Standard import Standard_ConstructionError, Standard_Failure
-from OCP.TopoDS import TopoDS_Edge
+from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex
from build123d.build_enums import Sagitta, Tangency
-from build123d.geometry import TOLERANCE, Vector, VectorLike
+from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike
from .zero_d import Vertex
-from .shape_core import ShapeList, downcast
+from .shape_core import ShapeList
if TYPE_CHECKING:
from build123d.topology.one_d import Edge # pragma: no cover
@@ -117,22 +119,16 @@ def _edge_to_qualified_2d(
"""Convert a TopoDS_Edge into 2d curve & extract properties"""
# 1) Underlying curve + range (also retrieve location to be safe)
- loc = edge.Location()
hcurve3d = BRep_Tool.Curve_s(edge, float(), float())
first, last = BRep_Tool.Range_s(edge)
- # 2) Apply location if the edge is positioned by a TopLoc_Location
- if not loc.IsIdentity():
- trsf = loc.Transformation()
- hcurve3d = tcast(Geom_Curve, hcurve3d.Transformed(trsf))
-
- # 3) Convert to 2D on Plane.XY (Z-up frame at origin)
+ # 2) Convert to 2D on Plane.XY (Z-up frame at origin)
hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve
- # 4) Wrap in an adaptor using the same parametric range
+ # 3) Wrap in an adaptor using the same parametric range
adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last)
- # 5) Create the qualified curve (unqualified is fine here)
+ # 4) Create the qualified curve (unqualified is fine here)
qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value)
return qcurve, hcurve2d, first, last, adapt2d
@@ -153,6 +149,18 @@ def _param_in_trim(
return (u >= first - TOLERANCE) and (u <= last + TOLERANCE)
+@overload
+def _as_gcc_arg(
+ obj: Edge, constaint: Tangency
+) -> tuple[
+ Geom2dGcc_QualifiedCurve, Geom2d_Curve | None, float | None, float | None, bool
+]: ...
+@overload
+def _as_gcc_arg(
+ obj: Vector, constaint: Tangency
+) -> tuple[Geom2d_CartesianPoint, None, None, None, bool]: ...
+
+
def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[
Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint,
Geom2d_Curve | None,
@@ -201,6 +209,41 @@ def _two_arc_edges_from_params(
return [minor, major]
+def _edge_from_line(
+ p1: gp_Pnt2d,
+ p2: gp_Pnt2d,
+) -> TopoDS_Edge:
+ """
+ Build a finite Edge from two 2D contact points.
+
+ Parameters
+ ----------
+ p1, p2 : gp_Pnt2d
+ Endpoints of the line segment (in 2D).
+ edge_factory : type[Edge], optional
+ Factory for building the Edge subtype (defaults to Edge).
+
+ Returns
+ -------
+ TopoDS_Edge
+ Finite line segment between the two points.
+ """
+ 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:
@@ -646,3 +689,134 @@ def _make_tan_on_rad_arcs(
out_topos.append(_edge_from_circle(h2d, 0.0, per))
return ShapeList([edge_factory(e) for e in out_topos])
+
+
+# -----------------------------------------------------------------------------
+# Line solvers (siblings of constrained arcs)
+# -----------------------------------------------------------------------------
+
+
+def _make_2tan_lines(
+ 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 31baf33..25b817a 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -56,7 +56,7 @@ import numpy as np
import warnings
from collections.abc import Iterable
from itertools import combinations
-from math import ceil, copysign, cos, floor, inf, isclose, pi, radians
+from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians
from typing import TYPE_CHECKING, Literal, TypeAlias, overload
from typing import cast as tcast
@@ -240,6 +240,8 @@ from .constrained_lines import (
_make_3tan_arcs,
_make_tan_cen_arcs,
_make_tan_on_rad_arcs,
+ _make_tan_oriented_lines,
+ _make_2tan_lines,
)
if TYPE_CHECKING: # pragma: no cover
@@ -356,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__(
@@ -792,6 +809,8 @@ class Mixin1D(Shape):
case Edge() as obj, Plane() as plane:
# Find any edge / plane intersection points & edges
# Find point intersections
+ if obj.wrapped is None:
+ continue
geom_line = BRep_Tool.Curve_s(
obj.wrapped, obj.param_at(0), obj.param_at(1)
)
@@ -818,10 +837,13 @@ class Mixin1D(Shape):
vts = common_set.vertices()
eds = common_set.edges()
if vts and eds:
- filtered_vts = ShapeList([
- v for v in vts
- if all(v.distance_to(e) > TOLERANCE for e in eds)
- ])
+ filtered_vts = ShapeList(
+ [
+ v
+ for v in vts
+ if all(v.distance_to(e) > TOLERANCE for e in eds)
+ ]
+ )
common_set = filtered_vts + eds
else:
return None
@@ -1958,6 +1980,157 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
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,
@@ -2814,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")
@@ -2842,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,
@@ -2852,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:
@@ -2871,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
@@ -2882,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
@@ -3392,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]
@@ -3908,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/two_d.py b/src/build123d/topology/two_d.py
index 621062b..862184b 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -64,6 +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_Curve, BRepAdaptor_Surface
from OCP.BRepAlgo import BRepAlgo
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section
from OCP.BRepBuilderAPI import (
@@ -80,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,
@@ -98,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 (
@@ -1029,6 +1041,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/utils.py b/src/build123d/topology/utils.py
index c1bbb1e..dbccc80 100644
--- a/src/build123d/topology/utils.py
+++ b/src/build123d/topology/utils.py
@@ -263,7 +263,10 @@ def _make_topods_face_from_wires(
for inner_wire in inner_wires:
if not BRep_Tool.IsClosed_s(inner_wire):
raise ValueError("Cannot build face(s): inner wire is not closed")
- face_builder.Add(inner_wire)
+ sf_s = ShapeFix_Shape(inner_wire)
+ sf_s.Perform()
+ fixed_inner_wire = TopoDS.Wire_s(sf_s.Shape())
+ face_builder.Add(fixed_inner_wire)
face_builder.Build()
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_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_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),