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