From 335f82d740fa43c11794d2171b452279b212f5de Mon Sep 17 00:00:00 2001 From: Paul Korzhyk Date: Thu, 28 Aug 2025 20:19:06 +0300 Subject: [PATCH 001/105] Add Mixin1D.discretize --- src/build123d/topology/one_d.py | 32 +++++++++++++++++++++++++- tests/test_direct_api/test_mixin1_d.py | 5 ++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index ff75d64..5df771a 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -91,7 +91,11 @@ 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.GCPnts import GCPnts_AbscissaPoint +from OCP.GCPnts import ( + GCPnts_AbscissaPoint, + GCPnts_QuasiUniformDeflection, + GCPnts_UniformDeflection, +) from OCP.GProp import GProp_GProps from OCP.Geom import ( Geom_BezierCurve, @@ -484,6 +488,32 @@ class Mixin1D(Shape): return result + def discretize(self, deflection: float = 0.1, quasi=True) -> list[Vector]: + """Discretize the shape into a list of points""" + if self.wrapped is None: + raise ValueError("Cannot discretize an empty shape") + curve = self.geom_adaptor() + if quasi: + discretizer = GCPnts_QuasiUniformDeflection() + else: + discretizer = GCPnts_UniformDeflection() + discretizer.Initialize( + curve, + deflection, + curve.FirstParameter(), + curve.LastParameter(), + ) + + assert discretizer.IsDone() + + return [ + Vector(v.X(), v.Y(), v.Z()) + for v in ( + curve.Value(discretizer.Parameter(i)) + for i in range(1, discretizer.NbPoints() + 1) + ) + ] + def edge(self) -> Edge | None: """Return the Edge""" return Shape.get_single_shape(self, "Edge") diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py index df8f2d4..8e3c61e 100644 --- a/tests/test_direct_api/test_mixin1_d.py +++ b/tests/test_direct_api/test_mixin1_d.py @@ -354,6 +354,11 @@ class TestMixin1D(unittest.TestCase): self.assertAlmostEqual(common.z_dir.Y, 0, 5) self.assertAlmostEqual(common.z_dir.Z, 0, 5) + def test_discretize(self): + edge = Edge.make_circle(2, start_angle=0, end_angle=180) + points = edge.discretize(0.1) + self.assertEqual(len(points), 6) + def test_edge_volume(self): edge = Edge.make_line((0, 0), (1, 1)) self.assertAlmostEqual(edge.volume, 0, 5) From 3d8bbcc539f3fbe6335fbfb461c269dd3ee65212 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 9 Sep 2025 23:21:05 -0400 Subject: [PATCH 002/105] Add basic b123d lexer and change pygments style --- docs/OpenSCAD.rst | 4 +- docs/advantages.rst | 9 +-- docs/algebra_performance.rst | 6 +- docs/assemblies.rst | 8 ++- docs/build123d_lexer.py | 75 ++++++++++++++++++++ docs/build_line.rst | 8 +++ docs/build_part.rst | 2 + docs/build_sketch.rst | 8 ++- docs/conf.py | 2 + docs/debugging_logging.rst | 2 +- docs/examples_1.rst | 33 +++++++++ docs/import_export.rst | 8 +-- docs/index.rst | 3 +- docs/introductory_examples.rst | 76 ++++++++++++++++++++- docs/joints.rst | 7 +- docs/key_concepts_algebra.rst | 26 +++---- docs/key_concepts_builder.rst | 32 ++++----- docs/location_arithmetic.rst | 16 ++--- docs/moving_objects.rst | 24 +++---- docs/objects.rst | 9 +-- docs/operations.rst | 4 +- docs/selectors.rst | 4 +- docs/tech_drawing_tutorial.rst | 14 ++-- docs/tips.rst | 12 ++-- docs/topology_selection.rst | 24 +++---- docs/topology_selection/filter_examples.rst | 28 ++++---- docs/topology_selection/group_examples.rst | 18 ++--- docs/topology_selection/sort_examples.rst | 22 +++--- docs/tttt.rst | 13 ++++ docs/tutorial_design.rst | 48 ++++++------- docs/tutorial_joints.rst | 15 ++++ docs/tutorial_lego.rst | 11 +++ docs/tutorial_selectors.rst | 8 ++- docs/tutorial_surface_modeling.rst | 14 ++-- 34 files changed, 421 insertions(+), 172 deletions(-) create mode 100644 docs/build123d_lexer.py diff --git a/docs/OpenSCAD.rst b/docs/OpenSCAD.rst index 899cee9..3c64382 100644 --- a/docs/OpenSCAD.rst +++ b/docs/OpenSCAD.rst @@ -124,7 +124,7 @@ build123d of a piece of angle iron: **build123d Approach** -.. code-block:: python +.. code-block:: build123d # Builder mode with BuildPart() as angle_iron: @@ -135,7 +135,7 @@ build123d of a piece of angle iron: fillet(angle_iron.edges().filter_by(lambda e: e.is_interior), 5 * MM) -.. code-block:: python +.. code-block:: build123d # Algebra mode profile = Rectangle(3 * CM, 4 * MM, align=Align.MIN) diff --git a/docs/advantages.rst b/docs/advantages.rst index 51a6ac0..15f2b7e 100644 --- a/docs/advantages.rst +++ b/docs/advantages.rst @@ -20,7 +20,7 @@ python context manager. ... ) -.. code-block:: python +.. code-block:: build123d # build123d API with BuildPart() as pillow_block: @@ -43,7 +43,7 @@ Each object and operation is now a class instantiation that interacts with the active context implicitly for the user. These instantiations can be assigned to an instance variable as with standard python programming for direct use. -.. code-block:: python +.. code-block:: build123d with BuildSketch() as plan: r = Rectangle(width, height) @@ -62,7 +62,7 @@ with tangents equal to the tangents of l5 and l6 at their end and beginning resp Being able to extract information from existing features allows the user to "snap" new features to these points without knowing their numeric values. -.. code-block:: python +.. code-block:: build123d with BuildLine() as outline: ... @@ -81,6 +81,7 @@ by the last operation and fillets them. Such a selection would be quite difficul otherwise. .. literalinclude:: ../examples/intersecting_pipes.py + :language: build123d :lines: 30, 39-49 @@ -104,7 +105,7 @@ sorting which opens up the full functionality of python lists. To aid the user, common operations have been optimized as shown here along with a fully custom selection: -.. code-block:: python +.. code-block:: build123d top = rail.faces().filter_by(Axis.Z)[-1] ... diff --git a/docs/algebra_performance.rst b/docs/algebra_performance.rst index 3ec9a20..12784c3 100644 --- a/docs/algebra_performance.rst +++ b/docs/algebra_performance.rst @@ -7,7 +7,7 @@ Creating lots of Shapes in a loop means for every step ``fuse`` and ``clean`` wi In an example like the below, both functions get slower and slower the more objects are already fused. Overall it takes on an M1 Mac 4.76 sec. -.. code-block:: python +.. code-block:: build123d diam = 80 holes = Sketch() @@ -22,7 +22,7 @@ already fused. Overall it takes on an M1 Mac 4.76 sec. One way to avoid it is to use lazy evaluation for the algebra operations. Just collect all objects and then call ``fuse`` (``+``) once with all objects and ``clean`` once. Overall it takes 0.19 sec. -.. code-block:: python +.. code-block:: build123d r = Rectangle(2, 2) holes = [ @@ -36,7 +36,7 @@ then call ``fuse`` (``+``) once with all objects and ``clean`` once. Overall it Another way to leverage the vectorized algebra operations is to add a list comprehension of objects to an empty ``Part``, ``Sketch`` or ``Curve``: -.. code-block:: python +.. code-block:: build123d polygons = Sketch() + [ loc * RegularPolygon(radius=5, side_count=5) diff --git a/docs/assemblies.rst b/docs/assemblies.rst index 8889c28..4fe1ada 100644 --- a/docs/assemblies.rst +++ b/docs/assemblies.rst @@ -22,6 +22,7 @@ Here we'll assign labels to all of the components that will be part of the box assembly: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Add labels] :end-before: [Create assembly] @@ -36,6 +37,7 @@ Creation of the assembly is done by simply creating a :class:`~topology.Compound appropriate ``parent`` and ``children`` attributes as shown here: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Create assembly] :end-before: [Display assembly] @@ -43,6 +45,7 @@ To display the topology of an assembly :class:`~topology.Compound`, the :meth:`~ method can be used as follows: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Display assembly] :end-before: [Add to the assembly by assigning the parent attribute of an object] @@ -59,6 +62,7 @@ which results in: To add to an assembly :class:`~topology.Compound` one can change either ``children`` or ``parent`` attributes. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Add to the assembly by assigning the parent attribute of an object] :end-before: [Check that the components in the assembly don't intersect] @@ -180,7 +184,7 @@ Compare this to assembly3_volume which only results in the volume of the top lev assembly2 = Compound(label='Assembly2', children=[assembly1, Box(1, 1, 1)]) assembly3 = Compound(label='Assembly3', children=[assembly2, Box(1, 1, 1)]) total_volume = sum(part.volume for part in assembly3.solids()) # 3 - assembly3_volume = assembly3.volume # 1 + assembly3_volume = assembly3.volume # 1 ****** pack @@ -269,6 +273,6 @@ If you place the arranged objects into a ``Compound``, you can easily determine # [bounding box] print(Compound(xy_pack).bounding_box()) # bbox: 0.0 <= x <= 159.0, 0.0 <= y <= 129.0, -54.0 <= z <= 100.0 - + print(Compound(z_pack).bounding_box()) # bbox: 0.0 <= x <= 159.0, 0.0 <= y <= 129.0, 0.0 <= z <= 100.0 diff --git a/docs/build123d_lexer.py b/docs/build123d_lexer.py new file mode 100644 index 0000000..f01e58f --- /dev/null +++ b/docs/build123d_lexer.py @@ -0,0 +1,75 @@ +import inspect +import enum +import sys +import os +from pygments.lexers.python import PythonLexer +from pygments.token import Name +from sphinx.highlighting import lexers + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) +import build123d + + +class Build123dLexer(PythonLexer): + """ + Python lexer extended with Build123d-specific highlighting. + Dynamically pulls symbols from build123d.__all__. + """ + + EXTRA_SYMBOLS = set(getattr(build123d, "__all__", [])) + + EXTRA_CLASSES = { + n for n in EXTRA_SYMBOLS + if n[0].isupper() + } + + EXTRA_CONSTANTS = { + n for n in EXTRA_SYMBOLS + if n.isupper() and not callable(getattr(build123d, n, None)) + } + + EXTRA_ENUMS = { + n for n in EXTRA_SYMBOLS + if inspect.isclass(getattr(build123d, n, None)) and issubclass(getattr(build123d, n), enum.Enum) + } + + EXTRA_FUNCTIONS = EXTRA_SYMBOLS - EXTRA_CLASSES - EXTRA_CONSTANTS - EXTRA_ENUMS + + def get_tokens_unprocessed(self, text): + """ + Yield tokens, highlighting Build123d symbols, including chained accesses. + """ + + dot_chain = False + for index, token, value in super().get_tokens_unprocessed(text): + if value == ".": + dot_chain = True + yield index, token, value + continue + + if dot_chain: + # In a chain, don't use top-level categories + if value[0].isupper(): + yield index, Name.Class, value + elif value.isupper(): + yield index, Name.Constant, value + else: + yield index, Name.Function, value + dot_chain = False + continue + + # Top-level classification from __all__ + if value in self.EXTRA_CLASSES: + yield index, Name.Class, value + elif value in self.EXTRA_FUNCTIONS: + yield index, Name.Function, value + elif value in self.EXTRA_CONSTANTS: + yield index, Name.Constant, value + elif value in self.EXTRA_ENUMS: + yield index, Name.Builtin, value + else: + yield index, token, value + +def setup(app): + lexers["build123d"] = Build123dLexer() + return {"version": "0.1"} \ No newline at end of file diff --git a/docs/build_line.rst b/docs/build_line.rst index 70c7f2a..f2f0f93 100644 --- a/docs/build_line.rst +++ b/docs/build_line.rst @@ -15,6 +15,7 @@ Basic Functionality The following is a simple BuildLine example: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 1] :end-before: [Ex. 1] @@ -50,6 +51,7 @@ point ``(0,0)`` and ``(2,0)``. This can be improved upon by specifying constraints that lock the arc to those two end points, as follows: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 2] :end-before: [Ex. 2] @@ -63,6 +65,7 @@ This example can be improved on further by calculating the mid-point of the arc as follows: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 3] :end-before: [Ex. 3] @@ -73,6 +76,7 @@ To make the design even more parametric, the height of the arc can be calculated from ``l1`` as follows: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 4] :end-before: [Ex. 4] @@ -87,6 +91,7 @@ The other operator that is commonly used within BuildLine is ``%`` the tangent a operator. Here is another example: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 5] :end-before: [Ex. 5] @@ -124,6 +129,7 @@ Here is an example of using BuildLine to create an object that otherwise might b difficult to create: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 6] :end-before: [Ex. 6] @@ -155,6 +161,7 @@ The other primary reasons to use BuildLine is to create paths for BuildPart define a path: .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 7] :end-before: [Ex. 7] @@ -184,6 +191,7 @@ to global coordinates. Sometimes it's convenient to work on another plane, espec creating paths for BuildPart ``Sweep`` operations. .. literalinclude:: objects_1d.py + :language: build123d :start-after: [Ex. 8] :end-before: [Ex. 8] diff --git a/docs/build_part.rst b/docs/build_part.rst index 6ea9d11..d5206c8 100644 --- a/docs/build_part.rst +++ b/docs/build_part.rst @@ -15,6 +15,7 @@ Basic Functionality The following is a simple BuildPart example: .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 2] :end-before: [Ex. 2] @@ -52,6 +53,7 @@ This tea cup example uses implicit parameters - note the :func:`~operations_gene operation on the last line: .. literalinclude:: ../examples/tea_cup.py + :language: build123d :start-after: [Code] :end-before: [End] :emphasize-lines: 52 diff --git a/docs/build_sketch.rst b/docs/build_sketch.rst index 803dcb7..76ed6f7 100644 --- a/docs/build_sketch.rst +++ b/docs/build_sketch.rst @@ -16,6 +16,7 @@ Basic Functionality The following is a simple BuildSketch example: .. literalinclude:: objects_2d.py + :language: build123d :start-after: [Ex. 13] :end-before: [Ex. 13] @@ -61,6 +62,7 @@ As an example, let's build the following simple control box with a display on an Here is the code: .. literalinclude:: objects_2d.py + :language: build123d :start-after: [Ex. 14] :end-before: [Ex. 14] :emphasize-lines: 14-25 @@ -88,14 +90,14 @@ on ``Plane.XY`` which one can see by looking at the ``sketch_local`` property of sketch. For example, to display the local version of the ``display`` sketch from above, one would use: -.. code-block:: python +.. code-block:: build123d show_object(display.sketch_local, name="sketch on Plane.XY") while the sketches as applied to their target workplanes is accessible through the ``sketch`` property, as follows: -.. code-block:: python +.. code-block:: build123d show_object(display.sketch, name="sketch on target workplane(s)") @@ -106,7 +108,7 @@ that the new Face may not be oriented as expected. To reorient the Face manually to ``Plane.XY`` one can use the :meth:`~geometry.to_local_coords` method as follows: -.. code-block:: python +.. code-block:: build123d reoriented_face = plane.to_local_coords(face) diff --git a/docs/conf.py b/docs/conf.py index 5ba9cea..ff46e9f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,7 @@ extensions = [ "sphinx_design", "sphinx_copybutton", "hoverxref.extension", + "build123d_lexer" ] # Napoleon settings @@ -99,6 +100,7 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # # html_theme = "alabaster" html_theme = "sphinx_rtd_theme" +pygments_style = "colorful" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/debugging_logging.rst b/docs/debugging_logging.rst index 3e27acd..eb24d68 100644 --- a/docs/debugging_logging.rst +++ b/docs/debugging_logging.rst @@ -85,7 +85,7 @@ Sometimes the best debugging aid is just placing a print statement in your code. of the build123d classes are setup to provide useful information beyond their class and location in memory, as follows: -.. code-block:: python +.. code-block:: build123d plane = Plane.XY.offset(1) print(f"{plane=}") diff --git a/docs/examples_1.rst b/docs/examples_1.rst index 7da07de..32cc77d 100644 --- a/docs/examples_1.rst +++ b/docs/examples_1.rst @@ -164,6 +164,7 @@ modify it by replacing chimney with a BREP version. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/benchy.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -184,6 +185,7 @@ surface. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/bicycle_tire.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -204,12 +206,14 @@ The builder mode example also generates the SVG file `logo.svg`. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/build123d_logo.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/build123d_logo_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -228,6 +232,7 @@ using the `draft` operation to add appropriate draft angles for mold release. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/cast_bearing_unit.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -257,12 +262,14 @@ This example also demonstrates building complex lines that snap to existing feat .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/canadian_flag.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/canadian_flag_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -293,12 +300,14 @@ This example demonstrates placing holes around a part. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/circuit_board.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/circuit_board_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -313,12 +322,14 @@ Clock Face .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/clock.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/clock_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -340,6 +351,7 @@ Fast Grid Holes .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/fast_grid_holes.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -367,12 +379,14 @@ Handle .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/handle.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/handle_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -388,12 +402,14 @@ Heat Exchanger .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/heat_exchanger.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/heat_exchanger_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -412,12 +428,14 @@ Key Cap .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/key_cap.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/key_cap_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -444,6 +462,7 @@ YouTube channel. There are two key features: .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/maker_coin.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -462,12 +481,14 @@ the top and bottom by type, and shelling. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/loft.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/loft_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -488,6 +509,7 @@ to aid 3D printing. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/pegboard_j_hook.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -495,6 +517,7 @@ to aid 3D printing. .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/pegboard_j_hook_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -521,6 +544,7 @@ embodying ideals of symmetry and balance. .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/platonic_solids.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -539,6 +563,7 @@ imported as code from an SVG file and modified to the code found here. .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/playing_cards.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -558,6 +583,7 @@ are used to position all of objects. .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/stud_wall.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -571,12 +597,14 @@ Tea Cup .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/tea_cup.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/tea_cup_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -610,6 +638,7 @@ Toy Truck .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/toy_truck.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -630,12 +659,14 @@ Vase .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/vase.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/vase_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -677,11 +708,13 @@ selecting edges by position range and type for the application of fillets .. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/boxes_on_faces.py + :language: build123d :start-after: [Code] :end-before: [End] .. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/boxes_on_faces_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] diff --git a/docs/import_export.rst b/docs/import_export.rst index 73b26b2..53e935f 100644 --- a/docs/import_export.rst +++ b/docs/import_export.rst @@ -6,7 +6,7 @@ Methods and functions specific to exporting and importing build123d objects are For example: -.. code-block:: python +.. code-block:: build123d with BuildPart() as box_builder: Box(1, 1, 1) @@ -142,7 +142,7 @@ The shapes generated from the above steps are to be added as shapes in one of the exporters described below and written as either a DXF or SVG file as shown in this example: -.. code-block:: python +.. code-block:: build123d view_port_origin=(-100, -50, 30) visible, hidden = part.project_to_viewport(view_port_origin) @@ -222,7 +222,7 @@ more complex API than the simple Shape exporters. For example: -.. code-block:: python +.. code-block:: build123d # Create the shapes and assign attributes blue_shape = Solid.make_cone(20, 0, 50) @@ -276,7 +276,7 @@ Both 3MF and STL import (and export) are provided with the :class:`~mesher.Meshe For example: -.. code-block:: python +.. code-block:: build123d importer = Mesher() cone, cyl = importer.read("example.3mf") diff --git a/docs/index.rst b/docs/index.rst index 0af6014..08f3299 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -66,7 +66,7 @@ file or used in an Assembly. There are three builders available: The three builders work together in a hierarchy as follows: -.. code-block:: python +.. code-block:: build123d with BuildPart() as my_part: ... @@ -83,6 +83,7 @@ added to ``my_part`` once the sketch is complete. As an example, consider the design of a tea cup: .. literalinclude:: ../examples/tea_cup.py + :language: build123d :start-after: [Code] :end-before: [End] diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index 1399a80..a887bb9 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -36,12 +36,14 @@ Just about the simplest possible example, a rectangular :class:`~objects_part.Bo * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 1] :end-before: [Ex. 1] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 1] :end-before: [Ex. 1] @@ -63,6 +65,7 @@ A rectangular box, but with a hole added. from the :class:`~objects_part.Box`. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 2] :end-before: [Ex. 2] @@ -73,6 +76,7 @@ A rectangular box, but with a hole added. from the :class:`~objects_part.Box`. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 2] :end-before: [Ex. 2] @@ -94,6 +98,7 @@ Build a prismatic solid using extrusion. and then use :class:`~build_part.BuildPart`'s :meth:`~operations_part.extrude` feature. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 3] :end-before: [Ex. 3] @@ -103,6 +108,7 @@ Build a prismatic solid using extrusion. :class:`~objects_sketch.Rectangle`` and then use the :meth:`~operations_part.extrude` operation for parts. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 3] :end-before: [Ex. 3] @@ -126,6 +132,7 @@ variables for the line segments, but it will be useful in a later example. from :class:`~build_line.BuildLine` into a closed Face. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 4] :end-before: [Ex. 4] @@ -138,6 +145,7 @@ variables for the line segments, but it will be useful in a later example. segments into a Face. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 4] :end-before: [Ex. 4] @@ -158,6 +166,7 @@ Note that to build a closed face it requires line segments that form a closed sh at one (or multiple) places. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 5] :end-before: [Ex. 5] @@ -168,6 +177,7 @@ Note that to build a closed face it requires line segments that form a closed sh (with :class:`geometry.Rot`) would rotate the object. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 5] :end-before: [Ex. 5] @@ -188,6 +198,7 @@ Sometimes you need to create a number of features at various You can use a list of points to construct multiple objects at once. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 6] :end-before: [Ex. 6] @@ -200,6 +211,7 @@ Sometimes you need to create a number of features at various is short for ``obj - obj1 - obj2 - ob3`` (and more efficient, see :ref:`algebra_performance`). .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 6] :end-before: [Ex. 6] @@ -218,6 +230,7 @@ Sometimes you need to create a number of features at various you would like. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 7] :end-before: [Ex. 7] @@ -227,6 +240,7 @@ Sometimes you need to create a number of features at various for each location via loops or list comprehensions. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 7] :end-before: [Ex. 7] @@ -247,12 +261,14 @@ create the final profile. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 8] :end-before: [Ex. 8] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 8] :end-before: [Ex. 8] @@ -273,12 +289,14 @@ edges, you could simply pass in ``ex9.edges()``. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 9] :end-before: [Ex. 9] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 9] :end-before: [Ex. 9] @@ -303,6 +321,7 @@ be the highest z-dimension group. makes use of :class:`~objects_part.Hole` which automatically cuts through the entire part. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 10] :end-before: [Ex. 10] @@ -314,6 +333,7 @@ be the highest z-dimension group. of :class:`~objects_part.Hole`. Different to the *context mode*, you have to add the ``depth`` of the whole. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 10] :end-before: [Ex. 10] @@ -339,6 +359,7 @@ be the highest z-dimension group. cut these from the parent. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 11] :end-before: [Ex. 11] @@ -355,6 +376,7 @@ be the highest z-dimension group. parent. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 11] :end-before: [Ex. 11] @@ -376,12 +398,14 @@ edge that needs a complex profile. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 12] :end-before: [Ex. 12] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 12] :end-before: [Ex. 12] @@ -401,6 +425,7 @@ Counter-sink and counter-bore holes are useful for creating recessed areas for f We use a face to establish a location for :class:`~build_common.Locations`. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 13] :end-before: [Ex. 13] @@ -410,6 +435,7 @@ Counter-sink and counter-bore holes are useful for creating recessed areas for f onto this plane. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 13] :end-before: [Ex. 13] @@ -417,7 +443,7 @@ Counter-sink and counter-bore holes are useful for creating recessed areas for f .. _ex 14: -14. Position on a line with '\@', '\%' and introduce Sweep +1. Position on a line with '\@', '\%' and introduce Sweep ------------------------------------------------------------ build123d includes a feature for finding the position along a line segment. This @@ -437,9 +463,10 @@ path, please see example 37 for a way to make this placement easier. The :meth:`~operations_generic.sweep` method takes any pending faces and sweeps them through the provided path (in this case the path is taken from the pending edges from ``ex14_ln``). - :meth:`~operations_part.revolve` requires a single connected wire. + :meth:`~operations_part.revolve` requires a single connected wire. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 14] :end-before: [Ex. 14] @@ -449,6 +476,7 @@ path, please see example 37 for a way to make this placement easier. path (in this case the path is taken from ``ex14_ln``). .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 14] :end-before: [Ex. 14] @@ -471,6 +499,7 @@ Additionally the '@' operator is used to simplify the line segment commands. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 15] :end-before: [Ex. 15] @@ -479,6 +508,7 @@ Additionally the '@' operator is used to simplify the line segment commands. Combine lines via the pattern ``Curve() + [l1, l2, l3, l4, l5]`` .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 15] :end-before: [Ex. 15] @@ -496,12 +526,14 @@ The ``Plane.offset()`` method shifts the plane in the normal direction (positive * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 16] :end-before: [Ex. 16] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 16] :end-before: [Ex. 16] @@ -520,12 +552,14 @@ Here we select the farthest face in the Y-direction and turn it into a :class:`~ * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 17] :end-before: [Ex. 17] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 17] :end-before: [Ex. 17] @@ -546,6 +580,7 @@ with a negative distance. We then use ``Mode.SUBTRACT`` to cut it out from the main body. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 18] :end-before: [Ex. 18] @@ -554,6 +589,7 @@ with a negative distance. We then use ``-=`` to cut it out from the main body. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 18] :end-before: [Ex. 18] @@ -578,6 +614,7 @@ this custom Axis. :class:`~build_common.Locations` then the part would be offset from the workplane by the vertex z-position. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 19] :end-before: [Ex. 19] @@ -588,6 +625,7 @@ this custom Axis. :class:`~geometry.Pos` then the part would be offset from the workplane by the vertex z-position. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 19] :end-before: [Ex. 19] @@ -606,12 +644,14 @@ negative x-direction. The resulting Plane is offset from the original position. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 20] :end-before: [Ex. 20] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 20] :end-before: [Ex. 20] @@ -630,12 +670,14 @@ positioning another cylinder perpendicular and halfway along the first. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 21] :end-before: [Ex. 21] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 21] :end-before: [Ex. 21] @@ -656,6 +698,7 @@ example. Use the :meth:`~geometry.Plane.rotated` method to rotate the workplane. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 22] :end-before: [Ex. 22] @@ -664,6 +707,7 @@ example. Use the operator ``*`` to relocate the plane (post-multiplication!). .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 22] :end-before: [Ex. 22] @@ -690,12 +734,14 @@ It is highly recommended to view your sketch before you attempt to call revolve. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 23] :end-before: [Ex. 23] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 23] :end-before: [Ex. 23] @@ -716,12 +762,14 @@ Loft can behave unexpectedly when the input faces are not parallel to each other * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 24] :end-before: [Ex. 24] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 24] :end-before: [Ex. 24] @@ -739,6 +787,7 @@ Loft can behave unexpectedly when the input faces are not parallel to each other BuildSketch faces can be transformed with a 2D :meth:`~operations_generic.offset`. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 25] :end-before: [Ex. 25] @@ -747,6 +796,7 @@ Loft can behave unexpectedly when the input faces are not parallel to each other Sketch faces can be transformed with a 2D :meth:`~operations_generic.offset`. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 25] :end-before: [Ex. 25] @@ -772,12 +822,14 @@ Note that self intersecting edges and/or faces can break both 2D and 3D offsets. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 26] :end-before: [Ex. 26] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 26] :end-before: [Ex. 26] @@ -796,12 +848,14 @@ a face and offset half the width of the box. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 27] :end-before: [Ex. 27] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 27] :end-before: [Ex. 27] @@ -820,6 +874,7 @@ a face and offset half the width of the box. use the faces of this object to cut holes in a sphere. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 28] :end-before: [Ex. 28] @@ -828,6 +883,7 @@ a face and offset half the width of the box. We create a triangular prism and then later use the faces of this object to cut holes in a sphere. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 28] :end-before: [Ex. 28] @@ -849,12 +905,14 @@ the bottle opening. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 29] :end-before: [Ex. 29] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 29] :end-before: [Ex. 29] @@ -874,12 +932,14 @@ create a closed line that is made into a face and extruded. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 30] :end-before: [Ex. 30] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 30] :end-before: [Ex. 30] @@ -899,12 +959,14 @@ rotates any "children" groups by default. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 31] :end-before: [Ex. 31] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 31] :end-before: [Ex. 31] @@ -927,12 +989,14 @@ separate calls to :meth:`~operations_part.extrude`. adding these faces until the for-loop. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 32] :end-before: [Ex. 32] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 32] :end-before: [Ex. 32] @@ -954,6 +1018,7 @@ progressively modify the size of each square. The function returns a :class:`~build_sketch.BuildSketch`. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 33] :end-before: [Ex. 33] @@ -962,6 +1027,7 @@ progressively modify the size of each square. The function returns a ``Sketch`` object. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 33] :end-before: [Ex. 33] @@ -983,6 +1049,7 @@ progressively modify the size of each square. the 2nd "World" text on the top of the "Hello" text. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 34] :end-before: [Ex. 34] @@ -993,6 +1060,7 @@ progressively modify the size of each square. the ``topf`` variable to select the same face and deboss (indented) the text "World". .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 34] :end-before: [Ex. 34] @@ -1012,6 +1080,7 @@ progressively modify the size of each square. arc for two instances of :class:`~objects_sketch.SlotArc`. .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 35] :end-before: [Ex. 35] @@ -1021,6 +1090,7 @@ progressively modify the size of each square. a :class:`~objects_curve.RadiusArc` to create an arc for two instances of :class:`~operations_sketch.SlotArc`. .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 35] :end-before: [Ex. 35] @@ -1041,11 +1111,13 @@ with ``Until.NEXT`` or ``Until.LAST``. * **Builder mode** .. literalinclude:: general_examples.py + :language: build123d :start-after: [Ex. 36] :end-before: [Ex. 36] * **Algebra mode** .. literalinclude:: general_examples_algebra.py + :language: build123d :start-after: [Ex. 36] :end-before: [Ex. 36] diff --git a/docs/joints.rst b/docs/joints.rst index 07cc623..3b117b0 100644 --- a/docs/joints.rst +++ b/docs/joints.rst @@ -46,14 +46,14 @@ A rigid joint positions two components relative to each another with no freedom and a ``joint_location`` which defines both the position and orientation of the joint (see :class:`~geometry.Location`) - as follows: -.. code-block:: python +.. code-block:: build123d RigidJoint(label="outlet", to_part=pipe, joint_location=path.location_at(1)) Once a joint is bound to a part this way, the :meth:`~topology.Joint.connect_to` method can be used to repositioning another part relative to ``self`` which stay fixed - as follows: -.. code-block:: python +.. code-block:: build123d pipe.joints["outlet"].connect_to(flange_outlet.joints["pipe"]) @@ -70,6 +70,7 @@ flanges are attached to the ends of a curved pipe: .. image:: assets/rigid_joints_pipe.png .. literalinclude:: rigid_joints_pipe.py + :language: build123d :emphasize-lines: 19-20, 23-24 Note how the locations of the joints are determined by the :meth:`~topology.Mixin1D.location_at` method @@ -132,6 +133,7 @@ Component moves along a single axis as with a sliding latch shown here: The code to generate these components follows: .. literalinclude:: slide_latch.py + :language: build123d :emphasize-lines: 30, 52, 55 .. image:: assets/joint-latch.png @@ -193,6 +195,7 @@ is found within a rod end as shown here: .. image:: assets/rod_end.png .. literalinclude:: rod_end.py + :language: build123d :emphasize-lines: 40-44,51,53 Note how limits are defined during the instantiation of the ball joint when ensures that the pin or bolt diff --git a/docs/key_concepts_algebra.rst b/docs/key_concepts_algebra.rst index 655b6c7..76f876a 100644 --- a/docs/key_concepts_algebra.rst +++ b/docs/key_concepts_algebra.rst @@ -12,26 +12,26 @@ Object arithmetic - Creating a box and a cylinder centered at ``(0, 0, 0)`` - .. code-block:: python + .. code-block:: build123d b = Box(1, 2, 3) c = Cylinder(0.2, 5) - Fusing a box and a cylinder - .. code-block:: python + .. code-block:: build123d r = Box(1, 2, 3) + Cylinder(0.2, 5) - Cutting a cylinder from a box - .. code-block:: python + .. code-block:: build123d r = Box(1, 2, 3) - Cylinder(0.2, 5) - Intersecting a box and a cylinder - .. code-block:: python + .. code-block:: build123d r = Box(1, 2, 3) & Cylinder(0.2, 5) @@ -54,7 +54,7 @@ The generic forms of object placement are: 1. Placement on ``plane`` or at ``location`` relative to XY plane: - .. code-block:: python + .. code-block:: build123d plane * alg_compound location * alg_compound @@ -62,7 +62,7 @@ The generic forms of object placement are: 2. Placement on the ``plane`` and then moved relative to the ``plane`` by ``location`` (the location is relative to the local coordinate system of the plane). - .. code-block:: python + .. code-block:: build123d plane * location * alg_compound @@ -73,7 +73,7 @@ Examples: - Box on the ``XY`` plane, centered at `(0, 0, 0)` (both forms are equivalent): - .. code-block:: python + .. code-block:: build123d Plane.XY * Box(1, 2, 3) @@ -84,7 +84,7 @@ Examples: - Box on the ``XY`` plane centered at `(0, 1, 0)` (all three are equivalent): - .. code-block:: python + .. code-block:: build123d Plane.XY * Pos(0, 1, 0) * Box(1, 2, 3) @@ -96,21 +96,21 @@ Examples: - Box on plane ``Plane.XZ``: - .. code-block:: python + .. code-block:: build123d Plane.XZ * Box(1, 2, 3) - Box on plane ``Plane.XZ`` with a location ``(X=1, Y=2, Z=3)`` relative to the ``XZ`` plane, i.e., using the x-, y- and z-axis of the ``XZ`` plane: - .. code-block:: python + .. code-block:: build123d Plane.XZ * Pos(1, 2, 3) * Box(1, 2, 3) - Box on plane ``Plane.XZ`` moved to ``(X=1, Y=2, Z=3)`` relative to this plane and rotated there by the angles `(X=0, Y=100, Z=45)` around ``Plane.XZ`` axes: - .. code-block:: python + .. code-block:: build123d Plane.XZ * Pos(1, 2, 3) * Rot(0, 100, 45) * Box(1, 2, 3) @@ -121,7 +121,7 @@ Examples: - Box on plane ``Plane.XZ`` rotated on this plane by the angles ``(X=0, Y=100, Z=45)`` (using the x-, y- and z-axis of the ``XZ`` plane) and then moved to ``(X=1, Y=2, Z=3)`` relative to the ``XZ`` plane: - .. code-block:: python + .. code-block:: build123d Plane.XZ * Rot(0, 100, 45) * Pos(0,1,2) * Box(1, 2, 3) @@ -131,7 +131,7 @@ Combing both concepts **Object arithmetic** and **Placement at locations** can be combined: - .. code-block:: python + .. code-block:: build123d b = Plane.XZ * Rot(X=30) * Box(1, 2, 3) + Plane.YZ * Pos(X=-1) * Cylinder(0.2, 5) diff --git a/docs/key_concepts_builder.rst b/docs/key_concepts_builder.rst index 20370f3..076882d 100644 --- a/docs/key_concepts_builder.rst +++ b/docs/key_concepts_builder.rst @@ -61,7 +61,7 @@ Example Workflow Here is an example of using a Builder to create a simple part: -.. code-block:: python +.. code-block:: build123d from build123d import * @@ -117,21 +117,21 @@ class for further processing. One can access the objects created by these builders by referencing the appropriate instance variable. For example: -.. code-block:: python +.. code-block:: build123d with BuildPart() as my_part: ... show_object(my_part.part) -.. code-block:: python +.. code-block:: build123d with BuildSketch() as my_sketch: ... show_object(my_sketch.sketch) -.. code-block:: python +.. code-block:: build123d with BuildLine() as my_line: ... @@ -144,7 +144,7 @@ Implicit Builder Instance Variables One might expect to have to reference a builder's instance variable when using objects or operations that impact that builder like this: -.. code-block:: python +.. code-block:: build123d with BuildPart() as part_builder: Box(part_builder, 10,10,10) @@ -153,7 +153,7 @@ Instead, build123d determines from the scope of the object or operation which builder it applies to thus eliminating the need for the user to provide this information - as follows: -.. code-block:: python +.. code-block:: build123d with BuildPart() as part_builder: Box(10,10,10) @@ -175,7 +175,7 @@ be generated on any plane which allows users to put a workplane where they are w and then work in local 2D coordinate space. -.. code-block:: python +.. code-block:: build123d with BuildPart(Plane.XY) as example: ... # a 3D-part @@ -199,7 +199,7 @@ One is not limited to a single workplane at a time. In the following example all faces of the first box are used to define workplanes which are then used to position rotated boxes. -.. code-block:: python +.. code-block:: build123d import build123d as bd @@ -223,7 +223,7 @@ When positioning objects or operations within a builder Location Contexts are us function in a very similar was to the builders in that they create a context where one or more locations are active within a scope. For example: -.. code-block:: python +.. code-block:: build123d with BuildPart(): with Locations((0,10),(0,-10)): @@ -244,7 +244,7 @@ its scope - much as the hour and minute indicator on an analogue clock. Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be nested. It's easy for a user to retrieve the global locations: -.. code-block:: python +.. code-block:: build123d with Locations(Plane.XY, Plane.XZ): locs = GridLocations(1, 1, 2, 2) @@ -271,7 +271,7 @@ an iterable of objects is often required (often a ShapeList). Here is the definition of :meth:`~operations_generic.fillet` to help illustrate: -.. code-block:: python +.. code-block:: build123d def fillet( objects: Union[Union[Edge, Vertex], Iterable[Union[Edge, Vertex]]], @@ -281,7 +281,7 @@ Here is the definition of :meth:`~operations_generic.fillet` to help illustrate: To use this fillet operation, an edge or vertex or iterable of edges or vertices must be provided followed by a fillet radius with or without the keyword as follows: -.. code-block:: python +.. code-block:: build123d with BuildPart() as pipes: Box(10, 10, 10, rotation=(10, 20, 30)) @@ -297,7 +297,7 @@ Combination Modes Almost all objects or operations have a ``mode`` parameter which is defined by the ``Mode`` Enum class as follows: -.. code-block:: python +.. code-block:: build123d class Mode(Enum): ADD = auto() @@ -329,7 +329,7 @@ build123d stores points (to be specific ``Location`` (s)) internally to be used positions for the placement of new objects. By default, a single location will be created at the origin of the given workplane such that: -.. code-block:: python +.. code-block:: build123d with BuildPart() as pipes: Box(10, 10, 10, rotation=(10, 20, 30)) @@ -338,7 +338,7 @@ will create a single 10x10x10 box centered at (0,0,0) - by default objects are centered. One can create multiple objects by pushing points prior to creating objects as follows: -.. code-block:: python +.. code-block:: build123d with BuildPart() as pipes: with Locations((-10, -10, -10), (10, 10, 10)): @@ -370,7 +370,7 @@ Builder's Pending Objects When a builder exits, it will push the object created back to its parent if there was one. Here is an example: -.. code-block:: python +.. code-block:: build123d height, width, thickness, f_rad = 60, 80, 20, 10 diff --git a/docs/location_arithmetic.rst b/docs/location_arithmetic.rst index f26834e..d5c4b0e 100644 --- a/docs/location_arithmetic.rst +++ b/docs/location_arithmetic.rst @@ -9,7 +9,7 @@ Position a shape relative to the XY plane For the following use the helper function: -.. code-block:: python +.. code-block:: build123d def location_symbol(location: Location, scale: float = 1) -> Compound: return Compound.make_triad(axes_scale=scale).locate(location) @@ -22,7 +22,7 @@ For the following use the helper function: 1. **Positioning at a location** - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) @@ -35,7 +35,7 @@ For the following use the helper function: 2) **Positioning on a plane** - .. code-block:: python + .. code-block:: build123d plane = Plane.XZ @@ -54,7 +54,7 @@ Relative positioning to a plane 1. **Position an object on a plane relative to the plane** - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) @@ -77,7 +77,7 @@ Relative positioning to a plane 2. **Rotate an object on a plane relative to the plane** - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) @@ -96,7 +96,7 @@ Relative positioning to a plane More general: - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) @@ -114,7 +114,7 @@ Relative positioning to a plane 3. **Rotate and position an object relative to a location** - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) @@ -133,7 +133,7 @@ Relative positioning to a plane 4. **Position and rotate an object relative to a location** - .. code-block:: python + .. code-block:: build123d loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) diff --git a/docs/moving_objects.rst b/docs/moving_objects.rst index 088973c..b36dde4 100644 --- a/docs/moving_objects.rst +++ b/docs/moving_objects.rst @@ -22,7 +22,7 @@ construction process. The following tools are commonly used to specify locations Example: -.. code-block:: python +.. code-block:: build123d with Locations((10, 20, 30)): Box(5, 5, 5) @@ -42,7 +42,7 @@ an existing one. Example: -.. code-block:: python +.. code-block:: build123d rotated_box = Rotation(45, 0, 0) * box @@ -55,13 +55,13 @@ Position ^^^^^^^^ - **Absolute Position:** Set the position directly. -.. code-block:: python +.. code-block:: build123d shape.position = (x, y, z) - **Relative Position:** Adjust the position incrementally. -.. code-block:: python +.. code-block:: build123d shape.position += (x, y, z) shape.position -= (x, y, z) @@ -71,13 +71,13 @@ Orientation ^^^^^^^^^^^ - **Absolute Orientation:** Set the orientation directly. -.. code-block:: python +.. code-block:: build123d shape.orientation = (X, Y, Z) - **Relative Orientation:** Adjust the orientation incrementally. -.. code-block:: python +.. code-block:: build123d shape.orientation += (X, Y, Z) shape.orientation -= (X, Y, Z) @@ -86,25 +86,25 @@ Movement Methods ^^^^^^^^^^^^^^^^ - **Relative Move:** -.. code-block:: python +.. code-block:: build123d shape.move(Location) - **Relative Move of Copy:** -.. code-block:: python +.. code-block:: build123d relocated_shape = shape.moved(Location) - **Absolute Move:** -.. code-block:: python +.. code-block:: build123d shape.locate(Location) - **Absolute Move of Copy:** -.. code-block:: python +.. code-block:: build123d relocated_shape = shape.located(Location) @@ -119,12 +119,12 @@ Transformation a.k.a. Translation and Rotation - **Translation:** Move a shape relative to its current position. -.. code-block:: python +.. code-block:: build123d relocated_shape = shape.translate(x, y, z) - **Rotation:** Rotate a shape around a specified axis by a given angle. -.. code-block:: python +.. code-block:: build123d rotated_shape = shape.rotate(Axis, angle_in_degrees) diff --git a/docs/objects.rst b/docs/objects.rst index 0cff926..395d81d 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -7,7 +7,7 @@ For example, a :class:`~objects_part.Torus` is defined by a major and minor radi Builder mode, objects are positioned with ``Locations`` while in Algebra mode, objects are positioned with the ``*`` operator and shown in these examples: -.. code-block:: python +.. code-block:: build123d with BuildPart() as disk: with BuildSketch(): @@ -18,7 +18,7 @@ are positioned with the ``*`` operator and shown in these examples: Circle(d, mode=Mode.SUBTRACT) extrude(amount=c) -.. code-block:: python +.. code-block:: build123d sketch = Circle(a) - Pos(b, 0.0) * Rectangle(c, c) - Pos(0.0, b) * Circle(d) disk = extrude(sketch, c) @@ -36,7 +36,7 @@ right or left of each Axis. The following diagram shows how this alignment works For example: -.. code-block:: python +.. code-block:: build123d with BuildSketch(): Circle(1, align=(Align.MIN, Align.MIN)) @@ -49,7 +49,7 @@ In 3D the ``align`` parameter also contains a Z align value but otherwise works Note that the ``align`` will also accept a single ``Align`` value which will be used on all axes - as shown here: -.. code-block:: python +.. code-block:: build123d with BuildSketch(): Circle(1, align=Align.MIN) @@ -511,6 +511,7 @@ Here is an example of a custom sketch object specially created as part of the de this playing card storage box (:download:`see the playing_cards.py example <../examples/playing_cards.py>`): .. literalinclude:: ../examples/playing_cards.py + :language: build123d :start-after: [Club] :end-before: [Club] diff --git a/docs/operations.rst b/docs/operations.rst index e7532b6..8dedac9 100644 --- a/docs/operations.rst +++ b/docs/operations.rst @@ -6,14 +6,14 @@ Operations are functions that take objects as inputs and transform them into new Here are a couple ways to use :func:`~operations_part.extrude`, in Builder and Algebra mode: -.. code-block:: python +.. code-block:: build123d with BuildPart() as cylinder: with BuildSketch(): Circle(radius) extrude(amount=height) -.. code-block:: python +.. code-block:: build123d cylinder = extrude(Circle(radius), amount=height) diff --git a/docs/selectors.rst b/docs/selectors.rst index 189b367..ca41f9b 100644 --- a/docs/selectors.rst +++ b/docs/selectors.rst @@ -74,7 +74,7 @@ It is important to note that standard list methods such as `sorted` or `filtered be used to easily build complex selectors beyond what is available with the predefined sorts and filters. Here is an example of a custom filters: -.. code-block:: python +.. code-block:: build123d with BuildSketch() as din: ... @@ -88,7 +88,7 @@ The :meth:`~topology.ShapeList.filter_by` method can take lambda expressions as fluent chain of operations which enables integration of custom filters into a larger change of selectors as shown in this example: -.. code-block:: python +.. code-block:: build123d obj = Box(1, 1, 1) - Cylinder(0.2, 1) faces_with_holes = obj.faces().filter_by(lambda f: f.inner_wires()) diff --git a/docs/tech_drawing_tutorial.rst b/docs/tech_drawing_tutorial.rst index 227caab..b4f9db6 100644 --- a/docs/tech_drawing_tutorial.rst +++ b/docs/tech_drawing_tutorial.rst @@ -4,14 +4,14 @@ Technical Drawing Tutorial ########################## -This example demonstrates how to generate a standard technical drawing of a 3D part -using `build123d`. It creates orthographic and isometric views of a Nema 23 stepper +This example demonstrates how to generate a standard technical drawing of a 3D part +using `build123d`. It creates orthographic and isometric views of a Nema 23 stepper motor and exports the result as an SVG file suitable for printing or inspection. Overview -------- -A technical drawing represents a 3D object in 2D using a series of standardized views. +A technical drawing represents a 3D object in 2D using a series of standardized views. These include: - **Plan (Top View)** – as seen from directly above (Z-axis down) @@ -24,8 +24,8 @@ Each view is aligned to a position on the page and optionally scaled or annotate How It Works ------------ -The script uses the `project_to_viewport` method to project the 3D part geometry into 2D. -A helper function, `project_to_2d`, sets up the viewport (camera origin and up direction) +The script uses the `project_to_viewport` method to project the 3D part geometry into 2D. +A helper function, `project_to_2d`, sets up the viewport (camera origin and up direction) and places the result onto a virtual drawing sheet. The steps involved are: @@ -34,7 +34,7 @@ The steps involved are: 2. Define a `TechnicalDrawing` border and title block using A4 page size. 3. Generate each of the standard views and apply transformations to place them. 4. Add dimensions using `ExtensionLine` and labels using `Text`. -5. Export the drawing using `ExportSVG`, separating visible and hidden edges by layer +5. Export the drawing using `ExportSVG`, separating visible and hidden edges by layer and style. Result @@ -59,7 +59,7 @@ Code ---- .. literalinclude:: technical_drawing.py - :language: python + :language: build123d :start-after: [code] :end-before: [end] diff --git a/docs/tips.rst b/docs/tips.rst index ad5c299..2567088 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -92,7 +92,7 @@ consider a plate with four chamfered holes like this: When selecting edges to be chamfered one might first select the face that these edges belong to then select the edges as shown here: -.. code-block:: python +.. code-block:: build123d from build123d import * @@ -118,7 +118,7 @@ a common OpenCascade Python wrapper (`OCP `_) i interchange objects both from CadQuery to build123d and vice-versa by transferring the ``wrapped`` objects as follows (first from CadQuery to build123d): -.. code-block:: python +.. code-block:: build123d import build123d as b3d b3d_solid = b3d.Solid.make_box(1,1,1) @@ -129,7 +129,7 @@ objects as follows (first from CadQuery to build123d): Secondly, from build123d to CadQuery as follows: -.. code-block:: python +.. code-block:: build123d import build123d as b3d import cadquery as cq @@ -209,7 +209,7 @@ Why doesn't BuildSketch(Plane.XZ) work? When creating a sketch not on the default ``Plane.XY`` users may expect that they are drawing directly on the workplane / coordinate system provided. For example: -.. code-block:: python +.. code-block:: build123d with BuildSketch(Plane.XZ) as vertical_sketch: Rectangle(1, 1) @@ -229,7 +229,7 @@ Why does ``BuildSketch`` work this way? Consider an example where the user wants plane not aligned with any Axis, as follows (this is often done when creating a sketch on a ``Face`` of a 3D part but is simulated here by rotating a ``Plane``): -.. code-block:: python +.. code-block:: build123d with BuildSketch(Plane.YZ.rotated((123, 45, 6))) as custom_plane: Rectangle(1, 1, align=Align.MIN) @@ -251,7 +251,7 @@ Why is BuildLine not working as expected within the scope of BuildSketch? As described above, all sketching is done on a local ``Plane.XY``; however, the following is a common issue: -.. code-block:: python +.. code-block:: build123d with BuildSketch() as sketch: with BuildLine(Plane.XZ): diff --git a/docs/topology_selection.rst b/docs/topology_selection.rst index f1ef50e..694c75f 100644 --- a/docs/topology_selection.rst +++ b/docs/topology_selection.rst @@ -40,7 +40,7 @@ Overview Both shape objects and builder objects have access to selector methods to select all of a feature as long as they can contain the feature being selected. -.. code-block:: python +.. code-block:: build123d # In context with BuildSketch() as context: @@ -70,7 +70,7 @@ existed in the referenced object before the last operation, nor the modifying ob :class:`~build_enums.Select` as selector criteria is only valid for builder objects! - .. code-block:: python + .. code-block:: build123d # In context with BuildPart() as context: @@ -85,7 +85,7 @@ existed in the referenced object before the last operation, nor the modifying ob Create a simple part to demonstrate selectors. Select using the default criteria ``Select.ALL``. Specifying ``Select.ALL`` for the selector is not required. -.. code-block:: python +.. code-block:: build123d with BuildPart() as part: Box(5, 5, 1) @@ -107,7 +107,7 @@ Create a simple part to demonstrate selectors. Select using the default criteria Select features changed in the last operation with criteria ``Select.LAST``. -.. code-block:: python +.. code-block:: build123d with BuildPart() as part: Box(5, 5, 1) @@ -125,7 +125,7 @@ Select features changed in the last operation with criteria ``Select.LAST``. Select only new edges from the last operation with ``Select.NEW``. This option is only available for a ``ShapeList`` of edges! -.. code-block:: python +.. code-block:: build123d with BuildPart() as part: Box(5, 5, 1) @@ -142,7 +142,7 @@ This only returns new edges which are not reused from Box or Cylinder, in this c the objects `intersect`. But what happens if the objects don't intersect and all the edges are reused? -.. code-block:: python +.. code-block:: build123d with BuildPart() as part: Box(5, 5, 1, align=(Align.CENTER, Align.CENTER, Align.MAX)) @@ -164,7 +164,7 @@ only completely new edges created by the operation. Chamfer and fillet modify the current object, but do not have new edges via ``Select.NEW``. - .. code-block:: python + .. code-block:: build123d with BuildPart() as part: Box(5, 5, 1) @@ -187,7 +187,7 @@ another "combined" shape object and returns the edges new to the combined shape. ``new_edges`` is available both Algebra mode or Builder mode, but is necessary in Algebra Mode where ``Select.NEW`` is unavailable -.. code-block:: python +.. code-block:: build123d box = Box(5, 5, 1) circle = Cylinder(2, 5) @@ -200,7 +200,7 @@ Algebra Mode where ``Select.NEW`` is unavailable ``new_edges`` can also find edges created during a chamfer or fillet operation by comparing the object before the operation to the "combined" object. -.. code-block:: python +.. code-block:: build123d box = Box(5, 5, 1) circle = Cylinder(2, 5) @@ -263,7 +263,7 @@ Finally, the vertices can be captured with a list slice for the last 4 list item items are sorted from least to greatest ``X`` position. Remember, ``ShapeList`` is a subclass of ``list``, so any list slice can be used. -.. code-block:: python +.. code-block:: build123d part.vertices().sort_by(Axis.X)[-4:] @@ -320,7 +320,7 @@ group by ``SortBy.AREA``. The ``ShapeList`` of smallest faces is available from list index. Finally, a ``ShapeList`` has access to selectors, so calling |edges| will return a new list of all edges in the previous list. -.. code-block:: python +.. code-block:: build123d part.faces().group_by(SortBy.AREA)[0].edges()) @@ -368,7 +368,7 @@ might be with a list comprehension, however |filter_by| has the capability to ta lambda function as a filter condition on the entire list. In this case, the normal of each face can be checked against a vector direction and filtered accordingly. -.. code-block:: python +.. code-block:: build123d part.faces().filter_by(lambda f: f.normal_at() == Vector(0, 0, 1)) diff --git a/docs/topology_selection/filter_examples.rst b/docs/topology_selection/filter_examples.rst index f7233b8..0b0f3fc 100644 --- a/docs/topology_selection/filter_examples.rst +++ b/docs/topology_selection/filter_examples.rst @@ -18,11 +18,11 @@ operations, and are sometimes necessary e.g. before sorting or filtering by radi .. dropdown:: Setup .. literalinclude:: examples/filter_geomtype.py - :language: python + :language: build123d :lines: 3, 8-13 .. literalinclude:: examples/filter_geomtype.py - :language: python + :language: build123d :lines: 15 .. figure:: ../assets/topology_selection/filter_geomtype_line.png @@ -31,7 +31,7 @@ operations, and are sometimes necessary e.g. before sorting or filtering by radi | .. literalinclude:: examples/filter_geomtype.py - :language: python + :language: build123d :lines: 17 .. figure:: ../assets/topology_selection/filter_geomtype_cylinder.png @@ -52,11 +52,11 @@ circular edges selects the counterbore faces that meet the joint criteria. .. dropdown:: Setup .. literalinclude:: examples/filter_all_edges_circle.py - :language: python + :language: build123d :lines: 3, 8-41 .. literalinclude:: examples/filter_all_edges_circle.py - :language: python + :language: build123d :lines: 43-47 .. figure:: ../assets/topology_selection/filter_all_edges_circle.png @@ -74,14 +74,14 @@ Plane will select faces parallel to the plane. .. dropdown:: Setup - .. code-block:: python + .. code-block:: build123d from build123d import * with BuildPart() as part: Box(1, 1, 1) -.. code-block:: python +.. code-block:: build123d part.faces().filter_by(Axis.Z) part.faces().filter_by(Plane.XY) @@ -96,7 +96,7 @@ accomplish this with feature properties or methods. Here, we are looking for fac the dot product of face normal and either the axis direction or the plane normal is about to 0. The result is faces parallel to the axis or perpendicular to the plane. -.. code-block:: python +.. code-block:: build123d part.faces().filter_by(lambda f: abs(f.normal_at().dot(Axis.Z.direction) < 1e-6) part.faces().filter_by(lambda f: abs(f.normal_at().dot(Plane.XY.z_dir)) < 1e-6) @@ -122,11 +122,11 @@ and then filtering for the specific inner wire by radius. .. dropdown:: Setup .. literalinclude:: examples/filter_inner_wire_count.py - :language: python + :language: build123d :lines: 4, 9-16 .. literalinclude:: examples/filter_inner_wire_count.py - :language: python + :language: build123d :lines: 18-21 .. figure:: ../assets/topology_selection/filter_inner_wire_count.png @@ -140,7 +140,7 @@ axis and range. To do that we can filter for faces with 6 inner wires, sort for select the top face, and then filter for the circular edges of the inner wires. .. literalinclude:: examples/filter_inner_wire_count.py - :language: python + :language: build123d :lines: 25-32 .. figure:: ../assets/topology_selection/filter_inner_wire_count_linear.png @@ -163,11 +163,11 @@ any line edges. .. dropdown:: Setup .. literalinclude:: examples/filter_nested.py - :language: python + :language: build123d :lines: 4, 9-22 .. literalinclude:: examples/filter_nested.py - :language: python + :language: build123d :lines: 26-32 .. figure:: ../assets/topology_selection/filter_nested.png @@ -186,7 +186,7 @@ different fillets accordingly. Then the ``Face`` ``is_circular_*`` properties ar to highlight the resulting fillets. .. literalinclude:: examples/filter_shape_properties.py - :language: python + :language: build123d :lines: 3-4, 8-22 .. figure:: ../assets/topology_selection/filter_shape_properties.png diff --git a/docs/topology_selection/group_examples.rst b/docs/topology_selection/group_examples.rst index 3f0057b..d91de21 100644 --- a/docs/topology_selection/group_examples.rst +++ b/docs/topology_selection/group_examples.rst @@ -14,7 +14,7 @@ result knowing how many edges to expect. .. dropdown:: Setup .. literalinclude:: examples/group_axis.py - :language: python + :language: build123d :lines: 4, 9-17 .. figure:: ../assets/topology_selection/group_axis_without.png @@ -26,7 +26,7 @@ However, ``group_by`` can be used to first group all the edges by z-axis positio group again by length. In both cases, you can select the desired edges from the last group. .. literalinclude:: examples/group_axis.py - :language: python + :language: build123d :lines: 21-22 .. figure:: ../assets/topology_selection/group_axis_with.png @@ -46,11 +46,11 @@ with the largest hole. .. dropdown:: Setup .. literalinclude:: examples/group_hole_area.py - :language: python + :language: build123d :lines: 4, 9-17 .. literalinclude:: examples/group_hole_area.py - :language: python + :language: build123d :lines: 21-24 .. figure:: ../assets/topology_selection/group_hole_area.png @@ -72,11 +72,11 @@ then the desired groups are selected with the ``group`` method using the lengths .. dropdown:: Setup .. literalinclude:: examples/group_properties_with_keys.py - :language: python + :language: build123d :lines: 4, 9-26 .. literalinclude:: examples/group_properties_with_keys.py - :language: python + :language: build123d :lines: 30, 31 .. figure:: ../assets/topology_selection/group_length_key.png @@ -94,11 +94,11 @@ and then further specify only the edges the bearings and pins are installed from .. dropdown:: Adding holes .. literalinclude:: examples/group_properties_with_keys.py - :language: python + :language: build123d :lines: 35-43 .. literalinclude:: examples/group_properties_with_keys.py - :language: python + :language: build123d :lines: 47-50 .. figure:: ../assets/topology_selection/group_radius_key.png @@ -109,7 +109,7 @@ and then further specify only the edges the bearings and pins are installed from Note that ``group_by`` is not the only way to capture edges with a known property value! ``filter_by`` with a lambda expression can be used as well: -.. code-block:: python +.. code-block:: build123d radius_groups = part.edges().filter_by(GeomType.CIRCLE) bearing_edges = radius_groups.filter_by(lambda e: e.radius == 8) diff --git a/docs/topology_selection/sort_examples.rst b/docs/topology_selection/sort_examples.rst index a4779fc..ecdbf96 100644 --- a/docs/topology_selection/sort_examples.rst +++ b/docs/topology_selection/sort_examples.rst @@ -23,11 +23,11 @@ be used with``group_by``. .. dropdown:: Setup .. literalinclude:: examples/sort_sortby.py - :language: python + :language: build123d :lines: 3, 8-13 .. literalinclude:: examples/sort_sortby.py - :language: python + :language: build123d :lines: 19-22 .. figure:: ../assets/topology_selection/sort_sortby_length.png @@ -36,7 +36,7 @@ be used with``group_by``. | .. literalinclude:: examples/sort_sortby.py - :language: python + :language: build123d :lines: 24-27 .. figure:: ../assets/topology_selection/sort_sortby_distance.png @@ -57,11 +57,11 @@ the order is random. .. dropdown:: Setup .. literalinclude:: examples/sort_along_wire.py - :language: python + :language: build123d :lines: 3, 8-12 .. literalinclude:: examples/sort_along_wire.py - :language: python + :language: build123d :lines: 14-15 .. figure:: ../assets/topology_selection/sort_not_along_wire.png @@ -73,7 +73,7 @@ Vertices may be sorted along the wire they fall on to create order. Notice the f radii now increase in order. .. literalinclude:: examples/sort_along_wire.py - :language: python + :language: build123d :lines: 26-28 .. figure:: ../assets/topology_selection/sort_along_wire.png @@ -94,11 +94,11 @@ edge can be found sorting along y-axis. .. dropdown:: Setup .. literalinclude:: examples/sort_axis.py - :language: python + :language: build123d :lines: 4, 9-18 .. literalinclude:: examples/sort_axis.py - :language: python + :language: build123d :lines: 22-24 .. figure:: ../assets/topology_selection/sort_axis.png @@ -118,11 +118,11 @@ Here we are sorting the boxes by distance from the origin, using an empty ``Vert .. dropdown:: Setup .. literalinclude:: examples/sort_distance_from.py - :language: python + :language: build123d :lines: 2-5, 9-13 .. literalinclude:: examples/sort_distance_from.py - :language: python + :language: build123d :lines: 15-16 .. figure:: ../assets/topology_selection/sort_distance_from_origin.png @@ -135,7 +135,7 @@ property ``volume``, and getting the last (largest) box. Then, the boxes sorted their distance from the largest box. .. literalinclude:: examples/sort_distance_from.py - :language: python + :language: build123d :lines: 19-20 .. figure:: ../assets/topology_selection/sort_distance_from_largest.png diff --git a/docs/tttt.rst b/docs/tttt.rst index 8379142..1c1f75f 100644 --- a/docs/tttt.rst +++ b/docs/tttt.rst @@ -98,6 +98,7 @@ Party Pack 01-01 Bearing Bracket .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0101.py + :language: build123d .. _ttt-ppp0102: @@ -114,6 +115,7 @@ Party Pack 01-02 Post Cap .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0102.py + :language: build123d .. _ttt-ppp0103: @@ -129,6 +131,7 @@ Party Pack 01-03 C Clamp Base .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0103.py + :language: build123d .. _ttt-ppp0104: @@ -144,6 +147,7 @@ Party Pack 01-04 Angle Bracket .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0104.py + :language: build123d .. _ttt-ppp0105: @@ -159,6 +163,7 @@ Party Pack 01-05 Paste Sleeve .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0105.py + :language: build123d .. _ttt-ppp0106: @@ -174,6 +179,7 @@ Party Pack 01-06 Bearing Jig .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0106.py + :language: build123d .. _ttt-ppp0107: @@ -189,6 +195,7 @@ Party Pack 01-07 Flanged Hub .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0107.py + :language: build123d .. _ttt-ppp0108: @@ -204,6 +211,7 @@ Party Pack 01-08 Tie Plate .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0108.py + :language: build123d .. _ttt-ppp0109: @@ -219,6 +227,7 @@ Party Pack 01-09 Corner Tie .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0109.py + :language: build123d .. _ttt-ppp0110: @@ -234,6 +243,7 @@ Party Pack 01-10 Light Cap .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-ppp0110.py + :language: build123d .. _ttt-23-02-02-sm_hanger: @@ -249,6 +259,7 @@ Party Pack 01-10 Light Cap .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-23-02-02-sm_hanger.py + :language: build123d .. _ttt-23-t-24: @@ -265,6 +276,7 @@ Party Pack 01-10 Light Cap .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-23-t-24-curved_support.py + :language: build123d .. _ttt-24-spo-06: @@ -281,3 +293,4 @@ Party Pack 01-10 Light Cap .. dropdown:: Reference Implementation .. literalinclude:: assets/ttt/ttt-24-SPO-06-Buffer_Stand.py + :language: build123d diff --git a/docs/tutorial_design.rst b/docs/tutorial_design.rst index 3950399..f16afbc 100644 --- a/docs/tutorial_design.rst +++ b/docs/tutorial_design.rst @@ -4,8 +4,8 @@ Designing a Part in build123d ############################# -Designing a part with build123d involves a systematic approach that leverages the power -of 2D profiles, extrusions, and revolutions. Where possible, always work in the lowest +Designing a part with build123d involves a systematic approach that leverages the power +of 2D profiles, extrusions, and revolutions. Where possible, always work in the lowest possible dimension, 1D lines before 2D sketches before 3D parts. The following guide will get you started: @@ -18,8 +18,8 @@ get you started: Step 1. Examine the Part in All Three Orientations ************************************************** -Start by visualizing the part from the front, top, and side views. Identify any symmetries -in these orientations, as symmetries can simplify the design by reducing the number of +Start by visualizing the part from the front, top, and side views. Identify any symmetries +in these orientations, as symmetries can simplify the design by reducing the number of unique features you need to model. *In the following view of the bracket one can see two planes of symmetry @@ -31,8 +31,8 @@ so we'll only need to design one quarter of it.* Step 2. Identify Rotational Symmetries ************************************** -Look for structures that could be created through the rotation of a 2D shape. For instance, -cylindrical or spherical features are often the result of revolving a profile around an axis. +Look for structures that could be created through the rotation of a 2D shape. For instance, +cylindrical or spherical features are often the result of revolving a profile around an axis. Identify the axis of rotation and make a note of it. *There are no rotational structures in the example bracket.* @@ -40,17 +40,17 @@ Identify the axis of rotation and make a note of it. Step 3. Select a Convenient Origin ********************************** -Choose an origin point that minimizes the need to move or transform components later in the -design process. Ideally, the origin should be placed at a natural center of symmetry or a +Choose an origin point that minimizes the need to move or transform components later in the +design process. Ideally, the origin should be placed at a natural center of symmetry or a critical reference point on the part. -*The planes of symmetry for the bracket was identified in step 1, making it logical to -place the origin at the intersection of these planes on the bracket's front face. Additionally, -we'll define the coordinate system we'll be working in: Plane.XY (the default), where -the origin is set at the global (0,0,0) position. In this system, the x-axis aligns with -the front of the bracket, and the z-axis corresponds to its width. It’s important to note +*The planes of symmetry for the bracket was identified in step 1, making it logical to +place the origin at the intersection of these planes on the bracket's front face. Additionally, +we'll define the coordinate system we'll be working in: Plane.XY (the default), where +the origin is set at the global (0,0,0) position. In this system, the x-axis aligns with +the front of the bracket, and the z-axis corresponds to its width. It’s important to note that all coordinate systems/planes in build123d adhere to the* -`right-hand rule `_ *meaning the y-axis is +`right-hand rule `_ *meaning the y-axis is automatically determined by this convention.* .. image:: assets/bracket_with_origin.png @@ -58,18 +58,18 @@ automatically determined by this convention.* Step 4. Create 2D Profiles ************************** -Design the 2D profiles of your part in the appropriate orientation(s). These profiles are -the foundation of the part's geometry and can often represent cross-sections of the part. +Design the 2D profiles of your part in the appropriate orientation(s). These profiles are +the foundation of the part's geometry and can often represent cross-sections of the part. Mirror parts of profiles across any axes of symmetry identified earlier. *The 2D profile of the bracket is as follows:* .. image:: assets/bracket_sketch.png :align: center - + *The build123d code to generate this profile is as follows:* -.. code-block:: python +.. code-block:: build123d with BuildSketch() as sketch: with BuildLine() as profile: @@ -109,7 +109,7 @@ Use the resulting geometry as sub-parts if needed. *The next step in implementing our design in build123d is to convert the above sketch into a part by extruding it as shown in this code:* -.. code-block:: python +.. code-block:: build123d with BuildPart() as bracket: with BuildSketch() as sketch: @@ -156,7 +156,7 @@ ensure the correct edges have been modified. define these corners need to be isolated. The following code, placed to follow the previous code block, captures just these edges:* -.. code-block:: python +.. code-block:: build123d corners = bracket.edges().filter_by(Axis.X).group_by(Axis.Y)[-1] fillet(corners, fillet_radius) @@ -191,7 +191,7 @@ and functionality in the final assembly. *Our example has two circular holes and a slot that need to be created. First we'll create the two circular holes:* -.. code-block:: python +.. code-block:: build123d with Locations(bracket.faces().sort_by(Axis.X)[-1]): Hole(hole_diameter / 2) @@ -219,7 +219,7 @@ the two circular holes:* *Next the slot needs to be created in the bracket with will be done by sketching a slot on the front of the bracket and extruding the sketch through the part.* -.. code-block:: python +.. code-block:: build123d with BuildSketch(bracket.faces().sort_by(Axis.Y)[0]): SlotOverall(20 * MM, hole_diameter) @@ -262,7 +262,7 @@ or if variations of the part are needed. *The dimensions of the bracket are defined as follows:* -.. code-block:: python +.. code-block:: build123d thickness = 3 * MM width = 25 * MM @@ -285,7 +285,7 @@ These steps should guide you through a logical and efficient workflow in build12 *The entire code block for the bracket example is shown here:* -.. code-block:: python +.. code-block:: build123d from build123d import * from ocp_vscode import show_all diff --git a/docs/tutorial_joints.rst b/docs/tutorial_joints.rst index 47a4026..d7c7658 100644 --- a/docs/tutorial_joints.rst +++ b/docs/tutorial_joints.rst @@ -19,6 +19,7 @@ Before getting to the CAD operations, this selector script needs to import the b environment. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [import] :end-before: [Hinge Class] @@ -32,6 +33,7 @@ tutorial is the joints and not the CAD operations to create objects, this code i described in detail. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Hinge Class] :end-before: [Create the Joints] @@ -62,6 +64,7 @@ The first joint to add is a :class:`~topology.RigidJoint` that is used to fix th or lid. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Create the Joints] :end-before: [Hinge Axis] @@ -78,6 +81,7 @@ The second joint to add is either a :class:`~topology.RigidJoint` (on the inner (on the outer leaf) that describes the hinge axis. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Create the Joints] :end-before: [Fastener holes] :emphasize-lines: 10-24 @@ -96,6 +100,7 @@ The third set of joints to add are :class:`~topology.CylindricalJoint`'s that de screws used to attach the leaves move. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Fastener holes] :end-before: [End Fastener holes] @@ -115,6 +120,7 @@ Step 3d: Call Super To finish off, the base class for the Hinge class is initialized: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [End Fastener holes] :end-before: [Hinge Class] @@ -125,6 +131,7 @@ Now that the Hinge class is complete it can be used to instantiate the two hinge required to attach the box and lid together. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Create instances of the two leaves of the hinge] :end-before: [Create the box with a RigidJoint to mount the hinge] @@ -139,6 +146,7 @@ the joint used to attach the outer hinge leaf. :align: center .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Create the box with a RigidJoint to mount the hinge] :end-before: [Demonstrate that objects with Joints can be moved and the joints follow] :emphasize-lines: 13-16 @@ -157,6 +165,7 @@ having to recreate or modify :class:`~topology.Joint`'s. Here is the box is move property. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Demonstrate that objects with Joints can be moved and the joints follow] :end-before: [The lid with a RigidJoint for the hinge] @@ -170,6 +179,7 @@ Much like the box, the lid is created in a :class:`~build_part.BuildPart` contex :align: center .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [The lid with a RigidJoint for the hinge] :end-before: [A screw to attach the hinge to the box] :emphasize-lines: 6-9 @@ -191,6 +201,7 @@ screw. :align: center .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [A screw to attach the hinge to the box] :end-before: [End of screw creation] @@ -210,6 +221,7 @@ Step 7a: Hinge to Box To start, the outer hinge leaf will be connected to the box, as follows: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Connect Box to Outer Hinge] :end-before: [Connect Box to Outer Hinge] @@ -227,6 +239,7 @@ Next, the hinge inner leaf is connected to the hinge outer leaf which is attache box. .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Connect Hinge Leaves] :end-before: [Connect Hinge Leaves] @@ -243,6 +256,7 @@ Step 7c: Lid to Hinge Now the ``lid`` is connected to the ``hinge_inner``: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Connect Hinge to Lid] :end-before: [Connect Hinge to Lid] @@ -260,6 +274,7 @@ Step 7d: Screw to Hinge The last step in this example is to place a screw in one of the hinges: .. literalinclude:: tutorial_joints.py + :language: build123d :start-after: [Connect Screw to Hole] :end-before: [Connect Screw to Hole] diff --git a/docs/tutorial_lego.rst b/docs/tutorial_lego.rst index 0ac2ab4..fdd02de 100644 --- a/docs/tutorial_lego.rst +++ b/docs/tutorial_lego.rst @@ -21,6 +21,7 @@ The dimensions of the Lego block follow. A key parameter is ``pip_count``, the l of the Lego blocks in pips. This parameter must be at least 2. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 30,31, 34-47 ******************** @@ -31,6 +32,7 @@ The Lego block will be created by the ``BuildPart`` builder as it's a discrete t dimensional part; therefore, we'll instantiate a ``BuildPart`` with the name ``lego``. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49 ********************** @@ -43,6 +45,7 @@ object. As this sketch will be part of the lego part, we'll create a sketch bui in the context of the part builder as follows: .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-51 :emphasize-lines: 3 @@ -59,6 +62,7 @@ of the Lego block. The following step is going to refer to this rectangle, so it be assigned the identifier ``perimeter``. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53 :emphasize-lines: 5 @@ -76,6 +80,7 @@ hollowed out. This will be done with the ``Offset`` operation which is going to create a new object from ``perimeter``. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64 :emphasize-lines: 7-12 @@ -104,6 +109,7 @@ objects are in the scope of a location context (``GridLocations`` in this case) that defined multiple points, multiple rectangles are created. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73 :emphasize-lines: 13-17 @@ -125,6 +131,7 @@ To convert the internal grid to ridges, the center needs to be removed. This wil with another ``Rectangle``. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73,78-83 :emphasize-lines: 18-23 @@ -142,6 +149,7 @@ Lego blocks use a set of internal hollow cylinders that the pips push against to hold two blocks together. These will be created with ``Circle``. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73,78-83,88-93 :emphasize-lines: 24-29 @@ -162,6 +170,7 @@ Now that the sketch is complete it needs to be extruded into the three dimension wall object. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73,78-83,88-93,98-99 :emphasize-lines: 30-31 @@ -183,6 +192,7 @@ Now that the walls are complete, the top of the block needs to be added. Althoug could be done with another sketch, we'll add a box to the top of the walls. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118 :emphasize-lines: 32-40 @@ -211,6 +221,7 @@ The final step is to add the pips to the top of the Lego block. To do this we'll a new workplane on top of the block where we can position the pips. .. literalinclude:: ../examples/lego.py + :language: build123d :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118,129-137 :emphasize-lines: 41-49 diff --git a/docs/tutorial_selectors.rst b/docs/tutorial_selectors.rst index ed974f1..f0ee8db 100644 --- a/docs/tutorial_selectors.rst +++ b/docs/tutorial_selectors.rst @@ -11,7 +11,7 @@ this part: .. note:: One can see any object in the following tutorial by using the ``ocp_vscode`` (or any other supported viewer) by using the ``show(object_to_be_viewed)`` command. - Alternatively, the ``show_all()`` command will display all objects that have been + Alternatively, the ``show_all()`` command will display all objects that have been assigned an identifier. ************* @@ -22,6 +22,7 @@ Before getting to the CAD operations, this selector script needs to import the b environment. .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-2 @@ -34,6 +35,7 @@ To start off, the part will be based on a cylinder so we'll use the :class:`~obj of :class:`~build_part.BuildPart`: .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-5 @@ -50,6 +52,7 @@ surfaces) , so we'll create a sketch centered on the top of the cylinder. To lo this sketch we'll use the cylinder's top Face as shown here: .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-6 @@ -82,6 +85,7 @@ The object has a hexagonal hole in the top with a central cylinder which we'll d in the sketch. .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-8 @@ -107,6 +111,7 @@ To create the hole we'll :func:`~operations_part.extrude` the sketch we just cre the :class:`~objects_part.Cylinder` and subtract it. .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-9 @@ -128,6 +133,7 @@ Step 6: Fillet the top perimeter Edge The final step is to apply a fillet to the top perimeter. .. literalinclude:: selector_example.py + :language: build123d :start-after: [Code] :end-before: [End] :lines: 1-9,18-24,33-34 diff --git a/docs/tutorial_surface_modeling.rst b/docs/tutorial_surface_modeling.rst index 08ed253..79aeada 100644 --- a/docs/tutorial_surface_modeling.rst +++ b/docs/tutorial_surface_modeling.rst @@ -31,7 +31,7 @@ the perimeter of the surface and a central point on that surface. 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: -.. code-block:: python +.. code-block:: build123d with BuildLine() as heart_half: l1 = JernArc((0, 0), (1, 1.4), 40, -17) @@ -48,13 +48,13 @@ of the heart and archs up off ``Plane.XY``. In preparation for creating the surface, we'll define a point on the surface: -.. code-block:: python +.. 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:: python +.. code-block:: build123d top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate( Pos(Z=0.5) @@ -71,7 +71,7 @@ is up, which isn't necessary but helps with viewing. Now that one half of the top of the heart has been created, the remainder of the top and bottom can be created by mirroring: -.. code-block:: python +.. code-block:: build123d top_left_surface = top_right_surface.mirror(Plane.YZ) bottom_right_surface = top_right_surface.mirror(Plane.XY) @@ -80,7 +80,7 @@ and bottom can be created by mirroring: The sides of the heart are going to be created by extruding the outside of the perimeter as follows: -.. code-block:: python +.. 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)) @@ -94,7 +94,7 @@ With the top, bottom, and sides, the complete boundary of the object is defined. now put them together, first into a :class:`~topology.Shell` and then into a :class:`~topology.Solid`: -.. code-block:: python +.. code-block:: build123d heart = Solid( Shell( @@ -122,7 +122,7 @@ now put them together, first into a :class:`~topology.Shell` and then into a 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: - .. code-block:: python + .. code-block:: build123d with BuildPart() as heart_token: with BuildSketch() as outline: From f4c79db263d42ec372328cb42fccdc9ef4da2622 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 24 Sep 2025 23:16:35 -0400 Subject: [PATCH 003/105] Change kwarg capitalization to fix #1026. Unindent code blocks, fix doublespace + formatting --- docs/location_arithmetic.rst | 136 +++++++++++++++++------------------ 1 file changed, 66 insertions(+), 70 deletions(-) diff --git a/docs/location_arithmetic.rst b/docs/location_arithmetic.rst index f26834e..a28c105 100644 --- a/docs/location_arithmetic.rst +++ b/docs/location_arithmetic.rst @@ -3,7 +3,6 @@ Location arithmetic for algebra mode ====================================== - Position a shape relative to the XY plane --------------------------------------------- @@ -19,134 +18,131 @@ For the following use the helper function: circle = Circle(scale * .8).edge() return (triad + circle).locate(plane.location) - 1. **Positioning at a location** - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1, 2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") - .. image:: assets/location-example-01.png +.. image:: assets/location-example-01.png 2) **Positioning on a plane** - .. code-block:: python +.. code-block:: python - plane = Plane.XZ + plane = Plane.XZ - face = plane * Rectangle(1, 2) + face = plane * Rectangle(1, 2) - show_object(face, name="face") - show_object(plane_symbol(plane), name="plane") + show_object(face, name="face") + show_object(plane_symbol(plane), name="plane") - .. image:: assets/location-example-07.png - - Note that the ``x``-axis and the ``y``-axis of the plane are on the ``x``-axis and the ``z``-axis of the world coordinate system (red and blue axis) +.. image:: assets/location-example-07.png +Note: The ``x``-axis and the ``y``-axis of the plane are on the ``x``-axis and the ``z``-axis of the world coordinate system (red and blue axis). Relative positioning to a plane ------------------------------------ 1. **Position an object on a plane relative to the plane** - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1,2) - box = Plane(loc) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) - # box = Plane(face.location) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) - # box = loc * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) + box = Plane(loc) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) + # box = Plane(face.location) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) + # box = loc * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") - show_object(box, name="box") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") + show_object(box, name="box") - .. image:: assets/location-example-02.png +.. image:: assets/location-example-02.png - The ``x``, ``y``, ``z`` components of ``Pos(0.2, 0.4, 0.1)`` are relative to the ``x``-axis, ``y``-axis or - ``z``-axis of the underlying location ``loc``. +The ``X``, ``Y``, ``Z`` components of ``Pos(0.2, 0.4, 0.1)`` are relative to the ``x``-axis, ``y``-axis or +``z``-axis of the underlying location ``loc``. - Note: ``Plane(loc) *``, ``Plane(face.location) *`` and ``loc *`` are equivalent in this example. +Note: ``Plane(loc) *``, ``Plane(face.location) *`` and ``loc *`` are equivalent in this example. 2. **Rotate an object on a plane relative to the plane** - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1,2) - box = Plane(loc) * Rot(z=80) * Box(0.2, 0.2, 0.2) + box = Plane(loc) * Rot(Z=80) * Box(0.2, 0.2, 0.2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") - show_object(box, name="box") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") + show_object(box, name="box") - .. image:: assets/location-example-03.png +.. image:: assets/location-example-03.png - The box is rotated via ``Rot(z=80)`` around the ``z``-axis of the underlying location - (and not of the z-axis of the world). +The box is rotated via ``Rot(Z=80)`` around the ``z``-axis of the underlying location +(and not of the z-axis of the world). - More general: +More general: - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1,2) - box = loc * Rot(20, 40, 80) * Box(0.2, 0.2, 0.2) + box = loc * Rot(20, 40, 80) * Box(0.2, 0.2, 0.2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") - show_object(box, name="box") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") + show_object(box, name="box") - .. image:: assets/location-example-04.png +.. image:: assets/location-example-04.png - The box is rotated via ``Rot(20, 40, 80)`` around all three axes relative to the plane. +The box is rotated via ``Rot(20, 40, 80)`` around all three axes relative to the plane. 3. **Rotate and position an object relative to a location** - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1,2) - box = loc * Rot(20, 40, 80) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) + box = loc * Rot(20, 40, 80) * Pos(0.2, 0.4, 0.1) * Box(0.2, 0.2, 0.2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") - show_object(box, name="box") - show_object(location_symbol(loc * Rot(20, 40, 80), 0.5), options={"color":(0, 255, 255)}, name="local_location") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") + show_object(box, name="box") + show_object(location_symbol(loc * Rot(20, 40, 80), 0.5), options={"color":(0, 255, 255)}, name="local_location") - .. image:: assets/location-example-05.png +.. image:: assets/location-example-05.png - The box is positioned via ``Pos(0.2, 0.4, 0.1)`` relative to the location ``loc * Rot(20, 40, 80)`` +The box is positioned via ``Pos(0.2, 0.4, 0.1)`` relative to the location ``loc * Rot(20, 40, 80)`` 4. **Position and rotate an object relative to a location** - .. code-block:: python +.. code-block:: python - loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) + loc = Location((0.1, 0.2, 0.3), (10, 20, 30)) - face = loc * Rectangle(1,2) + face = loc * Rectangle(1,2) - box = loc * Pos(0.2, 0.4, 0.1) * Rot(20, 40, 80) * Box(0.2, 0.2, 0.2) + box = loc * Pos(0.2, 0.4, 0.1) * Rot(20, 40, 80) * Box(0.2, 0.2, 0.2) - show_object(face, name="face") - show_object(location_symbol(loc), name="location") - show_object(box, name="box") - show_object(location_symbol(loc * Pos(0.2, 0.4, 0.1), 0.5), options={"color":(0, 255, 255)}, name="local_location") + show_object(face, name="face") + show_object(location_symbol(loc), name="location") + show_object(box, name="box") + show_object(location_symbol(loc * Pos(0.2, 0.4, 0.1), 0.5), options={"color":(0, 255, 255)}, name="local_location") - .. image:: assets/location-example-06.png - - Note: This is the same as `box = loc * Location((0.2, 0.4, 0.1), (20, 40, 80)) * Box(0.2, 0.2, 0.2)` +.. image:: assets/location-example-06.png +Note: This is the same as ``box = loc * Location((0.2, 0.4, 0.1), (20, 40, 80)) * Box(0.2, 0.2, 0.2)`` From bb9495a821970feaa1b6bbdf403d570e58d024cd Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 24 Sep 2025 23:28:22 -0400 Subject: [PATCH 004/105] Reorder mirror / make_face bot best practice to resolve #1053 --- docs/assets/ttt/ttt-ppp0106.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/ttt/ttt-ppp0106.py b/docs/assets/ttt/ttt-ppp0106.py index 596a47b..abd6751 100644 --- a/docs/assets/ttt/ttt-ppp0106.py +++ b/docs/assets/ttt/ttt-ppp0106.py @@ -21,8 +21,8 @@ with BuildSketch(Location((0, -r1, y3))) as sk_body: m3 = IntersectingLine(m2 @ 1, m2 % 1, c1) m4 = Line(m3 @ 1, (r1, r1)) m5 = JernArc(m4 @ 1, m4 % 1, r1, -90) - m6 = Line(m5 @ 1, m1 @ 0) - mirror(make_face(l.line), Plane.YZ) + mirror(about=Plane.YZ) + make_face() fillet(sk_body.vertices().group_by(Axis.Y)[1], 12) with Locations((x1 / 2, y_tot - 10), (-x1 / 2, y_tot - 10)): Circle(r2, mode=Mode.SUBTRACT) From 640b5300583151eb43860690e46f96983ad0d885 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 24 Sep 2025 23:48:46 -0400 Subject: [PATCH 005/105] Fix doctrings for sphinx make --- src/build123d/topology/one_d.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index bac4507..b750697 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -531,7 +531,7 @@ class Mixin1D(Shape): A curvature comb is a set of short line segments (“teeth”) erected perpendicular to the curve that visualize the signed curvature κ(u). - Tooth length is proportional to |κ| and the direction encodes the sign + Tooth length is proportional to \|κ\| and the direction encodes the sign (left normal for κ>0, right normal for κ<0). This is useful for inspecting fairness and continuity (C0/C1/C2) of edges and wires. @@ -554,7 +554,7 @@ class Mixin1D(Shape): - On straight segments, κ = 0 so no teeth are drawn. - At inflection points κ→0 and the tooth flips direction. - At C0 corners the tangent is discontinuous; nearby teeth may jump. - C1 yields continuous direction; C2 yields continuous magnitude as well. + C1 yields continuous direction; C2 yields continuous magnitude as well. Example: >>> comb = my_wire.curvature_comb(count=200, max_tooth_size=2.0) @@ -961,16 +961,16 @@ class Mixin1D(Shape): The meaning of the returned parameter depends on the type of self: - **Edge**: Returns the native OCCT curve parameter corresponding to the - given normalized `position` (0.0 → start, 1.0 → end). For closed/periodic - edges, OCCT may return a value **outside** the edge's nominal parameter - range `[param_min, param_max]` (e.g., by adding/subtracting multiples of - the period). If you require a value folded into the edge's range, apply a - modulo with the parameter span. + given normalized `position` (0.0 → start, 1.0 → end). For closed/periodic + edges, OCCT may return a value **outside** the edge's nominal parameter + range `[param_min, param_max]` (e.g., by adding/subtracting multiples of + the period). If you require a value folded into the edge's range, apply a + modulo with the parameter span. - **Wire**: Returns a *composite* parameter encoding both the edge index - and the position within that edge: the **integer part** is the zero-based - count of fully traversed edges, and the **fractional part** is the - normalized position in `[0.0, 1.0]` along the current edge. + and the position within that edge: the **integer part** is the zero-based + count of fully traversed edges, and the **fractional part** is the + normalized position in `[0.0, 1.0]` along the current edge. Args: position (float): Normalized arc-length position along the shape, From 99da8912df93adfca65b91a180d160aa1bc7c024 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 17 Oct 2025 11:45:11 -0400 Subject: [PATCH 006/105] Add 2d and 3d intersection tests --- tests/test_direct_api/test_intersection.py | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 6eebc41..6262a23 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -152,6 +152,7 @@ def test_shape_0d(obj, target, expected): run_test(obj, target, expected) +# 1d Shapes ed1 = Line((0, 0), (5, 0)).edge() ed2 = Line((0, -1), (5, 1)).edge() ed3 = Line((0, 0, 5), (5, 0, 5)).edge() @@ -220,6 +221,136 @@ def test_shape_1d(obj, target, expected): run_test(obj, target, expected) +# 2d Shapes +fc1 = Rectangle(5, 5).face() +fc2 = Pos(Z=5) * Rectangle(5, 5).face() +fc3 = Rot(Y=90) * Rectangle(5, 5).face() +fc4 = Rot(Z=45) * Rectangle(5, 5).face() +fc5 = Pos(2.5, 2.5, 2.5) * Rot(0, 90) * Rectangle(5, 5).face() +fc6 = Pos(2.5, 2.5) * Rot(0, 90, 45, Extrinsic.XYZ) * Rectangle(5, 5).face() +fc7 = (Rot(90) * Cylinder(2, 4)).faces().filter_by(GeomType.CYLINDER)[0] + +fc11 = Rectangle(4, 4).face() +fc22 = sweep(Rot(90) * CenterArc((0, 0), 2, 0, 180), Line((0, 2), (0, -2))) +sh1 = Shell([Pos(-4) * fc11, fc22]) +sh2 = Pos(Z=1) * sh1 +sh3 = Shell([Pos(-4) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11]) +sh4 = Shell([Pos(-4) * fc11, fc22, Pos(4) * fc11]) +sh5 = Pos(Z=1) * Shell([Pos(-2, 0, -2) * Rot(0, -90) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11]) + +shape_2d_matrix = [ + Case(fc1, vl2, None, "non-coincident", None), + Case(fc1, vl1, [Vertex], "coincident", None), + + Case(fc1, lc2, None, "non-coincident", None), + Case(fc1, lc1, [Vertex], "coincident", None), + + Case(fc2, ax1, None, "parallel/skew", None), + Case(fc3, ax1, [Vertex], "intersecting", None), + Case(fc1, ax1, [Edge], "collinear", None), + # Case(fc7, ax1, [Vertex, Vertex], "multi intersect", None), + + Case(fc1, pl3, None, "parallel/skew", None), + Case(fc1, pl1, [Edge], "intersecting", None), + Case(fc1, pl2, [Face], "collinear", None), + Case(fc7, pl1, [Edge, Edge], "multi intersect", None), + + Case(fc1, vt2, None, "non-coincident", None), + Case(fc1, vt1, [Vertex], "coincident", None), + + Case(fc1, ed3, None, "parallel/skew", None), + Case(Pos(1) * fc3, ed1, [Vertex], "intersecting", None), + Case(fc1, ed1, [Edge], "collinear", None), + Case(Pos(1.1) * fc3, ed4, [Vertex, Vertex], "multi intersect", None), + + Case(fc1, wi6, None, "parallel/skew", None), + Case(Pos(1) * fc3, wi4, [Vertex], "intersecting", None), + Case(fc1, wi1, [Edge, Edge], "2 collinear", None), + Case(Rot(90) * fc4, wi5, [Vertex, Vertex], "multi intersect", None), + Case(Rot(90) * fc4, wi2, [Vertex, Edge], "intersect + collinear", None), + + Case(fc1, fc2, None, "parallel/skew", None), + Case(fc1, fc3, [Edge], "intersecting", None), + Case(fc1, fc4, [Face], "coplanar", None), + Case(fc1, fc5, [Edge], "intersecting edge", None), + Case(fc1, fc6, [Vertex], "intersecting vertex", None), + Case(fc1, fc7, [Edge, Edge], "multi-intersecting", None), + Case(fc7, Pos(Y=2) * fc7, [Face], "cyl intersecting", None), + + Case(sh2, fc1, None, "parallel/skew", None), + Case(Pos(Z=1) * sh3, fc1, [Edge], "intersecting", None), + Case(sh1, fc1, [Face, Edge], "coplanar + intersecting", None), + Case(sh4, fc1, [Face, Face], "2 coplanar", None), + Case(sh5, fc1, [Edge, Edge], "2 intersecting", None), + + # Case(sh5, fc1, [Edge], "multi to_intersect, intersecting", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_2d_matrix)) +def test_shape_2d(obj, target, expected): + run_test(obj, target, expected) + +# 3d Shapes +sl1 = Box(2, 2, 2).solid() +sl2 = Pos(Z=5) * Box(2, 2, 2).solid() +sl3 = Cylinder(2, 1).solid() - Cylinder(1.5, 1).solid() + +wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edge().trim(.3, .4), + l2 := l1.trim(2, 3), + RadiusArc(l1 @ 1, l2 @ 0, 1, short_sagitta=False) + ]) + +shape_3d_matrix = [ + Case(sl2, vl1, None, "non-coincident", None), + Case(Pos(2) * sl1, vl1, [Vertex], "contained", None), + Case(Pos(1, 1, -1) * sl1, vl1, [Vertex], "coincident", None), + + Case(sl2, lc1, None, "non-coincident", None), + Case(Pos(2) * sl1, lc1, [Vertex], "contained", None), + Case(Pos(1, 1, -1) * sl1, lc1, [Vertex], "coincident", None), + + Case(sl2, ax1, None, "non-coincident", None), + Case(sl1, ax1, [Edge], "intersecting", None), + Case(Pos(1, 1, 1) * sl1, ax2, [Edge], "coincident", None), + + Case(sl1, pl3, None, "non-coincident", None), + Case(sl1, pl2, [Face], "intersecting", None), + + Case(sl2, vt1, None, "non-coincident", None), + Case(Pos(2) * sl1, vt1, [Vertex], "contained", None), + Case(Pos(1, 1, -1) * sl1, vt1, [Vertex], "coincident", None), + + Case(sl1, ed3, None, "non-coincident", None), + Case(sl1, ed1, [Edge], "intersecting", None), + Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", "BRepAlgoAPI_Common and _Section both return edge"), + Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None), + Case(Pos(2.1, 1) * sl1, ed4, [Edge, Edge], "multi-intersect", None), + + Case(Pos(2, .5, -1) * sl1, wi6, None, "non-coincident", None), + Case(Pos(2, .5, 1) * sl1, wi6, [Edge, Edge], "multi-intersecting", None), + Case(sl3, wi7, [Edge, Edge], "multi-coincident, is_equal check", None), + + Case(sl2, fc1, None, "non-coincident", None), + Case(sl1, fc1, [Face], "intersecting", None), + Case(Pos(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None), + Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", None), + Case(Pos(.9) * sl1, fc7, [Face, Face], "multi-intersecting", None), + + Case(sl2, sh1, None, "non-coincident", None), + Case(Pos(-2) * sl1, sh1, [Face, Face], "multi-intersecting", None), + + Case(sl1, sl2, None, "non-coincident", None), + Case(sl1, Pos(1, 1, 1) * sl1, [Solid], "intersecting", None), + Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None), + Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None), + Case(sl1, Pos(.45) * sl3, [Solid, Solid], "multi-intersect", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_3d_matrix)) +def test_shape_3d(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() From c7bf48c80c88320f9160534339740377e280a402 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Mon, 20 Oct 2025 17:59:19 -0400 Subject: [PATCH 007/105] Add intersect methods to Mixin2D and Mixin3D These methods are very similar using a branching structure to pick intersection method. --- src/build123d/topology/three_d.py | 131 ++++++++++++++++++++++++++++-- src/build123d/topology/two_d.py | 119 ++++++++++++++++++++++++++- 2 files changed, 243 insertions(+), 7 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index e4131ce..0331317 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -56,13 +56,13 @@ from __future__ import annotations import platform import warnings +from collections.abc import Iterable, Sequence from math import radians, cos, tan -from typing import Union, TYPE_CHECKING - -from collections.abc import Iterable +from typing import TYPE_CHECKING +from typing_extensions import Self import OCP.TopAbs as ta -from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Section from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.BRepFeat import BRepFeat_MakeDPrism @@ -95,6 +95,7 @@ from OCP.gp import gp_Ax2, gp_Pnt from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, + TOLERANCE, Axis, BoundBox, Color, @@ -104,7 +105,6 @@ from build123d.geometry import ( Vector, VectorLike, ) -from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D from .shape_core import Shape, ShapeList, Joint, downcast, shapetype @@ -420,6 +420,127 @@ class Mixin3D(Shape): return return_value + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face | Shape]: + """Intersect Solid with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, + faces, and/or solids. + """ + + 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]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, + ) -> ShapeList | None: + # Wrap Shape._bool_op for corrected output + intersections = args[0]._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or None + if (isinstance(intersections, Shape) and not intersections.is_null): + return ShapeList([intersections]) + return None + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Face] = ShapeList(self.solids()) + target: ShapeList | Shape + for other in to_intersect: + # Conform target type + # Vertices need to be Vector for set() + match other: + case Axis(): + target = Edge(other) + case Plane(): + target = Face.make_plane(other) + case Vector(): + target = Vertex(other) + case Location(): + target = Vertex(other.position) + 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 | Face] = [] + result: ShapeList | Shape | None + for obj in common_set: + match (obj, target): + case (Vertex(), Vertex()): + result = obj.intersect(target) + + case (Edge(), Edge() | Wire()): + result = obj.intersect(target) + + case _ if issubclass(type(target), Shape): + if isinstance(target, Wire): + targets = target.edges() + elif isinstance(target, Shell): + targets = target.faces() + else: + targets = ShapeList([target]) + + result = ShapeList() + for t in targets: + if ( + not isinstance(obj, Edge) and not isinstance(t, (Edge)) + ) or (isinstance(obj, Solid) or isinstance(t, Solid)): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + # Many Solid + Edge combinations need Common + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result.extend(bool_op((obj,), (t,), operation) or []) + + if result: + common.extend(to_vector(result)) + + if common: + common_set = to_vertex(set(common)) + common_set = filter_shapes_by_order( + common_set, [Vertex, Edge, Face, Solid] + ) + else: + return None + + return ShapeList(common_set) + def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: """Returns whether or not the point is inside a solid or compound object within the specified tolerance. diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 306c5b8..0195c30 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -64,9 +64,8 @@ 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.BRepAlgo import BRepAlgo -from OCP.BRepAlgoAPI import BRepAlgoAPI_Common +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, @@ -267,6 +266,122 @@ class Mixin2D(ABC, Shape): return result + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face]: + """Intersect Face with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face] | None: ShapeList of vertices, edges, and/or + faces. + """ + + 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]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, + ) -> ShapeList | None: + # Wrap Shape._bool_op for corrected output + intersections = args[0]._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or None + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return None + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Face] = ShapeList(self.faces()) + target: ShapeList | Shape + for other in to_intersect: + # Conform target type + # Vertices need to be Vector for set() + match other: + case Axis(): + target = Edge(other) + case Plane(): + target = Face.make_plane(other) + case Vector(): + target = Vertex(other) + case Location(): + target = Vertex(other.position) + 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 | Face] = [] + result: ShapeList | Shape | None + for obj in common_set: + match (obj, target): + case (Vertex(), Vertex()): + result = obj.intersect(target) + + case (Edge(), Edge() | Wire()): + result = obj.intersect(target) + + case _ if issubclass(type(target), Shape): + if isinstance(target, Wire): + targets = target.edges() + elif isinstance(target, Shell): + targets = target.faces() + else: + targets = ShapeList([target]) + + result = ShapeList() + for t in targets: + if not isinstance(obj, Edge) and not isinstance(t, (Edge)): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result.extend(bool_op((obj,), (t,), operation) or []) + + if result: + common.extend(to_vector(result)) + + if common: + common_set = to_vertex(set(common)) + common_set = filter_shapes_by_order(common_set, [Vertex, Edge, Face]) + else: + return None + + return ShapeList(common_set) + @abstractmethod def location_at(self, *args: Any, **kwargs: Any) -> Location: """A location from a face or shell""" From 5d485ee705acf3315757bd4cec553d5eec458d30 Mon Sep 17 00:00:00 2001 From: snoyer Date: Tue, 21 Oct 2025 08:12:29 +0400 Subject: [PATCH 008/105] use `_wrapped: TOPODS | None` member and `wrapped: TOPODS` property --- src/build123d/topology/composite.py | 6 +- src/build123d/topology/one_d.py | 57 ++++++++------- src/build123d/topology/shape_core.py | 102 +++++++++++++++------------ src/build123d/topology/two_d.py | 14 ++-- 4 files changed, 96 insertions(+), 83 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 823eece..a34fa23 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -455,7 +455,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): will be a Wire, otherwise a Shape. """ if self._dim == 1: - curve = Curve() if self.wrapped is None else Curve(self.wrapped) + curve = Curve() if self._wrapped is None else Curve(self.wrapped) sum1d: Edge | Wire | ShapeList[Edge] = curve + other if isinstance(sum1d, ShapeList): result1d: Curve | Wire = Curve(sum1d) @@ -517,7 +517,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): Check if empty. """ - return TopoDS_Iterator(self.wrapped).More() + return self._wrapped is not None and TopoDS_Iterator(self.wrapped).More() def __iter__(self) -> Iterator[Shape]: """ @@ -602,7 +602,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): def compounds(self) -> ShapeList[Compound]: """compounds - all the compounds in this Shape""" - if self.wrapped is None: + if self._wrapped is None: return ShapeList() if isinstance(self.wrapped, TopoDS_Compound): # pylint: disable=not-an-iterable diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 25b817a..c149f1e 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -263,14 +263,14 @@ class Mixin1D(Shape): @property def is_closed(self) -> bool: """Are the start and end points equal?""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine if empty Edge or Wire is closed") return BRep_Tool.IsClosed_s(self.wrapped) @property def is_forward(self) -> bool: """Does the Edge/Wire loop forward or reverse""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine direction of empty Edge or Wire") return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD @@ -388,8 +388,7 @@ class Mixin1D(Shape): shape # for o in (other if isinstance(other, (list, tuple)) else [other]) for o in ([other] if isinstance(other, Shape) else other) - if o is not None - for shape in get_top_level_topods_shapes(o.wrapped) + for shape in get_top_level_topods_shapes(o.wrapped if o else None) ] # If there is nothing to add return the original object if not topods_summands: @@ -404,7 +403,7 @@ class Mixin1D(Shape): ) summand_edges = [e for summand in summands for e in summand.edges()] - if self.wrapped is None: # an empty object + if self._wrapped is None: # an empty object if len(summands) == 1: sum_shape: Edge | Wire | ShapeList[Edge] = summands[0] else: @@ -452,7 +451,7 @@ class Mixin1D(Shape): Returns: Vector: center """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find center of empty edge/wire") if center_of == CenterOf.GEOMETRY: @@ -578,7 +577,7 @@ class Mixin1D(Shape): >>> show(my_wire, Curve(comb)) """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't create curvature_comb for empty curve") pln = self.common_plane() if pln is None or not isclose(abs(pln.z_dir.Z), 1.0, abs_tol=TOLERANCE): @@ -991,7 +990,7 @@ class Mixin1D(Shape): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find normal of empty edge/wire") curve = self.geom_adaptor() @@ -1225,7 +1224,7 @@ class Mixin1D(Shape): Returns: """ - if self.wrapped is None or face.wrapped is None: + if self._wrapped is None or face.wrapped is None: raise ValueError("Can't project an empty Edge or Wire onto empty Face") bldr = BRepProj_Projection( @@ -1297,7 +1296,7 @@ class Mixin1D(Shape): return edges - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't project empty edge/wire") # Setup the projector @@ -1400,7 +1399,7 @@ class Mixin1D(Shape): - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is either a `Self` or `list[Self]`, or `None` if no corresponding part is found. """ - if self.wrapped is None or tool.wrapped is None: + if self._wrapped is None or tool.wrapped is None: raise ValueError("Can't split an empty edge/wire/tool") shape_list = TopTools_ListOfShape() @@ -2538,7 +2537,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): extension_factor: float = 0.1, ): """Helper method to slightly extend an edge that is bound to a surface""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't extend empty spline") if self.geom_type != GeomType.BSPLINE: raise TypeError("_extend_spline only works with splines") @@ -2595,7 +2594,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: ShapeList[Vector]: list of intersection points """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find intersections of empty edge") # Convert an Axis into an edge at least as large as self and Axis start point @@ -2723,7 +2722,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): def geom_adaptor(self) -> BRepAdaptor_Curve: """Return the Geom Curve from this Edge""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find adaptor for empty edge") return BRepAdaptor_Curve(self.wrapped) @@ -2811,7 +2810,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): float: Normalized parameter in [0.0, 1.0] corresponding to the point's closest location on the edge. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find param on empty edge") pnt = Vector(point) @@ -2945,7 +2944,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: reversed """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("An empty edge can't be reversed") assert isinstance(self.wrapped, TopoDS_Edge) @@ -3025,7 +3024,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): # if start_u >= end_u: # raise ValueError(f"start ({start_u}) must be less than end ({end_u})") - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't trim empty edge") self_copy = copy.deepcopy(self) @@ -3060,7 +3059,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: trimmed edge """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't trim empty edge") start_u = Mixin1D._to_param(self, start, "start") @@ -3623,7 +3622,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: chamfered wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't chamfer empty wire") reference_edge = edge @@ -3695,7 +3694,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: filleted wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't fillet an empty wire") # Create a face to fillet @@ -3723,7 +3722,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: fixed wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't fix an empty edge") sf_w = ShapeFix_Wireframe(self.wrapped) @@ -3735,7 +3734,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): def geom_adaptor(self) -> BRepAdaptor_CompCurve: """Return the Geom Comp Curve for this Wire""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't get geom adaptor of empty wire") return BRepAdaptor_CompCurve(self.wrapped) @@ -3779,7 +3778,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): float: Normalized parameter in [0.0, 1.0] representing the relative position of the projected point along the wire. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find point on empty wire") point_on_curve = Vector(point) @@ -3932,7 +3931,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): """ # pylint: disable=too-many-branches - if self.wrapped is None or target_object.wrapped is None: + if self._wrapped is None or target_object.wrapped is None: raise ValueError("Can't project empty Wires or to empty Shapes") if direction is not None and center is None: @@ -4021,7 +4020,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: stitched wires """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or other.wrapped is None: raise ValueError("Can't stitch empty wires") wire_builder = BRepBuilderAPI_MakeWire() @@ -4065,7 +4064,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): """ # Build a single Geom_BSplineCurve from the wire, in *topological order* builder = GeomConvert_CompCurveToBSplineCurve() - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't convert an empty wire") wire_explorer = BRepTools_WireExplorer(self.wrapped) @@ -4217,9 +4216,9 @@ def topo_explore_connected_edges( parent = parent if parent is not None else edge.topo_parent if parent is None: raise ValueError("edge has no valid parent") - given_topods_edge = edge.wrapped - if given_topods_edge is None: + if not edge: raise ValueError("edge is empty") + given_topods_edge = edge.wrapped connected_edges = set() # Find all the TopoDS_Edges for this Shape @@ -4262,7 +4261,7 @@ def topo_explore_connected_faces( ) -> list[TopoDS_Face]: """Given an edge extracted from a Shape, return the topods_faces connected to it""" - if edge.wrapped is None: + if not edge: raise ValueError("Can't explore from an empty edge") parent = parent if parent is not None else edge.topo_parent diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 6402c3e..6a270e8 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -287,7 +287,7 @@ class Shape(NodeMixin, Generic[TOPODS]): color: ColorLike | None = None, parent: Compound | None = None, ): - self.wrapped: TOPODS | None = ( + self._wrapped: TOPODS | None = ( tcast(Optional[TOPODS], downcast(obj)) if obj is not None else None ) self.for_construction = False @@ -304,6 +304,18 @@ class Shape(NodeMixin, Generic[TOPODS]): # pylint: disable=too-many-instance-attributes, too-many-public-methods + @property + def wrapped(self): + assert self._wrapped + return self._wrapped + + @wrapped.setter + def wrapped(self, shape: TOPODS): + self._wrapped = shape + + def __bool__(self): + return self._wrapped is not None + @property @abstractmethod def _dim(self) -> int | None: @@ -312,7 +324,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def area(self) -> float: """area -the surface area of all faces in this Shape""" - if self.wrapped is None: + if self._wrapped is None: return 0.0 properties = GProp_GProps() BRepGProp.SurfaceProperties_s(self.wrapped, properties) @@ -351,7 +363,7 @@ class Shape(NodeMixin, Generic[TOPODS]): GeomType: The geometry type of the shape """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot determine geometry type of an empty shape") shape: TopAbs_ShapeEnum = shapetype(self.wrapped) @@ -380,7 +392,7 @@ class Shape(NodeMixin, Generic[TOPODS]): bool: is the shape manifold or water tight """ # Extract one or more (if a Compound) shape from self - if self.wrapped is None: + if self._wrapped is None: return False shape_stack = get_top_level_topods_shapes(self.wrapped) @@ -431,12 +443,12 @@ class Shape(NodeMixin, Generic[TOPODS]): underlying shape with the potential to be given a location and an orientation. """ - return self.wrapped is None or self.wrapped.IsNull() + return self._wrapped is None or self.wrapped.IsNull() @property def is_planar_face(self) -> bool: """Is the shape a planar face even though its geom_type may not be PLANE""" - if self.wrapped is None or not isinstance(self.wrapped, TopoDS_Face): + if self._wrapped is None or not isinstance(self.wrapped, TopoDS_Face): return False surface = BRep_Tool.Surface_s(self.wrapped) is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE) @@ -448,7 +460,7 @@ class Shape(NodeMixin, Generic[TOPODS]): subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full description of what is checked. """ - if self.wrapped is None: + if self._wrapped is None: return True chk = BRepCheck_Analyzer(self.wrapped) chk.SetParallel(True) @@ -474,7 +486,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def location(self) -> Location: """Get this Shape's Location""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find the location of an empty shape") return Location(self.wrapped.Location()) @@ -518,7 +530,7 @@ class Shape(NodeMixin, Generic[TOPODS]): - It is commonly used in structural analysis, mechanical simulations, and physics-based motion calculations. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate matrix for empty shape") properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) @@ -546,7 +558,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def position(self) -> Vector: """Get the position component of this Shape's Location""" - if self.wrapped is None or self.location is None: + if self._wrapped is None or self.location is None: raise ValueError("Can't find the position of an empty shape") return self.location.position @@ -575,7 +587,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (Vector(0, 1, 0), 1000.0), (Vector(0, 0, 1), 300.0)] """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate properties for empty shape") properties = GProp_GProps() @@ -615,7 +627,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (150.0, 200.0, 50.0) """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate moments for empty shape") properties = GProp_GProps() @@ -859,7 +871,7 @@ class Shape(NodeMixin, Generic[TOPODS]): if not all(summand._dim == addend_dim for summand in summands): raise ValueError("Only shapes with the same dimension can be added") - if self.wrapped is None: # an empty object + if self._wrapped is None: # an empty object if len(summands) == 1: sum_shape = summands[0] else: @@ -876,7 +888,7 @@ class Shape(NodeMixin, Generic[TOPODS]): """intersect shape with self operator &""" others = other if isinstance(other, (list, tuple)) else [other] - if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None): + if not self or (isinstance(other, Shape) and not other): raise ValueError("Cannot intersect shape with empty compound") new_shape = self.intersect(*others) @@ -948,7 +960,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def __hash__(self) -> int: """Return hash code""" - if self.wrapped is None: + if self._wrapped is None: return 0 return hash(self.wrapped) @@ -966,7 +978,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def __sub__(self, other: None | Shape | Iterable[Shape]) -> Self | ShapeList[Self]: """cut shape from self operator -""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot subtract shape from empty compound") # Convert `other` to list of base objects and filter out None values @@ -1014,7 +1026,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: BoundBox: A box sized to contain this Shape """ - if self.wrapped is None: + if self._wrapped is None: return BoundBox(Bnd_Box()) tolerance = TOLERANCE if tolerance is None else tolerance return BoundBox.from_topo_ds(self.wrapped, tolerance=tolerance, optimal=optimal) @@ -1033,7 +1045,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: Original object with extraneous internal edges removed """ - if self.wrapped is None: + if self._wrapped is None: return self upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True) upgrader.AllowInternalEdges(False) @@ -1112,7 +1124,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or other.wrapped is None: raise ValueError("Cannot calculate distance to or from an empty shape") return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value() @@ -1125,7 +1137,9 @@ class Shape(NodeMixin, Generic[TOPODS]): self, other: Shape | VectorLike ) -> tuple[float, Vector, Vector]: """Minimal distance between two shapes and the points on each shape""" - if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None): + if self._wrapped is None or ( + isinstance(other, Shape) and other.wrapped is None + ): raise ValueError("Cannot calculate distance to or from an empty shape") if isinstance(other, Shape): @@ -1155,7 +1169,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot calculate distance to or from an empty shape") dist_calc = BRepExtrema_DistShapeShape() @@ -1181,7 +1195,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def entities(self, topo_type: Shapes) -> list[TopoDS_Shape]: """Return all of the TopoDS sub entities of the given type""" - if self.wrapped is None: + if self._wrapped is None: return [] return _topods_entities(self.wrapped, topo_type) @@ -1209,7 +1223,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: list[Face]: A list of intersected faces sorted by distance from axis.position """ - if self.wrapped is None: + if self._wrapped is None: return ShapeList() line = gce_MakeLin(axis.wrapped).Value() @@ -1239,7 +1253,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def fix(self) -> Self: """fix - try to fix shape if not valid""" - if self.wrapped is None: + if self._wrapped is None: return self if not self.is_valid: shape_copy: Shape = copy.deepcopy(self, None) @@ -1281,7 +1295,7 @@ class Shape(NodeMixin, Generic[TOPODS]): # self, child_type: Shapes, parent_type: Shapes # ) -> Dict[Shape, list[Shape]]: # """This function is very slow on M1 macs and is currently unused""" - # if self.wrapped is None: + # if self._wrapped is None: # return {} # res = TopTools_IndexedDataMapOfShapeListOfShape() @@ -1319,7 +1333,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (e.g., edges, vertices) and other compounds, the method returns a list of only the simple shapes directly contained at the top level. """ - if self.wrapped is None: + if self._wrapped is None: return ShapeList() return ShapeList( self.__class__.cast(s) for s in get_top_level_topods_shapes(self.wrapped) @@ -1401,7 +1415,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or other.wrapped is None: return False return self.wrapped.IsEqual(other.wrapped) @@ -1416,7 +1430,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or other.wrapped is None: return False return self.wrapped.IsSame(other.wrapped) @@ -1429,7 +1443,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot locate an empty shape") if loc.wrapped is None: raise ValueError("Cannot locate a shape at an empty location") @@ -1448,7 +1462,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of Shape at location """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot locate an empty shape") if loc.wrapped is None: raise ValueError("Cannot locate a shape at an empty location") @@ -1466,7 +1480,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot mesh an empty shape") if not BRepTools.Triangulation_s(self.wrapped, tolerance): @@ -1487,7 +1501,7 @@ class Shape(NodeMixin, Generic[TOPODS]): if not mirror_plane: mirror_plane = Plane.XY - if self.wrapped is None: + if self._wrapped is None: return self transformation = gp_Trsf() transformation.SetMirror( @@ -1505,7 +1519,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot move an empty shape") if loc.wrapped is None: raise ValueError("Cannot move a shape at an empty location") @@ -1525,7 +1539,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of Shape moved to relative location """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot move an empty shape") if loc.wrapped is None: raise ValueError("Cannot move a shape at an empty location") @@ -1539,7 +1553,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: OrientedBoundBox: A box oriented and sized to contain this Shape """ - if self.wrapped is None: + if self._wrapped is None: return OrientedBoundBox(Bnd_OBB()) return OrientedBoundBox(self) @@ -1641,7 +1655,7 @@ class Shape(NodeMixin, Generic[TOPODS]): - The radius of gyration is computed based on the shape’s mass properties. - It is useful for evaluating structural stability and rotational behavior. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate radius of gyration for empty shape") properties = GProp_GProps() @@ -1660,7 +1674,7 @@ class Shape(NodeMixin, Generic[TOPODS]): DeprecationWarning, stacklevel=2, ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot relocate an empty shape") if loc.wrapped is None: raise ValueError("Cannot relocate a shape at an empty location") @@ -1855,7 +1869,7 @@ class Shape(NodeMixin, Generic[TOPODS]): "keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH" ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot split an empty shape") # Process the perimeter @@ -1900,7 +1914,7 @@ class Shape(NodeMixin, Generic[TOPODS]): self, tolerance: float, angular_tolerance: float = 0.1 ) -> tuple[list[Vector], list[tuple[int, int, int]]]: """General triangulated approximation""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot tessellate an empty shape") self.mesh(tolerance, angular_tolerance) @@ -1962,7 +1976,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Self: Approximated shape """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot approximate an empty shape") params = ShapeCustom_RestrictionParameters() @@ -1999,7 +2013,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: a copy of the object, but with geometry transformed """ - if self.wrapped is None: + if self._wrapped is None: return self new_shape = copy.deepcopy(self, None) transformed = downcast( @@ -2022,7 +2036,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of transformed shape with all objects keeping their type """ - if self.wrapped is None: + if self._wrapped is None: return self new_shape = copy.deepcopy(self, None) transformed = downcast( @@ -2095,7 +2109,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of transformed Shape """ - if self.wrapped is None: + if self._wrapped is None: return self shape_copy: Shape = copy.deepcopy(self, None) transformed_shape = BRepBuilderAPI_Transform( @@ -2200,7 +2214,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: tuple[ShapeList[Vertex], ShapeList[Edge]]: section results """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or other.wrapped is None: return (ShapeList(), ShapeList()) section = BRepAlgoAPI_Section(self.wrapped, other.wrapped) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8b8f264..c6102a7 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -213,7 +213,7 @@ class Mixin2D(ABC, Shape): def __neg__(self) -> Self: """Reverse normal operator -""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Invalid Shape") new_surface = copy.deepcopy(self) new_surface.wrapped = downcast(self.wrapped.Complemented()) @@ -244,7 +244,7 @@ class Mixin2D(ABC, Shape): Returns: list[tuple[Vector, Vector]]: Point and normal of intersection """ - if self.wrapped is None: + if self._wrapped is None: return [] intersection_line = gce_MakeLin(other.wrapped).Value() @@ -350,7 +350,7 @@ class Mixin2D(ABC, Shape): world_point, world_point - target_object_center ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't wrap around an empty face") # Initial setup @@ -545,7 +545,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): float: The total surface area, including the area of holes. Returns 0.0 if the face is empty. """ - if self.wrapped is None: + if self._wrapped is None: return 0.0 return self.without_holes().area @@ -605,7 +605,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ValueError: If the face or its underlying representation is empty. ValueError: If the face is not planar. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine axes_of_symmetry of empty face") if not self.is_planar_face: @@ -1940,7 +1940,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): DeprecationWarning, stacklevel=2, ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot approximate an empty shape") return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance)) @@ -1953,7 +1953,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): Returns: Face: A new Face instance identical to the original but without any holes. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot remove holes from an empty face") if not (inner_wires := self.inner_wires()): From 0013b9fa872e3806e533ee31d8115812d8a65911 Mon Sep 17 00:00:00 2001 From: snoyer Date: Tue, 21 Oct 2025 08:28:24 +0400 Subject: [PATCH 009/105] fix Mixins generic types --- src/build123d/topology/composite.py | 2 +- src/build123d/topology/one_d.py | 7 ++++--- src/build123d/topology/three_d.py | 6 +++--- src/build123d/topology/two_d.py | 7 ++++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index a34fa23..4faeb36 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -130,7 +130,7 @@ from .utils import ( from .zero_d import Vertex -class Compound(Mixin3D, Shape[TopoDS_Compound]): +class Compound(Mixin3D[TopoDS_Compound]): """A Compound in build123d is a topological entity representing a collection of geometric shapes grouped together within a single structure. It serves as a container for organizing diverse shapes like edges, faces, or solids. This diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index c149f1e..8e9939f 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -217,6 +217,7 @@ from build123d.geometry import ( ) from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, @@ -250,7 +251,7 @@ if TYPE_CHECKING: # pragma: no cover from .two_d import Face, Shell # pylint: disable=R0801 -class Mixin1D(Shape): +class Mixin1D(Shape[TOPODS]): """Methods to add to the Edge and Wire classes""" # ---- Properties ---- @@ -1565,7 +1566,7 @@ class Mixin1D(Shape): return Shape.get_shape_list(self, "Wire") -class Edge(Mixin1D, Shape[TopoDS_Edge]): +class Edge(Mixin1D[TopoDS_Edge]): """An Edge in build123d is a fundamental element in the topological data structure representing a one-dimensional geometric entity within a 3D model. It encapsulates information about a curve, which could be a line, arc, or other parametrically @@ -3088,7 +3089,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return Edge(new_edge) -class Wire(Mixin1D, Shape[TopoDS_Wire]): +class Wire(Mixin1D[TopoDS_Wire]): """A Wire in build123d is a topological entity representing a connected sequence of edges forming a continuous curve or path in 3D space. Wires are essential components in modeling complex objects, defining boundaries for surfaces or diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index e4131ce..ed3ba34 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -107,7 +107,7 @@ from build123d.geometry import ( from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D -from .shape_core import Shape, ShapeList, Joint, downcast, shapetype +from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell from .utils import ( _extrude_topods_shape, @@ -122,7 +122,7 @@ if TYPE_CHECKING: # pragma: no cover from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 -class Mixin3D(Shape): +class Mixin3D(Shape[TOPODS]): """Additional methods to add to 3D Shape classes""" project_to_viewport = Mixin1D.project_to_viewport @@ -590,7 +590,7 @@ class Mixin3D(Shape): return Shape.get_shape_list(self, "Solid") -class Solid(Mixin3D, Shape[TopoDS_Solid]): +class Solid(Mixin3D[TopoDS_Solid]): """A Solid in build123d represents a three-dimensional solid geometry in a topological structure. A solid is a closed and bounded volume, enclosing a region in 3D space. It comprises faces, edges, and vertices connected in a diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index c6102a7..65cee95 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -139,6 +139,7 @@ from build123d.geometry import ( from .one_d import Edge, Mixin1D, Wire from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, @@ -165,7 +166,7 @@ if TYPE_CHECKING: # pragma: no cover T = TypeVar("T", Edge, Wire, "Face") -class Mixin2D(ABC, Shape): +class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" project_to_viewport = Mixin1D.project_to_viewport @@ -434,7 +435,7 @@ class Mixin2D(ABC, Shape): return projected_edge -class Face(Mixin2D, Shape[TopoDS_Face]): +class Face(Mixin2D[TopoDS_Face]): """A Face in build123d represents a 3D bounded surface within the topological data structure. It encapsulates geometric information, defining a face of a 3D shape. These faces are integral components of complex structures, such as solids and @@ -2327,7 +2328,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return wrapped_wire -class Shell(Mixin2D, Shape[TopoDS_Shell]): +class Shell(Mixin2D[TopoDS_Shell]): """A Shell is a fundamental component in build123d's topological data structure representing a connected set of faces forming a closed surface in 3D space. As part of a geometric model, it defines a watertight enclosure, commonly encountered From a6d8f9bdc16a18d9ca82491db194f2b1bbc71386 Mon Sep 17 00:00:00 2001 From: snoyer Date: Tue, 21 Oct 2025 10:15:47 +0400 Subject: [PATCH 010/105] refactor `.wrapped is None` usages --- src/build123d/exporters.py | 6 +- src/build123d/mesher.py | 2 +- src/build123d/operations_generic.py | 4 +- src/build123d/topology/composite.py | 2 +- src/build123d/topology/constrained_lines.py | 2 +- src/build123d/topology/one_d.py | 16 ++--- src/build123d/topology/shape_core.py | 65 ++++++++++----------- src/build123d/topology/two_d.py | 14 ++--- src/build123d/vtk_tools.py | 2 +- 9 files changed, 56 insertions(+), 57 deletions(-) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 49339ee..a229fa2 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -758,7 +758,7 @@ class ExportDXF(Export2D): ) # need to apply the transform on the geometry level - if edge.wrapped is None or edge.location is None: + if not edge or edge.location is None: raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -1345,7 +1345,7 @@ class ExportSVG(Export2D): u2 = adaptor.LastParameter() # Apply the shape location to the geometry. - if edge.wrapped is None or edge.location is None: + if not edge or edge.location is None: raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -1411,7 +1411,7 @@ class ExportSVG(Export2D): } def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: - if edge.wrapped is None: + if not edge: raise ValueError(f"Edge is empty {edge}.") edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED geom_type = edge.geom_type diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index 5fb9a54..5433848 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -295,7 +295,7 @@ class Mesher: ocp_mesh_vertices.append(pnt) # Store the triangles from the triangulated faces - if facet.wrapped is None: + if not facet: continue facet_reversed = facet.wrapped.Orientation() == ta.TopAbs_REVERSED order = [1, 3, 2] if facet_reversed else [1, 2, 3] diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 69d75cc..2a2f007 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -365,7 +365,7 @@ def chamfer( if target._dim == 1: if isinstance(target, BaseLineObject): - if target.wrapped is None: + if not target: target = Wire([]) # empty wire else: target = Wire(target.wrapped) @@ -465,7 +465,7 @@ def fillet( if target._dim == 1: if isinstance(target, BaseLineObject): - if target.wrapped is None: + if not target: target = Wire([]) # empty wire else: target = Wire(target.wrapped) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 4faeb36..4e42b60 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -534,7 +534,7 @@ class Compound(Mixin3D[TopoDS_Compound]): def __len__(self) -> int: """Return the number of subshapes""" count = 0 - if self.wrapped is not None: + if self._wrapped is not None: for _ in self: count += 1 return count diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 9c316b6..4e53ddb 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -174,7 +174,7 @@ def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ - Edge -> (QualifiedCurve, h2d, first, last, True) - Vector -> (CartesianPoint, None, None, None, False) """ - if obj.wrapped is None: + if not obj: raise TypeError("Can't create a qualified curve from empty edge") if isinstance(obj.wrapped, TopoDS_Edge): diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 8e9939f..c12880f 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -809,7 +809,7 @@ class Mixin1D(Shape[TOPODS]): case Edge() as obj, Plane() as plane: # Find any edge / plane intersection points & edges # Find point intersections - if obj.wrapped is None: + if not obj: continue geom_line = BRep_Tool.Curve_s( obj.wrapped, obj.param_at(0), obj.param_at(1) @@ -1225,7 +1225,7 @@ class Mixin1D(Shape[TOPODS]): Returns: """ - if self._wrapped is None or face.wrapped is None: + if self._wrapped is None or not face: raise ValueError("Can't project an empty Edge or Wire onto empty Face") bldr = BRepProj_Projection( @@ -1400,7 +1400,7 @@ class Mixin1D(Shape[TOPODS]): - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is either a `Self` or `list[Self]`, or `None` if no corresponding part is found. """ - if self._wrapped is None or tool.wrapped is None: + if self._wrapped is None or not tool: raise ValueError("Can't split an empty edge/wire/tool") shape_list = TopTools_ListOfShape() @@ -1647,7 +1647,7 @@ class Edge(Mixin1D[TopoDS_Edge]): Returns: Edge: extruded shape """ - if obj.wrapped is None: + if not obj: raise ValueError("Can't extrude empty vertex") return Edge(TopoDS.Edge_s(_extrude_topods_shape(obj.wrapped, direction))) @@ -3638,7 +3638,7 @@ class Wire(Mixin1D[TopoDS_Wire]): ) for v in vertices: - if v.wrapped is None: + if not v: continue edge_list = vertex_edge_map.FindFromKey(v.wrapped) @@ -3932,7 +3932,7 @@ class Wire(Mixin1D[TopoDS_Wire]): """ # pylint: disable=too-many-branches - if self._wrapped is None or target_object.wrapped is None: + if self._wrapped is None or not target_object: raise ValueError("Can't project empty Wires or to empty Shapes") if direction is not None and center is None: @@ -4021,7 +4021,7 @@ class Wire(Mixin1D[TopoDS_Wire]): Returns: Wire: stitched wires """ - if self._wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: raise ValueError("Can't stitch empty wires") wire_builder = BRepBuilderAPI_MakeWire() @@ -4266,7 +4266,7 @@ def topo_explore_connected_faces( raise ValueError("Can't explore from an empty edge") parent = parent if parent is not None else edge.topo_parent - if parent is None or parent.wrapped is None: + if not parent: raise ValueError("edge has no valid parent") # make a edge --> faces mapping diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 6a270e8..1e18261 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -797,7 +797,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if obj.wrapped is None: + if not obj: return 0.0 properties = GProp_GProps() @@ -817,7 +817,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ], ) -> ShapeList: """Helper to extract entities of a specific type from a shape.""" - if shape.wrapped is None: + if not shape: return ShapeList() shape_list = ShapeList( [shape.__class__.cast(i) for i in shape.entities(entity_type)] @@ -1124,7 +1124,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self._wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: raise ValueError("Cannot calculate distance to or from an empty shape") return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value() @@ -1137,9 +1137,7 @@ class Shape(NodeMixin, Generic[TOPODS]): self, other: Shape | VectorLike ) -> tuple[float, Vector, Vector]: """Minimal distance between two shapes and the points on each shape""" - if self._wrapped is None or ( - isinstance(other, Shape) and other.wrapped is None - ): + if self._wrapped is None or (isinstance(other, Shape) and not other): raise ValueError("Cannot calculate distance to or from an empty shape") if isinstance(other, Shape): @@ -1176,7 +1174,7 @@ class Shape(NodeMixin, Generic[TOPODS]): dist_calc.LoadS1(self.wrapped) for other_shape in others: - if other_shape.wrapped is None: + if not other_shape: raise ValueError("Cannot calculate distance to or from an empty shape") dist_calc.LoadS2(other_shape.wrapped) dist_calc.Perform() @@ -1415,7 +1413,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self._wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return False return self.wrapped.IsEqual(other.wrapped) @@ -1430,7 +1428,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self._wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return False return self.wrapped.IsSame(other.wrapped) @@ -1877,7 +1875,7 @@ class Shape(NodeMixin, Generic[TOPODS]): raise ValueError("perimeter must be a closed Wire or Edge") perimeter_edges = TopTools_SequenceOfShape() for perimeter_edge in perimeter.edges(): - if perimeter_edge.wrapped is None: + if not perimeter_edge: continue perimeter_edges.Append(perimeter_edge.wrapped) @@ -1885,7 +1883,7 @@ class Shape(NodeMixin, Generic[TOPODS]): lefts: list[Shell] = [] rights: list[Shell] = [] for target_shell in self.shells(): - if target_shell.wrapped is None: + if not target_shell: continue constructor = BRepFeat_SplitShape(target_shell.wrapped) constructor.Add(perimeter_edges) @@ -2214,7 +2212,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: tuple[ShapeList[Vertex], ShapeList[Edge]]: section results """ - if self._wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return (ShapeList(), ShapeList()) section = BRepAlgoAPI_Section(self.wrapped, other.wrapped) @@ -2715,15 +2713,16 @@ class ShapeList(list[T]): tol_digits, ) - elif hasattr(group_by, "wrapped"): - if group_by.wrapped is None: - raise ValueError("Cannot group by an empty object") + elif not group_by: + raise ValueError("Cannot group by an empty object") - if isinstance(group_by.wrapped, (TopoDS_Edge, TopoDS_Wire)): + elif hasattr(group_by, "wrapped") and isinstance( + group_by.wrapped, (TopoDS_Edge, TopoDS_Wire) + ): - def key_f(obj): - pnt1, _pnt2 = group_by.closest_points(obj.center()) - return round(group_by.param_at_point(pnt1), tol_digits) + def key_f(obj): + pnt1, _pnt2 = group_by.closest_points(obj.center()) + return round(group_by.param_at_point(pnt1), tol_digits) elif isinstance(group_by, SortBy): if group_by == SortBy.LENGTH: @@ -2829,22 +2828,22 @@ class ShapeList(list[T]): ).position.Z, reverse=reverse, ) - elif hasattr(sort_by, "wrapped"): - if sort_by.wrapped is None: - raise ValueError("Cannot sort by an empty object") + elif not sort_by: + raise ValueError("Cannot sort by an empty object") + elif hasattr(sort_by, "wrapped") and isinstance( + sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire) + ): - if isinstance(sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire)): + def u_of_closest_center(obj) -> float: + """u-value of closest point between object center and sort_by""" + assert not isinstance(sort_by, SortBy) + pnt1, _pnt2 = sort_by.closest_points(obj.center()) + return sort_by.param_at_point(pnt1) - def u_of_closest_center(obj) -> float: - """u-value of closest point between object center and sort_by""" - assert not isinstance(sort_by, SortBy) - pnt1, _pnt2 = sort_by.closest_points(obj.center()) - return sort_by.param_at_point(pnt1) - - # pylint: disable=unnecessary-lambda - objects = sorted( - self, key=lambda o: u_of_closest_center(o), reverse=reverse - ) + # pylint: disable=unnecessary-lambda + objects = sorted( + self, key=lambda o: u_of_closest_center(o), reverse=reverse + ) elif isinstance(sort_by, SortBy): if sort_by == SortBy.LENGTH: diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 65cee95..8c340a5 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -412,7 +412,7 @@ class Mixin2D(ABC, Shape[TOPODS]): raise RuntimeError( f"Length error of {length_error:.6f} exceeds tolerance {tolerance}" ) - if wrapped_edge.wrapped is None or not wrapped_edge.is_valid: + if not wrapped_edge or not wrapped_edge.is_valid: raise RuntimeError("Wrapped edge is invalid") if not snap_to_face: @@ -872,7 +872,7 @@ class Face(Mixin2D[TopoDS_Face]): Returns: Face: extruded shape """ - if obj.wrapped is None: + if not obj: raise ValueError("Can't extrude empty object") return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction))) @@ -982,7 +982,7 @@ class Face(Mixin2D[TopoDS_Face]): ) return single_point_curve - if shape.wrapped is None: + if not shape: raise ValueError("input Edge cannot be empty") adaptor = BRepAdaptor_Curve(shape.wrapped) @@ -1105,7 +1105,7 @@ class Face(Mixin2D[TopoDS_Face]): raise ValueError("exterior must be a Wire or list of Edges") for edge in outside_edges: - if edge.wrapped is None: + if not edge: raise ValueError("exterior contains empty edges") surface.Add(edge.wrapped, GeomAbs_C0) @@ -1136,7 +1136,7 @@ class Face(Mixin2D[TopoDS_Face]): if interior_wires: makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped) for wire in interior_wires: - if wire.wrapped is None: + if not wire: raise ValueError("interior_wires contain an empty wire") makeface_object.Add(wire.wrapped) try: @@ -1330,7 +1330,7 @@ class Face(Mixin2D[TopoDS_Face]): ) from err result = result.fix() - if not result.is_valid or result.wrapped is None: + if not result.is_valid or not result: raise RuntimeError("Non planar face is invalid") return result @@ -2360,7 +2360,7 @@ class Shell(Mixin2D[TopoDS_Shell]): obj = obj_list[0] if isinstance(obj, Face): - if obj.wrapped is None: + if not obj.wrapped: raise ValueError(f"Can't create a Shell from empty Face") builder = BRep_Builder() shell = TopoDS_Shell() diff --git a/src/build123d/vtk_tools.py b/src/build123d/vtk_tools.py index 9d22185..a4af54a 100644 --- a/src/build123d/vtk_tools.py +++ b/src/build123d/vtk_tools.py @@ -80,7 +80,7 @@ def to_vtk_poly_data( if not HAS_VTK: warnings.warn("VTK not supported", stacklevel=2) - if obj.wrapped is None: + if not obj: raise ValueError("Cannot convert an empty shape") vtk_shape = IVtkOCC_Shape(obj.wrapped) From 6ce4a31355825233181cca478735133b138afa97 Mon Sep 17 00:00:00 2001 From: snoyer Date: Tue, 21 Oct 2025 10:31:41 +0400 Subject: [PATCH 011/105] appease mypy --- src/build123d/topology/three_d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index ed3ba34..143e257 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -1269,7 +1269,7 @@ class Solid(Mixin3D[TopoDS_Solid]): outer_wire = section inner_wires = inner_wires if inner_wires else [] - shapes = [] + shapes: list[Mixin3D[TopoDS_Shape]] = [] for wire in [outer_wire] + inner_wires: builder = BRepOffsetAPI_MakePipeShell(Wire(path).wrapped) From fb324adced38ca8aeb6808845aaecd16c034d7f0 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 21 Oct 2025 12:57:03 -0400 Subject: [PATCH 012/105] Add 2d and 3d multi to_intersect cases, exception cases --- tests/test_direct_api/test_intersection.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 6262a23..696fa10 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -248,7 +248,6 @@ shape_2d_matrix = [ Case(fc2, ax1, None, "parallel/skew", None), Case(fc3, ax1, [Vertex], "intersecting", None), Case(fc1, ax1, [Edge], "collinear", None), - # Case(fc7, ax1, [Vertex, Vertex], "multi intersect", None), Case(fc1, pl3, None, "parallel/skew", None), Case(fc1, pl1, [Edge], "intersecting", None), @@ -283,7 +282,9 @@ shape_2d_matrix = [ Case(sh4, fc1, [Face, Face], "2 coplanar", None), Case(sh5, fc1, [Edge, Edge], "2 intersecting", None), - # Case(sh5, fc1, [Edge], "multi to_intersect, intersecting", None), + Case(fc1, [fc4, Pos(2, 2) * fc1], [Face], "multi to_intersect, intersecting", None), + Case(fc1, [ed1, Pos(2.5, 2.5) * fc1], [Edge], "multi to_intersect, intersecting", None), + Case(fc7, [wi5, fc1], [Vertex], "multi to_intersect, intersecting", None), ] @pytest.mark.parametrize("obj, target, expected", make_params(shape_2d_matrix)) @@ -344,6 +345,9 @@ shape_3d_matrix = [ Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None), Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None), Case(sl1, Pos(.45) * sl3, [Solid, Solid], "multi-intersect", None), + + Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid], "multi to_intersect, intersecting", None), + Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None), ] @pytest.mark.parametrize("obj, target, expected", make_params(shape_3d_matrix)) @@ -410,6 +414,8 @@ def test_issues(obj, target, expected): exception_matrix = [ Case(vt1, Color(), None, "Unsupported type", None), Case(ed1, Color(), None, "Unsupported type", None), + Case(fc1, Color(), None, "Unsupported type", None), + Case(sl1, Color(), None, "Unsupported type", None), ] @pytest.mark.skip From 9a6c382ced3df76f2f12dea14f5e81fa78ee9f64 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 21 Oct 2025 13:31:14 -0400 Subject: [PATCH 013/105] Replace Face.make_plane() with Face(Plane) to match Edge(Axis) --- src/build123d/topology/two_d.py | 20 ++++++++------------ tests/test_direct_api/test_face.py | 4 ++-- tests/test_direct_api/test_shape.py | 4 ++-- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8b8f264..7c89746 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -449,7 +449,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): @overload def __init__( self, - obj: TopoDS_Face, + obj: TopoDS_Face | Plane, label: str = "", color: Color | None = None, parent: Compound | None = None, @@ -457,7 +457,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): """Build a Face from an OCCT TopoDS_Shape/TopoDS_Face Args: - obj (TopoDS_Shape, optional): OCCT Face. + obj (TopoDS_Shape | Plane, optional): OCCT Face or Plane. label (str, optional): Defaults to ''. color (Color, optional): Defaults to None. parent (Compound, optional): assembly parent. Defaults to None. @@ -487,7 +487,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if args: l_a = len(args) - if isinstance(args[0], TopoDS_Shape): + if isinstance(args[0], Plane): + obj = args[0] + elif isinstance(args[0], TopoDS_Shape): obj, label, color, parent = args[:4] + (None,) * (4 - l_a) elif isinstance(args[0], Wire): outer_wire, inner_wires, label, color, parent = args[:5] + (None,) * ( @@ -516,6 +518,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): color = kwargs.get("color", color) parent = kwargs.get("parent", parent) + if isinstance(obj, Plane): + obj = BRepBuilderAPI_MakeFace(obj.wrapped).Face() + if outer_wire is not None: inner_topods_wires = ( [w.wrapped for w in inner_wires] if inner_wires is not None else [] @@ -1009,15 +1014,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ).Face() ) - @classmethod - def make_plane( - cls, - plane: Plane = Plane.XY, - ) -> Face: - """Create a unlimited size Face aligned with plane""" - pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face() - return cls(pln_shape) - @classmethod def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face: """make_rect diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index f8619c5..2b71763 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -130,8 +130,8 @@ class TestFace(unittest.TestCase): distance=1, distance2=2, vertices=[vertex], edge=other_edge ) - def test_make_rect(self): - test_face = Face.make_plane() + def test_plane_as_face(self): + test_face = Face(Plane.XY) self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5) def test_length_width(self): diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 2c0bb3c..4f69dbf 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -475,7 +475,7 @@ class TestShape(unittest.TestCase): self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) - verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY)) + verts, edges = Vertex(1, 2, 0)._ocp_section(Face(Plane.XY)) self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) @@ -493,7 +493,7 @@ class TestShape(unittest.TestCase): self.assertEqual(len(edges1), 1) self.assertAlmostEqual(edges1[0].length, 20, 5) - vertices2, edges2 = cylinder._ocp_section(Face.make_plane(pln)) + vertices2, edges2 = cylinder._ocp_section(Face(pln)) self.assertEqual(len(vertices2), 1) self.assertEqual(len(edges2), 1) self.assertAlmostEqual(Vector(vertices2[0]), (5, 0, 0), 5) From 89dedd0888504a0d1344eb1f3785e60255c807c5 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 21 Oct 2025 14:03:22 -0400 Subject: [PATCH 014/105] Add lexer to surface tuts --- docs/tutorial_spitfire_wing_gordon.rst | 6 +++ docs/tutorial_surface_heart_token.rst | 53 +++++++++++++++----------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/tutorial_spitfire_wing_gordon.rst b/docs/tutorial_spitfire_wing_gordon.rst index 716f862..18dd1f2 100644 --- a/docs/tutorial_spitfire_wing_gordon.rst +++ b/docs/tutorial_spitfire_wing_gordon.rst @@ -34,6 +34,7 @@ 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 + :language: build123d :start-after: [Code] :end-before: [AirfoilSizes] @@ -45,6 +46,7 @@ We intersect the guides with planes normal to the span to size the airfoil secti The resulting chord lengths define uniform scales for each airfoil curve. .. literalinclude:: spitfire_wing_gordon.py + :language: build123d :start-after: [AirfoilSizes] :end-before: [Airfoils] @@ -56,6 +58,7 @@ shifted so the leading edge fraction is aligned—then scale to the chord length from Step 2. .. literalinclude:: spitfire_wing_gordon.py + :language: build123d :start-after: [Airfoils] :end-before: [Profiles] @@ -68,6 +71,7 @@ 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 + :language: build123d :start-after: [Profiles] :end-before: [Solid] @@ -82,6 +86,7 @@ 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 + :language: build123d :start-after: [Solid] :end-before: [End] @@ -102,5 +107,6 @@ Complete listing For convenience, here is the full script in one block: .. literalinclude:: spitfire_wing_gordon.py + :language: build123d :start-after: [Code] :end-before: [End] diff --git a/docs/tutorial_surface_heart_token.rst b/docs/tutorial_surface_heart_token.rst index 2c45f62..ac819ad 100644 --- a/docs/tutorial_surface_heart_token.rst +++ b/docs/tutorial_surface_heart_token.rst @@ -7,9 +7,9 @@ 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 +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 @@ -22,13 +22,14 @@ Useful :class:`~topology.Face` creation methods include 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 +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 +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 + :language: build123d :start-after: [Code] :end-before: [SurfaceEdges] @@ -42,12 +43,14 @@ of the heart and archs up off ``Plane.XY``. In preparation for creating the surface, we'll define a point on the surface: .. literalinclude:: heart_token.py + :language: build123d :start-after: [SurfaceEdges] :end-before: [SurfacePoint] We will then use this point to create a non-planar ``Face``: .. literalinclude:: heart_token.py + :language: build123d :start-after: [SurfacePoint] :end-before: [Surface] @@ -55,21 +58,23 @@ We will then use this point to create a non-planar ``Face``: :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 +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 +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 + :language: build123d :start-after: [Surface] :end-before: [Surfaces] -The sides of the heart are going to be created by extruding the outside of the perimeter +The sides of the heart are going to be created by extruding the outside of the perimeter as follows: .. literalinclude:: heart_token.py + :language: build123d :start-after: [Surfaces] :end-before: [Sides] @@ -77,11 +82,12 @@ as follows: :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 +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 + :language: build123d :start-after: [Sides] :end-before: [Solid] @@ -90,32 +96,33 @@ now put them together, first into a :class:`~topology.Shell` and then into a :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 + 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 +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 + :language: build123d :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 +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 +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 +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 From cfd45465854b0c750b65e57f96236ef010de321e Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 24 Oct 2025 22:36:56 -0400 Subject: [PATCH 015/105] Add Compound tests --- tests/test_direct_api/test_intersection.py | 58 +++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 696fa10..bdee46b 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -323,7 +323,7 @@ shape_3d_matrix = [ Case(sl1, ed3, None, "non-coincident", None), Case(sl1, ed1, [Edge], "intersecting", None), - Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", "BRepAlgoAPI_Common and _Section both return edge"), + Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"), Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None), Case(Pos(2.1, 1) * sl1, ed4, [Edge, Edge], "multi-intersect", None), @@ -354,6 +354,61 @@ shape_3d_matrix = [ def test_shape_3d(obj, target, expected): run_test(obj, target, expected) +# Compound Shapes +cp1 = Compound() + GridLocations(5, 0, 2, 1) * Vertex() +cp2 = Compound() + GridLocations(5, 0, 2, 1) * Line((0, -1), (0, 1)) +cp3 = Compound() + GridLocations(5, 0, 2, 1) * Rectangle(2, 2) +cp4 = Compound() + GridLocations(5, 0, 2, 1) * Box(2, 2, 2) + +cv1 = Curve() + [ed1, ed2, ed3] +sk1 = Sketch() + [fc1, fc2, fc3] +pt1 = Part() + [sl1, sl2, sl3] + + +shape_compound_matrix = [ + Case(cp1, vl1, None, "non-coincident", None), + Case(Pos(-.5) * cp1, vl1, [Vertex], "intersecting", None), + + Case(cp2, lc1, None, "non-coincident", None), + Case(Pos(-.5) * cp2, lc1, [Vertex], "intersecting", None), + + Case(Pos(Z=1) * cp3, ax1, None, "non-coincident", None), + Case(cp3, ax1, [Edge, Edge], "intersecting", None), + + Case(Pos(Z=3) * cp4, pl2, None, "non-coincident", None), + Case(cp4, pl2, [Face, Face], "intersecting", None), + + Case(cp1, vt1, None, "non-coincident", None), + Case(Pos(-.5) * cp1, vt1, [Vertex], "intersecting", None), + + Case(Pos(Z=1) * cp2, ed1, None, "non-coincident", None), + Case(cp2, ed1, [Vertex], "intersecting", None), + + Case(Pos(Z=1) * cp3, fc1, None, "non-coincident", None), + Case(cp3, fc1, [Face, Face], "intersecting", None), + + Case(Pos(Z=5) * cp4, sl1, None, "non-coincident", None), + Case(Pos(2) * cp4, sl1, [Solid], "intersecting", None), + + Case(cp1, Pos(Z=1) * cp1, None, "non-coincident", None), + Case(cp1, cp2, [Vertex, Vertex], "intersecting", None), + Case(cp2, cp3, [Edge, Edge], "intersecting", None), + Case(cp3, cp4, [Face, Face], "intersecting", None), + + Case(cp1, Compound(children=cp1.get_type(Vertex)), [Vertex, Vertex], "mixed child type", None), + Case(cp4, Compound(children=cp3.get_type(Face)), [Face, Face], "mixed child type", None), + + Case(cp2, [cp3, cp4], [Edge, Edge], "multi to_intersect, intersecting", None), + + Case(cv1, cp3, [Edge, Edge], "intersecting", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"), + Case(sk1, cp3, [Face, Face], "intersecting", None), + Case(pt1, cp3, [Face, Face], "intersecting", None), + +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_compound_matrix)) +def test_shape_compound(obj, target, expected): + run_test(obj, target, expected) # FreeCAD issue example c1 = CenterArc((0, 0), 10, 0, 360).edge() @@ -416,6 +471,7 @@ exception_matrix = [ Case(ed1, Color(), None, "Unsupported type", None), Case(fc1, Color(), None, "Unsupported type", None), Case(sl1, Color(), None, "Unsupported type", None), + Case(cp1, Color(), None, "Unsupported type", None), ] @pytest.mark.skip From a7b554001f9db3d124fe44cec8a8a163a7f3e622 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 24 Oct 2025 22:37:28 -0400 Subject: [PATCH 016/105] Add intersect method to Compound, similar to 2d and 3d --- src/build123d/topology/composite.py | 157 +++++++++++++++++++++++++++- src/build123d/topology/three_d.py | 11 +- src/build123d/topology/two_d.py | 5 +- 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 823eece..b5964a9 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -58,13 +58,12 @@ import copy import os import sys import warnings -from itertools import combinations -from typing import Type, Union - from collections.abc import Iterable, Iterator, Sequence +from itertools import combinations +from typing_extensions import Self import OCP.TopAbs as ta -from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse, BRepAlgoAPI_Section from OCP.Font import ( Font_FA_Bold, Font_FA_BoldItalic, @@ -107,7 +106,6 @@ from build123d.geometry import ( VectorLike, logger, ) -from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D from .shape_core import ( @@ -711,6 +709,155 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): return results + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face | Solid]: + """Intersect Compound with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, + faces, and/or solids. + """ + + 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]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, + ) -> ShapeList | None: + # Wrap Shape._bool_op for corrected output + intersections = args[0]._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or None + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return None + + def expand_compound(compound: Compound) -> ShapeList: + shapes = ShapeList(compound.children) + for shape_type in [Vertex, Edge, Wire, Face, Shell, Solid]: + new = compound.get_type(shape_type) + if shape_type == Wire: + new = [edge for new_shape in new for edge in new_shape.edges()] + elif shape_type == Shell: + new = [face for new_shape in new for face in new_shape.faces()] + shapes.extend(new) + return shapes + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Face | Solid] = expand_compound(self) + target: ShapeList | Shape + for other in to_intersect: + # Conform target type + # Vertices need to be Vector for set() + match other: + case Axis(): + target = Edge(other) + case Plane(): + target = Face.make_plane(other) + case Vector(): + target = Vertex(other) + case Location(): + target = Vertex(other.position) + case Compound(): + target = expand_compound(other) + 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 | Face] = [] + result: ShapeList | Shape | None + for obj in common_set: + match (obj, target): + case (Vertex(), Vertex()): + result = obj.intersect(target) + + case (Edge(), Edge() | Wire()): + result = obj.intersect(target) + + case (_, ShapeList()): + result = ShapeList() + for t in target: + if ( + not isinstance(obj, Edge) and not isinstance(t, (Edge)) + ) or (isinstance(obj, Solid) or isinstance(t, Solid)): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + # Many Solid + Edge combinations need Common + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result.extend(bool_op((obj,), (t,), operation) or []) + + case _ if issubclass(type(target), Shape): + if isinstance(target, Wire): + targets = target.edges() + elif isinstance(target, Shell): + targets = target.faces() + else: + targets = ShapeList([target]) + + result = ShapeList() + for t in targets: + if ( + not isinstance(obj, Edge) and not isinstance(t, (Edge)) + ) or (isinstance(obj, Solid) or isinstance(t, Solid)): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + # Many Solid + Edge combinations need Common + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result.extend(bool_op((obj,), (t,), operation) or []) + + if result: + common.extend(to_vector(result)) + + if common: + common_set = to_vertex(set(common)) + common_set = filter_shapes_by_order( + common_set, [Vertex, Edge, Face, Solid] + ) + else: + return None + + return ShapeList(common_set) + def unwrap(self, fully: bool = True) -> Self | Shape: """Strip unnecessary Compound wrappers diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 0331317..9e22ce5 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -422,7 +422,7 @@ class Mixin3D(Shape): def intersect( self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex | Edge | Face | Shape]: + ) -> None | ShapeList[Vertex | Edge | Face | Solid]: """Intersect Solid with Shape or geometry object Args: @@ -448,7 +448,7 @@ class Mixin3D(Shape): intersections = args[0]._bool_op(args, tools, operation) if isinstance(intersections, ShapeList): return intersections or None - if (isinstance(intersections, Shape) and not intersections.is_null): + if isinstance(intersections, Shape) and not intersections.is_null: return ShapeList([intersections]) return None @@ -476,7 +476,7 @@ class Mixin3D(Shape): return filtered_shapes - common_set: ShapeList[Vertex | Edge | Face] = ShapeList(self.solids()) + common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList(self.solids()) target: ShapeList | Shape for other in to_intersect: # Conform target type @@ -506,7 +506,7 @@ class Mixin3D(Shape): case (Edge(), Edge() | Wire()): result = obj.intersect(target) - case _ if issubclass(type(target), Shape): + case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): if isinstance(target, Wire): targets = target.edges() elif isinstance(target, Shell): @@ -528,6 +528,9 @@ class Mixin3D(Shape): operation = BRepAlgoAPI_Section() result.extend(bool_op((obj,), (t,), operation) or []) + case _ if issubclass(type(target), Shape): + result = target.intersect(obj) + if result: common.extend(to_vector(result)) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 0195c30..2a96f15 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -352,7 +352,7 @@ class Mixin2D(ABC, Shape): case (Edge(), Edge() | Wire()): result = obj.intersect(target) - case _ if issubclass(type(target), Shape): + case (_, Vertex() | Edge() | Wire() | Face() | Shell()): if isinstance(target, Wire): targets = target.edges() elif isinstance(target, Shell): @@ -371,6 +371,9 @@ class Mixin2D(ABC, Shape): operation = BRepAlgoAPI_Section() result.extend(bool_op((obj,), (t,), operation) or []) + case _ if issubclass(type(target), Shape): + result = target.intersect(obj) + if result: common.extend(to_vector(result)) From c13ef47cef35de0a593051658e7d1f00831dbbb8 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 28 Oct 2025 23:33:29 -0400 Subject: [PATCH 017/105] Correct ex26 by revolving 180 and removing mirror which creates invalid shape --- examples/extrude.py | 3 +-- examples/extrude_algebra.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/extrude.py b/examples/extrude.py index fd30edb..e2f645a 100644 --- a/examples/extrude.py +++ b/examples/extrude.py @@ -68,8 +68,7 @@ with BuildPart() as ex26: with BuildSketch() as ex26_sk: with Locations((0, rev)): Circle(rad) - revolve(axis=Axis.X, revolution_arc=90) - mirror(about=Plane.XZ) + revolve(axis=Axis.X, revolution_arc=180) with BuildSketch() as ex26_sk2: Rectangle(rad, rev) ex26_target = ex26.part diff --git a/examples/extrude_algebra.py b/examples/extrude_algebra.py index e5340bb..6f3d6a6 100644 --- a/examples/extrude_algebra.py +++ b/examples/extrude_algebra.py @@ -26,8 +26,8 @@ rad, rev = 3, 25 # Extrude last circle = Pos(0, rev) * Circle(rad) -ex26_target = revolve(circle, Axis.X, revolution_arc=90) -ex26_target = ex26_target + mirror(ex26_target, Plane.XZ) +ex26_target = revolve(circle, Axis.X, revolution_arc=180) +ex26_target = ex26_target rect = Rectangle(rad, rev) From 315605f485071340dce0bfa857d91d1639111e9c Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 28 Oct 2025 23:45:29 -0400 Subject: [PATCH 018/105] Correct area/volume calculations from intersect with new return type of ShapeList --- src/build123d/drafting.py | 4 ++-- src/build123d/topology/composite.py | 6 +----- src/build123d/topology/two_d.py | 6 ++---- tests/test_direct_api/test_oriented_bound_box.py | 6 ++++-- tests/test_direct_api/test_shape.py | 5 +++-- tests/test_direct_api/test_solid.py | 8 ++++++-- tests/test_drafting.py | 3 ++- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index 07a5193..415d33e 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -453,7 +453,7 @@ class DimensionLine(BaseSketchObject): if self_intersection is None: self_intersection_area = 0.0 else: - self_intersection_area = self_intersection.area + self_intersection_area = sum(f.area for f in self_intersection.faces()) d_line += placed_label bbox_size = d_line.bounding_box().diagonal @@ -467,7 +467,7 @@ class DimensionLine(BaseSketchObject): if line_intersection is None: common_area = 0.0 else: - common_area = line_intersection.area + common_area = sum(f.area for f in line_intersection.faces()) common_area += self_intersection_area score = (d_line.area - 10 * common_area) / bbox_size d_lines[d_line] = score diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index b5964a9..0849e69 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -649,11 +649,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): children[child_index_pair[1]] ) if obj_intersection is not None: - common_volume = ( - 0.0 - if isinstance(obj_intersection, list) - else obj_intersection.volume - ) + common_volume = sum(s.volume for s in obj_intersection.solids()) if common_volume > tolerance: return ( True, diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 2a96f15..621062b 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -778,15 +778,13 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ).sort_by(Axis(cog, cross_dir)) bottom_area = sum(f.area for f in bottom_list) - intersect_area = 0.0 for flipped_face, bottom_face in zip(top_flipped_list, bottom_list): intersection = flipped_face.intersect(bottom_face) - if intersection is None or isinstance(intersection, list): + if intersection is None: intersect_area = -1.0 break else: - assert isinstance(intersection, Face) - intersect_area += intersection.area + intersect_area = sum(f.area for f in intersection.faces()) if intersect_area == -1.0: continue diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py index bcdb566..a083f7b 100644 --- a/tests/test_direct_api/test_oriented_bound_box.py +++ b/tests/test_direct_api/test_oriented_bound_box.py @@ -229,13 +229,15 @@ class TestOrientedBoundBox(unittest.TestCase): obb = OrientedBoundBox(rect) corners = obb.corners poly = Polygon(*corners, align=None) - self.assertAlmostEqual(rect.intersect(poly).area, rect.area, 5) + area = sum(f.area for f in rect.intersect(poly).faces()) + self.assertAlmostEqual(area, rect.area, 5) for face in Box(1, 2, 3).faces(): obb = OrientedBoundBox(face) corners = obb.corners poly = Polygon(*corners, align=None) - self.assertAlmostEqual(face.intersect(poly).area, face.area, 5) + area = sum(f.area for f in face.intersect(poly).faces()) + self.assertAlmostEqual(area, face.area, 5) def test_line_corners(self): """ diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 2c0bb3c..a394d9e 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -299,7 +299,8 @@ class TestShape(unittest.TestCase): predicted_location = Location(offset) * Rotation(*rotation) located_shape = Solid.make_box(1, 1, 1).locate(predicted_location) intersect = shape.intersect(located_shape) - self.assertAlmostEqual(intersect.volume, 1, 5) + volume = sum(s.volume for s in intersect.solids()) + self.assertAlmostEqual(volume, 1, 5) def test_position_and_orientation(self): box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30))) @@ -588,7 +589,7 @@ class TestShape(unittest.TestCase): empty.distance_to_with_closest_points(Vector(1, 1, 1)) with self.assertRaises(ValueError): empty.distance_to(Vector(1, 1, 1)) - with self.assertRaises(ValueError): + with self.assertRaises(AttributeError): box.intersect(empty_loc) self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], [])) self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList()) diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py index a0fa0f3..75fad74 100644 --- a/tests/test_direct_api/test_solid.py +++ b/tests/test_direct_api/test_solid.py @@ -153,7 +153,9 @@ class TestSolid(unittest.TestCase): self.assertAlmostEqual(twist.volume, 1, 5) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) bottom = twist.faces().sort_by(Axis.Z)[0] - self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) + intersect = top.translate((0, 0, -1)).intersect(bottom) + area = sum(f.area for f in intersect.faces()) + self.assertAlmostEqual(area, 1, 5) # Wire base = Wire.make_rect(1, 1) twist = Solid.extrude_linear_with_rotation( @@ -162,7 +164,9 @@ class TestSolid(unittest.TestCase): self.assertAlmostEqual(twist.volume, 1, 5) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) bottom = twist.faces().sort_by(Axis.Z)[0] - self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) + intersect = top.translate((0, 0, -1)).intersect(bottom) + area = sum(f.area for f in intersect.faces()) + self.assertAlmostEqual(area, 1, 5) def test_make_loft(self): loft = Solid.make_loft( diff --git a/tests/test_drafting.py b/tests/test_drafting.py index 1bb97ab..2f1b301 100644 --- a/tests/test_drafting.py +++ b/tests/test_drafting.py @@ -231,7 +231,8 @@ class DimensionLineTestCase(unittest.TestCase): ], draft=metric, ) - self.assertGreater(hole.intersect(d_line).area, 0) + area = sum(f.area for f in hole.intersect(d_line).faces()) + self.assertGreater(area, 0) def test_outside_arrows(self): d_line = DimensionLine([(0, 0, 0), (15, 0, 0)], draft=metric) From 069b691964100117888b35fb940016f681af7743 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 28 Oct 2025 23:56:29 -0400 Subject: [PATCH 019/105] Conform Shape.intersect to None | ShapeList --- src/build123d/topology/shape_core.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 6402c3e..d366ead 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1327,7 +1327,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def intersect( self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | Self | ShapeList[Self]: + ) -> None | ShapeList[Self]: """Intersection of the arguments and this shape Args: @@ -1335,8 +1335,8 @@ class Shape(NodeMixin, Generic[TOPODS]): intersect with Returns: - Self | ShapeList[Self]: Resulting object may be of a different class than self - or a ShapeList if multiple non-Compound object created + None | ShapeList[Self]: Resulting ShapeList may contain different class + than self """ def _to_vertex(vec: Vector) -> Vertex: @@ -1380,15 +1380,12 @@ class Shape(NodeMixin, Generic[TOPODS]): # Find the shape intersections intersect_op = BRepAlgoAPI_Common() - shape_intersections = self._bool_op((self,), objs, intersect_op) - if isinstance(shape_intersections, ShapeList) and not shape_intersections: - return None - if ( - not isinstance(shape_intersections, ShapeList) - and shape_intersections.is_null - ): - return None - return shape_intersections + intersections = self._bool_op((self,), objs, intersect_op) + if isinstance(intersections, ShapeList): + return intersections or None + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return None def is_equal(self, other: Shape) -> bool: """Returns True if two shapes are equal, i.e. if they share the same From 5d7b0983791ca9484661439034ed5b74d6642194 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 29 Oct 2025 00:16:02 -0400 Subject: [PATCH 020/105] Correct mode == Mode.INTERSECT to iterate intersections instead of pass all in to_intersect Shape.intersect(A, B) through BRepAlgoAPI_Common appears to treat tool as a single object such that intersection is Shape ^ (A + B). The updated intersect methods treat this intersection as Shape ^ A ^ B. The intersections in this change need to be interated to accomadate. --- src/build123d/build_common.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index e5edde3..abfa4a0 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -466,7 +466,13 @@ class Builder(ABC, Generic[ShapeT]): elif mode == Mode.INTERSECT: if self._obj is None: raise RuntimeError("Nothing to intersect with") - combined = self._obj.intersect(*typed[self._shape]) + intersections: ShapeList[Shape] = ShapeList() + for target in typed[self._shape]: + result = self._obj.intersect(target) + if result is None: + continue + intersections.extend(result) + combined = self._sub_class(intersections) elif mode == Mode.REPLACE: combined = self._sub_class(list(typed[self._shape])) From 37135745193e89502e07fbb871bc8f180af4d421 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 29 Oct 2025 13:02:31 -0400 Subject: [PATCH 021/105] Remove xfail notes from issue tests --- tests/test_direct_api/test_intersection.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index bdee46b..daad2bf 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -453,11 +453,11 @@ 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"), + Case(t, t, [Face, Face], "issue #1015", None), + Case(l, s, [Edge], "issue #945", None), + Case(a, b, [Edge], "issue #918", None), + Case(e1, w1, [Vertex, Vertex], "issue #697", None), + Case(e1, f1, [Edge], "issue #697", None), ] @pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) From 27567a10efe5d233acdac749d0582409e76800b5 Mon Sep 17 00:00:00 2001 From: snoyer Date: Fri, 7 Nov 2025 21:29:06 +0400 Subject: [PATCH 022/105] fix typo --- src/build123d/topology/two_d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8c340a5..a41e249 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -2360,7 +2360,7 @@ class Shell(Mixin2D[TopoDS_Shell]): obj = obj_list[0] if isinstance(obj, Face): - if not obj.wrapped: + if not obj: raise ValueError(f"Can't create a Shell from empty Face") builder = BRep_Builder() shell = TopoDS_Shell() From 3bea4d32284b791cddff5ef4ee106b680eab22c2 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 7 Nov 2025 16:11:33 -0500 Subject: [PATCH 023/105] Re-add make_plane with depreciation warning --- src/build123d/topology/two_d.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 7c89746..82c09de 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -1014,6 +1014,21 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ).Face() ) + @classmethod + def make_plane( + cls, + plane: Plane = Plane.XY, + ) -> Face: + """Create a unlimited size Face aligned with plane""" + warnings.warn( + "The 'make_plane' method is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face() + return cls(pln_shape) + @classmethod def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face: """make_rect From 513c50530c9ca5ebcae5034f3c4ec305b4c36ea9 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sat, 8 Nov 2025 10:13:03 -0500 Subject: [PATCH 024/105] Added support for Face/cone properties: enhanced axis_of_rotation added semi_angle --- src/build123d/topology/two_d.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8b8f264..d8517ef 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -60,6 +60,7 @@ import sys import warnings from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence +from math import degrees from typing import TYPE_CHECKING, Any, TypeVar, overload import OCP.TopAbs as ta @@ -556,6 +557,11 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if type(self.geom_adaptor()) == Geom_RectangularTrimmedSurface: return None + if self.geom_type == GeomType.CONE: + return Axis( + self.geom_adaptor().Cone().Axis() # type:ignore[attr-defined] + ) + if self.geom_type == GeomType.CYLINDER: return Axis( self.geom_adaptor().Cylinder().Axis() # type:ignore[attr-defined] @@ -837,6 +843,17 @@ class Face(Mixin2D, Shape[TopoDS_Face]): else: return None + @property + def semi_angle(self) -> None | float: + """Return the semi angle of a cone, otherwise None""" + if ( + self.geom_type == GeomType.CONE + and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface + ): + return degrees(self.geom_adaptor().SemiAngle()) # type:ignore[attr-defined] + else: + return None + @property def volume(self) -> float: """volume - the volume of this Face, which is always zero""" From 5d84002aa5211ace05a39b4891df052a5a180a83 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 10:37:45 -0500 Subject: [PATCH 025/105] Add Color support for RGBA hex string --- src/build123d/geometry.py | 32 ++++++++++++++++++++++++----- tests/test_direct_api/test_color.py | 30 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index e4c0eeb..5952389 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1160,8 +1160,8 @@ class Color: Args: color_like (ColorLike): - name, ex: "red", - name + alpha, ex: ("red", 0.5), + name, ex: "red" or "#ff0000", + name + alpha, ex: ("red", 0.5) or "#ff000080", rgb, ex: (1., 0., 0.), rgb + alpha, ex: (1., 0., 0., 0.5), hex, ex: 0xff0000, @@ -1172,7 +1172,7 @@ class Color: @overload def __init__(self, name: str, alpha: float = 1.0): - """Color from name + """Color from name or hexadecimal string `CSS3 Color Names ` @@ -1180,8 +1180,10 @@ class Color: `OCCT Color Names `_ + Hexadecimal string may be RGB or RGBA format with leading "#" + Args: - name (str): color, e.g. "blue" + name (str): color, e.g. "blue" or "#0000ff"" alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0 """ @@ -1237,6 +1239,27 @@ class Color: return case str(): name, alpha = fill_defaults(args, (name, alpha)) + name = name.strip() + if "#" in name: + # extract alpha from hex string + hex_a = format(int(alpha * 255), "x") + if len(name) == 5: + hex_a = name[4] * 2 + name = name[:4] + elif len(name) == 9: + hex_a = name[7:9] + name = name[:7] + elif len(name) not in [4, 5, 7, 9]: + raise ValueError( + f'"{name}" is not a valid hexadecimal color value.' + ) + try: + if hex_a: + alpha = int(hex_a, 16) / 0xFF + except ValueError as ex: + raise ValueError( + f"Invald alpha hex string: {hex_a}" + ) from ex case int(): color_code, alpha = fill_defaults(args, (color_code, alpha)) case float(): @@ -1340,7 +1363,6 @@ class Color: @staticmethod def _rgb_from_str(name: str) -> tuple: - name = name.strip() if "#" not in name: try: # Use css3 color names by default diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 62c26bf..aab9254 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -137,6 +137,18 @@ class TestColor(unittest.TestCase): " #ff0000 ", ("#ff0000",), ("#ff0000", 1), + "#ff0000ff", + " #ff0000ff ", + ("#ff0000ff",), + ("#ff0000ff", .6), + "#f00", + " #f00 ", + ("#f00",), + ("#f00", 1), + "#f00f", + " #f00f ", + ("#f00f",), + ("#f00f", .6), 0xff0000, (0xff0000), (0xff0000, 0xff), @@ -164,6 +176,9 @@ class TestColor(unittest.TestCase): Quantity_ColorRGBA(1, 0, 0, 0.6), ("red", 0.6), ("#ff0000", 0.6), + ("#ff000099"), + ("#f00", 0.6), + ("#f009"), (0xff0000, 153), (1., 0., 0., 0.6) ] @@ -177,6 +192,21 @@ class TestColor(unittest.TestCase): with self.assertRaises(ValueError): Color("build123d") + with self.assertRaises(ValueError): + Color("#ff") + + with self.assertRaises(ValueError): + Color("#ffg") + + with self.assertRaises(ValueError): + Color("#fffff") + + with self.assertRaises(ValueError): + Color("#fffg") + + with self.assertRaises(ValueError): + Color("#fff00gg") + def test_bad_color_type(self): with self.assertRaises(TypeError): Color(dict({"name": "red", "alpha": 1})) From cc34b5a743f5983825988b9e28c46568b4aacd86 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 12:18:30 -0500 Subject: [PATCH 026/105] Convert to pytest with parameterization and test ids --- tests/test_direct_api/test_color.py | 317 ++++++++++++---------------- 1 file changed, 140 insertions(+), 177 deletions(-) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index aab9254..4fa91fe 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -27,198 +27,161 @@ license: """ import copy -import unittest import numpy as np -from build123d.geometry import Color +import pytest + from OCP.Quantity import Quantity_ColorRGBA +from build123d.geometry import Color -class TestColor(unittest.TestCase): - # name + alpha overload - def test_name1(self): - c = Color("blue") - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) +# Overloads +@pytest.mark.parametrize("color, expected", [ + pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), + pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), + pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), +]) +def test_overload_name(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_name2(self): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) +@pytest.mark.parametrize("color, expected", [ + pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), + pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), + pytest.param(Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha"), + pytest.param(Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), (0.1, 0.2, 0.3, 0.5), id="kw rgba"), +]) +def test_overload_rgba(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_name3(self): - c = Color("blue", 0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) +@pytest.mark.parametrize("color, expected", [ + pytest.param(Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code"), + pytest.param(Color(0x006692, 0x80), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), id="color_code + alpha"), + pytest.param(Color(0x006692, alpha=0x80), (0, 102 / 255, 146 / 255, 128 / 255), id="color_code + kw alpha"), + pytest.param(Color(color_code=0x996692, alpha=0xCC), (153 / 255, 102 / 255, 146 / 255, 204 / 255), id="kw color_code + alpha"), +]) +def test_overload_hex(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - # red + green + blue + alpha overload - def test_rgb0(self): - c = Color(0.0, 1.0, 0.0) - np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5) +@pytest.mark.parametrize("color, expected", [ + pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"), + pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"), + pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"), + pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga"), + pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"), +]) +def test_overload_tuple(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_rgba1(self): - c = Color(1.0, 1.0, 0.0, 0.5) - self.assertEqual(c.wrapped.GetRGB().Red(), 1.0) - self.assertEqual(c.wrapped.GetRGB().Green(), 1.0) - self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0) - self.assertEqual(c.wrapped.Alpha(), 0.5) +# ColorLikes +@pytest.mark.parametrize("color_like", [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"), + pytest.param("red", id="name str"), + pytest.param("red ", id="name str whitespace"), + pytest.param(("red",), id="tuple name str"), + pytest.param(("red", 1), id="tuple name str + alpha"), + pytest.param("#ff0000", id="hex str rgb 24bit"), + pytest.param(" #ff0000 ", id="hex str rgb 24bit whitespace"), + pytest.param(("#ff0000",), id="tuple hex str rgb 24bit"), + pytest.param(("#ff0000", 1), id="tuple hex str rgb 24bit + alpha"), + pytest.param("#ff0000ff", id="hex str rgba 24bit"), + pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"), + pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"), + pytest.param(("#ff0000ff", .6), id="tuple hex str rgba 24bit + alpha (not used)"), + pytest.param("#f00", id="hex str rgb 12bit"), + pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"), + pytest.param(("#f00",), id="tuple hex str rgb 12bit"), + pytest.param(("#f00", 1), id="tuple hex str rgb 12bit + alpha"), + pytest.param("#f00f", id="hex str rgba 12bit"), + pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"), + pytest.param(("#f00f",), id="tuple hex str rgba 12bit"), + pytest.param(("#f00f", .6), id="tuple hex str rgba 12bit + alpha (not used)"), + pytest.param(0xff0000, id="hex int"), + pytest.param((0xff0000), id="tuple hex int"), + pytest.param((0xff0000, 0xff), id="tuple hex int + alpha"), + pytest.param((1, 0, 0), id="tuple rgb int"), + pytest.param((1, 0, 0, 1), id="tuple rgba int"), + pytest.param((1., 0., 0.), id="tuple rgb float"), + pytest.param((1., 0., 0., 1.), id="tuple rgba float"), +]) +def test_color_likes(color_like): + expected = (1, 0, 0, 1) + np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) + np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) - def test_rgba2(self): - c = Color(1.0, 1.0, 0.0, alpha=0.5) - np.testing.assert_allclose(tuple(c), (1, 1, 0, 0.5), 1e-5) +@pytest.mark.parametrize("color_like, expected", [ + pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"), + pytest.param(1., (1, 1, 1, 1), id="r float"), + pytest.param((1.,), (1, 1, 1, 1), id="tuple r float"), + pytest.param((1., 0.), (1, 0, 1, 1), id="tuple rg float"), +]) +def test_color_likes_incomplete(color_like, expected): + np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) + np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) - def test_rgba3(self): - c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 1e-5) +@pytest.mark.parametrize("color_like", [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"), + pytest.param(("red", 0.6), id="tuple name str + alpha"), + pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"), + pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"), + pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"), + pytest.param(("#f009"), id="tuple hex str rgba 12bit"), + pytest.param((0xff0000, 153), id="tuple hex int + alpha int"), + pytest.param((1., 0., 0., 0.6), id="tuple rbga float"), +]) +def test_color_likes_alpha(color_like): + expected = (1, 0, 0, 0.6) + np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) + np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) - # hex (int) + alpha overload - def test_hex(self): - c = Color(0x996692) - np.testing.assert_allclose( - tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5 - ) +# Exceptions +@pytest.mark.parametrize("name", [ + pytest.param("build123d", id="invalid color name"), + pytest.param("#ffg", id="invalid rgb 12bit"), + pytest.param("#fffg", id="invalid rgba 12bit"), + pytest.param("#fffgg", id="invalid rgb 24bit"), + pytest.param("#fff00gg", id="invalid rgba 24bit"), + pytest.param("#ff", id="short rgb 12bit"), + pytest.param("#fffff", id="short rgb 24bit"), + pytest.param("#fffffff", id="short rgba 24bit"), + pytest.param("#fffffffff", id="long rgba 24bit"), +]) +def test_exceptions_color_name(name): + with pytest.raises(Exception): + Color(name) - c = Color(0x006692, 0x80) - np.testing.assert_allclose( - tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5 - ) +@pytest.mark.parametrize("color_type", [ + pytest.param((dict({"name": "red", "alpha": 1},)), id="dict arg"), + pytest.param(("red", "blue"), id="str + str"), + pytest.param((1., "blue"), id="float + str order"), + pytest.param((1, "blue"), id="int + str order"), +]) +def test_exceptions_color_type(color_type): + with pytest.raises(Exception): + Color(*color_type) - c = Color(0x006692, alpha=0x80) - np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5) +# Methods +def test_rgba_wrapped(): + c = Color(1.0, 1.0, 0.0, 0.5) + assert c.wrapped.GetRGB().Red() == 1.0 + assert c.wrapped.GetRGB().Green() == 1.0 + assert c.wrapped.GetRGB().Blue() == 0.0 + assert c.wrapped.Alpha() == 0.5 - c = Color(color_code=0x996692, alpha=0xCC) - np.testing.assert_allclose( - tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5 - ) +def test_to_tuple(): + c = Color("blue", alpha=0.5) + np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), rtol=1e-5) - c = Color(0.0, 0.0, 1.0, 1.0) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) +def test_copy(): + c = Color(0.1, 0.2, 0.3, alpha=0.4) + c_copy = copy.copy(c) + np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), rtol=1e-5) - c = Color(0, 0, 1, 1) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) +def test_str_repr_is(): + c = Color(1, 0, 0) + assert str(c) == "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'" + assert repr(c) == "Color(1.0, 0.0, 0.0, 1.0)" - # Methods - def test_to_tuple(self): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) - - def test_copy(self): - c = Color(0.1, 0.2, 0.3, alpha=0.4) - c_copy = copy.copy(c) - np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 1e-5) - - def test_str_repr(self): - c = Color(1, 0, 0) - self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'") - self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)") - - c = Color(1, .5, 0) - self.assertEqual(str(c), "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'") - self.assertEqual(repr(c), "Color(1.0, 0.5, 0.0, 1.0)") - - def test_tuple(self): - c = Color((0.1,)) - np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5) - c = Color((0.1, 0.2)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 1.0, 1.0), 1e-5) - c = Color((0.1, 0.2, 0.3)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5) - c = Color((0.1, 0.2, 0.3, 0.4)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5) - c = Color(color_like=(0.1, 0.2, 0.3, 0.4)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5) - - # color_like overload - def test_color_like(self): - red_color_likes = [ - Quantity_ColorRGBA(1, 0, 0, 1), - "red", - "red ", - ("red",), - ("red", 1), - "#ff0000", - " #ff0000 ", - ("#ff0000",), - ("#ff0000", 1), - "#ff0000ff", - " #ff0000ff ", - ("#ff0000ff",), - ("#ff0000ff", .6), - "#f00", - " #f00 ", - ("#f00",), - ("#f00", 1), - "#f00f", - " #f00f ", - ("#f00f",), - ("#f00f", .6), - 0xff0000, - (0xff0000), - (0xff0000, 0xff), - (1, 0, 0), - (1, 0, 0, 1), - (1., 0., 0.), - (1., 0., 0., 1.) - ] - expected = (1, 0, 0, 1) - for cl in red_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - incomplete_color_likes = [ - (Color(), (1, 1, 1, 1)), - (1., (1, 1, 1, 1)), - ((1.,), (1, 1, 1, 1)), - ((1., 0.), (1, 0, 1, 1)), - ] - for cl, expected in incomplete_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - alpha_color_likes = [ - Quantity_ColorRGBA(1, 0, 0, 0.6), - ("red", 0.6), - ("#ff0000", 0.6), - ("#ff000099"), - ("#f00", 0.6), - ("#f009"), - (0xff0000, 153), - (1., 0., 0., 0.6) - ] - expected = (1, 0, 0, 0.6) - for cl in alpha_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - # Exceptions - def test_bad_color_name(self): - with self.assertRaises(ValueError): - Color("build123d") - - with self.assertRaises(ValueError): - Color("#ff") - - with self.assertRaises(ValueError): - Color("#ffg") - - with self.assertRaises(ValueError): - Color("#fffff") - - with self.assertRaises(ValueError): - Color("#fffg") - - with self.assertRaises(ValueError): - Color("#fff00gg") - - def test_bad_color_type(self): - with self.assertRaises(TypeError): - Color(dict({"name": "red", "alpha": 1})) - - with self.assertRaises(TypeError): - Color("red", "blue") - - with self.assertRaises(TypeError): - Color(1., "blue") - - with self.assertRaises(TypeError): - Color(1, "blue") - -if __name__ == "__main__": - unittest.main() +def test_str_repr_near(): + c = Color(1, 0.5, 0) + assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'" + assert repr(c) == "Color(1.0, 0.5, 0.0, 1.0)" From 083cb1611cd4db739bd1b2e8239cfca074fd8399 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 12:29:48 -0500 Subject: [PATCH 027/105] Remove depreciated Color.to_tuple --- src/build123d/geometry.py | 10 ---------- tests/test_direct_api/test_color.py | 4 ---- 2 files changed, 14 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 5952389..e17d049 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1319,16 +1319,6 @@ class Color: self.iter_index += 1 return value - def to_tuple(self): - """Value as tuple""" - warnings.warn( - "to_tuple is deprecated and will be removed in a future version. " - "Use 'tuple(Color)' instead.", - DeprecationWarning, - stacklevel=2, - ) - return tuple(self) - def __copy__(self) -> Color: """Return copy of self""" return Color(*tuple(self)) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 4fa91fe..650f5c9 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -167,10 +167,6 @@ def test_rgba_wrapped(): assert c.wrapped.GetRGB().Blue() == 0.0 assert c.wrapped.Alpha() == 0.5 -def test_to_tuple(): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), rtol=1e-5) - def test_copy(): c = Color(0.1, 0.2, 0.3, alpha=0.4) c_copy = copy.copy(c) From 20854b3d4d3a3f6e4ecd35c4415807d4a106cf8a Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 12 Nov 2025 15:40:23 -0600 Subject: [PATCH 028/105] pyproject.toml -> pin to pytest==8.4.2 per pytest-dev/pytest-xdist/issues/1273 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fdb8bc0..22f6440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ development = [ "black", "mypy", "pylint", - "pytest", + "pytest==8.4.2", # TODO: unpin on resolution of pytest-dev/pytest-xdist/issues/1273 "pytest-benchmark", "pytest-cov", "pytest-xdist", From a5d4229fdaa2f847fa6e7939a5d416a88268bd11 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 13 Nov 2025 14:52:23 -0500 Subject: [PATCH 029/105] Update repr and str for Vector, Location, Axis, Plane --- src/build123d/geometry.py | 98 ++++++++++--------- src/build123d/topology/shape_core.py | 2 +- tests/test_direct_api/test_assembly.py | 6 +- tests/test_direct_api/test_axis.py | 4 +- tests/test_direct_api/test_location.py | 6 +- .../test_oriented_bound_box.py | 6 +- tests/test_direct_api/test_plane.py | 6 +- tests/test_direct_api/test_vector.py | 4 +- 8 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index e4c0eeb..b641b34 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -443,13 +443,19 @@ class Vector: return self.intersect(other) def __repr__(self) -> str: - """Display vector""" + """Represent vector""" x = round(self.X, 13) if abs(self.X) > TOLERANCE else 0.0 y = round(self.Y, 13) if abs(self.Y) > TOLERANCE else 0.0 z = round(self.Z, 13) if abs(self.Z) > TOLERANCE else 0.0 - return f"Vector({x:.14g}, {y:.14g}, {z:.14g})" + return f"{type(self).__name__}({x:.14g}, {y:.14g}, {z:.14g})" - __str__ = __repr__ + def __str__(self) -> str: + """Display vector""" + strf = f".{TOL_DIGITS}g" + x = round(self.X, 13) if abs(self.X) > TOLERANCE else 0.0 + y = round(self.Y, 13) if abs(self.Y) > TOLERANCE else 0.0 + z = round(self.Z, 13) if abs(self.Z) > TOLERANCE else 0.0 + return f"{type(self).__name__}: (X={x:{strf}}, Y={y:{strf}}, Z={z:{strf}})" def __eq__(self, other: object) -> bool: """Vectors equal operator ==""" @@ -738,14 +744,15 @@ class Axis(metaclass=AxisMeta): ) def __repr__(self) -> str: - """Display self""" - return f"({tuple(self.position)},{tuple(self.direction)})" + """Represent axis""" + return f"{type(self).__name__}({tuple(self.position)}, {tuple(self.direction)})" def __str__(self) -> str: - """Display self""" - return ( - f"{type(self).__name__}: ({tuple(self.position)},{tuple(self.direction)})" - ) + """Display axis""" + strf = f".{TOL_DIGITS}g" + position_str = ", ".join(f"{v:{strf}}" for v in tuple(self.position)) + direction_str = ", ".join(f"{v:{strf}}" for v in tuple(self.direction)) + return f"{type(self).__name__}: (position=({position_str}), direction=({direction_str}))" def __eq__(self, other: object) -> bool: if not isinstance(other, Axis): @@ -1315,7 +1322,7 @@ class Color: return Color(*tuple(self)) def __str__(self) -> str: - """Generate string""" + """Display color""" rgb = self.wrapped.GetRGB() rgb = (rgb.Red(), rgb.Green(), rgb.Blue()) try: @@ -1326,11 +1333,11 @@ class Color: quantity_color_enum = self.wrapped.GetRGB().Name() name = Quantity_Color.StringName_s(quantity_color_enum) qualifier = "near" - return f"Color: {str(tuple(self))} {qualifier} {name.upper()!r}" + return f"{type(self).__name__}: {str(tuple(self))} {qualifier} {name.upper()!r}" def __repr__(self) -> str: - """Color repr""" - return f"Color{str(tuple(self))}" + """Represent colr""" + return f"{type(self).__name__}{str(tuple(self))}" @staticmethod def _rgb_from_int(triplet: int) -> tuple[float, float, float]: @@ -1911,29 +1918,21 @@ class Location: return rv_trans, rv_rot - def __repr__(self): - """To String + def __repr__(self) -> str: + """Represent location""" + return ( + f"{type(self).__name__}({tuple(self.position)}, {tuple(self.orientation)})" + ) - Convert Location to String for display - - Returns: - Location as String - """ - position_str = ", ".join(f"{v:.2f}" for v in tuple(self)[0]) - orientation_str = ", ".join(f"{v:.2f}" for v in tuple(self)[1]) - return f"(p=({position_str}), o=({orientation_str}))" - - def __str__(self): - """To String - - Convert Location to String for display - - Returns: - Location as String - """ - position_str = ", ".join(f"{v:.2f}" for v in tuple(self)[0]) - orientation_str = ", ".join(f"{v:.2f}" for v in tuple(self)[1]) - return f"Location: (position=({position_str}), orientation=({orientation_str}))" + def __str__(self) -> str: + """Display location""" + strf = f".{TOL_DIGITS}g" + position_str = ", ".join(f"{v:{strf}}" for v in tuple(self.position)) + orientation_str = ", ".join(f"{v:{strf}}" for v in tuple(self.orientation)) + return ( + f"{type(self).__name__}: " + f"(position=({position_str}), orientation=({orientation_str}))" + ) @overload def intersect(self, vector: VectorLike) -> Vector | None: @@ -2231,7 +2230,7 @@ class OrientedBoundBox: return self.wrapped.IsOut(point.to_pnt()) def __repr__(self) -> str: - return f"OrientedBoundBox(center={self.center()}, size={self.size}, plane={self.plane})" + return f"OrientedBoundBox(center={self.center()!r}, size={self.size!r}, plane={self.plane!r})" class Rotation(Location): @@ -2854,18 +2853,23 @@ class Plane(metaclass=PlaneMeta): """intersect plane with other &""" return self.intersect(other) - def __repr__(self): - """To String + def __repr__(self) -> str: + """Represent plane""" + return ( + f"{type(self).__name__}" + f"({tuple(self._origin)}, {tuple(self.x_dir)}, {tuple(self.z_dir)})" + ) - Convert Plane to String for display - - Returns: - Plane as String - """ - origin_str = ", ".join(f"{v:.2f}" for v in tuple(self._origin)) - x_dir_str = ", ".join(f"{v:.2f}" for v in tuple(self.x_dir)) - z_dir_str = ", ".join(f"{v:.2f}" for v in tuple(self.z_dir)) - return f"Plane(o=({origin_str}), x=({x_dir_str}), z=({z_dir_str}))" + def __str__(self) -> str: + """Display plane""" + strf = f".{TOL_DIGITS}g" + origin_str = ", ".join(f"{v:{strf}}" for v in tuple(self.origin)) + x_dir_str = ", ".join(f"{v:{strf}}" for v in tuple(self.x_dir)) + z_dir_str = ", ".join(f"{v:{strf}}" for v in tuple(self.z_dir)) + return ( + f"{type(self).__name__}: " + f"(origin=({origin_str}), x_dir=({x_dir_str}), z_dir=({z_dir_str}))" + ) def reverse(self) -> Plane: """Reverse z direction of plane""" diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 1e18261..24eb0fe 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -735,7 +735,7 @@ class Shape(NodeMixin, Generic[TOPODS]): loc = ( "Center" + str(tuple(node.center())) if show_center - else "Location" + repr(node.location) + else repr(node.location) ) result += f"{treestr}{name}at {address:#x}, {loc}\n" return result diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py index 71e862b..d746478 100644 --- a/tests/test_direct_api/test_assembly.py +++ b/tests/test_direct_api/test_assembly.py @@ -70,9 +70,9 @@ class TestAssembly(unittest.TestCase): def test_show_topology_compound(self): assembly = TestAssembly.create_test_assembly() expected = [ - "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))", - "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))", - "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))", + "assembly Compound at 0x7fced0fd1b50, Location((0.0, 0.0, 0.0), (-0.0, 0.0, -0.0))", + "├── box Solid at 0x7fced102d3a0, Location((0.0, 0.0, 0.0), (45.0, 45.0, -0.0))", + "└── sphere Solid at 0x7fced0fd1f10, Location((1.0, 2.0, 3.0), (-0.0, 0.0, -0.0))", ] self.assertTopoEqual(assembly.show_topology("Solid"), expected) diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py index c0bbd46..0296e0d 100644 --- a/tests/test_direct_api/test_axis.py +++ b/tests/test_direct_api/test_axis.py @@ -85,8 +85,8 @@ class TestAxis(unittest.TestCase): self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5) def test_axis_repr_and_str(self): - self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))") - self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))") + self.assertEqual(repr(Axis.X), "Axis((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))") + self.assertEqual(str(Axis.Y), "Axis: (position=(0, 0, 0), direction=(0, 1, 0))") def test_axis_copy(self): x_copy = copy.copy(Axis.X) diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index d22cb6c..6c3a134 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -228,16 +228,16 @@ class TestLocation(unittest.TestCase): def test_location_repr_and_str(self): self.assertEqual( - repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))" + repr(Location()), "Location((0.0, 0.0, 0.0), (-0.0, 0.0, -0.0))" ) self.assertEqual( str(Location()), - "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))", + "Location: (position=(0, 0, 0), orientation=(-0, 0, -0))", ) loc = Location((1, 2, 3), (33, 45, 67)) self.assertEqual( str(loc), - "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))", + "Location: (position=(1, 2, 3), orientation=(33, 45, 67))", ) def test_location_inverted(self): diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py index bcdb566..7a2bff3 100644 --- a/tests/test_direct_api/test_oriented_bound_box.py +++ b/tests/test_direct_api/test_oriented_bound_box.py @@ -157,9 +157,9 @@ class TestOrientedBoundBox(unittest.TestCase): pattern = ( r"OrientedBoundBox\(center=Vector\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " r"size=Vector\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " - r"plane=Plane\(o=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " - r"x=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " - r"z=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\)\)\)" + r"plane=Plane\(\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " + r"\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), " + r"\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\)\)\)" ) m = re.match(pattern, rep) self.assertIsNotNone( diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py index e9e7faa..59b6d4e 100644 --- a/tests/test_direct_api/test_plane.py +++ b/tests/test_direct_api/test_plane.py @@ -335,7 +335,11 @@ class TestPlane(unittest.TestCase): def test_repr(self): self.assertEqual( repr(Plane.XY), - "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))", + "Plane((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0))", + ) + self.assertEqual( + str(Plane.XY), + "Plane: (origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1))", ) def test_shift_origin_axis(self): diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py index 521c47c..b0bb0a9 100644 --- a/tests/test_direct_api/test_vector.py +++ b/tests/test_direct_api/test_vector.py @@ -221,10 +221,10 @@ class TestVector(unittest.TestCase): def test_vector_special_methods(self): self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)") - self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)") + self.assertEqual(str(Vector(1, 2, 3)), "Vector: (X=1, Y=2, Z=3)") self.assertEqual( str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)), - "Vector(10, -23.65, 0)", + "Vector: (X=10, Y=-23.65, Z=0)", ) def test_vector_iter(self): From 73a0a0ea21f2cd3b20aed35b6e5720c44e80ab5e Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 10:50:32 -0500 Subject: [PATCH 030/105] Correct Location constructor kwargs and update docstrings --- src/build123d/geometry.py | 46 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index b641b34..299e416 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1483,56 +1483,50 @@ class Location: @overload def __init__(self): - """Empty location with not rotation or translation with respect to the original location.""" + """Location with no position or orientation""" @overload def __init__(self, location: Location): - """Location with another given location.""" + """Location from Location""" @overload - def __init__(self, translation: VectorLike, angle: float = 0): - """Location with translation with respect to the original location. - If angle != 0 then the location includes a rotation around z-axis by angle""" + def __init__(self, position: VectorLike, angle: float = 0): + """Location from position and rotation around z-axis by optional angle""" @overload - def __init__(self, translation: VectorLike, rotation: RotationLike | None = None): - """Location with translation with respect to the original location. - If rotation is not None then the location includes the rotation (see also Rotation class) - """ + def __init__(self, position: VectorLike, orientation: RotationLike | None = None): + """Location from position and optional orientation (see Rotation class)""" @overload def __init__( self, - translation: VectorLike, - rotation: RotationLike, + position: VectorLike, + orientation: RotationLike, ordering: Extrinsic | Intrinsic, ): - """Location with translation with respect to the original location. - If rotation is not None then the location includes the rotation (see also Rotation class) - ordering defaults to Intrinsic.XYZ, but can also be set to Extrinsic + """Location from position and optional orientation (see Rotation class). + Orientation determined by optional ordering, defaults to Intrinsic.XYZ """ @overload def __init__(self, plane: Plane): - """Location corresponding to the location of the Plane.""" + """Location from location of Plane.""" @overload def __init__(self, plane: Plane, plane_offset: VectorLike): - """Location corresponding to the angular location of the Plane with - translation plane_offset.""" + """Location from location of Plane translated by plane_offset""" @overload def __init__(self, top_loc: TopLoc_Location): - """Location wrapping the low-level TopLoc_Location object t""" + """Location from low-level TopLoc_Location object""" @overload def __init__(self, gp_trsf: gp_Trsf): - """Location wrapping the low-level gp_Trsf object t""" + """Location from low-level gp_Trsf object""" @overload - def __init__(self, translation: VectorLike, direction: VectorLike, angle: float): - """Location with translation t and rotation around direction by angle - with respect to the original location.""" + def __init__(self, position: VectorLike, direction: VectorLike, angle: float): + """Location from position and rotation around direction by angle""" def __init__( self, *args, **kwargs @@ -1542,6 +1536,7 @@ class Location: position = kwargs.pop("position", None) orientation = kwargs.pop("orientation", None) + direction = kwargs.pop("direction", None) ordering = kwargs.pop("ordering", None) angle = kwargs.pop("angle", None) plane = kwargs.pop("plane", None) @@ -1571,7 +1566,10 @@ class Location: elif isinstance(args[1], (int, float)): angle = args[1] if len(args) > 2: - if isinstance(args[2], (int, float)) and orientation is not None: + if isinstance(args[1], (Vector, Iterable)) and isinstance( + args[2], (int, float) + ): + direction = Vector(args[1]) angle = args[2] elif isinstance(args[2], (Intrinsic, Extrinsic)): ordering = args[2] @@ -1600,7 +1598,7 @@ class Location: elif angle is not None: axis = gp_Ax1( gp_Pnt(0, 0, 0), - Vector(orientation).to_dir() if orientation else gp_Dir(0, 0, 1), + Vector(direction).to_dir() if direction else gp_Dir(0, 0, 1), ) trsf.SetRotation(axis, radians(angle)) From 3877fd58762dba9a835f38eb5287841e3c071d4a Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 12:58:46 -0500 Subject: [PATCH 031/105] Ignore orderless Shapes in _bool_op --- src/build123d/topology/shape_core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index d366ead..3940276 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -2123,7 +2123,11 @@ class Shape(NodeMixin, Generic[TOPODS]): args = list(args) tools = list(tools) # Find the highest order class from all the inputs Solid > Vertex - order_dict = {type(s): type(s).order for s in [self] + args + tools} + order_dict = { + type(s): type(s).order + for s in [self] + args + tools + if hasattr(type(s), "order") + } highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1] # The base of the operation From 68f6ef2125e03aa34fe205b46c739afaeff1a363 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 13:26:17 -0500 Subject: [PATCH 032/105] Convert intersect to use _bool_op and split Wire after intersect --- src/build123d/topology/one_d.py | 172 +++++++++------------ tests/test_direct_api/test_intersection.py | 4 +- 2 files changed, 78 insertions(+), 98 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 25b817a..b0a59c3 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -52,12 +52,11 @@ license: from __future__ import annotations import copy -import numpy as np import warnings -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from itertools import combinations from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians -from typing import TYPE_CHECKING, Literal, TypeAlias, overload +from typing import TYPE_CHECKING, Literal, overload from typing import cast as tcast import numpy as np @@ -729,122 +728,103 @@ class Mixin1D(Shape): 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 + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, + ) -> ShapeList: + # Wrap Shape._bool_op for corrected output + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or ShapeList() + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return ShapeList() + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Wire] = ShapeList([self]) + target: 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)]) + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) 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 + common: list[Vertex | Edge | Wire] = [] + result: ShapeList | 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) + case (_, Plane()): + target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) + operation = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation) + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) - if result: - if not isinstance(result, list): - result = ShapeList([result]) - common.extend(to_vector(result)) + case (_, Vertex() | Edge() | Wire()): + operation = BRepAlgoAPI_Section() + section = bool_op((obj,), (target,), operation) + result = section + if not section: + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) - case Vertex() as obj, target: - if not isinstance(target, ShapeList): - target = ShapeList([target]) + case _ if issubclass(type(target), Shape): + result = target.intersect(obj) - 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 result: + common.extend(result) 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 + common_set = ShapeList() + for shape in common: + if isinstance(shape, Wire): + common_set.extend(shape.edges()) + else: + common_set.append(shape) + common_set = to_vertex(set(to_vector(common_set))) + common_set = filter_shapes_by_order(common_set, [Vertex, Edge]) else: return None diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index daad2bf..3a67415 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -296,7 +296,7 @@ sl1 = Box(2, 2, 2).solid() sl2 = Pos(Z=5) * Box(2, 2, 2).solid() sl3 = Cylinder(2, 1).solid() - Cylinder(1.5, 1).solid() -wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edge().trim(.3, .4), +wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edges()[0].trim(.3, .4), l2 := l1.trim(2, 3), RadiusArc(l1 @ 1, l2 @ 0, 1, short_sagitta=False) ]) @@ -430,7 +430,7 @@ freecad_matrix = [ 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, c2, [Vertex, Vertex], "circle, skew, intersect", None), Case(c1, horz, [Vertex], "circle, horiz, tangent", None), Case(c2, horz, [Vertex], "circle, horiz, tangent", None), Case(c1, vert, [Vertex], "circle, vert, tangent", None), From c384df21c7755d5bdaa58890704dce6c0d19c8f2 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 13:31:40 -0500 Subject: [PATCH 033/105] Intersect: dissolve Wire, Shell after intersection, no need to process 0d, 1d separately --- src/build123d/topology/composite.py | 113 +++++++++++++--------------- src/build123d/topology/three_d.py | 76 +++++++++---------- src/build123d/topology/two_d.py | 71 ++++++++--------- 3 files changed, 127 insertions(+), 133 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 0849e69..3e0b4b3 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -728,24 +728,19 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): args: Sequence, tools: Sequence, operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, - ) -> ShapeList | None: + ) -> ShapeList: # Wrap Shape._bool_op for corrected output - intersections = args[0]._bool_op(args, tools, operation) + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) if isinstance(intersections, ShapeList): - return intersections or None + return intersections if isinstance(intersections, Shape) and not intersections.is_null: return ShapeList([intersections]) - return None + return ShapeList() def expand_compound(compound: Compound) -> ShapeList: shapes = ShapeList(compound.children) for shape_type in [Vertex, Edge, Wire, Face, Shell, Solid]: - new = compound.get_type(shape_type) - if shape_type == Wire: - new = [edge for new_shape in new for edge in new_shape.edges()] - elif shape_type == Shell: - new = [face for new_shape in new for face in new_shape.faces()] - shapes.extend(new) + shapes.extend(compound.get_type(shape_type)) return shapes def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: @@ -772,14 +767,20 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): return filtered_shapes - common_set: ShapeList[Vertex | Edge | Face | Solid] = expand_compound(self) + common_set: ShapeList[Shape] = expand_compound(self) target: ShapeList | Shape for other in to_intersect: # Conform target type - # Vertices need to be Vector for set() match other: case Axis(): - target = Edge(other) + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) case Plane(): target = Face.make_plane(other) case Vector(): @@ -794,58 +795,50 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): raise ValueError(f"Unsupported type to_intersect: {type(other)}") # Find common matches - common: list[Vector | Edge | Face] = [] - result: ShapeList | Shape | None + common: list[Vertex | Edge | Wire | Face | Shell | Solid] = [] + result: ShapeList for obj in common_set: - match (obj, target): - case (Vertex(), Vertex()): - result = obj.intersect(target) - - case (Edge(), Edge() | Wire()): - result = obj.intersect(target) - - case (_, ShapeList()): - result = ShapeList() - for t in target: - if ( - not isinstance(obj, Edge) and not isinstance(t, (Edge)) - ) or (isinstance(obj, Solid) or isinstance(t, Solid)): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - # Many Solid + Edge combinations need Common - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (t,), operation) or []) - operation = BRepAlgoAPI_Section() - result.extend(bool_op((obj,), (t,), operation) or []) - - case _ if issubclass(type(target), Shape): - if isinstance(target, Wire): - targets = target.edges() - elif isinstance(target, Shell): - targets = target.faces() - else: - targets = ShapeList([target]) - - result = ShapeList() - for t in targets: - if ( - not isinstance(obj, Edge) and not isinstance(t, (Edge)) - ) or (isinstance(obj, Solid) or isinstance(t, Solid)): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - # Many Solid + Edge combinations need Common - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (t,), operation) or []) - operation = BRepAlgoAPI_Section() - result.extend(bool_op((obj,), (t,), operation) or []) + if isinstance(target, Shape): + target = ShapeList([target]) + result = ShapeList() + for t in target: + operation = BRepAlgoAPI_Section() + result.extend(bool_op((obj,), (t,), operation)) + if ( + not isinstance(obj, Edge | Wire) + and not isinstance(t, Edge | Wire) + ) or ( + isinstance(obj, Solid | Compound) + or isinstance(t, Solid | Compound) + ): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + # Many Solid + Edge combinations need Common + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (t,), operation)) if result: - common.extend(to_vector(result)) + common.extend(result) + expanded: ShapeList = ShapeList() if common: - common_set = to_vertex(set(common)) + for shape in common: + if isinstance(shape, Compound): + expanded.extend(expand_compound(shape)) + else: + expanded.append(shape) + + if expanded: + common_set = ShapeList() + for shape in expanded: + if isinstance(shape, Wire): + common_set.extend(shape.edges()) + elif isinstance(shape, Shell): + common_set.extend(shape.faces()) + else: + common_set.append(shape) + common_set = to_vertex(set(to_vector(common_set))) common_set = filter_shapes_by_order( common_set, [Vertex, Edge, Face, Solid] ) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 9e22ce5..16379fb 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -443,14 +443,14 @@ class Mixin3D(Shape): args: Sequence, tools: Sequence, operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, - ) -> ShapeList | None: + ) -> ShapeList: # Wrap Shape._bool_op for corrected output - intersections = args[0]._bool_op(args, tools, operation) + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) if isinstance(intersections, ShapeList): - return intersections or None + return intersections or ShapeList() if isinstance(intersections, Shape) and not intersections.is_null: return ShapeList([intersections]) - return None + return ShapeList() def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: # Remove lower order shapes from list which *appear* to be part of @@ -476,14 +476,20 @@ class Mixin3D(Shape): return filtered_shapes - common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList(self.solids()) - target: ShapeList | Shape + common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList([self]) + target: Shape for other in to_intersect: # Conform target type - # Vertices need to be Vector for set() match other: case Axis(): - target = Edge(other) + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) case Plane(): target = Face.make_plane(other) case Vector(): @@ -496,46 +502,40 @@ class Mixin3D(Shape): raise ValueError(f"Unsupported type to_intersect: {type(other)}") # Find common matches - common: list[Vector | Edge | Face] = [] - result: ShapeList | Shape | None + common: list[Vertex | Edge | Wire | Face | Shell | Solid] = [] + result: ShapeList | None for obj in common_set: match (obj, target): - case (Vertex(), Vertex()): - result = obj.intersect(target) - - case (Edge(), Edge() | Wire()): - result = obj.intersect(target) - case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): - if isinstance(target, Wire): - targets = target.edges() - elif isinstance(target, Shell): - targets = target.faces() - else: - targets = ShapeList([target]) - - result = ShapeList() - for t in targets: - if ( - not isinstance(obj, Edge) and not isinstance(t, (Edge)) - ) or (isinstance(obj, Solid) or isinstance(t, Solid)): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - # Many Solid + Edge combinations need Common - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (t,), operation) or []) - operation = BRepAlgoAPI_Section() - result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation) + if ( + not isinstance(obj, Edge | Wire) + and not isinstance(target, (Edge | Wire)) + ) or (isinstance(obj, Solid) or isinstance(target, Solid)): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + # Many Solid + Edge combinations need Common + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) case _ if issubclass(type(target), Shape): result = target.intersect(obj) if result: - common.extend(to_vector(result)) + common.extend(result) if common: - common_set = to_vertex(set(common)) + common_set = ShapeList() + for shape in common: + if isinstance(shape, Wire): + common_set.extend(shape.edges()) + elif isinstance(shape, Shell): + common_set.extend(shape.faces()) + else: + common_set.append(shape) + common_set = to_vertex(set(to_vector(common_set))) common_set = filter_shapes_by_order( common_set, [Vertex, Edge, Face, Solid] ) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 862184b..2519c82 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -301,14 +301,14 @@ class Mixin2D(ABC, Shape): args: Sequence, tools: Sequence, operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, - ) -> ShapeList | None: + ) -> ShapeList: # Wrap Shape._bool_op for corrected output - intersections = args[0]._bool_op(args, tools, operation) + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) if isinstance(intersections, ShapeList): - return intersections or None + return intersections or ShapeList() if isinstance(intersections, Shape) and not intersections.is_null: return ShapeList([intersections]) - return None + return ShapeList() def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: # Remove lower order shapes from list which *appear* to be part of @@ -334,14 +334,20 @@ class Mixin2D(ABC, Shape): return filtered_shapes - common_set: ShapeList[Vertex | Edge | Face] = ShapeList(self.faces()) - target: ShapeList | Shape + common_set: ShapeList[Vertex | Edge | Face | Shell] = ShapeList([self]) + target: Shape for other in to_intersect: # Conform target type - # Vertices need to be Vector for set() match other: case Axis(): - target = Edge(other) + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) case Plane(): target = Face.make_plane(other) case Vector(): @@ -354,43 +360,38 @@ class Mixin2D(ABC, Shape): raise ValueError(f"Unsupported type to_intersect: {type(other)}") # Find common matches - common: list[Vector | Edge | Face] = [] - result: ShapeList | Shape | None + common: list[Vertex | Edge | Wire | Face | Shell] = [] + result: ShapeList | None for obj in common_set: match (obj, target): - case (Vertex(), Vertex()): - result = obj.intersect(target) - - case (Edge(), Edge() | Wire()): - result = obj.intersect(target) - case (_, Vertex() | Edge() | Wire() | Face() | Shell()): - if isinstance(target, Wire): - targets = target.edges() - elif isinstance(target, Shell): - targets = target.faces() - else: - targets = ShapeList([target]) - - result = ShapeList() - for t in targets: - if not isinstance(obj, Edge) and not isinstance(t, (Edge)): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (t,), operation) or []) - operation = BRepAlgoAPI_Section() - result.extend(bool_op((obj,), (t,), operation) or []) + operation = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation) + if not isinstance(obj, Edge | Wire) and not isinstance( + target, (Edge | Wire) + ): + # Face + Edge combinations may produce an intersection + # with Common but always with Section. + # No easy way to deduplicate + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) case _ if issubclass(type(target), Shape): result = target.intersect(obj) if result: - common.extend(to_vector(result)) + common.extend(result) if common: - common_set = to_vertex(set(common)) + common_set = ShapeList() + for shape in common: + if isinstance(shape, Wire): + common_set.extend(shape.edges()) + elif isinstance(shape, Shell): + common_set.extend(shape.faces()) + else: + common_set.append(shape) + common_set = to_vertex(set(to_vector(common_set))) common_set = filter_shapes_by_order(common_set, [Vertex, Edge, Face]) else: return None From 5523a2184c27a7936294d312d86f4535f08d05d5 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 14:40:58 -0500 Subject: [PATCH 034/105] Revert mode == Mode.INTERSECT iteration. pass Compound instead --- src/build123d/build_common.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index abfa4a0..d14b556 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -466,13 +466,7 @@ class Builder(ABC, Generic[ShapeT]): elif mode == Mode.INTERSECT: if self._obj is None: raise RuntimeError("Nothing to intersect with") - intersections: ShapeList[Shape] = ShapeList() - for target in typed[self._shape]: - result = self._obj.intersect(target) - if result is None: - continue - intersections.extend(result) - combined = self._sub_class(intersections) + combined = self._obj.intersect(Compound(typed[self._shape])) elif mode == Mode.REPLACE: combined = self._sub_class(list(typed[self._shape])) From 5f67a1932afc03f425bd4e8231e80b4c439d2a42 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Fri, 14 Nov 2025 17:30:55 -0500 Subject: [PATCH 035/105] Update for dev merge to Compound and Face(Plane) --- src/build123d/topology/composite.py | 2 +- src/build123d/topology/three_d.py | 2 +- src/build123d/topology/two_d.py | 2 +- tests/test_direct_api/test_intersection.py | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index be136b2..14c67b4 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -782,7 +782,7 @@ class Compound(Mixin3D[TopoDS_Compound]): other.position + other.direction * bbox.diagonal * dist, ) case Plane(): - target = Face.make_plane(other) + target = Face(other) case Vector(): target = Vertex(other) case Location(): diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 80a8239..279f46f 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -491,7 +491,7 @@ class Mixin3D(Shape[TOPODS]): other.position + other.direction * bbox.diagonal * dist, ) case Plane(): - target = Face.make_plane(other) + target = Face(other) case Vector(): target = Vertex(other) case Location(): diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 37f51f0..450fa10 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -350,7 +350,7 @@ class Mixin2D(ABC, Shape[TOPODS]): other.position + other.direction * bbox.diagonal * dist, ) case Plane(): - target = Face.make_plane(other) + target = Face(other) case Vector(): target = Vertex(other) case Location(): diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 3a67415..758fd6f 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -355,10 +355,10 @@ def test_shape_3d(obj, target, expected): run_test(obj, target, expected) # Compound Shapes -cp1 = Compound() + GridLocations(5, 0, 2, 1) * Vertex() -cp2 = Compound() + GridLocations(5, 0, 2, 1) * Line((0, -1), (0, 1)) -cp3 = Compound() + GridLocations(5, 0, 2, 1) * Rectangle(2, 2) -cp4 = Compound() + GridLocations(5, 0, 2, 1) * Box(2, 2, 2) +cp1 = Compound(GridLocations(5, 0, 2, 1) * Vertex()) +cp2 = Compound(GridLocations(5, 0, 2, 1) * Line((0, -1), (0, 1))) +cp3 = Compound(GridLocations(5, 0, 2, 1) * Rectangle(2, 2)) +cp4 = Compound(GridLocations(5, 0, 2, 1) * Box(2, 2, 2)) cv1 = Curve() + [ed1, ed2, ed3] sk1 = Sketch() + [fc1, fc2, fc3] From 173c7b08e22f961265377b595c49fa9ca7920c3a Mon Sep 17 00:00:00 2001 From: x0pherl Date: Thu, 30 Oct 2025 21:08:26 -0400 Subject: [PATCH 036/105] added support for passing an iterable of radii to FilletPolyline. --- src/build123d/objects_curve.py | 22 +++++++++++++---- tests/test_build_line.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 262f9cf..6fbfbf6 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -793,7 +793,7 @@ class FilletPolyline(BaseLineObject): Args: pts (VectorLike | Iterable[VectorLike]): sequence of two or more points - radius (float): fillet radius + radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices close (bool, optional): close end points with extra Edge and corner fillets. Defaults to False mode (Mode, optional): combination mode. Defaults to Mode.ADD @@ -808,7 +808,7 @@ class FilletPolyline(BaseLineObject): def __init__( self, *pts: VectorLike | Iterable[VectorLike], - radius: float, + radius: float | Iterable[float], close: bool = False, mode: Mode = Mode.ADD, ): @@ -819,7 +819,16 @@ class FilletPolyline(BaseLineObject): if len(points) < 2: raise ValueError("FilletPolyline requires two or more pts") - if radius <= 0: + + if isinstance(radius, (int, float)): + radius_list = [radius] * len(points) # Single radius for all points + else: + radius_list = list(radius) + if len(radius_list) != len(points): + raise ValueError( + f"radius list length ({len(radius_list)}) must match points ({len(points)})" + ) + if any(r <= 0 for r in radius_list): raise ValueError("radius must be positive") lines_pts = WorkplaneList.localize(*points) @@ -852,12 +861,14 @@ class FilletPolyline(BaseLineObject): # For each corner vertex create a new fillet Edge fillets = [] - for vertex, edges in vertex_to_edges.items(): + for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: continue other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} third_edge = Edge.make_line(*[v for v in other_vertices]) - fillet_face = Face(Wire(edges + [third_edge])).fillet_2d(radius, [vertex]) + fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( + radius_list[i], [vertex] + ) fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) # Create the Edges that join the fillets @@ -1597,6 +1608,7 @@ class ArcArcTangentLine(BaseEdgeObject): Defaults to Keep.INSIDE mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( "The 'ArcArcTangentLine' object is deprecated and will be removed in a future version.", DeprecationWarning, diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 01c2fbe..7b5858f 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -183,6 +183,49 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 2) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2, 3, 0), + close=True, + ) + + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=-1, + close=True, + ) + + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2), + close=True, + ) + + with BuildLine(Plane.YZ): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2, 3, 4), + close=True, + ) + self.assertEqual(len(p.edges()), 8) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 4) + self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) + with BuildLine(Plane.YZ): p = FilletPolyline( (0, 0, 0), (0, 0, 10), (10, 2, 10), (10, 0, 0), radius=2, close=True From e92255cefcda727304e2022655773813aa728db3 Mon Sep 17 00:00:00 2001 From: x0pherl Date: Fri, 31 Oct 2025 23:18:54 -0400 Subject: [PATCH 037/105] updated to handle polygons without closed lines --- src/build123d/objects_curve.py | 11 ++++++----- tests/test_build_line.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 6fbfbf6..71d788b 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -824,12 +824,13 @@ class FilletPolyline(BaseLineObject): radius_list = [radius] * len(points) # Single radius for all points else: radius_list = list(radius) - if len(radius_list) != len(points): + if len(radius_list) != len(points) - int(not close) * 2: raise ValueError( - f"radius list length ({len(radius_list)}) must match points ({len(points)})" + f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})" ) - if any(r <= 0 for r in radius_list): - raise ValueError("radius must be positive") + for r in radius_list: + if r <= 0: + raise ValueError(f"radius {r} must be positive") lines_pts = WorkplaneList.localize(*points) @@ -867,7 +868,7 @@ class FilletPolyline(BaseLineObject): other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} third_edge = Edge.make_line(*[v for v in other_vertices]) fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( - radius_list[i], [vertex] + radius_list[i - int(not close)], [vertex] ) fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 7b5858f..be4cd8d 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -193,6 +193,16 @@ class BuildLineTests(unittest.TestCase): close=True, ) + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2, 3, 4), + close=False, + ) + with self.assertRaises(ValueError): p = FilletPolyline( (0, 0), From dc90a4b15a291b2584bd4917f9bd71eef7889769 Mon Sep 17 00:00:00 2001 From: Alex Verschoot Date: Sun, 16 Nov 2025 15:48:30 +0100 Subject: [PATCH 038/105] Changed the FilletPolyLine to be compatible with 0-radius fillets, where it should behave like a normal Polyline --- src/build123d/objects_curve.py | 109 ++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 71d788b..61731bd 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -787,20 +787,22 @@ class Helix(BaseEdgeObject): class FilletPolyline(BaseLineObject): """Line Object: Fillet Polyline - Create a sequence of straight lines defined by successive points that are filleted to a given radius. Args: pts (VectorLike | Iterable[VectorLike]): sequence of two or more points - radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices + radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices. + A radius of 0 will create a sharp corner (vertex without fillet). + close (bool, optional): close end points with extra Edge and corner fillets. Defaults to False + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Two or more points not provided - ValueError: radius must be positive + ValueError: radius must be non-negative """ _applies_to = [BuildLine._tag] @@ -812,9 +814,9 @@ class FilletPolyline(BaseLineObject): close: bool = False, mode: Mode = Mode.ADD, ): + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - points = flatten_sequence(*pts) if len(points) < 2: @@ -822,30 +824,35 @@ class FilletPolyline(BaseLineObject): if isinstance(radius, (int, float)): radius_list = [radius] * len(points) # Single radius for all points + else: radius_list = list(radius) if len(radius_list) != len(points) - int(not close) * 2: raise ValueError( f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})" ) + for r in radius_list: - if r <= 0: - raise ValueError(f"radius {r} must be positive") + if r < 0: + raise ValueError(f"radius {r} must be non-negative") lines_pts = WorkplaneList.localize(*points) - # Create the polyline + new_edges = [ Edge.make_line(lines_pts[i], lines_pts[i + 1]) for i in range(len(lines_pts) - 1) ] + if close and (new_edges[0] @ 0 - new_edges[-1] @ 1).length > 1e-5: new_edges.append(Edge.make_line(new_edges[-1] @ 1, new_edges[0] @ 0)) + wire_of_lines = Wire(new_edges) # Create a list of vertices from wire_of_lines in the same order as # the original points so the resulting fillet edges are ordered ordered_vertices = [] + for pnts in lines_pts: distance = { v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() @@ -853,46 +860,90 @@ class FilletPolyline(BaseLineObject): ordered_vertices.append(sorted(distance.items(), key=lambda x: x[1])[0][0]) # Fillet the corners - # Create a map of vertices to edges containing that vertex vertex_to_edges = { v: [e for e in wire_of_lines.edges() if v in e.vertices()] for v in ordered_vertices } - # For each corner vertex create a new fillet Edge + # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) fillets = [] + for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: continue - other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} - third_edge = Edge.make_line(*[v for v in other_vertices]) - fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( - radius_list[i - int(not close)], [vertex] - ) - fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) + current_radius = radius_list[i - int(not close)] + + if current_radius == 0: + # For 0 radius, store the vertex as a marker for a sharp corner + fillets.append(None) + + else: + other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} + third_edge = Edge.make_line(*[v for v in other_vertices]) + fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( + current_radius, [vertex] + ) + fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) # Create the Edges that join the fillets if close: - interior_edges = [ - Edge.make_line(fillets[i - 1] @ 1, fillets[i] @ 0) - for i in range(len(fillets)) - ] - end_edges = [] - else: - interior_edges = [ - Edge.make_line(fillets[i] @ 1, f @ 0) for i, f in enumerate(fillets[1:]) - ] - end_edges = [ - Edge.make_line(wire_of_lines @ 0, fillets[0] @ 0), - Edge.make_line(fillets[-1] @ 1, wire_of_lines @ 1), - ] + interior_edges = [] - new_wire = Wire(end_edges + interior_edges + fillets) + for i in range(len(fillets)): + prev_idx = i - 1 + curr_idx = i + # Determine start and end points + if fillets[prev_idx] is None: + start_pt = ordered_vertices[prev_idx] + else: + start_pt = fillets[prev_idx] @ 1 + + if fillets[curr_idx] is None: + end_pt = ordered_vertices[curr_idx] + else: + end_pt = fillets[curr_idx] @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + end_edges = [] + + else: + interior_edges = [] + for i in range(len(fillets) - 1): + curr_idx = i + next_idx = i + 1 + # Determine start and end points + if fillets[curr_idx] is None: + start_pt = ordered_vertices[curr_idx + 1] # +1 because first vertex has no fillet + else: + start_pt = fillets[curr_idx] @ 1 + + if fillets[next_idx] is None: + end_pt = ordered_vertices[next_idx + 1] + else: + end_pt = fillets[next_idx] @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + # Handle end edges + if fillets[0] is None: + start_edge = Edge.make_line(wire_of_lines @ 0, ordered_vertices[1]) + else: + start_edge = Edge.make_line(wire_of_lines @ 0, fillets[0] @ 0) + + if fillets[-1] is None: + end_edge = Edge.make_line(ordered_vertices[-2], wire_of_lines @ 1) + else: + end_edge = Edge.make_line(fillets[-1] @ 1, wire_of_lines @ 1) + end_edges = [start_edge, end_edge] + + # Filter out None values from fillets (these are 0-radius corners) + actual_fillets = [f for f in fillets if f is not None] + new_wire = Wire(end_edges + interior_edges + actual_fillets) super().__init__(new_wire, mode=mode) + class JernArc(BaseEdgeObject): """Line Object: Jern Arc From c7034202f31b909703a2b310aa4ff2886df6cdee Mon Sep 17 00:00:00 2001 From: Alex Verschoot Date: Sun, 16 Nov 2025 16:15:13 +0100 Subject: [PATCH 039/105] Changed the tests to not expect a valueorrer when having a 0 radius, but add two assertEquals so the number of Circles and Lines should be correct --- tests/test_build_line.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index be4cd8d..16f89ce 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -183,7 +183,7 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 2) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) - with self.assertRaises(ValueError): + with BuildLine(Plane.YZ): p = FilletPolyline( (0, 0), (10, 0), @@ -192,6 +192,9 @@ class BuildLineTests(unittest.TestCase): radius=(1, 2, 3, 0), close=True, ) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 3) + self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) + with self.assertRaises(ValueError): p = FilletPolyline( From 1095f3ee4c02debba2f7473e9938dfcb50b63449 Mon Sep 17 00:00:00 2001 From: x0pherl Date: Fri, 7 Nov 2025 21:40:11 -0500 Subject: [PATCH 040/105] changes to make development more friendly on MacOS --- .gitignore | 3 +++ CONTRIBUTING.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ed011f3..a79817d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ venv.bak/ # Profiling debris. prof/ + +# MacOS cruft +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1344c3..78f6540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,8 +3,8 @@ tests, ensure they build and pass, and ensure that `pylint` and `mypy` are happy with your code. - Install `pip` following their [documentation](https://pip.pypa.io/en/stable/installation/). -- Install development dependencies: `pip install -e .[development]` -- Install docs dependencies: `pip install -e .[docs]` +- Install development dependencies: `pip install -e ".[development]"` +- Install docs dependencies: `pip install -e ".[docs]"` - Install `build123d` in editable mode from current dir: `pip install -e .` - Run tests with: `python -m pytest -n auto` - Build docs with: `cd docs && make html` From d329cf109484ccbb96872b311d4779d949c813bb Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Mon, 17 Nov 2025 10:09:54 -0600 Subject: [PATCH 041/105] initial changes to support BytesIO --- src/build123d/exporters3d.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py index 749e331..505d4aa 100644 --- a/src/build123d/exporters3d.py +++ b/src/build123d/exporters3d.py @@ -182,7 +182,7 @@ def export_brep( def export_gltf( to_export: Shape, - file_path: PathLike | str | bytes, + file_path: PathLike | str | bytes | BytesIO, unit: Unit = Unit.MM, binary: bool = False, linear_deflection: float = 0.001, @@ -198,7 +198,7 @@ def export_gltf( Args: to_export (Shape): object or assembly - file_path (Union[PathLike, str, bytes]): glTF file path + file_path (Union[PathLike, str, bytes, BytesIO]): glTF file path unit (Unit, optional): shape units. Defaults to Unit.MM. binary (bool, optional): output format. Defaults to False. linear_deflection (float, optional): A linear deflection setting which limits @@ -234,9 +234,12 @@ def export_gltf( # Create the XCAF document doc: TDocStd_Document = _create_xde(to_export, unit) + if not isinstance(file_path, BytesIO): + file_path = fsdecode(file_path) + # Write the glTF file writer = RWGltf_CafWriter( - theFile=TCollection_AsciiString(fsdecode(file_path)), theIsBinary=binary + theFile=TCollection_AsciiString(file_path, theIsBinary=binary ) writer.SetParallel(True) index_map = TColStd_IndexedDataMapOfStringString() @@ -262,7 +265,7 @@ def export_gltf( def export_step( to_export: Shape, - file_path: PathLike | str | bytes, + file_path: PathLike | str | bytes | BytesIO, unit: Unit = Unit.MM, write_pcurves: bool = True, precision_mode: PrecisionMode = PrecisionMode.AVERAGE, @@ -277,7 +280,7 @@ def export_step( Args: to_export (Shape): object or assembly - file_path (Union[PathLike, str, bytes]): step file path + file_path (Union[PathLike, str, bytes, BytesIO]): step file path unit (Unit, optional): shape units. Defaults to Unit.MM. write_pcurves (bool, optional): write parametric curves to the STEP file. Defaults to True. @@ -326,7 +329,10 @@ def export_step( Interface_Static.SetIVal_s("write.precision.mode", precision_mode.value) writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs) - status = writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone + if not isinstance(file_path, BytesIO): + file_path = fspath(file_path) + + status = writer.Write(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone if not status: raise RuntimeError("Failed to write STEP file") @@ -335,7 +341,7 @@ def export_step( def export_stl( to_export: Shape, - file_path: PathLike | str | bytes, + file_path: PathLike | str | bytes | BytesIO, tolerance: float = 1e-3, angular_tolerance: float = 0.1, ascii_format: bool = False, @@ -346,7 +352,7 @@ def export_stl( Args: to_export (Shape): object or assembly - file_path (str): The path and file name to write the STL output to. + file_path (Union[PathLike, str, bytes, BytesIO]): The path and file name to write the STL output to. tolerance (float, optional): A linear deflection setting which limits the distance between a curve and its tessellation. Setting this value too low will result in large meshes that can consume computing resources. Setting the value too high can @@ -369,6 +375,7 @@ def export_stl( writer.ASCIIMode = ascii_format - file_path = str(file_path) + if not isinstance(file_path, BytesIO): + file_path = fsdecode(file_path) return writer.Write(to_export.wrapped, file_path) From 10ef3b0734b442abad02bd97f156bd2146a3b5d7 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Mon, 17 Nov 2025 18:40:50 -0500 Subject: [PATCH 042/105] Shrink margin and center --- docs/assets/build123d_logo/logo-banner.svg | 349 ++++++++++++++++++++- 1 file changed, 347 insertions(+), 2 deletions(-) diff --git a/docs/assets/build123d_logo/logo-banner.svg b/docs/assets/build123d_logo/logo-banner.svg index cb11160..cbfb8d7 100644 --- a/docs/assets/build123d_logo/logo-banner.svg +++ b/docs/assets/build123d_logo/logo-banner.svg @@ -1,2 +1,347 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7f4e92f0bf21f7d08725c376de99d25644ac58c2 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Mon, 17 Nov 2025 22:05:45 -0600 Subject: [PATCH 043/105] enable BytesIO in STEP, STL and 3MF (via lib3mf/Mesher). Add necessary tests --- src/build123d/exporters.py | 20 +++++++++++++++----- src/build123d/exporters3d.py | 26 +++++++++++--------------- src/build123d/mesher.py | 29 ++++++++++++++++++----------- tests/test_exporters.py | 14 +++++++++++++- tests/test_exporters3d.py | 5 +++-- tests/test_mesher.py | 9 +++++++++ 6 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index a229fa2..687e2f1 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -34,6 +34,7 @@ import math import xml.etree.ElementTree as ET from copy import copy from enum import Enum, auto +from io import BytesIO from os import PathLike, fsdecode from typing import Any, TypeAlias from warnings import warn @@ -636,13 +637,13 @@ class ExportDXF(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, file_name: PathLike | str | bytes): + def write(self, file_name: PathLike | str | bytes | BytesIO): """write Writes the DXF data to the specified file name. Args: - file_name (PathLike | str | bytes): The file name (including path) where + file_name (PathLike | str | bytes | BytesIO): The file name (including path) where the DXF data will be written. """ # Reset the main CAD viewport of the model space to the @@ -650,7 +651,12 @@ class ExportDXF(Export2D): # https://github.com/gumyr/build123d/issues/382 tracks # exposing viewport control to the user. zoom.extents(self._modelspace) - self._document.saveas(fsdecode(file_name)) + + if not isinstance(file_name, BytesIO): + file_name = fsdecode(file_name) + self._document.saveas(file_name) + else: + self._document.write(file_name, fmt="bin") # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1497,13 +1503,13 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, path: PathLike | str | bytes): + def write(self, path: PathLike | str | bytes | BytesIO): """write Writes the SVG data to the specified file path. Args: - path (PathLike | str | bytes): The file path where the SVG data will be written. + path (PathLike | str | bytes | BytesIO): The file path where the SVG data will be written. """ # pylint: disable=too-many-locals bb = self._bounds @@ -1549,5 +1555,9 @@ class ExportSVG(Export2D): xml = ET.ElementTree(svg) ET.indent(xml, " ") + + if not isinstance(path, BytesIO): + path = fsdecode(path) + # xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=False) xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=None) diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py index 505d4aa..759464a 100644 --- a/src/build123d/exporters3d.py +++ b/src/build123d/exporters3d.py @@ -182,7 +182,7 @@ def export_brep( def export_gltf( to_export: Shape, - file_path: PathLike | str | bytes | BytesIO, + file_path: PathLike | str | bytes, unit: Unit = Unit.MM, binary: bool = False, linear_deflection: float = 0.001, @@ -198,7 +198,7 @@ def export_gltf( Args: to_export (Shape): object or assembly - file_path (Union[PathLike, str, bytes, BytesIO]): glTF file path + file_path (Union[PathLike, str, bytes]): glTF file path unit (Unit, optional): shape units. Defaults to Unit.MM. binary (bool, optional): output format. Defaults to False. linear_deflection (float, optional): A linear deflection setting which limits @@ -234,12 +234,9 @@ def export_gltf( # Create the XCAF document doc: TDocStd_Document = _create_xde(to_export, unit) - if not isinstance(file_path, BytesIO): - file_path = fsdecode(file_path) - # Write the glTF file writer = RWGltf_CafWriter( - theFile=TCollection_AsciiString(file_path, theIsBinary=binary + theFile=TCollection_AsciiString(fsdecode(file_path)), theIsBinary=binary ) writer.SetParallel(True) index_map = TColStd_IndexedDataMapOfStringString() @@ -330,9 +327,12 @@ def export_step( writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs) if not isinstance(file_path, BytesIO): - file_path = fspath(file_path) + status = ( + writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone + ) + else: + status = writer.WriteStream(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone - status = writer.Write(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone if not status: raise RuntimeError("Failed to write STEP file") @@ -341,7 +341,7 @@ def export_step( def export_stl( to_export: Shape, - file_path: PathLike | str | bytes | BytesIO, + file_path: PathLike | str | bytes, tolerance: float = 1e-3, angular_tolerance: float = 0.1, ascii_format: bool = False, @@ -352,7 +352,7 @@ def export_stl( Args: to_export (Shape): object or assembly - file_path (Union[PathLike, str, bytes, BytesIO]): The path and file name to write the STL output to. + file_path (Union[PathLike, str, bytes]): The path and file name to write the STL output to. tolerance (float, optional): A linear deflection setting which limits the distance between a curve and its tessellation. Setting this value too low will result in large meshes that can consume computing resources. Setting the value too high can @@ -374,8 +374,4 @@ def export_stl( writer = StlAPI_Writer() writer.ASCIIMode = ascii_format - - if not isinstance(file_path, BytesIO): - file_path = fsdecode(file_path) - - return writer.Write(to_export.wrapped, file_path) + return writer.Write(to_export.wrapped, fsdecode(file_path)) diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index 5433848..8c4ce42 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -83,6 +83,7 @@ license: # pylint: disable=no-name-in-module, import-error import copy as copy_module import ctypes +from io import BytesIO import math import os import sys @@ -312,12 +313,12 @@ class Mesher: # Round off the vertices to avoid vertices within tolerance being # considered as different vertices digits = -int(round(math.log(TOLERANCE, 10), 1)) - + # Create vertex to index mapping directly vertex_to_idx = {} next_idx = 0 vert_table = {} - + # First pass - create mapping for i, (x, y, z) in enumerate(ocp_mesh_vertices): key = (round(x, digits), round(y, digits), round(z, digits)) @@ -325,17 +326,16 @@ class Mesher: vertex_to_idx[key] = next_idx next_idx += 1 vert_table[i] = vertex_to_idx[key] - + # Create vertices array in one shot vertices_3mf = [ - Lib3MF.Position((ctypes.c_float * 3)(*v)) - for v in vertex_to_idx.keys() + Lib3MF.Position((ctypes.c_float * 3)(*v)) for v in vertex_to_idx.keys() ] - + # Pre-allocate triangles array and process in bulk c_uint3 = ctypes.c_uint * 3 triangles_3mf = [] - + # Process triangles in bulk for tri in triangles: # Map indices directly without list comprehension @@ -343,11 +343,13 @@ class Mesher: mapped_a = vert_table[a] mapped_b = vert_table[b] mapped_c = vert_table[c] - + # Quick degenerate check without set creation if mapped_a != mapped_b and mapped_b != mapped_c and mapped_c != mapped_a: - triangles_3mf.append(Lib3MF.Triangle(c_uint3(mapped_a, mapped_b, mapped_c))) - + triangles_3mf.append( + Lib3MF.Triangle(c_uint3(mapped_a, mapped_b, mapped_c)) + ) + return (vertices_3mf, triangles_3mf) def _add_color(self, b3d_shape: Shape, mesh_3mf: Lib3MF.MeshObject): @@ -540,7 +542,7 @@ class Mesher: """write Args: - file_name Union[Pathlike, str, bytes]: file path + file_name Union[Pathlike, str, bytes, BytesIO]: file path Raises: ValueError: Unknown file format - must be 3mf or stl @@ -551,3 +553,8 @@ class Mesher: raise ValueError(f"Unknown file format {output_file_extension}") writer = self.model.QueryWriter(output_file_extension[1:]) writer.WriteToFile(file_name) + + def write_stream(self, stream: BytesIO, file_type: str): + writer = self.model.QueryWriter(file_type) + result = bytes(writer.WriteToBuffer()) + stream.write(result) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index f95a92e..11c63da 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -1,3 +1,4 @@ +from io import BytesIO from os import fsdecode, fsencode from typing import Union, Iterable import math @@ -194,7 +195,9 @@ class ExportersTestCase(unittest.TestCase): @pytest.mark.parametrize( - "format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"] + "format", + (Path, fsencode, fsdecode), + ids=["path", "bytes", "str"], ) @pytest.mark.parametrize("Exporter", (ExportSVG, ExportDXF)) def test_pathlike_exporters(tmp_path, format, Exporter): @@ -205,5 +208,14 @@ def test_pathlike_exporters(tmp_path, format, Exporter): exporter.write(path) +@pytest.mark.parametrize("Exporter", (ExportSVG, ExportDXF)) +def test_exporters_in_memory(Exporter): + buffer = BytesIO() + sketch = ExportersTestCase.create_test_sketch() + exporter = Exporter() + exporter.add_shape(sketch) + exporter.write(buffer) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_exporters3d.py b/tests/test_exporters3d.py index 644ac3e..bf1c1bd 100644 --- a/tests/test_exporters3d.py +++ b/tests/test_exporters3d.py @@ -206,10 +206,11 @@ def test_pathlike_exporters(tmp_path, format, exporter): exporter(box, path) -def test_export_brep_in_memory(): +@pytest.mark.parametrize("exporter", (export_step, export_brep)) +def test_exporters_in_memory(exporter): buffer = io.BytesIO() box = Box(1, 1, 1).locate(Pos(-1, -2, -3)) - export_brep(box, buffer) + exporter(box, buffer) if __name__ == "__main__": diff --git a/tests/test_mesher.py b/tests/test_mesher.py index 0be4ccb..59b7214 100644 --- a/tests/test_mesher.py +++ b/tests/test_mesher.py @@ -1,4 +1,5 @@ import unittest, uuid +from io import BytesIO from packaging.specifiers import SpecifierSet from pathlib import Path from os import fsdecode, fsencode @@ -237,5 +238,13 @@ def test_pathlike_mesher(tmp_path, format): importer.read(path) +@pytest.mark.parametrize("file_type", ("3mf", "stl")) +def test_in_memory_mesher(file_type): + stream = BytesIO() + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + exporter.write_stream(stream, file_type) + + if __name__ == "__main__": unittest.main() From f144ca5aa89d1df5be325befcd5d45d946d1ca8d Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 18 Nov 2025 10:34:21 -0500 Subject: [PATCH 044/105] Fix tutorial links --- docs/tutorial_surface_heart_token.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial_surface_heart_token.rst b/docs/tutorial_surface_heart_token.rst index ac819ad..9931108 100644 --- a/docs/tutorial_surface_heart_token.rst +++ b/docs/tutorial_surface_heart_token.rst @@ -20,7 +20,7 @@ the object. To illustrate this process, we will create the following game token: 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. +:doc:`tutorial_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. @@ -128,5 +128,5 @@ from the heart. Next steps ---------- -Continue to :doc:`tutorial_heart_token` for an advanced example using +Continue to :doc:`tutorial_spitfire_wing_gordon` for an advanced example using :meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing. From 68e1c19c73aa2306b5dd05b66a7e8e8d31e44edf Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Tue, 18 Nov 2025 13:16:13 -0500 Subject: [PATCH 045/105] Add headings, demo images, section with basic usage and feature overview, slim down installation --- CONTRIBUTING.md | 37 ++++- README.md | 234 ++++++++++++++++++++++++++---- docs/assets/readme/add_part.png | Bin 0 -> 22053 bytes docs/assets/readme/create_1d.png | Bin 0 -> 9742 bytes docs/assets/readme/extend.png | Bin 0 -> 22857 bytes docs/assets/readme/readme.py | 95 ++++++++++++ docs/assets/readme/upgrade_2d.png | Bin 0 -> 39215 bytes 7 files changed, 329 insertions(+), 37 deletions(-) create mode 100644 docs/assets/readme/add_part.png create mode 100644 docs/assets/readme/create_1d.png create mode 100644 docs/assets/readme/extend.png create mode 100644 docs/assets/readme/readme.py create mode 100644 docs/assets/readme/upgrade_2d.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78f6540..24a462c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,34 @@ -When writing code for inclusion in build123d please add docs and +# Contributing + +When writing code for inclusion in build123d please add docstrings and tests, ensure they build and pass, and ensure that `pylint` and `mypy` are happy with your code. -- Install `pip` following their [documentation](https://pip.pypa.io/en/stable/installation/). -- Install development dependencies: `pip install -e ".[development]"` -- Install docs dependencies: `pip install -e ".[docs]"` -- Install `build123d` in editable mode from current dir: `pip install -e .` +## Setup + +Ensure `pip` is installed and [up-to-date](https://pip.pypa.io/en/stable/installation/#upgrading-pip). +Clone the build123d repo and install in editable mode: + +``` +git clone https://github.com/gumyr/build123d.git +cd build123d +pip install -e . +``` + +Install development and docs dependencies: + +``` +pip install -e ".[development]" +pip install -e ".[docs]" +``` + +## Before submitting a PR + - Run tests with: `python -m pytest -n auto` -- Build docs with: `cd docs && make html` -- Check added files' style with: `pylint ` +- Check added files' style with: `pylint ` - Check added files' type annotations with: `mypy ` -- Run black formatter against files' changed: `black --config pyproject.toml ` (where the pyproject.toml is from this project's repository) +- Run black formatter against files' changed: `black ` + +To verify documentation changes build docs with: +- Linux/macOS: `./docs/make html` +- Windows: `./docs/make.bat html` diff --git a/README.md b/README.md index cb7c309..afb9320 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -

+

build123d logo -

+

[![Documentation Status](https://readthedocs.org/projects/build123d/badge/?version=latest)](https://build123d.readthedocs.io/en/latest/?badge=latest) [![tests](https://github.com/gumyr/build123d/actions/workflows/test.yml/badge.svg)](https://github.com/gumyr/build123d/actions/workflows/test.yml) @@ -18,63 +18,239 @@ [![PyPI - Wheel](https://img.shields.io/pypi/wheel/build123d.svg)](https://pypi.org/project/build123d/) [![DOI](https://zenodo.org/badge/510925389.svg)](https://doi.org/10.5281/zenodo.14872322) +[Documentation](https://build123d.readthedocs.io/en/latest/index.html) | +[Cheat Sheet](https://build123d.readthedocs.io/en/latest/cheat_sheet.html) | +[Discord](https://discord.com/invite/Bj9AQPsCfx) | +[Discussions](https://github.com/gumyr/build123d/discussions) | +[Issues](https://github.com/gumyr/build123d/issues ) | +[Contributing]() -Build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to popular CAD tools such as [FreeCAD] and SolidWorks. +build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. + +
+ bracket + key cap + hangar +
+ +## Features Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers: - Minimal or no internal state depending on mode, - Explicit 1D, 2D, and 3D geometry classes with well-defined operations, - Extensibility through subclassing and functional composition—no monkey patching, - Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints, -- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)), -- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic. +- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (`Solid(shell)`, `tuple(Vector)`), +- Operator-driven modeling (`obj += sub_obj`, `Plane.XZ * Pos(X=5) * Rectangle(1, 1)`) for algebraic, readable, and composable design logic, +- Export formats to popular CAD tools such as [FreeCAD] and SolidWorks. -The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath. +## Usage -The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html). +Although wildcard imports are generally bad practice, build123d scripts are usually self contained and importing the large number of objects and methods into the namespace is common: -There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with [CadQuery]) where you can ask for help in the build123d channel. +```py +from build123d import * +``` -The recommended method for most users to install **build123d** is: +### Constructing a 1D Shape + +Edges, Wires (multiple connected Edges), and Curves (a Compound of Edges and Wires) are the 1D Shapes available in build123d. A single Edge can be created from a Line object with two vector-like positions: + +```py +line = Line((0, -3), (6, -3)) +``` + +Additional Edges and Wires may be added to (or subtracted from) the initial line. These objects can reference coordinates along another line through the position (`@`) and tangent (`%`) operators to specify input Vectors: + +```py +line += JernArc(line @ 1, line % 1, radius=3, arc_size=180) +line += PolarLine(line @ 1, 6, direction=line % 1) +``` + +
+ create 1d +
+ +### Upgrading to 2D and 3D + +Faces, Shells (multiple connected Faces), and Sketches (a Compound of Faces and Shells) are the 2d Shapes available in build123d. The previous line is sufficiently defined to close the Wire and create a Face with `make_hull`: + +```py +sketch = make_hull(line) +``` + +A Circle face is translated with `Pos`, a Location object like `Rot` for transforming Shapes, and subtracted from the sketch. This sketch face is then extruded into a Solid part: + +```py +sketch -= Pos(6, 0, 0) * Circle(2) +part = extrude(sketch, amount= 2) +``` + +
+ upgrade 2d +
+ +### Adding to and modifying part + +Solids and Parts (a Compound of Solids) are the 1D Shapes available in build123d. A second part can be created from an additional Face. Planes can also be used for positioning and orienting Shape objects. Many objects offer an affordance for alignment relative to the object origin: + +```py +plate_sketch = Plane.YZ * RectangleRounded(16, 6, 1.5, align=(Align.CENTER, Align.MIN)) +plate = extrude(plate_sketch, amount=-2) +``` + +Shape topology can be extracted from Shapes with selectors which return ShapeLists. ShapeLists offer methods for sorting, grouping, and filtering Shapes by Shape properties, such as finding a Face by area and selecting position along an Axis and specifying a target with a list slice. A Plane is created from the specified Face to locate an iterable of Locations to place multiple objects on the second part before it is added to the main part: + +```py +plate_face = plate.faces().group_by(Face.area)[-1].sort_by(Axis.X)[-1] +plate -= Plane(plate_face) * GridLocations(13, 3, 2, 2) * CounterSinkHole(.5, 1, 2) + +part += plate +``` + +ShapeList selectors and operators offer powerful methods for specifying Shape features through properties such as length/area/volume, orientation relative to an Axis or Plane, and geometry type: + +```py +part = fillet(part.edges().filter_by(lambda e: e.length == 2).filter_by(Axis.Z), 1) +bore = part.faces().filter_by(GeomType.CYLINDER).filter_by(lambda f: f.radius == 2) +part = chamfer(bore.edges(), .2) +``` + +
+ modify part +
+ +### Builder Mode + +The previous construction is through the **Algebra Mode** interface, which follows a stateless paradigm where each object is explicitly tracked and mutated by algebraic operators. + +**Builder Mode** is an alternative build123d interface where state is tracked and structured in a design history-like way where each dimension is distinct. Operations are aware pending faces and edges from Build contexts and location transformations are applied to all child objects in Build and Locations contexts. Builder mode also introduces the `mode` affordance to objects to specify how new Shapes are combined with the context: + +```py +with BuildPart() as part_context: + with BuildSketch() as sketch: + with BuildLine() as line: + l1 = Line((0, -3), (6, -3)) + l2 = JernArc(l1 @ 1, l1 % 1, radius=3, arc_size=180) + l3 = PolarLine(l2 @ 1, 6, direction=l2 % 1) + l4 = Line(l1 @ 0, l3 @ 1) + make_face() + + with Locations((6, 0, 0)): + Circle(2, mode=Mode.SUBTRACT) + + extrude(amount=2) + + with BuildSketch(Plane.YZ) as plate_sketch: + RectangleRounded(16, 6, 1.5, align=(Align.CENTER, Align.MIN)) + + plate = extrude(amount=-2) + + with Locations(plate.faces().group_by(Face.area)[-1].sort_by(Axis.X)[-1]): + with GridLocations(13, 3, 2, 2): + CounterSinkHole(.5, 1) + + fillet(edges().filter_by(lambda e: e.length == 2).filter_by(Axis.Z), 1) + bore = faces().filter_by(GeomType.CYLINDER).filter_by(lambda f: f.radius == 2) + chamfer(bore.edges(), .2) +``` + +### Extending objects + +New objects may be created for parametric reusability from base object classes: + +```py +class Punch(BaseSketchObject): + def __init__( + self, + radius: float, + size: float, + blobs: float, + mode: Mode = Mode.ADD, + ): + with BuildSketch() as punch: + if blobs == 1: + Circle(size) + else: + with PolarLocations(radius, blobs): + Circle(size) + + if len(faces()) > 1: + raise RuntimeError("radius is too large for number and size of blobs") + + add(Face(faces()[0].outer_wire()), mode=Mode.REPLACE) + + super().__init__(obj=punch.sketch, mode=mode) + +tape = Rectangle(20, 5) +for i, location in enumerate(GridLocations(5, 0, 4, 1)): + tape -= location * Punch(.8, 1, i + 1) +``` + +
+ extend +
+ +### Data interchange + +build123d can import and export a number data formats for interchange with 2d and 3d design tools, 3D printing slicers, and traditional CAM: + +```py +svg = import_svg("spade.svg") +step = import_step("nema-17-bracket.step") + +export_stl(part, "bracket.stl") +export_step(part_context.part, "bracket.step") +``` + +### Further reading + +More [Examples](https://build123d.readthedocs.io/en/latest/introductory_examples.html) and [Tutorials](https://build123d.readthedocs.io/en/latest/tutorials.html) are found in the documentation. + +## Installation + +For additional installation options see [Installation](https://build123d.readthedocs.io/en/latest/installation.html) + +### Current release + +Installing build123d from `pip` is recommended for most users: ``` pip install build123d ``` -To get the latest non-released version of **build123d** one can install from GitHub using one of the following two commands: - -Linux/MacOS: +If you receive errors about conflicting dependencies, retry the installation after upgrading pip to the latest version: ``` -python3 -m pip install git+https://github.com/gumyr/build123d +pip install --upgrade pip ``` -Windows: +### Pre- release + +build123d is under active development and up-to-date features are found in the +development branch: ``` -python -m pip install git+https://github.com/gumyr/build123d +pip install git+https://github.com/gumyr/build123d ``` -If you receive errors about conflicting dependencies, you can retry the installation after having upgraded pip to the latest version with the following command: -``` -python3 -m pip install --upgrade pip -``` +### Viewers -Development install: +build123d is best used with a viewer. The most popular viewer is [ocp_vscode](https://github.com/bernhard-42/vscode-ocp-cad-viewer), a Python package with a standalone viewer and VS Code extension. Other [Editors & Viewers](https://build123d.readthedocs.io/en/latest/external.html#external) are found in the documentation. -``` -git clone https://github.com/gumyr/build123d.git -cd build123d -python3 -m pip install -e . -``` +## Contributing -Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html). +build123d is a rapidly growing project and we welcome all contributions. Whether you want to share ideas, report bugs, or implement new features, your contribution is welcome! Please see our [CONTRIBUTING.md](CONTRIBUTING.md) file to get started. -Attribution: +## Attribution -Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system. +build123d is derived from portions of [CadQuery], but is extensively refactored and restructured into an independent framework over [Open Cascade]. + +## License + +This project is licensed under the [Apache License 2.0](LICENSE). [BREP]: https://en.wikipedia.org/wiki/Boundary_representation [CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html [FreeCAD]: https://www.freecad.org/ -[Open Cascade]: https://dev.opencascade.org/ +[Open Cascade]: https://dev.opencascade.org/ \ No newline at end of file diff --git a/docs/assets/readme/add_part.png b/docs/assets/readme/add_part.png new file mode 100644 index 0000000000000000000000000000000000000000..87220399ddf7896832a92d28cbdb3f05ae4f7932 GIT binary patch literal 22053 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 ipoUyk?x~~y<SPZ!6KiaBrZa+b&( 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 G2~7a~9~Y$n literal 0 HcmV?d00001 diff --git a/docs/assets/readme/extend.png b/docs/assets/readme/extend.png new file mode 100644 index 0000000000000000000000000000000000000000..d69f726752db0677ec9e3777efd272baae66d7a3 GIT binary patch literal 22857 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 literal 0 HcmV?d00001 diff --git a/docs/assets/readme/readme.py b/docs/assets/readme/readme.py new file mode 100644 index 0000000..2e5f24e --- /dev/null +++ b/docs/assets/readme/readme.py @@ -0,0 +1,95 @@ +import os +from copy import copy + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) + +with BuildPart() as part_context: + with BuildSketch() as sketch: + with BuildLine() as line: + l1 = Line((0, -3), (6, -3)) + l2 = JernArc(l1 @ 1, l1 % 1, radius=3, arc_size=180) + l3 = PolarLine(l2 @ 1, 6, direction=l2 % 1) + l4 = Line(l1 @ 0, l3 @ 1) + make_face() + + with Locations((6, 0, 0)): + Circle(2, mode=Mode.SUBTRACT) + + extrude(amount=2) + + with BuildSketch(Plane.YZ) as plate_sketch: + RectangleRounded(16, 6, 1.5, align=(Align.CENTER, Align.MIN)) + + plate = extrude(amount=-2) + + with Locations(plate.faces().group_by(Face.area)[-1].sort_by(Axis.X)[-1]): + with GridLocations(13, 3, 2, 2): + CounterSinkHole(.5, 1) + + fillet(edges().filter_by(lambda e: e.length == 2).filter_by(Axis.Z), 1) + bore = faces().filter_by(GeomType.CYLINDER).filter_by(lambda f: f.radius == 2) + chamfer(bore.edges(), .2) + +line = Line((0, -3), (6, -3)) +line += JernArc(line @ 1, line % 1, radius=3, arc_size=180) +line += PolarLine(line @ 1, 6, direction=line % 1) + +sketch = make_hull(line.edges()) +sketch -= Pos(6, 0, 0) * Circle(2) +part = extrude(sketch, amount= 2) +part_before = copy(part) + +plate_sketch = Plane.YZ * RectangleRounded(16, 6, 1.5, align=(Align.CENTER, Align.MIN)) +plate = extrude(plate_sketch, amount=-2) +plate_face = plate.faces().group_by(Face.area)[-1].sort_by(Axis.X)[-1] +plate -= Plane(plate_face) * GridLocations(13, 3, 2, 2) * CounterSinkHole(.5, 1, 2) + +part += plate +part_before2 = copy(part) + +part = fillet(part.edges().filter_by(lambda e: e.length == 2).filter_by(Axis.Z), 1) +bore = part.faces().filter_by(GeomType.CYLINDER).filter_by(lambda f: f.radius == 2) +part = chamfer(bore.edges(), .2) + +class Punch(BaseSketchObject): + def __init__( + self, + radius: float, + size: float, + blobs: float, + rotation: float = 0, + mode: Mode = Mode.ADD, + ): + with BuildSketch() as punch: + if blobs == 1: + Circle(size) + else: + with PolarLocations(radius, blobs): + Circle(size) + + if len(faces()) > 1: + raise RuntimeError("radius is too large for number and size of blobs") + + add(Face(faces()[0].outer_wire()), mode=Mode.REPLACE) + + super().__init__(obj=punch.sketch, rotation=rotation, mode=mode) + +tape = Rectangle(20, 5) +for i, location in enumerate(GridLocations(5, 0, 4, 1)): + tape -= location * Punch(.8, 1, i + 1) + +set_defaults(reset_camera=Camera.RESET) +show(line) +save_screenshot(os.path.join(working_path, "create_1d.png")) + +show(sketch, Pos(10, 10) * part_before) +save_screenshot(os.path.join(working_path, "upgrade_2d.png")) + +show(plate, Pos(12, 12) * part_before2, Pos(24, 24) * part) +save_screenshot(os.path.join(working_path, "add_part.png")) + +show(tape) +save_screenshot(os.path.join(working_path, "extend.png")) \ No newline at end of file diff --git a/docs/assets/readme/upgrade_2d.png b/docs/assets/readme/upgrade_2d.png new file mode 100644 index 0000000000000000000000000000000000000000..419ce01c64729a3ff3d86f66468b28201a675e46 GIT binary patch literal 39215 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 literal 0 HcmV?d00001 From 4507d78fff514b10dffce751d099d462dff11e75 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 19 Nov 2025 10:01:58 -0500 Subject: [PATCH 046/105] Added Color.categorical_set that generates a creates a list of visually distinct colors --- src/build123d/geometry.py | 71 ++++++- tests/test_direct_api/test_color.py | 314 ++++++++++++++++++++-------- 2 files changed, 296 insertions(+), 89 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index e17d049..ff8f264 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -34,6 +34,7 @@ from __future__ import annotations # other pylint warning to temp remove: # too-many-arguments, too-many-locals, too-many-public-methods, # too-many-statements, too-many-instance-attributes, too-many-branches +import colorsys import copy as copy_module import itertools import json @@ -1180,7 +1181,7 @@ class Color: `OCCT Color Names `_ - Hexadecimal string may be RGB or RGBA format with leading "#" + Hexadecimal string may be RGB or RGBA format with leading "#" Args: name (str): color, e.g. "blue" or "#0000ff"" @@ -1345,6 +1346,74 @@ class Color: """Color repr""" return f"Color{str(tuple(self))}" + @classmethod + def categorical_set( + cls, + color_count: int, + starting_hue: ColorLike | float = 0.0, + alpha: float | Iterable[float] = 1.0, + ) -> list[Color]: + """Generate a palette of evenly spaced colors. + + Creates a list of visually distinct colors suitable for representing + discrete categories (such as different parts, assemblies, or data + series). Colors are evenly spaced around the hue circle and share + consistent lightness and saturation levels, resulting in balanced + perceptual contrast across all hues. + + Produces palettes similar in appearance to the **Tableau 10** and **D3 + Category10** color sets—both widely recognized standards in data + visualization for their clarity and accessibility. These values have + been empirically chosen to maintain consistent perceived brightness + across hues while avoiding overly vivid or dark colors. + + Args: + color_count (int): Number of colors to generate. + starting_hue (ColorLike | float): Either a Color-like object or + a hue value in the range [0.0, 1.0] that defines the starting color. + alpha (float | Iterable[float]): Alpha value(s) for the colors. Can be a + single float or an iterable of length `color_count`. + + Returns: + list[Color]: List of generated colors. + + Raises: + ValueError: If starting_hue is out of range or alpha length mismatch. + """ + + # --- Determine starting hue --- + if isinstance(starting_hue, float): + if not (0.0 <= starting_hue <= 1.0): + raise ValueError("Starting hue must be within range 0.0–1.0") + elif isinstance(starting_hue, int): + if starting_hue < 0: + raise ValueError("Starting color integer must be non-negative") + rgb = tuple(Color(starting_hue))[:3] + starting_hue = colorsys.rgb_to_hls(*rgb)[0] + else: + raise TypeError( + "Starting hue must be a float in [0,1] or an integer color literal" + ) + + # --- Normalize alpha values --- + if isinstance(alpha, (float, int)): + alphas = [float(alpha)] * color_count + else: + alphas = list(alpha) + if len(alphas) != color_count: + raise ValueError("Number of alpha values must match color_count") + + # --- Generate color list --- + hues = np.linspace( + starting_hue, starting_hue + 1.0, color_count, endpoint=False + ) + colors = [ + cls(*colorsys.hls_to_rgb(h % 1.0, 0.55, 0.9), a) + for h, a in zip(hues, alphas) + ] + + return colors + @staticmethod def _rgb_from_int(triplet: int) -> tuple[float, float, float]: red, remainder = divmod(triplet, 256**2) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 650f5c9..9e50a8a 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -26,8 +26,9 @@ license: """ +import colorsys import copy - +import math import numpy as np import pytest @@ -36,129 +37,196 @@ from build123d.geometry import Color # Overloads -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), - pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), - pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), -]) +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), + pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), + pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), + ], +) def test_overload_name(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), - pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), - pytest.param(Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha"), - pytest.param(Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), (0.1, 0.2, 0.3, 0.5), id="kw rgba"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), + pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), + pytest.param( + Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha" + ), + pytest.param( + Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), + (0.1, 0.2, 0.3, 0.5), + id="kw rgba", + ), + ], +) def test_overload_rgba(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code"), - pytest.param(Color(0x006692, 0x80), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), id="color_code + alpha"), - pytest.param(Color(0x006692, alpha=0x80), (0, 102 / 255, 146 / 255, 128 / 255), id="color_code + kw alpha"), - pytest.param(Color(color_code=0x996692, alpha=0xCC), (153 / 255, 102 / 255, 146 / 255, 204 / 255), id="kw color_code + alpha"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param( + Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code" + ), + pytest.param( + Color(0x006692, 0x80), + (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), + id="color_code + alpha", + ), + pytest.param( + Color(0x006692, alpha=0x80), + (0, 102 / 255, 146 / 255, 128 / 255), + id="color_code + kw alpha", + ), + pytest.param( + Color(color_code=0x996692, alpha=0xCC), + (153 / 255, 102 / 255, 146 / 255, 204 / 255), + id="kw color_code + alpha", + ), + ], +) def test_overload_hex(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"), - pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"), - pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"), - pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga"), - pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"), + pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"), + pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"), + pytest.param( + Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga" + ), + pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"), + ], +) def test_overload_tuple(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) + # ColorLikes -@pytest.mark.parametrize("color_like", [ - pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"), - pytest.param("red", id="name str"), - pytest.param("red ", id="name str whitespace"), - pytest.param(("red",), id="tuple name str"), - pytest.param(("red", 1), id="tuple name str + alpha"), - pytest.param("#ff0000", id="hex str rgb 24bit"), - pytest.param(" #ff0000 ", id="hex str rgb 24bit whitespace"), - pytest.param(("#ff0000",), id="tuple hex str rgb 24bit"), - pytest.param(("#ff0000", 1), id="tuple hex str rgb 24bit + alpha"), - pytest.param("#ff0000ff", id="hex str rgba 24bit"), - pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"), - pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"), - pytest.param(("#ff0000ff", .6), id="tuple hex str rgba 24bit + alpha (not used)"), - pytest.param("#f00", id="hex str rgb 12bit"), - pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"), - pytest.param(("#f00",), id="tuple hex str rgb 12bit"), - pytest.param(("#f00", 1), id="tuple hex str rgb 12bit + alpha"), - pytest.param("#f00f", id="hex str rgba 12bit"), - pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"), - pytest.param(("#f00f",), id="tuple hex str rgba 12bit"), - pytest.param(("#f00f", .6), id="tuple hex str rgba 12bit + alpha (not used)"), - pytest.param(0xff0000, id="hex int"), - pytest.param((0xff0000), id="tuple hex int"), - pytest.param((0xff0000, 0xff), id="tuple hex int + alpha"), - pytest.param((1, 0, 0), id="tuple rgb int"), - pytest.param((1, 0, 0, 1), id="tuple rgba int"), - pytest.param((1., 0., 0.), id="tuple rgb float"), - pytest.param((1., 0., 0., 1.), id="tuple rgba float"), -]) +@pytest.mark.parametrize( + "color_like", + [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"), + pytest.param("red", id="name str"), + pytest.param("red ", id="name str whitespace"), + pytest.param(("red",), id="tuple name str"), + pytest.param(("red", 1), id="tuple name str + alpha"), + pytest.param("#ff0000", id="hex str rgb 24bit"), + pytest.param(" #ff0000 ", id="hex str rgb 24bit whitespace"), + pytest.param(("#ff0000",), id="tuple hex str rgb 24bit"), + pytest.param(("#ff0000", 1), id="tuple hex str rgb 24bit + alpha"), + pytest.param("#ff0000ff", id="hex str rgba 24bit"), + pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"), + pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"), + pytest.param( + ("#ff0000ff", 0.6), id="tuple hex str rgba 24bit + alpha (not used)" + ), + pytest.param("#f00", id="hex str rgb 12bit"), + pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"), + pytest.param(("#f00",), id="tuple hex str rgb 12bit"), + pytest.param(("#f00", 1), id="tuple hex str rgb 12bit + alpha"), + pytest.param("#f00f", id="hex str rgba 12bit"), + pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"), + pytest.param(("#f00f",), id="tuple hex str rgba 12bit"), + pytest.param(("#f00f", 0.6), id="tuple hex str rgba 12bit + alpha (not used)"), + pytest.param(0xFF0000, id="hex int"), + pytest.param((0xFF0000), id="tuple hex int"), + pytest.param((0xFF0000, 0xFF), id="tuple hex int + alpha"), + pytest.param((1, 0, 0), id="tuple rgb int"), + pytest.param((1, 0, 0, 1), id="tuple rgba int"), + pytest.param((1.0, 0.0, 0.0), id="tuple rgb float"), + pytest.param((1.0, 0.0, 0.0, 1.0), id="tuple rgba float"), + ], +) def test_color_likes(color_like): expected = (1, 0, 0, 1) np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) -@pytest.mark.parametrize("color_like, expected", [ - pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"), - pytest.param(1., (1, 1, 1, 1), id="r float"), - pytest.param((1.,), (1, 1, 1, 1), id="tuple r float"), - pytest.param((1., 0.), (1, 0, 1, 1), id="tuple rg float"), -]) + +@pytest.mark.parametrize( + "color_like, expected", + [ + pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"), + pytest.param(1.0, (1, 1, 1, 1), id="r float"), + pytest.param((1.0,), (1, 1, 1, 1), id="tuple r float"), + pytest.param((1.0, 0.0), (1, 0, 1, 1), id="tuple rg float"), + ], +) def test_color_likes_incomplete(color_like, expected): np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) -@pytest.mark.parametrize("color_like", [ - pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"), - pytest.param(("red", 0.6), id="tuple name str + alpha"), - pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"), - pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"), - pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"), - pytest.param(("#f009"), id="tuple hex str rgba 12bit"), - pytest.param((0xff0000, 153), id="tuple hex int + alpha int"), - pytest.param((1., 0., 0., 0.6), id="tuple rbga float"), -]) + +@pytest.mark.parametrize( + "color_like", + [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"), + pytest.param(("red", 0.6), id="tuple name str + alpha"), + pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"), + pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"), + pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"), + pytest.param(("#f009"), id="tuple hex str rgba 12bit"), + pytest.param((0xFF0000, 153), id="tuple hex int + alpha int"), + pytest.param((1.0, 0.0, 0.0, 0.6), id="tuple rbga float"), + ], +) def test_color_likes_alpha(color_like): expected = (1, 0, 0, 0.6) np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) + # Exceptions -@pytest.mark.parametrize("name", [ - pytest.param("build123d", id="invalid color name"), - pytest.param("#ffg", id="invalid rgb 12bit"), - pytest.param("#fffg", id="invalid rgba 12bit"), - pytest.param("#fffgg", id="invalid rgb 24bit"), - pytest.param("#fff00gg", id="invalid rgba 24bit"), - pytest.param("#ff", id="short rgb 12bit"), - pytest.param("#fffff", id="short rgb 24bit"), - pytest.param("#fffffff", id="short rgba 24bit"), - pytest.param("#fffffffff", id="long rgba 24bit"), -]) +@pytest.mark.parametrize( + "name", + [ + pytest.param("build123d", id="invalid color name"), + pytest.param("#ffg", id="invalid rgb 12bit"), + pytest.param("#fffg", id="invalid rgba 12bit"), + pytest.param("#fffgg", id="invalid rgb 24bit"), + pytest.param("#fff00gg", id="invalid rgba 24bit"), + pytest.param("#ff", id="short rgb 12bit"), + pytest.param("#fffff", id="short rgb 24bit"), + pytest.param("#fffffff", id="short rgba 24bit"), + pytest.param("#fffffffff", id="long rgba 24bit"), + ], +) def test_exceptions_color_name(name): with pytest.raises(Exception): Color(name) -@pytest.mark.parametrize("color_type", [ - pytest.param((dict({"name": "red", "alpha": 1},)), id="dict arg"), - pytest.param(("red", "blue"), id="str + str"), - pytest.param((1., "blue"), id="float + str order"), - pytest.param((1, "blue"), id="int + str order"), -]) + +@pytest.mark.parametrize( + "color_type", + [ + pytest.param( + ( + dict( + {"name": "red", "alpha": 1}, + ) + ), + id="dict arg", + ), + pytest.param(("red", "blue"), id="str + str"), + pytest.param((1.0, "blue"), id="float + str order"), + pytest.param((1, "blue"), id="int + str order"), + ], +) def test_exceptions_color_type(color_type): with pytest.raises(Exception): Color(*color_type) + # Methods def test_rgba_wrapped(): c = Color(1.0, 1.0, 0.0, 0.5) @@ -167,17 +235,87 @@ def test_rgba_wrapped(): assert c.wrapped.GetRGB().Blue() == 0.0 assert c.wrapped.Alpha() == 0.5 + def test_copy(): c = Color(0.1, 0.2, 0.3, alpha=0.4) c_copy = copy.copy(c) np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), rtol=1e-5) + def test_str_repr_is(): c = Color(1, 0, 0) assert str(c) == "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'" assert repr(c) == "Color(1.0, 0.0, 0.0, 1.0)" + def test_str_repr_near(): c = Color(1, 0.5, 0) assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'" assert repr(c) == "Color(1.0, 0.5, 0.0, 1.0)" + + +class TestColorCategoricalSet: + def test_returns_expected_number_of_colors(self): + colors = Color.categorical_set(5) + assert len(colors) == 5 + assert all(isinstance(c, Color) for c in colors) + + def test_colors_are_evenly_spaced_in_hue(self): + count = 8 + colors = Color.categorical_set(count) + hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors] + diffs = [(hues[(i + 1) % count] - hues[i]) % 1.0 for i in range(count)] + avg_diff = sum(diffs) / len(diffs) + assert all(math.isclose(d, avg_diff, rel_tol=1e-2) for d in diffs) + + def test_starting_hue_as_float(self): + (r, g, b, _) = tuple(Color.categorical_set(1, starting_hue=0.25)[0]) + h = colorsys.rgb_to_hls(r, g, b)[0] + assert math.isclose(h, 0.25, rel_tol=0.05) + + def test_starting_hue_as_int_hex(self): + # Blue (0x0000FF) should be valid and return a Color + c = Color.categorical_set(1, starting_hue=0x0000FF)[0] + assert isinstance(c, Color) + + def test_starting_hue_invalid_type(self): + with pytest.raises(TypeError): + Color.categorical_set(3, starting_hue="invalid") + + def test_starting_hue_out_of_range(self): + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=1.5) + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=-0.1) + + def test_starting_hue_negative_int(self): + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=-1) + + def test_constant_alpha_applied(self): + colors = Color.categorical_set(3, alpha=0.7) + for c in colors: + (_, _, _, a) = tuple(c) + assert math.isclose(a, 0.7, rel_tol=1e-6) + + def test_iterable_alpha_applied(self): + alphas = (0.1, 0.5, 0.9) + colors = Color.categorical_set(3, alpha=alphas) + for a, c in zip(alphas, colors): + (_, _, _, returned_alpha) = tuple(c) + assert math.isclose(a, returned_alpha, rel_tol=1e-6) + + def test_iterable_alpha_length_mismatch(self): + with pytest.raises(ValueError): + Color.categorical_set(4, alpha=[0.5, 0.7]) + + def test_hues_wrap_around(self): + colors = Color.categorical_set(10, starting_hue=0.95) + hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors] + assert all(0.0 <= h <= 1.0 for h in hues) + + def test_alpha_defaults_to_one(self): + colors = Color.categorical_set(4) + for c in colors: + (_, _, _, a) = tuple(c) + assert math.isclose(a, 1.0, rel_tol=1e-6) From a00cecbc380eadd8fd1dd29df1c31ea8d976234d Mon Sep 17 00:00:00 2001 From: Luke H-W Date: Thu, 20 Nov 2025 02:36:46 +1030 Subject: [PATCH 047/105] Fix Example 14 header in introductory_examples.rst Header had "1." instead of "14." --- docs/introductory_examples.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index a887bb9..610fb6b 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -443,7 +443,7 @@ Counter-sink and counter-bore holes are useful for creating recessed areas for f .. _ex 14: -1. Position on a line with '\@', '\%' and introduce Sweep +14. Position on a line with '\@', '\%' and introduce Sweep ------------------------------------------------------------ build123d includes a feature for finding the position along a line segment. This @@ -1121,3 +1121,4 @@ with ``Until.NEXT`` or ``Until.LAST``. :language: build123d :start-after: [Ex. 36] :end-before: [Ex. 36] + From a5e95fe72f1cbe8c4661e63e114a926ada0e5663 Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 20 Nov 2025 11:15:12 -0500 Subject: [PATCH 048/105] Enhanced make_face so faces can have holes. Added BoundBox.measure --- src/build123d/geometry.py | 12 +++++++++++- src/build123d/operations_sketch.py | 26 ++++++++++++++++--------- tests/test_build_sketch.py | 11 +++++++++++ tests/test_direct_api/test_bound_box.py | 3 +++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index ff8f264..3e3807f 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -41,7 +41,7 @@ import json import logging import warnings from collections.abc import Callable, Iterable, Sequence -from math import degrees, isclose, log10, pi, radians +from math import degrees, isclose, log10, pi, radians, prod from typing import TYPE_CHECKING, Any, TypeAlias, overload import numpy as np @@ -1001,6 +1001,16 @@ class BoundBox: self.max = Vector(x_max, y_max, z_max) #: location of maximum corner self.size = Vector(x_max - x_min, y_max - y_min, z_max - z_min) #: overall size + @property + def measure(self) -> float: + """Return the overall Lebesgue measure of the bounding box. + + - For 1D objects: length + - For 2D objects: area + - For 3D objects: volume + """ + return prod([x for x in self.size if x > TOLERANCE]) + @property def diagonal(self) -> float: """body diagonal length (i.e. object maximum size)""" diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index 6cdf780..e05a542 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -44,6 +44,7 @@ from build123d.topology import ( Sketch, topo_explore_connected_edges, topo_explore_common_vertex, + edges_to_wires, ) from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs @@ -200,26 +201,33 @@ def make_face( ) -> Sketch: """Sketch Operation: make_face - Create a face from the given perimeter edges. + Create a face from the given edges. Args: - edges (Edge): sequence of perimeter edges. Defaults to all - sketch pending edges. + edges (Edge): sequence of edges. Defaults to all sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: - outer_edges = flatten_sequence(edges) + raw_edges = flatten_sequence(edges) elif context is not None: - outer_edges = context.pending_edges + raw_edges = context.pending_edges else: raise ValueError("No objects to create a face") - if not outer_edges: - raise ValueError("No objects to create a hull") - validate_inputs(context, "make_face", outer_edges) + if not raw_edges: + raise ValueError("No objects to create a face") + validate_inputs(context, "make_face", raw_edges) - pending_face = Face(Wire.combine(outer_edges)[0]) + wires = list( + edges_to_wires(raw_edges).sort_by( + lambda w: w.bounding_box().measure, reverse=True + ) + ) + if len(wires) > 1: + pending_face = Face(wires[0], wires[1:]) + else: + pending_face = Face(wires[0]) if pending_face.normal_at().Z < 0: # flip up-side-down faces pending_face = -pending_face diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index c00a504..3909733 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -168,6 +168,17 @@ class TestUpSideDown(unittest.TestCase): sketch = make_face(wire.edges()) self.assertTrue(sketch.faces()[0].normal_at().Z > 0) + def test_make_face_with_holes(self): + with BuildSketch() as skt: + with BuildLine() as perimeter: + CenterArc((0, 0), 3, 0, 360) + with BuildLine() as hole1: + Polyline((-1, 1), (1, 1), (1, 2), (-1, 2), (-1, 1)) + with BuildLine() as hole2: + Airfoil("4020") + make_face() + self.assertEqual(len(skt.face().inner_wires()), 2) + class TestBuildSketchExceptions(unittest.TestCase): """Test exception handling""" diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py index de4ebee..26e4ddf 100644 --- a/tests/test_direct_api/test_bound_box.py +++ b/tests/test_direct_api/test_bound_box.py @@ -43,6 +43,7 @@ class TestBoundBox(unittest.TestCase): # OCC uses some approximations self.assertAlmostEqual(bb1.size.X, 1.0, 1) + self.assertAlmostEqual(bb1.measure, 1.0, 5) # Test adding to an existing bounding box v0 = Vertex(0, 0, 0) @@ -50,6 +51,7 @@ class TestBoundBox(unittest.TestCase): bb3 = bb1.add(bb2) self.assertAlmostEqual(bb3.size, (2, 2, 2), 7) + self.assertAlmostEqual(bb3.measure, 8, 5) bb3 = bb2.add((3, 3, 3)) self.assertAlmostEqual(bb3.size, (3, 3, 3), 7) @@ -61,6 +63,7 @@ class TestBoundBox(unittest.TestCase): bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box()) bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box()) bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box()) + self.assertAlmostEqual(bb2.measure, 9, 5) # Test that bb2 contains bb1 self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2)) self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1)) From 607efade2711eb17d7cf2edf9a5e66921afeb0bb Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 20 Nov 2025 11:50:15 -0500 Subject: [PATCH 049/105] Revert "Enhanced make_face so faces can have holes. Added BoundBox.measure" This reverts commit a5e95fe72f1cbe8c4661e63e114a926ada0e5663. --- src/build123d/geometry.py | 12 +----------- src/build123d/operations_sketch.py | 26 +++++++++---------------- tests/test_build_sketch.py | 11 ----------- tests/test_direct_api/test_bound_box.py | 3 --- 4 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 3e3807f..ff8f264 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -41,7 +41,7 @@ import json import logging import warnings from collections.abc import Callable, Iterable, Sequence -from math import degrees, isclose, log10, pi, radians, prod +from math import degrees, isclose, log10, pi, radians from typing import TYPE_CHECKING, Any, TypeAlias, overload import numpy as np @@ -1001,16 +1001,6 @@ class BoundBox: self.max = Vector(x_max, y_max, z_max) #: location of maximum corner self.size = Vector(x_max - x_min, y_max - y_min, z_max - z_min) #: overall size - @property - def measure(self) -> float: - """Return the overall Lebesgue measure of the bounding box. - - - For 1D objects: length - - For 2D objects: area - - For 3D objects: volume - """ - return prod([x for x in self.size if x > TOLERANCE]) - @property def diagonal(self) -> float: """body diagonal length (i.e. object maximum size)""" diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index e05a542..6cdf780 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -44,7 +44,6 @@ from build123d.topology import ( Sketch, topo_explore_connected_edges, topo_explore_common_vertex, - edges_to_wires, ) from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs @@ -201,33 +200,26 @@ def make_face( ) -> Sketch: """Sketch Operation: make_face - Create a face from the given edges. + Create a face from the given perimeter edges. Args: - edges (Edge): sequence of edges. Defaults to all sketch pending edges. + edges (Edge): sequence of perimeter edges. Defaults to all + sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: - raw_edges = flatten_sequence(edges) + outer_edges = flatten_sequence(edges) elif context is not None: - raw_edges = context.pending_edges + outer_edges = context.pending_edges else: raise ValueError("No objects to create a face") - if not raw_edges: - raise ValueError("No objects to create a face") - validate_inputs(context, "make_face", raw_edges) + if not outer_edges: + raise ValueError("No objects to create a hull") + validate_inputs(context, "make_face", outer_edges) - wires = list( - edges_to_wires(raw_edges).sort_by( - lambda w: w.bounding_box().measure, reverse=True - ) - ) - if len(wires) > 1: - pending_face = Face(wires[0], wires[1:]) - else: - pending_face = Face(wires[0]) + pending_face = Face(Wire.combine(outer_edges)[0]) if pending_face.normal_at().Z < 0: # flip up-side-down faces pending_face = -pending_face diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index 3909733..c00a504 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -168,17 +168,6 @@ class TestUpSideDown(unittest.TestCase): sketch = make_face(wire.edges()) self.assertTrue(sketch.faces()[0].normal_at().Z > 0) - def test_make_face_with_holes(self): - with BuildSketch() as skt: - with BuildLine() as perimeter: - CenterArc((0, 0), 3, 0, 360) - with BuildLine() as hole1: - Polyline((-1, 1), (1, 1), (1, 2), (-1, 2), (-1, 1)) - with BuildLine() as hole2: - Airfoil("4020") - make_face() - self.assertEqual(len(skt.face().inner_wires()), 2) - class TestBuildSketchExceptions(unittest.TestCase): """Test exception handling""" diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py index 26e4ddf..de4ebee 100644 --- a/tests/test_direct_api/test_bound_box.py +++ b/tests/test_direct_api/test_bound_box.py @@ -43,7 +43,6 @@ class TestBoundBox(unittest.TestCase): # OCC uses some approximations self.assertAlmostEqual(bb1.size.X, 1.0, 1) - self.assertAlmostEqual(bb1.measure, 1.0, 5) # Test adding to an existing bounding box v0 = Vertex(0, 0, 0) @@ -51,7 +50,6 @@ class TestBoundBox(unittest.TestCase): bb3 = bb1.add(bb2) self.assertAlmostEqual(bb3.size, (2, 2, 2), 7) - self.assertAlmostEqual(bb3.measure, 8, 5) bb3 = bb2.add((3, 3, 3)) self.assertAlmostEqual(bb3.size, (3, 3, 3), 7) @@ -63,7 +61,6 @@ class TestBoundBox(unittest.TestCase): bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box()) bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box()) bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box()) - self.assertAlmostEqual(bb2.measure, 9, 5) # Test that bb2 contains bb1 self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2)) self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1)) From 02a8c07e0afd5f22f55f231effc96ced6890abdd Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 20 Nov 2025 11:51:04 -0500 Subject: [PATCH 050/105] Reapply "Enhanced make_face so faces can have holes. Added BoundBox.measure" This reverts commit 607efade2711eb17d7cf2edf9a5e66921afeb0bb. --- src/build123d/geometry.py | 12 +++++++++++- src/build123d/operations_sketch.py | 26 ++++++++++++++++--------- tests/test_build_sketch.py | 11 +++++++++++ tests/test_direct_api/test_bound_box.py | 3 +++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index ff8f264..3e3807f 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -41,7 +41,7 @@ import json import logging import warnings from collections.abc import Callable, Iterable, Sequence -from math import degrees, isclose, log10, pi, radians +from math import degrees, isclose, log10, pi, radians, prod from typing import TYPE_CHECKING, Any, TypeAlias, overload import numpy as np @@ -1001,6 +1001,16 @@ class BoundBox: self.max = Vector(x_max, y_max, z_max) #: location of maximum corner self.size = Vector(x_max - x_min, y_max - y_min, z_max - z_min) #: overall size + @property + def measure(self) -> float: + """Return the overall Lebesgue measure of the bounding box. + + - For 1D objects: length + - For 2D objects: area + - For 3D objects: volume + """ + return prod([x for x in self.size if x > TOLERANCE]) + @property def diagonal(self) -> float: """body diagonal length (i.e. object maximum size)""" diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index 6cdf780..e05a542 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -44,6 +44,7 @@ from build123d.topology import ( Sketch, topo_explore_connected_edges, topo_explore_common_vertex, + edges_to_wires, ) from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs @@ -200,26 +201,33 @@ def make_face( ) -> Sketch: """Sketch Operation: make_face - Create a face from the given perimeter edges. + Create a face from the given edges. Args: - edges (Edge): sequence of perimeter edges. Defaults to all - sketch pending edges. + edges (Edge): sequence of edges. Defaults to all sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: - outer_edges = flatten_sequence(edges) + raw_edges = flatten_sequence(edges) elif context is not None: - outer_edges = context.pending_edges + raw_edges = context.pending_edges else: raise ValueError("No objects to create a face") - if not outer_edges: - raise ValueError("No objects to create a hull") - validate_inputs(context, "make_face", outer_edges) + if not raw_edges: + raise ValueError("No objects to create a face") + validate_inputs(context, "make_face", raw_edges) - pending_face = Face(Wire.combine(outer_edges)[0]) + wires = list( + edges_to_wires(raw_edges).sort_by( + lambda w: w.bounding_box().measure, reverse=True + ) + ) + if len(wires) > 1: + pending_face = Face(wires[0], wires[1:]) + else: + pending_face = Face(wires[0]) if pending_face.normal_at().Z < 0: # flip up-side-down faces pending_face = -pending_face diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index c00a504..3909733 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -168,6 +168,17 @@ class TestUpSideDown(unittest.TestCase): sketch = make_face(wire.edges()) self.assertTrue(sketch.faces()[0].normal_at().Z > 0) + def test_make_face_with_holes(self): + with BuildSketch() as skt: + with BuildLine() as perimeter: + CenterArc((0, 0), 3, 0, 360) + with BuildLine() as hole1: + Polyline((-1, 1), (1, 1), (1, 2), (-1, 2), (-1, 1)) + with BuildLine() as hole2: + Airfoil("4020") + make_face() + self.assertEqual(len(skt.face().inner_wires()), 2) + class TestBuildSketchExceptions(unittest.TestCase): """Test exception handling""" diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py index de4ebee..26e4ddf 100644 --- a/tests/test_direct_api/test_bound_box.py +++ b/tests/test_direct_api/test_bound_box.py @@ -43,6 +43,7 @@ class TestBoundBox(unittest.TestCase): # OCC uses some approximations self.assertAlmostEqual(bb1.size.X, 1.0, 1) + self.assertAlmostEqual(bb1.measure, 1.0, 5) # Test adding to an existing bounding box v0 = Vertex(0, 0, 0) @@ -50,6 +51,7 @@ class TestBoundBox(unittest.TestCase): bb3 = bb1.add(bb2) self.assertAlmostEqual(bb3.size, (2, 2, 2), 7) + self.assertAlmostEqual(bb3.measure, 8, 5) bb3 = bb2.add((3, 3, 3)) self.assertAlmostEqual(bb3.size, (3, 3, 3), 7) @@ -61,6 +63,7 @@ class TestBoundBox(unittest.TestCase): bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box()) bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box()) bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box()) + self.assertAlmostEqual(bb2.measure, 9, 5) # Test that bb2 contains bb1 self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2)) self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1)) From 26caed754ca70fd0e73baa05fded717f72d2f94c Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 20 Nov 2025 13:31:25 -0500 Subject: [PATCH 051/105] Removing make_face changes keeping BoundBox.extent --- src/build123d/operations_sketch.py | 27 +++++++++------------------ tests/test_build_sketch.py | 11 ----------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index e05a542..be5380c 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -43,8 +43,6 @@ from build123d.topology import ( Wire, Sketch, topo_explore_connected_edges, - topo_explore_common_vertex, - edges_to_wires, ) from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs @@ -201,33 +199,26 @@ def make_face( ) -> Sketch: """Sketch Operation: make_face - Create a face from the given edges. + Create a face from the given perimeter edges. Args: - edges (Edge): sequence of edges. Defaults to all sketch pending edges. + edges (Edge): sequence of perimeter edges. Defaults to all + sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: - raw_edges = flatten_sequence(edges) + outer_edges = flatten_sequence(edges) elif context is not None: - raw_edges = context.pending_edges + outer_edges = context.pending_edges else: raise ValueError("No objects to create a face") - if not raw_edges: - raise ValueError("No objects to create a face") - validate_inputs(context, "make_face", raw_edges) + if not outer_edges: + raise ValueError("No objects to create a hull") + validate_inputs(context, "make_face", outer_edges) - wires = list( - edges_to_wires(raw_edges).sort_by( - lambda w: w.bounding_box().measure, reverse=True - ) - ) - if len(wires) > 1: - pending_face = Face(wires[0], wires[1:]) - else: - pending_face = Face(wires[0]) + pending_face = Face(Wire.combine(outer_edges)[0]) if pending_face.normal_at().Z < 0: # flip up-side-down faces pending_face = -pending_face diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index 3909733..c00a504 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -168,17 +168,6 @@ class TestUpSideDown(unittest.TestCase): sketch = make_face(wire.edges()) self.assertTrue(sketch.faces()[0].normal_at().Z > 0) - def test_make_face_with_holes(self): - with BuildSketch() as skt: - with BuildLine() as perimeter: - CenterArc((0, 0), 3, 0, 360) - with BuildLine() as hole1: - Polyline((-1, 1), (1, 1), (1, 2), (-1, 2), (-1, 1)) - with BuildLine() as hole2: - Airfoil("4020") - make_face() - self.assertEqual(len(skt.face().inner_wires()), 2) - class TestBuildSketchExceptions(unittest.TestCase): """Test exception handling""" From 70764bbe08d99d047db1711ea6f4d6e587565cbf Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Thu, 20 Nov 2025 15:28:37 -0600 Subject: [PATCH 052/105] revert spurious docstring change for Mesher.write --- src/build123d/mesher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index 8c4ce42..deed500 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -542,7 +542,7 @@ class Mesher: """write Args: - file_name Union[Pathlike, str, bytes, BytesIO]: file path + file_name Union[Pathlike, str, bytes]: file path Raises: ValueError: Unknown file format - must be 3mf or stl From bc8d01dc7ee21b0d4f6f64a10b8e7eb2ea130735 Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 21 Nov 2025 15:09:11 -0500 Subject: [PATCH 053/105] Improve length accuracy Issue #1136, minor typing fixes --- src/build123d/topology/one_d.py | 23 +++++++++++++---------- tests/test_build_line.py | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 0964721..27f9b11 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -305,7 +305,9 @@ class Mixin1D(Shape[TOPODS]): @property def length(self) -> float: """Edge or Wire length""" - return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor()) + props = GProp_GProps() + BRepGProp.LinearProperties_s(self.wrapped, props) + return props.Mass() @property def radius(self) -> float: @@ -796,19 +798,20 @@ class Mixin1D(Shape[TOPODS]): for obj in common_set: match (obj, target): case (_, Plane()): + assert isinstance(other.wrapped, gp_Pln) target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) - operation = BRepAlgoAPI_Section() - result = bool_op((obj,), (target,), operation) - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation)) + operation1 = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation1) + operation2 = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation2)) case (_, Vertex() | Edge() | Wire()): - operation = BRepAlgoAPI_Section() - section = bool_op((obj,), (target,), operation) + operation1 = BRepAlgoAPI_Section() + section = bool_op((obj,), (target,), operation1) result = section if not section: - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation)) + operation2 = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation2)) case _ if issubclass(type(target), Shape): result = target.intersect(obj) @@ -2940,7 +2943,7 @@ class Edge(Mixin1D[TopoDS_Edge]): topods_edge = BRepBuilderAPI_MakeEdge(curve.Reversed(), last, first).Edge() reversed_edge.wrapped = topods_edge else: - reversed_edge.wrapped = downcast(self.wrapped.Reversed()) + reversed_edge.wrapped = TopoDS.Edge_s(self.wrapped.Reversed()) return reversed_edge def to_axis(self) -> Axis: diff --git a/tests/test_build_line.py b/tests/test_build_line.py index be4cd8d..ae7364a 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -98,14 +98,14 @@ class BuildLineTests(unittest.TestCase): powerup @ 0, tangents=(screw % 1, powerup % 0), ) - self.assertAlmostEqual(roller_coaster.wires()[0].length, 678.983628932414, 5) + self.assertAlmostEqual(roller_coaster.wires()[0].length, 678.9785865257071, 5) def test_bezier(self): pts = [(0, 0), (20, 20), (40, 0), (0, -40), (-60, 0), (0, 100), (100, 0)] wts = [1.0, 1.0, 2.0, 3.0, 4.0, 2.0, 1.0] with BuildLine() as bz: b1 = Bezier(*pts, weights=wts) - self.assertAlmostEqual(bz.wires()[0].length, 225.86389406824566, 5) + self.assertAlmostEqual(bz.wires()[0].length, 225.98661946375782, 5) self.assertTrue(isinstance(b1, Edge)) def test_double_tangent_arc(self): From f5682fe13f8e99f42b00bff1843962752b0e817a Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Fri, 21 Nov 2025 16:05:51 -0600 Subject: [PATCH 054/105] prevent unnecessary logging and string creation when debug is not enabled --- src/build123d/topology/composite.py | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 14c67b4..4b41cb6 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -106,7 +106,7 @@ from build123d.geometry import ( VectorLike, logger, ) - +import logging from .one_d import Edge, Wire, Mixin1D from .shape_core import ( Shape, @@ -884,12 +884,23 @@ class Compound(Mixin3D[TopoDS_Compound]): def _post_attach_children(self, children: Iterable[Shape]): """Method call after attaching `children`.""" - if children: - kids = ",".join([child.label for child in children]) - logger.debug("Adding children %s to %s", kids, self.label) - self.wrapped = _make_topods_compound_from_shapes( - [c.wrapped for c in self.children] - ) + # 1. Safety check for empty updates + if not children: + return + if logger.isEnabledFor(logging.DEBUG): + children_list = list(children) + count = len(children_list) + if count > 10: + # For large batches, just log the count. + # Constructing a string of 1000+ labels is expensive and unreadable. + logger.debug("Adding %d children to %s", count, self.label) + else: + # For small batches, log the specific labels. + kids = ",".join([child.label for child in children_list]) + logger.debug("Adding children %s to %s", kids, self.label) + self.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in self.children] + ) # else: # logger.debug("Adding no children to %s", self.label) @@ -906,8 +917,9 @@ class Compound(Mixin3D[TopoDS_Compound]): def _post_detach_children(self, children): """Method call before detaching `children`.""" if children: - kids = ",".join([child.label for child in children]) - logger.debug("Removing children %s from %s", kids, self.label) + if logger.isEnabledFor(logging.DEBUG): + kids = ",".join([child.label for child in children]) + logger.debug("Removing children %s from %s", kids, self.label) self.wrapped = _make_topods_compound_from_shapes( [c.wrapped for c in self.children] ) From ce1f509340be64761215db07789346e0caec891b Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 22 Nov 2025 11:10:00 -0600 Subject: [PATCH 055/105] attempt fix anytree O(n^2) scaling without side effects --- src/build123d/topology/composite.py | 25 ++++++++++ src/build123d/topology/shape_core.py | 68 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 4b41cb6..3628eea 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -189,6 +189,31 @@ class Compound(Mixin3D[TopoDS_Compound]): """The dimension of the shapes within the Compound - None if inconsistent""" return topods_dim(self.wrapped) + def _update_geometry(self): + """ + Rebuild the internal TopoDS_Compound from the children. + This runs once per batch assignment. + """ + # Safety check: if no children, maybe handle as empty compound or return + if not self.children: + # Optional: Decide if an empty compound should be null or an empty TopoDS + return + + # 1. OPTIMIZED LOGGING + if logger.isEnabledFor(logging.DEBUG): + count = len(self.children) + if count > 10: + logger.debug("Rebuilding Compound with %d children", count) + else: + kids = ",".join([child.label for child in self.children]) + logger.debug("Rebuilding Compound with children: %s", kids) + + # 2. GEOMETRY RECONSTRUCTION + # Note: Ensure _make_topods_compound_from_shapes is imported + self.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in self.children] + ) + @property def volume(self) -> float: """volume - the volume of this Compound""" diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 1a3d4a8..e86e6ad 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -294,6 +294,9 @@ class Shape(NodeMixin, Generic[TOPODS]): self.label = label self.color = color + # Flag to suppress expensive geometry rebuilds during batch operations + self._suppress_update = False + # parent must be set following children as post install accesses children self.parent = parent @@ -321,6 +324,71 @@ class Shape(NodeMixin, Generic[TOPODS]): def _dim(self) -> int | None: """Dimension of the object""" + @property + def children(self): + """Override anytree children getter.""" + return super().children + + @children.setter + def children(self, values: Iterable[Shape]): + """ + Optimized children setter. + Bypasses anytree's O(n^2) validation (duplicate/loop checks) for massive speedups. + """ + self._suppress_update = True + try: + # 1. Standardize input to a LIST (Critical: AnyTree requires a mutable list internally) + new_children = list(values) + + # 2. Access internal AnyTree storage (Name mangling required) + children_storage = "_NodeMixin__children" + parent_storage = "_NodeMixin__parent" + + # 3. Detach existing children (Manual NodeMixin logic) + if hasattr(self, children_storage): + old_children = getattr(self, children_storage) + for child in old_children: + setattr(child, parent_storage, None) + + # 4. Set new children LIST (Skipping set() validation) + setattr(self, children_storage, new_children) + + # 5. Attach new children (Manual NodeMixin logic) + for child in new_children: + # Fast link to self + setattr(child, parent_storage, self) + + finally: + self._suppress_update = False + # Trigger the geometry rebuild exactly once + self._update_geometry() + + @children.deleter + def children(self): + """ + Delegate deletion to NodeMixin (equivalent to setting children = ()). + """ + self._suppress_update = True + try: + NodeMixin.children.__delete__(self) + finally: + self._suppress_update = False + self._update_geometry() + + def _post_attach_children(self, children: Iterable[Shape]): + """Called by anytree after a child is attached via `child.parent = self`.""" + if self._suppress_update: + return + self._update_geometry() + + def _update_geometry(self): + """ + Virtual method. + By default, Shapes (Solids, Faces, etc.) do NOT change their + geometry when children are added. + """ + pass + @property def area(self) -> float: """area -the surface area of all faces in this Shape""" From 9a9a47529eef6a498ca34d54e68de8c28f07f2d9 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 22 Nov 2025 11:10:59 -0600 Subject: [PATCH 056/105] formatting --- src/build123d/topology/shape_core.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index e86e6ad..0241ec3 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -332,14 +332,14 @@ class Shape(NodeMixin, Generic[TOPODS]): @children.setter def children(self, values: Iterable[Shape]): """ - Optimized children setter. + Optimized children setter. Bypasses anytree's O(n^2) validation (duplicate/loop checks) for massive speedups. """ self._suppress_update = True try: # 1. Standardize input to a LIST (Critical: AnyTree requires a mutable list internally) new_children = list(values) - + # 2. Access internal AnyTree storage (Name mangling required) children_storage = "_NodeMixin__children" parent_storage = "_NodeMixin__parent" @@ -349,15 +349,15 @@ class Shape(NodeMixin, Generic[TOPODS]): old_children = getattr(self, children_storage) for child in old_children: setattr(child, parent_storage, None) - + # 4. Set new children LIST (Skipping set() validation) setattr(self, children_storage, new_children) - + # 5. Attach new children (Manual NodeMixin logic) for child in new_children: # Fast link to self setattr(child, parent_storage, self) - + finally: self._suppress_update = False # Trigger the geometry rebuild exactly once @@ -383,9 +383,9 @@ class Shape(NodeMixin, Generic[TOPODS]): def _update_geometry(self): """ - Virtual method. - By default, Shapes (Solids, Faces, etc.) do NOT change their - geometry when children are added. + Virtual method. + By default, Shapes (Solids, Faces, etc.) do NOT change their + geometry when children are added. """ pass From c4b23baeb8e1fbaf47e748b4e6ff2e183ef1c476 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 22 Nov 2025 11:29:52 -0600 Subject: [PATCH 057/105] Revert "formatting" This reverts commit 9a9a47529eef6a498ca34d54e68de8c28f07f2d9. --- src/build123d/topology/shape_core.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 0241ec3..e86e6ad 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -332,14 +332,14 @@ class Shape(NodeMixin, Generic[TOPODS]): @children.setter def children(self, values: Iterable[Shape]): """ - Optimized children setter. + Optimized children setter. Bypasses anytree's O(n^2) validation (duplicate/loop checks) for massive speedups. """ self._suppress_update = True try: # 1. Standardize input to a LIST (Critical: AnyTree requires a mutable list internally) new_children = list(values) - + # 2. Access internal AnyTree storage (Name mangling required) children_storage = "_NodeMixin__children" parent_storage = "_NodeMixin__parent" @@ -349,15 +349,15 @@ class Shape(NodeMixin, Generic[TOPODS]): old_children = getattr(self, children_storage) for child in old_children: setattr(child, parent_storage, None) - + # 4. Set new children LIST (Skipping set() validation) setattr(self, children_storage, new_children) - + # 5. Attach new children (Manual NodeMixin logic) for child in new_children: # Fast link to self setattr(child, parent_storage, self) - + finally: self._suppress_update = False # Trigger the geometry rebuild exactly once @@ -383,9 +383,9 @@ class Shape(NodeMixin, Generic[TOPODS]): def _update_geometry(self): """ - Virtual method. - By default, Shapes (Solids, Faces, etc.) do NOT change their - geometry when children are added. + Virtual method. + By default, Shapes (Solids, Faces, etc.) do NOT change their + geometry when children are added. """ pass From 6773a8dea14cc265139f2ff2ffdf1ae65638d1d7 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 22 Nov 2025 11:30:06 -0600 Subject: [PATCH 058/105] Revert "attempt fix anytree O(n^2) scaling without side effects" This reverts commit ce1f509340be64761215db07789346e0caec891b. --- src/build123d/topology/composite.py | 25 ---------- src/build123d/topology/shape_core.py | 68 ---------------------------- 2 files changed, 93 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 3628eea..4b41cb6 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -189,31 +189,6 @@ class Compound(Mixin3D[TopoDS_Compound]): """The dimension of the shapes within the Compound - None if inconsistent""" return topods_dim(self.wrapped) - def _update_geometry(self): - """ - Rebuild the internal TopoDS_Compound from the children. - This runs once per batch assignment. - """ - # Safety check: if no children, maybe handle as empty compound or return - if not self.children: - # Optional: Decide if an empty compound should be null or an empty TopoDS - return - - # 1. OPTIMIZED LOGGING - if logger.isEnabledFor(logging.DEBUG): - count = len(self.children) - if count > 10: - logger.debug("Rebuilding Compound with %d children", count) - else: - kids = ",".join([child.label for child in self.children]) - logger.debug("Rebuilding Compound with children: %s", kids) - - # 2. GEOMETRY RECONSTRUCTION - # Note: Ensure _make_topods_compound_from_shapes is imported - self.wrapped = _make_topods_compound_from_shapes( - [c.wrapped for c in self.children] - ) - @property def volume(self) -> float: """volume - the volume of this Compound""" diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index e86e6ad..1a3d4a8 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -294,9 +294,6 @@ class Shape(NodeMixin, Generic[TOPODS]): self.label = label self.color = color - # Flag to suppress expensive geometry rebuilds during batch operations - self._suppress_update = False - # parent must be set following children as post install accesses children self.parent = parent @@ -324,71 +321,6 @@ class Shape(NodeMixin, Generic[TOPODS]): def _dim(self) -> int | None: """Dimension of the object""" - @property - def children(self): - """Override anytree children getter.""" - return super().children - - @children.setter - def children(self, values: Iterable[Shape]): - """ - Optimized children setter. - Bypasses anytree's O(n^2) validation (duplicate/loop checks) for massive speedups. - """ - self._suppress_update = True - try: - # 1. Standardize input to a LIST (Critical: AnyTree requires a mutable list internally) - new_children = list(values) - - # 2. Access internal AnyTree storage (Name mangling required) - children_storage = "_NodeMixin__children" - parent_storage = "_NodeMixin__parent" - - # 3. Detach existing children (Manual NodeMixin logic) - if hasattr(self, children_storage): - old_children = getattr(self, children_storage) - for child in old_children: - setattr(child, parent_storage, None) - - # 4. Set new children LIST (Skipping set() validation) - setattr(self, children_storage, new_children) - - # 5. Attach new children (Manual NodeMixin logic) - for child in new_children: - # Fast link to self - setattr(child, parent_storage, self) - - finally: - self._suppress_update = False - # Trigger the geometry rebuild exactly once - self._update_geometry() - - @children.deleter - def children(self): - """ - Delegate deletion to NodeMixin (equivalent to setting children = ()). - """ - self._suppress_update = True - try: - NodeMixin.children.__delete__(self) - finally: - self._suppress_update = False - self._update_geometry() - - def _post_attach_children(self, children: Iterable[Shape]): - """Called by anytree after a child is attached via `child.parent = self`.""" - if self._suppress_update: - return - self._update_geometry() - - def _update_geometry(self): - """ - Virtual method. - By default, Shapes (Solids, Faces, etc.) do NOT change their - geometry when children are added. - """ - pass - @property def area(self) -> float: """area -the surface area of all faces in this Shape""" From 1616670a01be1585a6059f329edfe6abfd3e9c86 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 22 Nov 2025 11:30:15 -0600 Subject: [PATCH 059/105] Revert "prevent unnecessary logging and string creation when debug is not enabled" This reverts commit f5682fe13f8e99f42b00bff1843962752b0e817a. --- src/build123d/topology/composite.py | 30 +++++++++-------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 4b41cb6..14c67b4 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -106,7 +106,7 @@ from build123d.geometry import ( VectorLike, logger, ) -import logging + from .one_d import Edge, Wire, Mixin1D from .shape_core import ( Shape, @@ -884,23 +884,12 @@ class Compound(Mixin3D[TopoDS_Compound]): def _post_attach_children(self, children: Iterable[Shape]): """Method call after attaching `children`.""" - # 1. Safety check for empty updates - if not children: - return - if logger.isEnabledFor(logging.DEBUG): - children_list = list(children) - count = len(children_list) - if count > 10: - # For large batches, just log the count. - # Constructing a string of 1000+ labels is expensive and unreadable. - logger.debug("Adding %d children to %s", count, self.label) - else: - # For small batches, log the specific labels. - kids = ",".join([child.label for child in children_list]) - logger.debug("Adding children %s to %s", kids, self.label) - self.wrapped = _make_topods_compound_from_shapes( - [c.wrapped for c in self.children] - ) + if children: + kids = ",".join([child.label for child in children]) + logger.debug("Adding children %s to %s", kids, self.label) + self.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in self.children] + ) # else: # logger.debug("Adding no children to %s", self.label) @@ -917,9 +906,8 @@ class Compound(Mixin3D[TopoDS_Compound]): def _post_detach_children(self, children): """Method call before detaching `children`.""" if children: - if logger.isEnabledFor(logging.DEBUG): - kids = ",".join([child.label for child in children]) - logger.debug("Removing children %s from %s", kids, self.label) + kids = ",".join([child.label for child in children]) + logger.debug("Removing children %s from %s", kids, self.label) self.wrapped = _make_topods_compound_from_shapes( [c.wrapped for c in self.children] ) From 7f6d44249b83bc0506c0ba97b77da83450d2f8a4 Mon Sep 17 00:00:00 2001 From: gumyr Date: Fri, 21 Nov 2025 14:18:48 -0500 Subject: [PATCH 060/105] Added GCPnts_UniformDeflection to positions --- src/build123d/topology/one_d.py | 47 ++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 27f9b11..d416be0 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -90,7 +90,11 @@ 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.GCPnts import ( + GCPnts_AbscissaPoint, + GCPnts_QuasiUniformDeflection, + GCPnts_UniformDeflection, +) from OCP.Geom import ( Geom_BezierCurve, Geom_BSplineCurve, @@ -116,6 +120,9 @@ from OCP.GeomAbs import ( GeomAbs_C0, GeomAbs_C1, GeomAbs_C2, + GeomAbs_C3, + GeomAbs_CN, + GeomAbs_C1, GeomAbs_G1, GeomAbs_G2, GeomAbs_JoinType, @@ -1178,22 +1185,50 @@ class Mixin1D(Shape[TOPODS]): def positions( self, - distances: Iterable[float], + distances: Iterable[float] | None = None, position_mode: PositionMode = PositionMode.PARAMETER, + deflection: float | None = None, ) -> list[Vector]: """Positions along curve Generate positions along the underlying curve Args: - distances (Iterable[float]): distance or parameter values - position_mode (PositionMode, optional): position calculation mode. - Defaults to PositionMode.PARAMETER. + distances (Iterable[float] | None, optional): distance or parameter values. + Defaults to None. + position_mode (PositionMode, optional): position calculation mode only applies + when using distances. Defaults to PositionMode.PARAMETER. + deflection (float | None, optional): maximum deflection between the curve and + the polygon that results from the computed points. Defaults to None. + Returns: list[Vector]: positions along curve """ - return [self.position_at(d, position_mode) for d in distances] + if deflection is not None: + curve: BRepAdaptor_Curve | BRepAdaptor_CompCurve = self.geom_adaptor() + # GCPnts_UniformDeflection provides the best results but is limited + if curve.Continuity() in (GeomAbs_C2, GeomAbs_C3, GeomAbs_CN): + discretizer = GCPnts_UniformDeflection() + else: + discretizer = GCPnts_QuasiUniformDeflection() + + discretizer.Initialize( + curve, + deflection, + curve.FirstParameter(), + curve.LastParameter(), + ) + if not discretizer.IsDone() or discretizer.NbPoints() == 0: + raise RuntimeError("Deflection calculation failed") + return [ + Vector(curve.Value(discretizer.Parameter(i + 1))) + for i in range(discretizer.NbPoints()) + ] + elif distances is not None: + return [self.position_at(d, position_mode) for d in distances] + else: + raise ValueError("Either distances or deflection must be provided") def project( self, face: Face, direction: VectorLike, closest: bool = True From 2d82b2ca5cde5b1ad50e23ac2eb9ededd388f2fe Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 25 Nov 2025 11:27:17 -0500 Subject: [PATCH 061/105] Adding tests for positions with deflection --- src/build123d/topology/one_d.py | 9 +++- tests/test_direct_api/test_mixin1_d.py | 63 +++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index d416be0..231b844 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -373,7 +373,10 @@ class Mixin1D(Shape[TOPODS]): 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) + if edge_wire.is_forward: + return float(value) + else: + return 1.0 - float(value) try: point = Vector(value) except TypeError as exc: @@ -1209,7 +1212,9 @@ class Mixin1D(Shape[TOPODS]): curve: BRepAdaptor_Curve | BRepAdaptor_CompCurve = self.geom_adaptor() # GCPnts_UniformDeflection provides the best results but is limited if curve.Continuity() in (GeomAbs_C2, GeomAbs_C3, GeomAbs_CN): - discretizer = GCPnts_UniformDeflection() + discretizer: ( + GCPnts_UniformDeflection | GCPnts_QuasiUniformDeflection + ) = GCPnts_UniformDeflection() else: discretizer = GCPnts_QuasiUniformDeflection() diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py index 1d7791b..efd4989 100644 --- a/tests/test_direct_api/test_mixin1_d.py +++ b/tests/test_direct_api/test_mixin1_d.py @@ -28,6 +28,7 @@ license: import math import unittest +from unittest.mock import patch from build123d.build_enums import ( CenterOf, @@ -106,13 +107,73 @@ class TestMixin1D(unittest.TestCase): 5, ) - def test_positions(self): + def test_positions_with_distances(self): e = Edge.make_line((0, 0, 0), (1, 1, 1)) distances = [i / 4 for i in range(3)] pts = e.positions(distances) for i, position in enumerate(pts): self.assertAlmostEqual(position, (i / 4, i / 4, i / 4), 5) + def test_positions_deflection_line(self): + """Deflection sampling on a straight line should yield exactly 2 points.""" + e = Edge.make_line((0, 0, 0), (10, 0, 0)) + pts = e.positions(deflection=0.1) + + self.assertEqual(len(pts), 2) + self.assertAlmostEqual(pts[0], (0, 0, 0), 7) + self.assertAlmostEqual(pts[1], (10, 0, 0), 7) + + def test_positions_deflection_circle(self): + """Deflection on a C2 curve (circle) should produce multiple points.""" + radius = 5 + e = Edge.make_circle(radius) + + pts = e.positions(deflection=0.1) + + # Should produce more than just two points + self.assertGreater(len(pts), 2) + + # Endpoints should match curve endpoints + first, last = pts[0], pts[-1] + curve = e.geom_adaptor() + p0 = Vector(curve.Value(curve.FirstParameter())) + p1 = Vector(curve.Value(curve.LastParameter())) + + self.assertAlmostEqual(first, p0, 7) + self.assertAlmostEqual(last, p1, 7) + + def test_positions_deflection_resolution(self): + """Smaller deflection tolerance should produce more points.""" + e = Edge.make_circle(10) + + pts_coarse = e.positions(deflection=0.5) + pts_fine = e.positions(deflection=0.05) + + self.assertGreater(len(pts_fine), len(pts_coarse)) + + def test_positions_deflection_C0_curve(self): + """C0 spline should use QuasiUniformDeflection and still succeed.""" + e = Polyline((0, 0), (1, 2), (2, 0))._to_bspline() # C0 + pts = e.positions(deflection=0.1) + + self.assertGreater(len(pts), 2) + + def test_positions_missing_arguments(self): + e = Edge.make_line((0, 0, 0), (1, 0, 0)) + with self.assertRaises(ValueError): + e.positions() + + def test_positions_deflection_failure(self): + e = Edge.make_circle(1.0) + + with patch("build123d.edge.GCPnts_UniformDeflection") as MockDefl: + instance = MockDefl.return_value + instance.IsDone.return_value = False + instance.NbPoints.return_value = 0 + + with self.assertRaises(RuntimeError): + e.positions(deflection=0.1) + def test_tangent_at(self): self.assertAlmostEqual( Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0), From 82aa0aa36724aa6b6a437e3eaa7cf2ac7963ae26 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 25 Nov 2025 11:39:39 -0500 Subject: [PATCH 062/105] Updating positions tests --- src/build123d/topology/one_d.py | 5 +---- tests/test_direct_api/test_mixin1_d.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 231b844..f76f6bc 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -373,10 +373,7 @@ class Mixin1D(Shape[TOPODS]): 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)): - if edge_wire.is_forward: - return float(value) - else: - return 1.0 - float(value) + return float(value) try: point = Vector(value) except TypeError as exc: diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py index efd4989..864711b 100644 --- a/tests/test_direct_api/test_mixin1_d.py +++ b/tests/test_direct_api/test_mixin1_d.py @@ -166,7 +166,7 @@ class TestMixin1D(unittest.TestCase): def test_positions_deflection_failure(self): e = Edge.make_circle(1.0) - with patch("build123d.edge.GCPnts_UniformDeflection") as MockDefl: + with patch("build123d.topology.one_d.GCPnts_UniformDeflection") as MockDefl: instance = MockDefl.return_value instance.IsDone.return_value = False instance.NbPoints.return_value = 0 From 05876c15aa49eb6312463adb61d875307160b525 Mon Sep 17 00:00:00 2001 From: Kuravi H Date: Fri, 28 Nov 2025 17:00:13 -0500 Subject: [PATCH 063/105] Add ParabolicCenterArc function --- docs/algebra_definition.rst | 2 +- docs/cheat_sheet.rst | 1 + docs/objects.rst | 7 +++++ docs/objects_1d.py | 8 +++++ src/build123d/__init__.py | 1 + src/build123d/importers.py | 1 + src/build123d/objects_curve.py | 55 +++++++++++++++++++++++++++++++++ src/build123d/topology/one_d.py | 39 ++++++++++++++++++++++- 8 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/algebra_definition.rst b/docs/algebra_definition.rst index e87c42c..7172bbc 100644 --- a/docs/algebra_definition.rst +++ b/docs/algebra_definition.rst @@ -29,7 +29,7 @@ Objects and arithmetic :math:`B^2 := \lbrace` ``Sketch``, ``Rectangle``, ``Circle``, ``Ellipse``, ``Rectangle``, ``Polygon``, ``RegularPolygon``, ``Text``, ``Trapezoid``, ``SlotArc``, ``SlotCenterPoint``, ``SlotCenterToCenter``, ``SlotOverall`` :math:`\rbrace` -:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``FilletPolyline``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` +:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``FilletPolyline``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``ParabolicCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` with :math:`B^3 \subset C^3, B^2 \subset C^2` and :math:`B^1 \subset C^1` diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index 8bd0d86..1f2ebb8 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -23,6 +23,7 @@ Cheat Sheet | :class:`~objects_curve.CenterArc` | :class:`~objects_curve.DoubleTangentArc` | :class:`~objects_curve.EllipticalCenterArc` + | :class:`~objects_curve.ParabolicCenterArc` | :class:`~objects_curve.FilletPolyline` | :class:`~objects_curve.Helix` | :class:`~objects_curve.IntersectingLine` diff --git a/docs/objects.rst b/docs/objects.rst index 85204bf..8b547b0 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -118,6 +118,13 @@ The following objects all can be used in BuildLine contexts. Note that +++ Elliptical arc defined by center, radii & angles + .. grid-item-card:: :class:`~objects_curve.ParabolicCenterArc` + + .. image:: assets/parabolic_center_arc_example.svg + + +++ + Parabolic arc defined by vertex, focal length & angles + .. grid-item-card:: :class:`~objects_curve.FilletPolyline` .. image:: assets/filletpolyline_example.svg diff --git a/docs/objects_1d.py b/docs/objects_1d.py index 1d72359..79baa74 100644 --- a/docs/objects_1d.py +++ b/docs/objects_1d.py @@ -125,6 +125,14 @@ svg.add_shape(elliptical_center_arc.line) svg.add_shape(dot.moved(Location(Vector((0, 0))))) svg.write("assets/elliptical_center_arc_example.svg") +with BuildLine() as parabolic_center_arc: + ParabolicCenterArc((0, 0), 0.5, 60, 0) +s = 100 / max(*parabolic_center_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(parabolic_center_arc.line) +svg.add_shape(dot.moved(Location(Vector((0, 0))))) +svg.write("assets/parabolic_center_arc_example.svg") + with BuildLine() as helix: Helix(1, 3, 1) scene = Compound(helix.line) + Compound.make_triad(0.5) diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index 2dcf0b0..2217685 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -88,6 +88,7 @@ __all__ = [ "DoubleTangentArc", "EllipticalCenterArc", "EllipticalStartArc", + "ParabolicCenterArc", "FilletPolyline", "Helix", "IntersectingLine", diff --git a/src/build123d/importers.py b/src/build123d/importers.py index d53628a..b4b5743 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -287,6 +287,7 @@ def import_svg_as_buildline_code( "QuadraticBezier": ["Bezier", "start", "control", "end"], "Arc": [ "EllipticalCenterArc", + "ParabolicCenterArc", # "EllipticalStartArc", "start", "end", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 71d788b..dc0b8e0 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -741,6 +741,61 @@ class EllipticalCenterArc(BaseEdgeObject): super().__init__(curve, mode=mode) +class ParabolicCenterArc(BaseEdgeObject): + """Line Object: Parabolic Center Arc + + Create a parabolic arc defined by a vertex point and focal length (distance from focus to vertex). + + Args: + vertex (VectorLike): parabola vertex + focal_length (float): focal length the parabola (distance from the vertex to focus along the x-axis of plane) + start_angle (float, optional): arc start angle. + Defaults to 0.0 + end_angle (float, optional): arc end angle. + Defaults to 90.0 + rotation (float, optional): angle to rotate arc. Defaults to 0.0 + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE + plane (Plane, optional): base plane. Defaults to Plane.XY + mode (Mode, optional): combination mode. Defaults to Mode.ADD + """ + + _applies_to = [BuildLine._tag] + + def __init__( + self, + vertex: VectorLike, + focal_length: float, + start_angle: float = 0.0, + end_angle: float = 90.0, + rotation: float = 0.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + mode: Mode = Mode.ADD, + ): + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + vertex_pnt = WorkplaneList.localize(vertex) + if context is None: + parabola_workplane = Plane.XY + else: + parabola_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) + parabola_workplane.origin = vertex_pnt + curve = Edge.make_parabola( + focal_length=focal_length, + plane=parabola_workplane, + start_angle=start_angle, + end_angle=end_angle, + angular_direction=angular_direction, + ).rotate( + Axis(parabola_workplane.origin, parabola_workplane.z_dir.to_dir()), rotation + ) + + super().__init__(curve, mode=mode) + + class Helix(BaseEdgeObject): """Line Object: Helix diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index f48c7bb..a9e0172 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -88,7 +88,7 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset 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.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse, GC_MakeArcOfParabola from OCP.GCPnts import ( GCPnts_AbscissaPoint, GCPnts_QuasiUniformDeflection, @@ -151,6 +151,7 @@ from OCP.gp import ( gp_Dir, gp_Dir2d, gp_Elips, + gp_Parab, gp_Pln, gp_Pnt, gp_Pnt2d, @@ -2204,6 +2205,42 @@ class Edge(Mixin1D[TopoDS_Edge]): return ellipse + @classmethod + def make_parabola( + cls, + focal_length: float, + plane: Plane = Plane.XY, + start_angle: float = 0.0, + end_angle: float = 90.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + ) -> Edge: + """make parabola + + Makes an parabola centered at the origin of plane. + + Args: + focal_length (float): focal length the parabola (distance from the vertex to focus along the x-axis of plane) + plane (Plane, optional): base plane. Defaults to Plane.XY. + start_angle (float, optional): Defaults to 0.0. + end_angle (float, optional): Defaults to 90.0. + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE. + + Returns: + Edge: full or partial parabola + """ + parabola_gp = gp_Parab(plane.to_gp_ax2(), focal_length) + + parabola_geom = GC_MakeArcOfParabola( + parabola_gp, + start_angle * DEG2RAD, + end_angle * DEG2RAD, + angular_direction == AngularDirection.COUNTER_CLOCKWISE, + ).Value() + parabola = cls(BRepBuilderAPI_MakeEdge(parabola_geom).Edge()) + + return parabola + @classmethod def make_helix( cls, From ae93ee282d666b401969c12639430ee2fe7579bc Mon Sep 17 00:00:00 2001 From: Kuravi H Date: Fri, 28 Nov 2025 17:14:49 -0500 Subject: [PATCH 064/105] Update docs --- docs/objects.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/objects.rst b/docs/objects.rst index 8b547b0..a4365c7 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -248,6 +248,7 @@ Reference .. autoclass:: CenterArc .. autoclass:: DoubleTangentArc .. autoclass:: EllipticalCenterArc +.. autoclass:: ParabolicCenterArc .. autoclass:: FilletPolyline .. autoclass:: Helix .. autoclass:: IntersectingLine From 4652df3e71bef9a0243462ec57598f0c6c88143d Mon Sep 17 00:00:00 2001 From: Kuravi H Date: Fri, 28 Nov 2025 18:02:44 -0500 Subject: [PATCH 065/105] Add HyperbolicCenterArc function --- docs/algebra_definition.rst | 2 +- docs/cheat_sheet.rst | 1 + docs/objects.rst | 8 +++++ docs/objects_1d.py | 8 +++++ src/build123d/__init__.py | 1 + src/build123d/importers.py | 1 + src/build123d/objects_curve.py | 58 +++++++++++++++++++++++++++++++++ src/build123d/topology/one_d.py | 56 ++++++++++++++++++++++++++++++- 8 files changed, 133 insertions(+), 2 deletions(-) diff --git a/docs/algebra_definition.rst b/docs/algebra_definition.rst index 7172bbc..53f1062 100644 --- a/docs/algebra_definition.rst +++ b/docs/algebra_definition.rst @@ -29,7 +29,7 @@ Objects and arithmetic :math:`B^2 := \lbrace` ``Sketch``, ``Rectangle``, ``Circle``, ``Ellipse``, ``Rectangle``, ``Polygon``, ``RegularPolygon``, ``Text``, ``Trapezoid``, ``SlotArc``, ``SlotCenterPoint``, ``SlotCenterToCenter``, ``SlotOverall`` :math:`\rbrace` -:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``FilletPolyline``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``ParabolicCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` +:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``FilletPolyline``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``ParabolicCenterArc``, ``HyperbolicCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` with :math:`B^3 \subset C^3, B^2 \subset C^2` and :math:`B^1 \subset C^1` diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index 1f2ebb8..3361f71 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -24,6 +24,7 @@ Cheat Sheet | :class:`~objects_curve.DoubleTangentArc` | :class:`~objects_curve.EllipticalCenterArc` | :class:`~objects_curve.ParabolicCenterArc` + | :class:`~objects_curve.HyperbolicCenterArc` | :class:`~objects_curve.FilletPolyline` | :class:`~objects_curve.Helix` | :class:`~objects_curve.IntersectingLine` diff --git a/docs/objects.rst b/docs/objects.rst index a4365c7..df6b0e2 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -125,6 +125,13 @@ The following objects all can be used in BuildLine contexts. Note that +++ Parabolic arc defined by vertex, focal length & angles + .. grid-item-card:: :class:`~objects_curve.HyperbolicCenterArc` + + .. image:: assets/hyperbolic_center_arc_example.svg + + +++ + Hyperbolic arc defined by center, radii & angles + .. grid-item-card:: :class:`~objects_curve.FilletPolyline` .. image:: assets/filletpolyline_example.svg @@ -249,6 +256,7 @@ Reference .. autoclass:: DoubleTangentArc .. autoclass:: EllipticalCenterArc .. autoclass:: ParabolicCenterArc +.. autoclass:: HyperbolicCenterArc .. autoclass:: FilletPolyline .. autoclass:: Helix .. autoclass:: IntersectingLine diff --git a/docs/objects_1d.py b/docs/objects_1d.py index 79baa74..a7d24d7 100644 --- a/docs/objects_1d.py +++ b/docs/objects_1d.py @@ -133,6 +133,14 @@ svg.add_shape(parabolic_center_arc.line) svg.add_shape(dot.moved(Location(Vector((0, 0))))) svg.write("assets/parabolic_center_arc_example.svg") +with BuildLine() as hyperbolic_center_arc: + HyperbolicCenterArc((0, 0), 0.5, 1, 45, 90) +s = 100 / max(*hyperbolic_center_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(hyperbolic_center_arc.line) +svg.add_shape(dot.moved(Location(Vector((0, 0))))) +svg.write("assets/hyperbolic_center_arc_example.svg") + with BuildLine() as helix: Helix(1, 3, 1) scene = Compound(helix.line) + Compound.make_triad(0.5) diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index 2217685..69df5fe 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -89,6 +89,7 @@ __all__ = [ "EllipticalCenterArc", "EllipticalStartArc", "ParabolicCenterArc", + "HyperbolicCenterArc", "FilletPolyline", "Helix", "IntersectingLine", diff --git a/src/build123d/importers.py b/src/build123d/importers.py index b4b5743..1b376a0 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -288,6 +288,7 @@ def import_svg_as_buildline_code( "Arc": [ "EllipticalCenterArc", "ParabolicCenterArc", + "HyperbolicCenterArc", # "EllipticalStartArc", "start", "end", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index dc0b8e0..26ce6dc 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -796,6 +796,64 @@ class ParabolicCenterArc(BaseEdgeObject): super().__init__(curve, mode=mode) +class HyperbolicCenterArc(BaseEdgeObject): + """Line Object: Hyperbolic Center Arc + + Create a hyperbolic arc defined by a center point and focal length (distance from focus to vertex). + + Args: + center (VectorLike): hyperbola center + x_radius (float): x radius of the ellipse (along the x-axis of plane) + y_radius (float): y radius of the ellipse (along the y-axis of plane) + start_angle (float, optional): arc start angle from x-axis. + Defaults to 0.0 + end_angle (float, optional): arc end angle from x-axis. + Defaults to 90.0 + rotation (float, optional): angle to rotate arc. Defaults to 0.0 + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE + plane (Plane, optional): base plane. Defaults to Plane.XY + mode (Mode, optional): combination mode. Defaults to Mode.ADD + """ + + _applies_to = [BuildLine._tag] + + def __init__( + self, + center: VectorLike, + x_radius: float, + y_radius: float, + start_angle: float = 0.0, + end_angle: float = 90.0, + rotation: float = 0.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + mode: Mode = Mode.ADD, + ): + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + center_pnt = WorkplaneList.localize(center) + if context is None: + hyperbola_workplane = Plane.XY + else: + hyperbola_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) + hyperbola_workplane.origin = center_pnt + curve = Edge.make_hyperbola( + x_radius=x_radius, + y_radius=y_radius, + plane=hyperbola_workplane, + start_angle=start_angle, + end_angle=end_angle, + angular_direction=angular_direction, + ).rotate( + Axis(hyperbola_workplane.origin, hyperbola_workplane.z_dir.to_dir()), rotation + ) + + super().__init__(curve, mode=mode) + + class Helix(BaseEdgeObject): """Line Object: Helix diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index a9e0172..1df41b9 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -88,7 +88,7 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset 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, GC_MakeArcOfParabola +from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse, GC_MakeArcOfParabola, GC_MakeArcOfHyperbola from OCP.GCPnts import ( GCPnts_AbscissaPoint, GCPnts_QuasiUniformDeflection, @@ -152,6 +152,7 @@ from OCP.gp import ( gp_Dir2d, gp_Elips, gp_Parab, + gp_Hypr, gp_Pln, gp_Pnt, gp_Pnt2d, @@ -2241,6 +2242,59 @@ class Edge(Mixin1D[TopoDS_Edge]): return parabola + @classmethod + def make_hyperbola( + cls, + x_radius: float, + y_radius: float, + plane: Plane = Plane.XY, + start_angle: float = 360.0, + end_angle: float = 360.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + ) -> Edge: + """make hyperbola + + Makes a hyperbola centered at the origin of plane. + + Args: + x_radius (float): x radius of the hyperbola (along the x-axis of plane) + y_radius (float): y radius of the hyperbola (along the y-axis of plane) + plane (Plane, optional): base plane. Defaults to Plane.XY. + start_angle (float, optional): Defaults to 360.0. + end_angle (float, optional): Defaults to 360.0. + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE. + + Returns: + Edge: full or partial hyperbola + """ + ax1 = gp_Ax1(plane.origin.to_pnt(), plane.z_dir.to_dir()) + + if y_radius > x_radius: + # swap x and y radius and rotate by 90° afterwards to create an ellipse + # with x_radius < y_radius + correction_angle = 90.0 * DEG2RAD + hyperbola_gp = gp_Hypr(plane.to_gp_ax2(), y_radius, x_radius).Rotated( + ax1, correction_angle + ) + else: + correction_angle = 0.0 + hyperbola_gp = gp_Hypr(plane.to_gp_ax2(), x_radius, y_radius) + + if start_angle == end_angle: # full hyperbola case + hyperbola = cls(BRepBuilderAPI_MakeEdge(hyperbola_gp).Edge()) + else: # arc case + # take correction_angle into account + hyperbola_geom = GC_MakeArcOfHyperbola( + hyperbola_gp, + start_angle * DEG2RAD - correction_angle, + end_angle * DEG2RAD - correction_angle, + angular_direction == AngularDirection.COUNTER_CLOCKWISE, + ).Value() + hyperbola = cls(BRepBuilderAPI_MakeEdge(hyperbola_geom).Edge()) + + return hyperbola + @classmethod def make_helix( cls, From 0bedc9c9add0f13d990cef1db17b4273f902f403 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sat, 29 Nov 2025 11:43:27 -0500 Subject: [PATCH 066/105] Fixed typing problems and increased coverage to 100% --- src/build123d/objects_curve.py | 39 ++++++++++++++++++++-------------- tests/test_build_line.py | 34 +++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 61731bd..ad2a17a 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -50,7 +50,7 @@ from build123d.build_enums import ( ) from build123d.build_line import BuildLine from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE -from build123d.topology import Edge, Face, Wire, Curve +from build123d.topology import Curve, Edge, Face, Vertex, Wire from build123d.topology.shape_core import ShapeList @@ -851,7 +851,7 @@ class FilletPolyline(BaseLineObject): # Create a list of vertices from wire_of_lines in the same order as # the original points so the resulting fillet edges are ordered - ordered_vertices = [] + ordered_vertices: list[Vertex] = [] for pnts in lines_pts: distance = { @@ -867,7 +867,7 @@ class FilletPolyline(BaseLineObject): } # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) - fillets = [] + fillets: list[None | Edge] = [] for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: @@ -879,7 +879,9 @@ class FilletPolyline(BaseLineObject): fillets.append(None) else: - other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} + other_vertices = { + ve for e in edges for ve in e.vertices() if ve != vertex + } third_edge = Edge.make_line(*[v for v in other_vertices]) fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( current_radius, [vertex] @@ -891,18 +893,20 @@ class FilletPolyline(BaseLineObject): interior_edges = [] for i in range(len(fillets)): + prev_fillet = fillets[i - 1] + curr_fillet = fillets[i] prev_idx = i - 1 curr_idx = i # Determine start and end points - if fillets[prev_idx] is None: - start_pt = ordered_vertices[prev_idx] + if prev_fillet is None: + start_pt: Vertex | Vector = ordered_vertices[prev_idx] else: - start_pt = fillets[prev_idx] @ 1 + start_pt = prev_fillet @ 1 - if fillets[curr_idx] is None: - end_pt = ordered_vertices[curr_idx] + if curr_fillet is None: + end_pt: Vertex | Vector = ordered_vertices[curr_idx] else: - end_pt = fillets[curr_idx] @ 0 + end_pt = curr_fillet @ 0 interior_edges.append(Edge.make_line(start_pt, end_pt)) end_edges = [] @@ -910,18 +914,22 @@ class FilletPolyline(BaseLineObject): else: interior_edges = [] for i in range(len(fillets) - 1): + next_fillet = fillets[i + 1] + curr_fillet = fillets[i] curr_idx = i next_idx = i + 1 # Determine start and end points - if fillets[curr_idx] is None: - start_pt = ordered_vertices[curr_idx + 1] # +1 because first vertex has no fillet + if curr_fillet is None: + start_pt = ordered_vertices[ + curr_idx + 1 + ] # +1 because first vertex has no fillet else: - start_pt = fillets[curr_idx] @ 1 + start_pt = curr_fillet @ 1 - if fillets[next_idx] is None: + if next_fillet is None: end_pt = ordered_vertices[next_idx + 1] else: - end_pt = fillets[next_idx] @ 0 + end_pt = next_fillet @ 0 interior_edges.append(Edge.make_line(start_pt, end_pt)) # Handle end edges @@ -943,7 +951,6 @@ class FilletPolyline(BaseLineObject): super().__init__(new_wire, mode=mode) - class JernArc(BaseEdgeObject): """Line Object: Jern Arc diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 16f89ce..2696432 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -195,7 +195,6 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 3) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) - with self.assertRaises(ValueError): p = FilletPolyline( (0, 0), @@ -253,6 +252,33 @@ class BuildLineTests(unittest.TestCase): with self.assertRaises(ValueError): FilletPolyline((0, 0), (1, 0), (1, 1), radius=-1) + # test filletpolyline curr_fillet None + # Middle corner radius = 0 → curr_fillet is None + with BuildLine(): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (20, 10), + radius=(0, 1), # middle corner is sharp + close=False, + ) + # 1 circular fillet, 3 line fillets + assert len(p.edges().filter_by(GeomType.CIRCLE)) == 1 + + # test filletpolyline next_fillet None: + # Second corner is sharp (radius 0) → next_fillet is None + with BuildLine(): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 0), # next_fillet is None at last interior corner + close=False, + ) + assert len(p.edges()) > 0 + def test_intersecting_line(self): with BuildLine(): l1 = Line((0, 0), (10, 0)) @@ -861,9 +887,9 @@ class BuildLineTests(unittest.TestCase): min_r = 0 if case[2][0] is None else (flip_min * case[0] + case[2][0]) / 2 max_r = 1e6 if case[2][1] is None else (flip_max * case[0] + case[2][1]) / 2 - print(case[1], min_r, max_r, case[0]) - print(min_r + 0.01, min_r * 0.99, max_r - 0.01, max_r + 0.01) - print((case[0] - 1 * (r1 + r2)) / 2) + # print(case[1], min_r, max_r, case[0]) + # print(min_r + 0.01, min_r * 0.99, max_r - 0.01, max_r + 0.01) + # print((case[0] - 1 * (r1 + r2)) / 2) # Greater than min l1 = ArcArcTangentArc(start_arc, end_arc, min_r + 0.01, keep=case[1]) From ddd236c7878e73d9c3d2de3d31eee9c9f98b43dc Mon Sep 17 00:00:00 2001 From: Kuravi H Date: Sat, 29 Nov 2025 12:16:01 -0500 Subject: [PATCH 067/105] Remove plane from docstring Remove parabola and hyperbola from importers --- src/build123d/importers.py | 2 -- src/build123d/objects_curve.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/build123d/importers.py b/src/build123d/importers.py index 1b376a0..d53628a 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -287,8 +287,6 @@ def import_svg_as_buildline_code( "QuadraticBezier": ["Bezier", "start", "control", "end"], "Arc": [ "EllipticalCenterArc", - "ParabolicCenterArc", - "HyperbolicCenterArc", # "EllipticalStartArc", "start", "end", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 26ce6dc..7846043 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -756,7 +756,6 @@ class ParabolicCenterArc(BaseEdgeObject): rotation (float, optional): angle to rotate arc. Defaults to 0.0 angular_direction (AngularDirection, optional): arc direction. Defaults to AngularDirection.COUNTER_CLOCKWISE - plane (Plane, optional): base plane. Defaults to Plane.XY mode (Mode, optional): combination mode. Defaults to Mode.ADD """ @@ -812,7 +811,6 @@ class HyperbolicCenterArc(BaseEdgeObject): rotation (float, optional): angle to rotate arc. Defaults to 0.0 angular_direction (AngularDirection, optional): arc direction. Defaults to AngularDirection.COUNTER_CLOCKWISE - plane (Plane, optional): base plane. Defaults to Plane.XY mode (Mode, optional): combination mode. Defaults to Mode.ADD """ From 2fa0dd22da13e7e238299c6cccac40f6528258a6 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 1 Dec 2025 20:04:48 -0500 Subject: [PATCH 068/105] Refactored Solid.extrude_until, moved split to Shape, fixed misc typing problems --- docs/assets/ttt/ttt-ppp0109.py | 11 +- src/build123d/operations_part.py | 4 +- src/build123d/topology/one_d.py | 140 +---------- src/build123d/topology/shape_core.py | 180 +++++++++++++- src/build123d/topology/three_d.py | 349 +++++++++++++++------------ src/build123d/topology/two_d.py | 3 +- src/build123d/topology/utils.py | 40 --- src/build123d/topology/zero_d.py | 10 +- tests/test_build_part.py | 54 +++++ 9 files changed, 449 insertions(+), 342 deletions(-) diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py index b00b0bc..49863af 100644 --- a/docs/assets/ttt/ttt-ppp0109.py +++ b/docs/assets/ttt/ttt-ppp0109.py @@ -47,16 +47,17 @@ with BuildPart() as ppp109: split(bisect_by=Plane.YZ) extrude(amount=6) f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0] - # extrude(f, until=Until.NEXT) # throws a warning - extrude(f, amount=10) - fillet(ppp109.edge(Select.NEW), 16) + extrude(f, until=Until.NEXT) + fillet(ppp109.edges().filter_by(Axis.Y).sort_by(Axis.Z)[2], 16) + # extrude(f, amount=10) + # fillet(ppp109.edges(Select.NEW), 16) show(ppp109) -got_mass = ppp109.part.volume*densb +got_mass = ppp109.part.volume * densb want_mass = 307.23 tolerance = 1 delta = abs(got_mass - want_mass) print(f"Mass: {got_mass:0.2f} g") -assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}' +assert delta < tolerance, f"{got_mass=}, {want_mass=}, {delta=}, {tolerance=}" diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index ee765d0..e3fe8dd 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -223,8 +223,8 @@ def extrude( new_solids.append( Solid.extrude_until( - section=face, - target_object=target_object, + face, + target=target_object, direction=plane.z_dir * direction, until=until, ) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index f48c7bb..2b9a211 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -233,11 +233,11 @@ from .shape_core import ( shapetype, topods_dim, unwrap_topods_compound, + _topods_bool_op, ) from .utils import ( _extrude_topods_shape, _make_topods_face_from_wires, - _topods_bool_op, isclose_b, ) from .zero_d import Vertex, topo_explore_common_vertex @@ -1377,144 +1377,6 @@ class Mixin1D(Shape[TOPODS]): return (visible_edges, hidden_edges) - @overload - def split( - self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM] - ) -> Self | list[Self] | None: - """split and keep inside or outside""" - - @overload - def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]: - """split and return the unordered pieces""" - - @overload - def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[ - Self | list[Self] | None, - Self | list[Self] | None, - ]: - """split and keep inside and outside""" - - @overload - def split(self, tool: TrimmingTool) -> Self | list[Self] | None: - """split and keep inside (default)""" - - def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): - """split - - Split this shape by the provided plane or face. - - Args: - surface (Plane | Face): surface to segment shape - keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. - - Returns: - Shape: result of split - Returns: - Self | list[Self] | None, - Tuple[Self | list[Self] | None]: The result of the split operation. - - - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None` - if no top is found. - - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None` - if no bottom is found. - - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is - either a `Self` or `list[Self]`, or `None` if no corresponding part is found. - """ - if self._wrapped is None or not tool: - raise ValueError("Can't split an empty edge/wire/tool") - - shape_list = TopTools_ListOfShape() - shape_list.Append(self.wrapped) - - # Define the splitting tool - trim_tool = ( - BRepBuilderAPI_MakeFace(tool.wrapped).Face() # gp_Pln to Face - if isinstance(tool, Plane) - else tool.wrapped - ) - tool_list = TopTools_ListOfShape() - tool_list.Append(trim_tool) - - # Create the splitter algorithm - splitter = BRepAlgoAPI_Splitter() - - # Set the shape to be split and the splitting tool (plane face) - splitter.SetArguments(shape_list) - splitter.SetTools(tool_list) - - # Perform the splitting operation - splitter.Build() - - split_result = downcast(splitter.Shape()) - # Remove unnecessary TopoDS_Compound around single shape - if isinstance(split_result, TopoDS_Compound): - split_result = unwrap_topods_compound(split_result, True) - - # For speed the user may just want all the objects which they - # can sort more efficiently then the generic algorithm below - if keep == Keep.ALL: - return ShapeList( - self.__class__.cast(part) - for part in get_top_level_topods_shapes(split_result) - ) - - if not isinstance(tool, Plane): - # Get a TopoDS_Face to work with from the tool - if isinstance(trim_tool, TopoDS_Shell): - face_explorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE) - tool_face = TopoDS.Face_s(face_explorer.Current()) - else: - tool_face = trim_tool - - # Create a reference point off the +ve side of the tool - surface_gppnt = gp_Pnt() - surface_normal = gp_Vec() - u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face) - BRepGProp_Face(tool_face).Normal( - (u_min + u_max) / 2, (v_min + v_max) / 2, surface_gppnt, surface_normal - ) - normalized_surface_normal = Vector( - surface_normal.X(), surface_normal.Y(), surface_normal.Z() - ).normalized() - surface_point = Vector(surface_gppnt) - ref_point = surface_point + normalized_surface_normal - - # Create a HalfSpace - Solidish object to determine top/bottom - # Note: BRepPrimAPI_MakeHalfSpace takes either a TopoDS_Shell or TopoDS_Face but the - # mypy expects only a TopoDS_Shell here - half_space_maker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt()) - # type: ignore - tool_solid = half_space_maker.Solid() - - tops: list[Shape] = [] - bottoms: list[Shape] = [] - properties = GProp_GProps() - for part in get_top_level_topods_shapes(split_result): - sub_shape = self.__class__.cast(part) - if isinstance(tool, Plane): - is_up = tool.to_local_coords(sub_shape).center().Z >= 0 - else: - # Intersect self and the thickened tool - is_up_obj = _topods_bool_op( - (part,), (tool_solid,), BRepAlgoAPI_Common() - ) - # Check for valid intersections - BRepGProp.LinearProperties_s(is_up_obj, properties) - # Mass represents the total length for linear properties - is_up = properties.Mass() >= TOLERANCE - (tops if is_up else bottoms).append(sub_shape) - - top = None if not tops else tops[0] if len(tops) == 1 else tops - bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms - - if keep == Keep.BOTH: - return (top, bottom) - if keep == Keep.TOP: - return top - if keep == Keep.BOTTOM: - return bottom - return None - def start_point(self) -> Vector: """The start point of this edge diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 1a3d4a8..2760367 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -100,6 +100,7 @@ from OCP.BRepFeat import BRepFeat_SplitShape from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepTools import BRepTools from OCP.gce import gce_MakeLin from OCP.Geom import Geom_Line @@ -196,7 +197,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ta.TopAbs_COMPSOLID: "CompSolid", } - shape_properties_LUT = { + shape_properties_LUT: dict[TopAbs_ShapeEnum:function] = { ta.TopAbs_VERTEX: None, ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, @@ -1786,6 +1787,144 @@ class Shape(NodeMixin, Generic[TOPODS]): """solids - all the solids in this Shape""" return ShapeList() + @overload + def split( + self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM] + ) -> Self | list[Self] | None: + """split and keep inside or outside""" + + @overload + def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]: + """split and return the unordered pieces""" + + @overload + def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[ + Self | list[Self] | None, + Self | list[Self] | None, + ]: + """split and keep inside and outside""" + + @overload + def split(self, tool: TrimmingTool) -> Self | list[Self] | None: + """split and keep inside (default)""" + + def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): + """split + + Split this shape by the provided plane or face. + + Args: + surface (Plane | Face): surface to segment shape + keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. + + Returns: + Shape: result of split + Returns: + Self | list[Self] | None, + Tuple[Self | list[Self] | None]: The result of the split operation. + + - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None` + if no top is found. + - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None` + if no bottom is found. + - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is + either a `Self` or `list[Self]`, or `None` if no corresponding part is found. + """ + if self._wrapped is None or not tool: + raise ValueError("Can't split an empty edge/wire/tool") + + shape_list = TopTools_ListOfShape() + shape_list.Append(self.wrapped) + + # Define the splitting tool + trim_tool = ( + BRepBuilderAPI_MakeFace(tool.wrapped).Face() # gp_Pln to Face + if isinstance(tool, Plane) + else tool.wrapped + ) + tool_list = TopTools_ListOfShape() + tool_list.Append(trim_tool) + + # Create the splitter algorithm + splitter = BRepAlgoAPI_Splitter() + + # Set the shape to be split and the splitting tool (plane face) + splitter.SetArguments(shape_list) + splitter.SetTools(tool_list) + + # Perform the splitting operation + splitter.Build() + + split_result = downcast(splitter.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(split_result, TopoDS_Compound): + split_result = unwrap_topods_compound(split_result, True) + + # For speed the user may just want all the objects which they + # can sort more efficiently then the generic algorithm below + if keep == Keep.ALL: + return ShapeList( + self.__class__.cast(part) + for part in get_top_level_topods_shapes(split_result) + ) + + if not isinstance(tool, Plane): + # Get a TopoDS_Face to work with from the tool + if isinstance(trim_tool, TopoDS_Shell): + face_explorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE) + tool_face = TopoDS.Face_s(face_explorer.Current()) + else: + tool_face = trim_tool + + # Create a reference point off the +ve side of the tool + surface_gppnt = gp_Pnt() + surface_normal = gp_Vec() + u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face) + BRepGProp_Face(tool_face).Normal( + (u_min + u_max) / 2, (v_min + v_max) / 2, surface_gppnt, surface_normal + ) + normalized_surface_normal = Vector( + surface_normal.X(), surface_normal.Y(), surface_normal.Z() + ).normalized() + surface_point = Vector(surface_gppnt) + ref_point = surface_point + normalized_surface_normal + + # Create a HalfSpace - Solidish object to determine top/bottom + # Note: BRepPrimAPI_MakeHalfSpace takes either a TopoDS_Shell or TopoDS_Face but the + # mypy expects only a TopoDS_Shell here + half_space_maker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt()) + # type: ignore + tool_solid = half_space_maker.Solid() + + tops: list[Shape] = [] + bottoms: list[Shape] = [] + properties = GProp_GProps() + for part in get_top_level_topods_shapes(split_result): + sub_shape = self.__class__.cast(part) + if isinstance(tool, Plane): + is_up = tool.to_local_coords(sub_shape).center().Z >= 0 + else: + # Intersect self and the thickened tool + is_up_obj = _topods_bool_op( + (part,), (tool_solid,), BRepAlgoAPI_Common() + ) + # Check for valid intersections + BRepGProp.LinearProperties_s(is_up_obj, properties) + # Mass represents the total length for linear properties + is_up = properties.Mass() >= TOLERANCE + (tops if is_up else bottoms).append(sub_shape) + + top = None if not tops else tops[0] if len(tops) == 1 else tops + bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms + + if keep == Keep.BOTH: + return (top, bottom) + if keep == Keep.TOP: + return top + if keep == Keep.BOTTOM: + return bottom + return None + @overload def split_by_perimeter( self, perimeter: Edge | Wire, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] @@ -3011,6 +3150,45 @@ def _sew_topods_faces(faces: Iterable[TopoDS_Face]) -> TopoDS_Shape: return downcast(shell_builder.SewedShape()) +def _topods_bool_op( + args: Iterable[TopoDS_Shape], + tools: Iterable[TopoDS_Shape], + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, +) -> TopoDS_Shape: + """Generic boolean operation for TopoDS_Shapes + + Args: + args: Iterable[TopoDS_Shape]: + tools: Iterable[TopoDS_Shape]: + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: + + Returns: TopoDS_Shape + + """ + args = list(args) + tools = list(tools) + arg = TopTools_ListOfShape() + for obj in args: + arg.Append(obj) + + tool = TopTools_ListOfShape() + for obj in tools: + tool.Append(obj) + + operation.SetArguments(arg) + operation.SetTools(tool) + + operation.SetRunParallel(True) + operation.Build() + + result = downcast(operation.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(result, TopoDS_Compound): + result = unwrap_topods_compound(result, True) + + return result + + def _topods_entities(shape: TopoDS_Shape, topo_type: Shapes) -> list[TopoDS_Shape]: """Return the TopoDS_Shapes of topo_type from this TopoDS_Shape""" out = {} # using dict to prevent duplicates diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 279f46f..ed0011a 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -54,11 +54,10 @@ license: from __future__ import annotations -import platform -import warnings from collections.abc import Iterable, Sequence from math import radians, cos, tan -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, overload +from typing import cast as tcast from typing_extensions import Self import OCP.TopAbs as ta @@ -86,13 +85,20 @@ from OCP.GProp import GProp_GProps from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_JoinType from OCP.LocOpe import LocOpe_DPrism from OCP.ShapeFix import ShapeFix_Solid -from OCP.Standard import Standard_Failure +from OCP.Standard import Standard_Failure, Standard_TypeMismatch from OCP.StdFail import StdFail_NotDone -from OCP.TopExp import TopExp +from OCP.TopExp import TopExp, TopExp_Explorer from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape -from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Solid, TopoDS_Wire +from OCP.TopoDS import ( + TopoDS, + TopoDS_Face, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Solid, + TopoDS_Wire, +) from OCP.gp import gp_Ax2, gp_Pnt -from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until +from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, TOLERANCE, @@ -107,7 +113,19 @@ from build123d.geometry import ( ) from .one_d import Edge, Wire, Mixin1D -from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype +from .shape_core import ( + TOPODS, + Shape, + ShapeList, + Joint, + TrimmingTool, + downcast, + shapetype, + _sew_topods_faces, + get_top_level_topods_shapes, + unwrap_topods_compound, +) + from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell from .utils import ( _extrude_topods_shape, @@ -126,7 +144,6 @@ class Mixin3D(Shape[TOPODS]): """Additional methods to add to 3D Shape classes""" project_to_viewport = Mixin1D.project_to_viewport - split = Mixin1D.split find_intersection_points = Mixin2D.find_intersection_points vertices = Mixin1D.vertices @@ -507,7 +524,9 @@ class Mixin3D(Shape[TOPODS]): for obj in common_set: match (obj, target): case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): - operation = BRepAlgoAPI_Section() + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( + BRepAlgoAPI_Section() + ) result = bool_op((obj,), (target,), operation) if ( not isinstance(obj, Edge | Wire) @@ -610,8 +629,10 @@ class Mixin3D(Shape[TOPODS]): try: new_shape = self.__class__(fillet_builder.Shape()) if not new_shape.is_valid: - raise fillet_exception - except fillet_exception: + # raise fillet_exception + raise Standard_Failure + # except fillet_exception: + except (Standard_Failure, StdFail_NotDone): return __max_fillet(window_min, window_mid, current_iteration + 1) # These numbers work, are they close enough? - if not try larger window @@ -630,10 +651,10 @@ class Mixin3D(Shape[TOPODS]): # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform # specific exceptions are required. - if platform.system() == "Darwin": - fillet_exception = Standard_Failure - else: - fillet_exception = StdFail_NotDone + # if platform.system() == "Darwin": + # fillet_exception = Standard_Failure + # else: + # fillet_exception = StdFail_NotDone max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0) @@ -892,7 +913,17 @@ class Solid(Mixin3D[TopoDS_Solid]): inner_comp = _make_topods_compound_from_shapes(inner_solids) # subtract from the outer solid - return Solid(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape()) + difference = BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape() + + # convert to a TopoDS_Solid - might be wrapped in a TopoDS_Compound + try: + result = TopoDS.Solid_s(difference) + except Standard_TypeMismatch: + result = TopoDS.Solid_s( + unwrap_topods_compound(TopoDS.Compound_s(difference), True) + ) + + return Solid(result) @classmethod def extrude_taper( @@ -933,7 +964,7 @@ class Solid(Mixin3D[TopoDS_Solid]): direction.length / cos(radians(taper)), radians(taper), ) - new_solid = Solid(prism_builder.Shape()) + new_solid = Solid(TopoDS.Solid_s(prism_builder.Shape())) else: # Determine the offset to get the taper offset_amt = -direction.length * tan(radians(taper)) @@ -972,110 +1003,116 @@ class Solid(Mixin3D[TopoDS_Solid]): @classmethod def extrude_until( cls, - section: Face, - target_object: Compound | Solid, + profile: Face, + target: Compound | Solid, direction: VectorLike, until: Until = Until.NEXT, - ) -> Compound | Solid: + ) -> Solid: """extrude_until - Extrude section in provided direction until it encounters either the - NEXT or LAST surface of target_object. Note that the bounding surface - must be larger than the extruded face where they contact. + Extrude `profile` in the provided `direction` until it encounters a + bounding surface on the `target`. The termination surface is chosen + according to the `until` option: + + * ``Until.NEXT`` — Extrude forward until the first intersecting surface. + * ``Until.LAST`` — Extrude forward through all intersections, stopping at + the farthest surface. + * ``Until.PREVIOUS`` — Reverse the extrusion direction and stop at the + first intersecting surface behind the profile. + * ``Until.FIRST`` — Reverse the direction and stop at the farthest + surface behind the profile. + + When ``Until.PREVIOUS`` or ``Until.FIRST`` are used, the extrusion + direction is automatically inverted before execution. + + Note: + The bounding surface on the target must be large enough to + completely cover the extruded profile at the contact region. + Partial overlaps may yield open or invalid solids. Args: - section (Face): Face to extrude - target_object (Union[Compound, Solid]): object to limit extrusion - direction (VectorLike): extrusion direction - until (Until, optional): surface to limit extrusion. Defaults to Until.NEXT. + profile (Face): The face to extrude. + target (Union[Compound, Solid]): The object that limits the extrusion. + direction (VectorLike): Extrusion direction. + until (Until, optional): Surface selection mode controlling which + intersection to stop at. Defaults to ``Until.NEXT``. Raises: - ValueError: provided face does not intersect target_object + ValueError: If the provided profile does not intersect the target. Returns: - Union[Compound, Solid]: extruded Face + Solid: The extruded and limited solid. """ direction = Vector(direction) if until in [Until.PREVIOUS, Until.FIRST]: direction *= -1 until = Until.NEXT if until == Until.PREVIOUS else Until.LAST - max_dimension = find_max_dimension([section, target_object]) - clipping_direction = ( - direction * max_dimension - if until == Until.NEXT - else -direction * max_dimension + # 1: Create extrusion of length the maximum distance between profile and target + max_dimension = find_max_dimension([profile, target]) + extrusion = Solid.extrude(profile, direction * max_dimension) + + # 2: Intersect the extrusion with the target to find the target's modified faces + intersect_op = BRepAlgoAPI_Common(target.wrapped, extrusion.wrapped) + intersect_op.Build() + intersection = intersect_op.Shape() + face_exp = TopExp_Explorer(intersection, ta.TopAbs_FACE) + if not face_exp.More(): + raise ValueError("No intersection: extrusion does not contact target") + + # Find the faces from the intersection that originated on the target + history = intersect_op.History() + modified_target_faces = [] + face_explorer = TopExp_Explorer(target.wrapped, ta.TopAbs_FACE) + while face_explorer.More(): + target_face = TopoDS.Face_s(face_explorer.Current()) + modified_los: TopTools_ListOfShape = history.Modified(target_face) + while not modified_los.IsEmpty(): + modified_face = TopoDS.Face_s(modified_los.First()) + modified_los.RemoveFirst() + modified_target_faces.append(modified_face) + face_explorer.Next() + + # 3: Sew the resulting faces into shells - one for each surface the extrusion + # passes through and sort by distance from the profile + sewed_shape = _sew_topods_faces(modified_target_faces) + + # From the sewed shape extract the shells and single faces + top_level_shapes = get_top_level_topods_shapes(sewed_shape) + modified_target_surfaces: ShapeList[Face | Shell] = ShapeList() + + # For each of the top level Shells and Faces + for top_level_shape in top_level_shapes: + if isinstance(top_level_shape, TopoDS_Face): + modified_target_surfaces.append(Face(top_level_shape)) + elif isinstance(top_level_shape, TopoDS_Shell): + modified_target_surfaces.append(Shell(top_level_shape)) + else: + raise RuntimeError(f"Invalid sewn shape {type(top_level_shape)}") + + modified_target_surfaces = modified_target_surfaces.sort_by( + lambda s: s.distance_to(profile) ) - direction_axis = Axis(section.center(), clipping_direction) - # Create a linear extrusion to start - extrusion = Solid.extrude(section, direction * max_dimension) - - # Project section onto the shape to generate faces that will clip the extrusion - # and exclude the planar faces normal to the direction of extrusion and these - # will have no volume when extruded - faces = [] - for face in section.project_to_shape(target_object, direction): - if isinstance(face, Face): - faces.append(face) - else: - faces += face.faces() - - clip_faces = [ - f - for f in faces - if not (f.is_planar and f.normal_at().dot(direction) == 0.0) + limit = modified_target_surfaces[ + 0 if until in [Until.NEXT, Until.PREVIOUS] else -1 ] - if not clip_faces: - raise ValueError("provided face does not intersect target_object") + keep: Literal[Keep.TOP, Keep.BOTTOM] = ( + Keep.TOP if until in [Until.NEXT, Until.PREVIOUS] else Keep.BOTTOM + ) - # Create the objects that will clip the linear extrusion - clipping_objects = [ - Solid.extrude(f, clipping_direction).fix() for f in clip_faces - ] - clipping_objects = [o for o in clipping_objects if o.volume > 1e-9] + # 4: Split the extrusion by the appropriate shell + clipped_extrusion = extrusion.split(limit, keep=keep) - if until == Until.NEXT: - trimmed_extrusion = extrusion.cut(target_object) - if isinstance(trimmed_extrusion, ShapeList): - closest_extrusion = trimmed_extrusion.sort_by(direction_axis)[0] - else: - closest_extrusion = trimmed_extrusion - for clipping_object in clipping_objects: - # It's possible for clipping faces to self intersect when they are extruded - # thus they could be non manifold which results failed boolean operations - # - so skip these objects - try: - extrusion_shapes = closest_extrusion.cut(clipping_object) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) + # 5: Return the appropriate type + if clipped_extrusion is None: + raise RuntimeError("Extrusion is None") # None isn't an option here + elif isinstance(clipped_extrusion, Solid): + return clipped_extrusion else: - base_part = extrusion.intersect(target_object) - if isinstance(base_part, ShapeList): - extrusion_parts = base_part - elif base_part is None: - extrusion_parts = ShapeList() - else: - extrusion_parts = ShapeList([base_part]) - for clipping_object in clipping_objects: - try: - clipped_extrusion = extrusion.intersect(clipping_object) - if clipped_extrusion is not None: - extrusion_parts.append( - clipped_extrusion.solids().sort_by(direction_axis)[0] - ) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) - extrusion_shapes = Solid.fuse(*extrusion_parts) - - result = extrusion_shapes.solids().sort_by(direction_axis)[0] - - return result + # isinstance(clipped_extrusion, list): + return ShapeList(clipped_extrusion).sort_by( + Axis(profile.center(), direction) + )[0] @classmethod def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid: @@ -1106,12 +1143,14 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Box """ return cls( - BRepPrimAPI_MakeBox( - plane.to_gp_ax2(), - length, - width, - height, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeBox( + plane.to_gp_ax2(), + length, + width, + height, + ).Shape() + ) ) @classmethod @@ -1138,13 +1177,15 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial cone """ return cls( - BRepPrimAPI_MakeCone( - plane.to_gp_ax2(), - base_radius, - top_radius, - height, - angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeCone( + plane.to_gp_ax2(), + base_radius, + top_radius, + height, + angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1169,12 +1210,14 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial cylinder """ return cls( - BRepPrimAPI_MakeCylinder( - plane.to_gp_ax2(), - radius, - height, - angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeCylinder( + plane.to_gp_ax2(), + radius, + height, + angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1195,7 +1238,7 @@ class Solid(Mixin3D[TopoDS_Solid]): Returns: Solid: Lofted object """ - return cls(_make_loft(objs, True, ruled)) + return cls(TopoDS.Solid_s(_make_loft(objs, True, ruled))) @classmethod def make_sphere( @@ -1221,13 +1264,15 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: sphere """ return cls( - BRepPrimAPI_MakeSphere( - plane.to_gp_ax2(), - radius, - angle1 * DEG2RAD, - angle2 * DEG2RAD, - angle3 * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeSphere( + plane.to_gp_ax2(), + radius, + angle1 * DEG2RAD, + angle2 * DEG2RAD, + angle3 * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1255,14 +1300,16 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial torus """ return cls( - BRepPrimAPI_MakeTorus( - plane.to_gp_ax2(), - major_radius, - minor_radius, - start_angle * DEG2RAD, - end_angle * DEG2RAD, - major_angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeTorus( + plane.to_gp_ax2(), + major_radius, + minor_radius, + start_angle * DEG2RAD, + end_angle * DEG2RAD, + major_angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1293,16 +1340,18 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: wedge """ return cls( - BRepPrimAPI_MakeWedge( - plane.to_gp_ax2(), - delta_x, - delta_y, - delta_z, - min_x, - min_z, - max_x, - max_z, - ).Solid() + TopoDS.Solid_s( + BRepPrimAPI_MakeWedge( + plane.to_gp_ax2(), + delta_x, + delta_y, + delta_z, + min_x, + min_z, + max_x, + max_z, + ).Solid() + ) ) @classmethod @@ -1340,7 +1389,7 @@ class Solid(Mixin3D[TopoDS_Solid]): True, ) - return cls(revol_builder.Shape()) + return cls(TopoDS.Solid_s(revol_builder.Shape())) @classmethod def sweep( @@ -1488,7 +1537,7 @@ class Solid(Mixin3D[TopoDS_Solid]): if make_solid: builder.MakeSolid() - return cls(builder.Shape()) + return cls(TopoDS.Solid_s(builder.Shape())) @classmethod def thicken( @@ -1544,7 +1593,7 @@ class Solid(Mixin3D[TopoDS_Solid]): ) offset_builder.MakeOffsetShape() try: - result = Solid(offset_builder.Shape()) + result = Solid(TopoDS.Solid_s(offset_builder.Shape())) except StdFail_NotDone as err: raise RuntimeError("Error applying thicken to given surface") from err @@ -1591,7 +1640,7 @@ class Solid(Mixin3D[TopoDS_Solid]): try: draft_angle_builder.Build() - result = Solid(draft_angle_builder.Shape()) + result = Solid(TopoDS.Solid_s(draft_angle_builder.Shape())) except StdFail_NotDone as err: raise DraftAngleError( "Draft build failed on the given solid.", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index ec920c0..a131c50 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -145,6 +145,7 @@ from .shape_core import ( ShapeList, SkipClean, _sew_topods_faces, + _topods_bool_op, _topods_entities, _topods_face_normal_at, downcast, @@ -155,7 +156,6 @@ from .utils import ( _extrude_topods_shape, _make_loft, _make_topods_face_from_wires, - _topods_bool_op, find_max_dimension, ) from .zero_d import Vertex @@ -171,7 +171,6 @@ class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" project_to_viewport = Mixin1D.project_to_viewport - split = Mixin1D.split vertices = Mixin1D.vertices vertex = Mixin1D.vertex diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index dbccc80..b59bcca 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -24,7 +24,6 @@ Key Features: - `_make_topods_face_from_wires`: Generates planar faces with optional holes. - **Boolean Operations**: - - `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes. - `new_edges`: Identifies newly created edges from combined shapes. - **Enhanced Math**: @@ -282,45 +281,6 @@ def _make_topods_face_from_wires( return TopoDS.Face_s(sf_f.Result()) -def _topods_bool_op( - args: Iterable[TopoDS_Shape], - tools: Iterable[TopoDS_Shape], - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, -) -> TopoDS_Shape: - """Generic boolean operation for TopoDS_Shapes - - Args: - args: Iterable[TopoDS_Shape]: - tools: Iterable[TopoDS_Shape]: - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: - - Returns: TopoDS_Shape - - """ - args = list(args) - tools = list(tools) - arg = TopTools_ListOfShape() - for obj in args: - arg.Append(obj) - - tool = TopTools_ListOfShape() - for obj in tools: - tool.Append(obj) - - operation.SetArguments(arg) - operation.SetTools(tool) - - operation.SetRunParallel(True) - operation.Build() - - result = downcast(operation.Shape()) - # Remove unnecessary TopoDS_Compound around single shape - if isinstance(result, TopoDS_Compound): - result = unwrap_topods_compound(result, True) - - return result - - def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]: """Compare the OCCT objects of each list and return the differences""" shapes_one = list(shapes_one) diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index cf53676..dc536e9 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -68,8 +68,8 @@ 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, Location, Axis, Plane - -from .shape_core import Shape, ShapeList, downcast, shapetype +from build123d.build_enums import Keep +from .shape_core import Shape, ShapeList, TrimmingTool, downcast, shapetype if TYPE_CHECKING: # pragma: no cover @@ -161,7 +161,7 @@ class Vertex(Shape[TopoDS_Vertex]): shape_type = shapetype(obj) # NB downcast is needed to handle TopoDS_Shape types - return constructor_lut[shape_type](downcast(obj)) + return constructor_lut[shape_type](TopoDS.Vertex_s(obj)) @classmethod def extrude(cls, obj: Shape, direction: VectorLike) -> Vertex: @@ -312,6 +312,10 @@ class Vertex(Shape[TopoDS_Vertex]): """The center of a vertex is itself!""" return Vector(self) + def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): + """split - not implemented""" + raise NotImplementedError("Vertices cannot be split.") + def to_tuple(self) -> tuple[float, float, float]: """Return vertex as three tuple of floats""" warnings.warn( diff --git a/tests/test_build_part.py b/tests/test_build_part.py index 0f6331a..d5dd6c7 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -330,6 +330,60 @@ class TestExtrude(unittest.TestCase): extrude(until=Until.NEXT) self.assertAlmostEqual(test.part.volume, 10**3 - 8**3 + 1**2 * 8, 5) + def test_extrude_until2(self): + target = Box(10, 5, 5) - Pos(X=2.5) * Cylinder(0.5, 5) + pln = Plane((7, 0, 7), z_dir=(-1, 0, -1)) + profile = (pln * Circle(1)).face() + extrusion = extrude(profile, dir=pln.z_dir, until=Until.NEXT, target=target) + self.assertLess(extrusion.bounding_box().min.Z, 2.5) + + def test_extrude_until3(self): + with BuildPart() as p: + with BuildSketch(Plane.XZ): + Rectangle(8, 8, align=Align.MIN) + with Locations((1, 1)): + Rectangle(7, 7, align=Align.MIN, mode=Mode.SUBTRACT) + extrude(amount=2, both=True) + with BuildSketch( + Plane((-2, 0, -2), x_dir=(0, 1, 0), z_dir=(1, 0, 1)) + ) as profile: + Rectangle(4, 1) + extrude(until=Until.NEXT) + + self.assertAlmostEqual(p.part.volume, 72.313, 2) + + def test_extrude_until_errors(self): + with self.assertRaises(ValueError): + extrude( + Rectangle(1, 1), + until=Until.NEXT, + dir=(0, 0, 1), + target=Pos(Z=-10) * Box(1, 1, 1), + ) + + def test_extrude_until_invalid_sewn_shape(self): + profile = Face.make_rect(1, 1) + target = Box(2, 2, 2) + direction = Vector(0, 0, 1) + + bad_shape = Box(1, 1, 1).wrapped # not a Face or Shell → forces RuntimeError + + with patch( + "build123d.topology.three_d.get_top_level_topods_shapes", + return_value=[bad_shape], + ): + with self.assertRaises(RuntimeError): + extrude(profile, dir=direction, until=Until.NEXT, target=target) + + def test_extrude_until_invalid_split(self): + profile = Face.make_rect(1, 1) + target = Box(2, 2, 2) + direction = Vector(0, 0, 1) + + with patch("build123d.topology.three_d.Solid.split", return_value=None): + with self.assertRaises(RuntimeError): + extrude(profile, dir=direction, until=Until.NEXT, target=target) + def test_extrude_face(self): with BuildPart(Plane.XZ) as box: with BuildSketch(Plane.XZ, mode=Mode.PRIVATE) as square: From 8985220c79163de07537ccde179b35a4c9dc4606 Mon Sep 17 00:00:00 2001 From: gumyr Date: Mon, 1 Dec 2025 21:05:38 -0500 Subject: [PATCH 069/105] Typing improvements --- src/build123d/operations_generic.py | 4 ++-- src/build123d/topology/shape_core.py | 5 +++-- src/build123d/topology/three_d.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 2a2f007..72c86e2 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -963,9 +963,9 @@ def split( for obj in object_list: bottom = None if keep == Keep.BOTH: - top, bottom = obj.split(bisect_by, keep) + top, bottom = obj.split(bisect_by, keep) # type: ignore[arg-type] else: - top = obj.split(bisect_by, keep) + top = obj.split(bisect_by, keep) # type: ignore[arg-type] for subpart in [top, bottom]: if isinstance(subpart, Iterable): new_objects.extend(subpart) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 2760367..c9822b7 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -163,6 +163,7 @@ if TYPE_CHECKING: # pragma: no cover Shapes = Literal["Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"] TrimmingTool = Union[Plane, "Shell", "Face"] TOPODS = TypeVar("TOPODS", bound=TopoDS_Shape) +CalcFn = Callable[[TopoDS_Shape, GProp_GProps], None] class Shape(NodeMixin, Generic[TOPODS]): @@ -197,7 +198,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ta.TopAbs_COMPSOLID: "CompSolid", } - shape_properties_LUT: dict[TopAbs_ShapeEnum:function] = { + shape_properties_LUT: dict[TopAbs_ShapeEnum, CalcFn | None] = { ta.TopAbs_VERTEX: None, ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, @@ -804,7 +805,7 @@ class Shape(NodeMixin, Generic[TOPODS]): properties = GProp_GProps() calc_function = Shape.shape_properties_LUT[shapetype(obj.wrapped)] - if not calc_function: + if calc_function is None: raise NotImplementedError calc_function(obj.wrapped, properties) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index ed0011a..b40a813 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -212,6 +212,7 @@ class Mixin3D(Shape[TOPODS]): if center_of == CenterOf.MASS: properties = GProp_GProps() calc_function = Shape.shape_properties_LUT[shapetype(self.wrapped)] + assert calc_function is not None calc_function(self.wrapped, properties) middle = Vector(properties.CentreOfMass()) elif center_of == CenterOf.BOUNDING_BOX: From 5adf296fd87715de6bbe39c07e8a74cc73372946 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 2 Dec 2025 11:04:08 -0500 Subject: [PATCH 070/105] Fixed typing and linting issues --- src/build123d/topology/three_d.py | 6 ++--- src/build123d/topology/two_d.py | 41 ++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index b40a813..c9ce928 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -56,8 +56,7 @@ from __future__ import annotations from collections.abc import Iterable, Sequence from math import radians, cos, tan -from typing import TYPE_CHECKING, Literal, overload -from typing import cast as tcast +from typing import TYPE_CHECKING, Literal from typing_extensions import Self import OCP.TopAbs as ta @@ -118,7 +117,6 @@ from .shape_core import ( Shape, ShapeList, Joint, - TrimmingTool, downcast, shapetype, _sew_topods_faces, @@ -137,7 +135,7 @@ from .zero_d import Vertex if TYPE_CHECKING: # pragma: no cover - from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 + from .composite import Compound # pylint: disable=R0801 class Mixin3D(Shape[TOPODS]): diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index a131c50..8a5879d 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -62,6 +62,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence from math import degrees from typing import TYPE_CHECKING, Any, TypeVar, overload +from typing import cast as tcast import OCP.TopAbs as ta from OCP.BRep import BRep_Builder, BRep_Tool @@ -104,6 +105,7 @@ from OCP.Standard import ( Standard_ConstructionError, Standard_Failure, Standard_NoSuchObject, + Standard_TypeMismatch, ) from OCP.StdFail import StdFail_NotDone from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt @@ -217,7 +219,7 @@ class Mixin2D(ABC, Shape[TOPODS]): if self._wrapped is None: raise ValueError("Invalid Shape") new_surface = copy.deepcopy(self) - new_surface.wrapped = downcast(self.wrapped.Complemented()) + new_surface.wrapped = tcast(TOPODS, downcast(self.wrapped.Complemented())) # As the surface has been modified, the parent is no longer valid new_surface.topo_parent = None @@ -366,7 +368,9 @@ class Mixin2D(ABC, Shape[TOPODS]): for obj in common_set: match (obj, target): case (_, Vertex() | Edge() | Wire() | Face() | Shell()): - operation = BRepAlgoAPI_Section() + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( + BRepAlgoAPI_Section() + ) result = bool_op((obj,), (target,), operation) if not isinstance(obj, Edge | Wire) and not isinstance( target, (Edge | Wire) @@ -604,6 +608,7 @@ class Face(Mixin2D[TopoDS_Face]): """ def __init__(self, *args: Any, **kwargs: Any): + obj: TopoDS_Face | Plane | None outer_wire, inner_wires, obj, label, color, parent = (None,) * 6 if args: @@ -1463,7 +1468,7 @@ class Face(Mixin2D[TopoDS_Face]): try: patch.Build() - result = cls(patch.Shape()) + result = cls(TopoDS.Face_s(patch.Shape())) except ( Standard_Failure, StdFail_NotDone, @@ -1579,8 +1584,12 @@ class Face(Mixin2D[TopoDS_Face]): if len(profile.edges()) != 1 or len(path.edges()) != 1: raise ValueError("Use Shell.sweep for multi Edge objects") - profile = Wire([profile.edge()]) - path = Wire([path.edge()]) + profile_edge = profile.edge() + path_edge = path.edge() + assert profile_edge is not None + assert path_edge is not None + profile = Wire([profile_edge]) + path = Wire([path_edge]) builder = BRepOffsetAPI_MakePipeShell(path.wrapped) builder.Add(profile.wrapped, False, False) builder.SetTransitionMode(Shape._transModeDict[transition]) @@ -1604,6 +1613,7 @@ class Face(Mixin2D[TopoDS_Face]): Returns: Vector: center """ + center_point: Vector | gp_Pnt if (center_of == CenterOf.MASS) or ( center_of == CenterOf.GEOMETRY and self.is_planar ): @@ -1663,7 +1673,10 @@ class Face(Mixin2D[TopoDS_Face]): # Index or iterator access to OCP.TopTools.TopTools_ListOfShape is slow on M1 macs # Using First() and Last() to omit - edges = (Edge(edge_list.First()), Edge(edge_list.Last())) + edges = ( + Edge(TopoDS.Edge_s(edge_list.First())), + Edge(TopoDS.Edge_s(edge_list.Last())), + ) edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges) @@ -2053,7 +2066,7 @@ class Face(Mixin2D[TopoDS_Face]): BRepAlgoAPI_Common(), ) for topods_shell in get_top_level_topods_shapes(topods_shape): - intersected_shapes.append(Shell(topods_shell)) + intersected_shapes.append(Shell(TopoDS.Shell_s(topods_shell))) intersected_shapes = intersected_shapes.sort_by(Axis(self.center(), direction)) projected_shapes: ShapeList[Face | Shell] = ShapeList() @@ -2110,7 +2123,7 @@ class Face(Mixin2D[TopoDS_Face]): for hole_wire in inner_wires: reshaper.Remove(hole_wire.wrapped) modified_shape = downcast(reshaper.Apply(self.wrapped)) - holeless.wrapped = modified_shape + holeless.wrapped = TopoDS.Face_s(modified_shape) return holeless def wire(self) -> Wire: @@ -2513,7 +2526,10 @@ class Shell(Mixin2D[TopoDS_Shell]): builder.Add(shell, obj.wrapped) obj = shell elif isinstance(obj, Iterable): - obj = _sew_topods_faces([f.wrapped for f in obj]) + try: + obj = TopoDS.Shell_s(_sew_topods_faces([f.wrapped for f in obj])) + except Standard_TypeMismatch: + raise TypeError("Unable to create Shell, invalid input type") super().__init__( obj=obj, @@ -2531,6 +2547,7 @@ class Shell(Mixin2D[TopoDS_Shell]): solid_shell = ShapeFix_Solid().SolidFromShell(self.wrapped) properties = GProp_GProps() calc_function = Shape.shape_properties_LUT[shapetype(solid_shell)] + assert calc_function is not None calc_function(solid_shell, properties) return properties.Mass() return 0.0 @@ -2573,7 +2590,7 @@ class Shell(Mixin2D[TopoDS_Shell]): Returns: Shell: Lofted object """ - return cls(_make_loft(objs, False, ruled)) + return cls(TopoDS.Shell_s(_make_loft(objs, False, ruled))) @classmethod def revolve( @@ -2599,7 +2616,7 @@ class Shell(Mixin2D[TopoDS_Shell]): profile.wrapped, axis.wrapped, angle * DEG2RAD, True ) - return cls(revol_builder.Shape()) + return cls(TopoDS.Shell_s(revol_builder.Shape())) @classmethod def sweep( @@ -2627,7 +2644,7 @@ class Shell(Mixin2D[TopoDS_Shell]): builder.Add(profile.wrapped, False, False) builder.SetTransitionMode(Shape._transModeDict[transition]) builder.Build() - result = Shell(builder.Shape()) + result = Shell(TopoDS.Shell_s(builder.Shape())) if SkipClean.clean: result = result.clean() From 3474dc61d25683045d5beb12694038cf9a021c57 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 2 Dec 2025 13:03:58 -0500 Subject: [PATCH 071/105] Fixed typing @ OCCT level --- src/build123d/topology/composite.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 14c67b4..0b434bc 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -60,6 +60,8 @@ import sys import warnings from collections.abc import Iterable, Iterator, Sequence from itertools import combinations +from typing import TypeVar + from typing_extensions import Self import OCP.TopAbs as ta @@ -164,7 +166,7 @@ class Compound(Mixin3D[TopoDS_Compound]): parent (Compound, optional): assembly parent. Defaults to None. children (Sequence[Shape], optional): assembly children. Defaults to None. """ - + topods_compound: TopoDS_Compound | None if isinstance(obj, Iterable): topods_compound = _make_topods_compound_from_shapes( [s.wrapped for s in obj] @@ -376,8 +378,14 @@ class Compound(Mixin3D[TopoDS_Compound]): ) text_flat = Compound( - builder.Perform( - font_i, NCollection_Utf8String(txt), gp_Ax3(), horiz_align, vert_align + TopoDS.Compound_s( + builder.Perform( + font_i, + NCollection_Utf8String(txt), + gp_Ax3(), + horiz_align, + vert_align, + ) ) ) @@ -504,6 +512,8 @@ class Compound(Mixin3D[TopoDS_Compound]): def __and__(self, other: Shape | Iterable[Shape]) -> Compound: """Intersect other to self `&` operator""" intersection = Shape.__and__(self, other) + if intersection is None: + return Compound() intersection = Compound( intersection if isinstance(intersection, list) else [intersection] ) @@ -700,7 +710,7 @@ class Compound(Mixin3D[TopoDS_Compound]): while iterator.More(): child = iterator.Value() if child.ShapeType() == type_map[obj_type]: - results.append(obj_type(downcast(child))) + results.append(obj_type(downcast(child))) # type: ignore iterator.Next() return results @@ -802,7 +812,9 @@ class Compound(Mixin3D[TopoDS_Compound]): target = ShapeList([target]) result = ShapeList() for t in target: - operation = BRepAlgoAPI_Section() + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( + BRepAlgoAPI_Section() + ) result.extend(bool_op((obj,), (t,), operation)) if ( not isinstance(obj, Edge | Wire) @@ -900,8 +912,8 @@ class Compound(Mixin3D[TopoDS_Compound]): parent.wrapped = _make_topods_compound_from_shapes( [c.wrapped for c in parent.children] ) - else: - parent.wrapped = None + # else: + # parent.wrapped = None def _post_detach_children(self, children): """Method call before detaching `children`.""" From 17ccdd01cc7b7eea8f7130671ab1527325acde93 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 2 Dec 2025 20:24:55 -0500 Subject: [PATCH 072/105] Fixing OCCT typing problems --- src/build123d/exporters.py | 13 +++++++------ src/build123d/exporters3d.py | 6 +++--- src/build123d/topology/composite.py | 1 - 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 687e2f1..f5828bd 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -37,6 +37,7 @@ from enum import Enum, auto from io import BytesIO from os import PathLike, fsdecode from typing import Any, TypeAlias +from typing import cast as tcast from warnings import warn from collections.abc import Callable, Iterable @@ -48,7 +49,7 @@ from ezdxf.colors import RGB, aci2rgb from ezdxf.math import Vec2 from OCP.BRepLib import BRepLib from OCP.BRepTools import BRepTools_WireExplorer -from OCP.Geom import Geom_BezierCurve +from OCP.Geom import Geom_BezierCurve, Geom_BSplineCurve from OCP.GeomConvert import GeomConvert from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ @@ -757,7 +758,7 @@ class ExportDXF(Export2D): # Extract the relevant segment of the curve. spline = GeomConvert.SplitBSplineCurve_s( - curve, + tcast(Geom_BSplineCurve, curve), u1, u2, Export2D.PARAMETRIC_TOLERANCE, @@ -1136,7 +1137,7 @@ class ExportSVG(Export2D): ) while explorer.More(): topo_edge = explorer.Current() - loose_edges.append(Edge(topo_edge)) + loose_edges.append(Edge(TopoDS.Edge_s(topo_edge))) explorer.Next() # print(f"{len(loose_edges)} loose edges") loose_edge_elements = [self._edge_element(edge) for edge in loose_edges] @@ -1263,7 +1264,7 @@ class ExportSVG(Export2D): (u0, u1) = (lp, fp) if reverse else (fp, lp) start = self._path_point(curve.Value(u0)) end = self._path_point(curve.Value(u1)) - radius = complex(radius, radius) + radius = complex(radius, radius) # type: ignore[assignment] rotation = math.degrees(gp_Dir(1, 0, 0).AngleWithRef(x_axis, gp_Dir(0, 0, 1))) if curve.IsClosed(): midway = self._path_point(curve.Value((u0 + u1) / 2)) @@ -1316,7 +1317,7 @@ class ExportSVG(Export2D): (u0, u1) = (lp, fp) if reverse else (fp, lp) start = self._path_point(curve.Value(u0)) end = self._path_point(curve.Value(u1)) - radius = complex(major_radius, minor_radius) + radius = complex(major_radius, minor_radius) # type: ignore[assignment] rotation = math.degrees(gp_Dir(1, 0, 0).AngleWithRef(x_axis, gp_Dir(0, 0, 1))) if curve.IsClosed(): midway = self._path_point(curve.Value((u0 + u1) / 2)) @@ -1361,7 +1362,7 @@ class ExportSVG(Export2D): # According to the OCCT 7.6.0 documentation, # "ParametricTolerance is not used." converter = GeomConvert_BSplineCurveToBezierCurve( - spline, u1, u2, Export2D.PARAMETRIC_TOLERANCE + tcast(Geom_BSplineCurve, spline), u1, u2, Export2D.PARAMETRIC_TOLERANCE ) def make_segment(bezier: Geom_BezierCurve, reverse: bool) -> PathSegment: diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py index 759464a..52fe4e9 100644 --- a/src/build123d/exporters3d.py +++ b/src/build123d/exporters3d.py @@ -244,7 +244,7 @@ def export_gltf( messenger = Message.DefaultMessenger_s() for printer in messenger.Printers(): - printer.SetTraceLevel(Message_Gravity(Message_Gravity.Message_Fail)) + printer.SetTraceLevel(Message_Gravity.Message_Fail) status = writer.Perform(doc, index_map, progress) @@ -297,7 +297,7 @@ def export_step( # Disable writing OCCT info to console messenger = Message.DefaultMessenger_s() for printer in messenger.Printers(): - printer.SetTraceLevel(Message_Gravity(Message_Gravity.Message_Fail)) + printer.SetTraceLevel(Message_Gravity.Message_Fail) session = XSControl_WorkSession() writer = STEPCAFControl_Writer(session, False) @@ -328,7 +328,7 @@ def export_step( if not isinstance(file_path, BytesIO): status = ( - writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone + writer.Write(fsdecode(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone ) else: status = writer.WriteStream(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 0b434bc..7299e32 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -60,7 +60,6 @@ import sys import warnings from collections.abc import Iterable, Iterator, Sequence from itertools import combinations -from typing import TypeVar from typing_extensions import Self From 6605b676a3bf8413e1ddc37eda838ce52e51df5e Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 2 Dec 2025 20:25:34 -0500 Subject: [PATCH 073/105] Fixed problem with hollow STL files --- src/build123d/mesher.py | 40 +++++++++++++++++++++++++++++----------- tests/test_mesher.py | 1 + 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index deed500..c268a2f 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -106,15 +106,23 @@ from OCP.BRepGProp import BRepGProp from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.gp import gp_Pnt from OCP.GProp import GProp_GProps +from OCP.Standard import Standard_TypeMismatch from OCP.TopAbs import TopAbs_ShapeEnum from OCP.TopExp import TopExp_Explorer from OCP.TopLoc import TopLoc_Location -from OCP.TopoDS import TopoDS_Compound +from OCP.TopoDS import TopoDS, TopoDS_Compound, TopoDS_Shell from lib3mf import Lib3MF from build123d.build_enums import MeshType, Unit from build123d.geometry import TOLERANCE, Color -from build123d.topology import Compound, Shape, Shell, Solid, downcast +from build123d.topology import ( + Compound, + Shape, + Shell, + Solid, + downcast, + unwrap_topods_compound, +) class Mesher: @@ -466,7 +474,9 @@ class Mesher: # Convert to a list of gp_Pnt ocp_vertices = [gp_pnts[tri_indices[i]] for i in range(3)] # Create the triangular face using the polygon - polygon_builder = BRepBuilderAPI_MakePolygon(*ocp_vertices, Close=True) + polygon_builder = BRepBuilderAPI_MakePolygon( + ocp_vertices[0], ocp_vertices[1], ocp_vertices[2], Close=True + ) face_builder = BRepBuilderAPI_MakeFace(polygon_builder.Wire()) facet = face_builder.Face() facet_properties = GProp_GProps() @@ -479,19 +489,27 @@ class Mesher: occ_sewed_shape = downcast(shell_builder.SewedShape()) if isinstance(occ_sewed_shape, TopoDS_Compound): - occ_shells = [] + bd_shells = [] explorer = TopExp_Explorer(occ_sewed_shape, TopAbs_ShapeEnum.TopAbs_SHELL) while explorer.More(): - occ_shells.append(downcast(explorer.Current())) + # occ_shells.append(downcast(explorer.Current())) + bd_shells.append(Shell(TopoDS.Shell_s(explorer.Current()))) explorer.Next() else: - occ_shells = [occ_sewed_shape] + assert isinstance(occ_sewed_shape, TopoDS_Shell) + bd_shells = [Shell(occ_sewed_shape)] - # Create a solid if manifold - shape_obj = Shell(occ_sewed_shape) - if shape_obj.is_manifold: - solid_builder = BRepBuilderAPI_MakeSolid(*occ_shells) - shape_obj = Solid(solid_builder.Solid()) + outer_shell = max(bd_shells, key=lambda s: math.prod(s.bounding_box().size)) + inner_shells = [s for s in bd_shells if s is not outer_shell] + + # The the shell isn't water tight just return it else create a solid + if not outer_shell.is_manifold: + return outer_shell + + solid_builder = BRepBuilderAPI_MakeSolid(outer_shell.wrapped) + for inner_shell in inner_shells: + solid_builder.Add(inner_shell.wrapped) + shape_obj = Solid(solid_builder.Solid()) return shape_obj diff --git a/tests/test_mesher.py b/tests/test_mesher.py index 59b7214..ef3a2af 100644 --- a/tests/test_mesher.py +++ b/tests/test_mesher.py @@ -210,6 +210,7 @@ class TestHollowImport(unittest.TestCase): importer = Mesher() stl = importer.read("test.stl") self.assertTrue(stl[0].is_valid) + self.assertAlmostEqual(test_shape.volume, stl[0].volume, 0) class TestImportDegenerateTriangles(unittest.TestCase): From 3871345dcd68469eb03cf107bcb28366e3032656 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 3 Dec 2025 10:13:09 -0500 Subject: [PATCH 074/105] Improving split to explicitly handle all Keep Enum values --- src/build123d/operations_generic.py | 4 ++-- src/build123d/topology/shape_core.py | 10 +++++++++- tests/test_direct_api/test_shape.py | 9 +++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 72c86e2..2a2f007 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -963,9 +963,9 @@ def split( for obj in object_list: bottom = None if keep == Keep.BOTH: - top, bottom = obj.split(bisect_by, keep) # type: ignore[arg-type] + top, bottom = obj.split(bisect_by, keep) else: - top = obj.split(bisect_by, keep) # type: ignore[arg-type] + top = obj.split(bisect_by, keep) for subpart in [top, bottom]: if isinstance(subpart, Iterable): new_objects.extend(subpart) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index c9822b7..3858e4a 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1805,6 +1805,12 @@ class Shape(NodeMixin, Generic[TOPODS]): ]: """split and keep inside and outside""" + @overload + def split( + self, tool: TrimmingTool, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] + ) -> None: + """invalid split""" + @overload def split(self, tool: TrimmingTool) -> Self | list[Self] | None: """split and keep inside (default)""" @@ -1834,6 +1840,9 @@ class Shape(NodeMixin, Generic[TOPODS]): if self._wrapped is None or not tool: raise ValueError("Can't split an empty edge/wire/tool") + if keep in [Keep.INSIDE, Keep.OUTSIDE]: + raise ValueError(f"{keep} is invalid") + shape_list = TopTools_ListOfShape() shape_list.Append(self.wrapped) @@ -1924,7 +1933,6 @@ class Shape(NodeMixin, Generic[TOPODS]): return top if keep == Keep.BOTTOM: return bottom - return None @overload def split_by_perimeter( diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index bb290e7..a261f8f 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -172,10 +172,11 @@ class TestShape(unittest.TestCase): self.assertEqual(len(top), 2) self.assertAlmostEqual(top[0].length, 3, 5) - def test_split_return_none(self): - shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5) - split_shape = shape.split(Plane.XY, keep=Keep.INSIDE) - self.assertIsNone(split_shape) + def test_split_invalid_keep(self): + with self.assertRaises(ValueError): + Box(1, 1, 1).split(Plane.XY, keep=Keep.INSIDE) + with self.assertRaises(ValueError): + Box(1, 1, 1).split(Plane.XY, keep=Keep.OUTSIDE) def test_split_by_perimeter(self): # Test 0 - extract a spherical cap From 2d12c9f452e0467a5621bb36aee00bd8fb6bf80b Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:28:17 -0600 Subject: [PATCH 075/105] action.yml -> route optional dependencies for work towards including stubs --- .github/actions/setup/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 2af009a..87d4ff9 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -3,6 +3,9 @@ inputs: python-version: # id of input description: 'Python version' required: true + optional-dependencies: + description: 'Optional build123d dependencies' + required: true runs: using: "composite" @@ -15,4 +18,4 @@ runs: - name: Install Requirements shell: bash run: | - uv pip install .[development] + uv pip install .[${{ inputs.optional-dependencies }}] From 92f44f2b42ce036b724720fc54a40d8ed892cb61 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:28:50 -0600 Subject: [PATCH 076/105] Update benchmark.yml --- .github/workflows/benchmark.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index ff389dc..9582418 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -20,6 +20,7 @@ jobs: - uses: ./.github/actions/setup/ with: python-version: ${{ matrix.python-version }} + optional-dependencies: development - name: benchmark run: | python -m pytest --benchmark-only --benchmark-autosave From be12ec3d300e625ffc503d9807403015170ce009 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:29:20 -0600 Subject: [PATCH 077/105] Update coverage.yml --- .github/workflows/coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 25bc1f2..35ef154 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,6 +10,7 @@ jobs: uses: ./.github/actions/setup/ with: python-version: "3.10" + optional-dependencies: "development" - name: Run tests and collect coverage run: pytest --cov=build123d - name: Upload coverage to Codecov From 621658b1af4a4b8c086001e8c586425a896b14a0 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:29:33 -0600 Subject: [PATCH 078/105] Update lint.yml --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b5ae54d..f92e896 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,6 @@ jobs: - uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} - + optional-dependencies: "development" - name: lint run: pylint --rcfile=.pylintrc --fail-under=9.5 src/build123d From 417fa8bae246efa42d842ff5d210260dce488761 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:30:17 -0600 Subject: [PATCH 079/105] Update mypy.yml --- .github/workflows/mypy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 79f10a2..1e07266 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -20,6 +20,7 @@ jobs: uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} + optional-dependencies: "development,stubs" - name: Typecheck run: | From 091b6782931bb8c07cbd0823fd2a20c8729da2bc Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:30:38 -0600 Subject: [PATCH 080/105] Update test.yml --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f9dabc..dc4f4c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: - uses: ./.github/actions/setup/ with: python-version: ${{ matrix.python-version }} + optional-dependencies: "development" - name: test run: | python -m pytest -n auto --benchmark-disable From a81cbbf04ad3624fa9f111377c97cf872b180135 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Wed, 3 Dec 2025 10:31:29 -0600 Subject: [PATCH 081/105] Update benchmark.yml --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 9582418..4299379 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -20,7 +20,7 @@ jobs: - uses: ./.github/actions/setup/ with: python-version: ${{ matrix.python-version }} - optional-dependencies: development + optional-dependencies: "development" - name: benchmark run: | python -m pytest --benchmark-only --benchmark-autosave From 726a72a20b4dbea511d0eb7c970fae3f64cb099c Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 3 Dec 2025 11:35:20 -0500 Subject: [PATCH 082/105] Eliminating copying exploration methods in higher order classes --- src/build123d/topology/composite.py | 2 +- src/build123d/topology/one_d.py | 83 ++++++++++++++++------------ src/build123d/topology/shape_core.py | 39 ++++++++----- src/build123d/topology/three_d.py | 22 ++------ src/build123d/topology/two_d.py | 29 ++++------ 5 files changed, 93 insertions(+), 82 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 7299e32..365808d 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -600,7 +600,7 @@ class Compound(Mixin3D[TopoDS_Compound]): """Return the Compound""" shape_list = self.compounds() entity_count = len(shape_list) - if entity_count != 1: + if entity_count > 1: warnings.warn( f"Found {entity_count} compounds, returning first", stacklevel=2, diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 2b9a211..d2a4913 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -556,7 +556,7 @@ class Mixin1D(Shape[TOPODS]): A curvature comb is a set of short line segments (“teeth”) erected perpendicular to the curve that visualize the signed curvature κ(u). - Tooth length is proportional to \|κ\| and the direction encodes the sign + Tooth length is proportional to |κ| and the direction encodes the sign (left normal for κ>0, right normal for κ<0). This is useful for inspecting fairness and continuity (C0/C1/C2) of edges and wires. @@ -684,30 +684,30 @@ class Mixin1D(Shape[TOPODS]): return derivative - def edge(self) -> Edge | None: - """Return the Edge""" - return Shape.get_single_shape(self, "Edge") + # def edge(self) -> Edge | None: + # """Return the Edge""" + # return Shape.get_single_shape(self, "Edge") - def edges(self) -> ShapeList[Edge]: - """edges - all the edges in this Shape""" - if isinstance(self, Wire) and self.wrapped is not None: - # The WireExplorer is a tool to explore the edges of a wire in a connection order. - explorer = BRepTools_WireExplorer(self.wrapped) + # def edges(self) -> ShapeList[Edge]: + # """edges - all the edges in this Shape""" + # if isinstance(self, Wire) and self.wrapped is not None: + # # The WireExplorer is a tool to explore the edges of a wire in a connection order. + # explorer = BRepTools_WireExplorer(self.wrapped) - edge_list: ShapeList[Edge] = ShapeList() - while explorer.More(): - next_edge = Edge(explorer.Current()) - next_edge.topo_parent = ( - self if self.topo_parent is None else self.topo_parent - ) - edge_list.append(next_edge) - explorer.Next() - return edge_list + # edge_list: ShapeList[Edge] = ShapeList() + # while explorer.More(): + # next_edge = Edge(explorer.Current()) + # next_edge.topo_parent = ( + # self if self.topo_parent is None else self.topo_parent + # ) + # edge_list.append(next_edge) + # explorer.Next() + # return edge_list - edge_list = Shape.get_shape_list(self, "Edge") - return edge_list.filter_by( - lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True - ) + # edge_list = Shape.get_shape_list(self, "Edge") + # return edge_list.filter_by( + # lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True + # ) def end_point(self) -> Vector: """The end point of this edge. @@ -1431,21 +1431,21 @@ class Mixin1D(Shape[TOPODS]): """ return self.derivative_at(position, 1, position_mode).normalized() - def vertex(self) -> Vertex | None: - """Return the Vertex""" - return Shape.get_single_shape(self, "Vertex") + # def vertex(self) -> Vertex | None: + # """Return the Vertex""" + # return Shape.get_single_shape(self, "Vertex") - def vertices(self) -> ShapeList[Vertex]: - """vertices - all the vertices in this Shape""" - return Shape.get_shape_list(self, "Vertex") + # def vertices(self) -> ShapeList[Vertex]: + # """vertices - all the vertices in this Shape""" + # return Shape.get_shape_list(self, "Vertex") - def wire(self) -> Wire | None: - """Return the Wire""" - return Shape.get_single_shape(self, "Wire") + # def wire(self) -> Wire | None: + # """Return the Wire""" + # return Shape.get_single_shape(self, "Wire") - def wires(self) -> ShapeList[Wire]: - """wires - all the wires in this Shape""" - return Shape.get_shape_list(self, "Wire") + # def wires(self) -> ShapeList[Wire]: + # """wires - all the wires in this Shape""" + # return Shape.get_shape_list(self, "Wire") class Edge(Mixin1D[TopoDS_Edge]): @@ -3561,6 +3561,21 @@ class Wire(Mixin1D[TopoDS_Wire]): return return_value + def edges(self) -> ShapeList[Edge]: + """edges - all the edges in this Shape""" + # The WireExplorer is a tool to explore the edges of a wire in a connection order. + explorer = BRepTools_WireExplorer(self.wrapped) + + edge_list: ShapeList[Edge] = ShapeList() + while explorer.More(): + next_edge = Edge(explorer.Current()) + next_edge.topo_parent = ( + self if self.topo_parent is None else self.topo_parent + ) + edge_list.append(next_edge) + explorer.Next() + return edge_list + def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Wire: """fillet_2d diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 3858e4a..6e84222 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -101,7 +101,7 @@ from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace -from OCP.BRepTools import BRepTools +from OCP.BRepTools import BRepTools, BRepTools_WireExplorer from OCP.gce import gce_MakeLin from OCP.Geom import Geom_Line from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf @@ -839,7 +839,9 @@ class Shape(NodeMixin, Generic[TOPODS]): with a warning if count != 1.""" shape_list = Shape.get_shape_list(shape, entity_type) entity_count = len(shape_list) - if entity_count != 1: + if entity_count == 0: + return None + elif entity_count > 1: warnings.warn( f"Found {entity_count} {entity_type.lower()}s, returning first", stacklevel=3, @@ -1185,13 +1187,14 @@ class Shape(NodeMixin, Generic[TOPODS]): def edge(self) -> Edge | None: """Return the Edge""" - return None - - # Note all sub-classes have vertices and vertex methods + return Shape.get_single_shape(self, "Edge") def edges(self) -> ShapeList[Edge]: """edges - all the edges in this Shape - subclasses may override""" - return ShapeList() + edge_list = Shape.get_shape_list(self, "Edge") + return edge_list.filter_by( + lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True + ) def entities(self, topo_type: Shapes) -> list[TopoDS_Shape]: """Return all of the TopoDS sub entities of the given type""" @@ -1201,11 +1204,11 @@ class Shape(NodeMixin, Generic[TOPODS]): def face(self) -> Face | None: """Return the Face""" - return None + return Shape.get_single_shape(self, "Face") def faces(self) -> ShapeList[Face]: """faces - all the faces in this Shape""" - return ShapeList() + return Shape.get_shape_list(self, "Face") def faces_intersected_by_axis( self, @@ -1724,11 +1727,11 @@ class Shape(NodeMixin, Generic[TOPODS]): def shell(self) -> Shell | None: """Return the Shell""" - return None + return Shape.get_single_shape(self, "Shell") def shells(self) -> ShapeList[Shell]: """shells - all the shells in this Shape""" - return ShapeList() + return Shape.get_shape_list(self, "Shell") def show_topology( self, @@ -1782,11 +1785,11 @@ class Shape(NodeMixin, Generic[TOPODS]): def solid(self) -> Solid | None: """Return the Solid""" - return None + return Shape.get_single_shape(self, "Solid") def solids(self) -> ShapeList[Solid]: """solids - all the solids in this Shape""" - return ShapeList() + return Shape.get_shape_list(self, "Solid") @overload def split( @@ -2235,11 +2238,11 @@ class Shape(NodeMixin, Generic[TOPODS]): def wire(self) -> Wire | None: """Return the Wire""" - return None + return Shape.get_single_shape(self, "Wire") def wires(self) -> ShapeList[Wire]: """wires - all the wires in this Shape""" - return ShapeList() + return Shape.get_shape_list(self, "Wire") def _apply_transform(self, transformation: gp_Trsf) -> Self: """Private Apply Transform @@ -2395,6 +2398,14 @@ class Shape(NodeMixin, Generic[TOPODS]): return shape_to_html(self)._repr_html_() + def vertex(self) -> Vertex | None: + """Return the Vertex""" + return Shape.get_single_shape(self, "Vertex") + + def vertices(self) -> ShapeList[Vertex]: + """vertices - all the vertices in this Shape""" + return Shape.get_shape_list(self, "Vertex") + class Comparable(ABC): """Abstract base class that requires comparison methods""" diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index c9ce928..e6a7c38 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -144,16 +144,6 @@ class Mixin3D(Shape[TOPODS]): project_to_viewport = Mixin1D.project_to_viewport find_intersection_points = Mixin2D.find_intersection_points - vertices = Mixin1D.vertices - vertex = Mixin1D.vertex - edges = Mixin1D.edges - edge = Mixin1D.edge - wires = Mixin1D.wires - wire = Mixin1D.wire - faces = Mixin2D.faces - face = Mixin2D.face - shells = Mixin2D.shells - shell = Mixin2D.shell # ---- Properties ---- @property @@ -725,13 +715,13 @@ class Mixin3D(Shape[TOPODS]): return offset_solid - def solid(self) -> Solid | None: - """Return the Solid""" - return Shape.get_single_shape(self, "Solid") + # def solid(self) -> Solid | None: + # """Return the Solid""" + # return Shape.get_single_shape(self, "Solid") - def solids(self) -> ShapeList[Solid]: - """solids - all the solids in this Shape""" - return Shape.get_shape_list(self, "Solid") + # def solids(self) -> ShapeList[Solid]: + # """solids - all the solids in this Shape""" + # return Shape.get_shape_list(self, "Solid") class Solid(Mixin3D[TopoDS_Solid]): diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8a5879d..5c3e35c 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -174,11 +174,6 @@ class Mixin2D(ABC, Shape[TOPODS]): project_to_viewport = Mixin1D.project_to_viewport - vertices = Mixin1D.vertices - vertex = Mixin1D.vertex - edges = Mixin1D.edges - edge = Mixin1D.edge - wires = Mixin1D.wires # ---- Properties ---- @property @@ -226,13 +221,13 @@ class Mixin2D(ABC, Shape[TOPODS]): return new_surface - def face(self) -> Face | None: - """Return the Face""" - return Shape.get_single_shape(self, "Face") + # def face(self) -> Face | None: + # """Return the Face""" + # return Shape.get_single_shape(self, "Face") - def faces(self) -> ShapeList[Face]: - """faces - all the faces in this Shape""" - return Shape.get_shape_list(self, "Face") + # def faces(self) -> ShapeList[Face]: + # """faces - all the faces in this Shape""" + # return Shape.get_shape_list(self, "Face") def find_intersection_points( self, other: Axis, tolerance: float = TOLERANCE @@ -412,13 +407,13 @@ class Mixin2D(ABC, Shape[TOPODS]): """Return a copy of self moved along the normal by amount""" return copy.deepcopy(self).moved(Location(self.normal_at() * amount)) - def shell(self) -> Shell | None: - """Return the Shell""" - return Shape.get_single_shape(self, "Shell") + # def shell(self) -> Shell | None: + # """Return the Shell""" + # return Shape.get_single_shape(self, "Shell") - def shells(self) -> ShapeList[Shell]: - """shells - all the shells in this Shape""" - return Shape.get_shape_list(self, "Shell") + # def shells(self) -> ShapeList[Shell]: + # """shells - all the shells in this Shape""" + # return Shape.get_shape_list(self, "Shell") def _wrap_edge( self, From a971cbbad656aeb502b2ba622b84563425b3399c Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 3 Dec 2025 13:41:53 -0500 Subject: [PATCH 083/105] Making project_to_viewport a proper method --- src/build123d/topology/composite.py | 28 +++++++++++++++++++++++- src/build123d/topology/three_d.py | 32 ++++++++++++++++++++++------ src/build123d/topology/two_d.py | 33 +++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 365808d..0919312 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -141,7 +141,6 @@ class Compound(Mixin3D[TopoDS_Compound]): order = 4.0 - project_to_viewport = Mixin1D.project_to_viewport # ---- Constructor ---- def __init__( @@ -858,6 +857,33 @@ class Compound(Mixin3D[TopoDS_Compound]): return ShapeList(common_set) + def project_to_viewport( + self, + viewport_origin: VectorLike, + viewport_up: VectorLike = (0, 0, 1), + look_at: VectorLike | None = None, + focus: float | None = None, + ) -> tuple[ShapeList[Edge], ShapeList[Edge]]: + """project_to_viewport + + Project a shape onto a viewport returning visible and hidden Edges. + + Args: + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike, optional): direction of the viewport y axis. + Defaults to (0, 0, 1). + look_at (VectorLike, optional): point to look at. + Defaults to None (center of shape). + focus (float, optional): the focal length for perspective projection + Defaults to None (orthographic projection) + + Returns: + tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges + """ + return Mixin1D.project_to_viewport( + self, viewport_origin, viewport_up, look_at, focus + ) + def unwrap(self, fully: bool = True) -> Self | Shape: """Strip unnecessary Compound wrappers diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index e6a7c38..5b0fb30 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -141,7 +141,6 @@ if TYPE_CHECKING: # pragma: no cover class Mixin3D(Shape[TOPODS]): """Additional methods to add to 3D Shape classes""" - project_to_viewport = Mixin1D.project_to_viewport find_intersection_points = Mixin2D.find_intersection_points # ---- Properties ---- @@ -715,13 +714,32 @@ class Mixin3D(Shape[TOPODS]): return offset_solid - # def solid(self) -> Solid | None: - # """Return the Solid""" - # return Shape.get_single_shape(self, "Solid") + def project_to_viewport( + self, + viewport_origin: VectorLike, + viewport_up: VectorLike = (0, 0, 1), + look_at: VectorLike | None = None, + focus: float | None = None, + ) -> tuple[ShapeList[Edge], ShapeList[Edge]]: + """project_to_viewport - # def solids(self) -> ShapeList[Solid]: - # """solids - all the solids in this Shape""" - # return Shape.get_shape_list(self, "Solid") + Project a shape onto a viewport returning visible and hidden Edges. + + Args: + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike, optional): direction of the viewport y axis. + Defaults to (0, 0, 1). + look_at (VectorLike, optional): point to look at. + Defaults to None (center of shape). + focus (float, optional): the focal length for perspective projection + Defaults to None (orthographic projection) + + Returns: + tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges + """ + return Mixin1D.project_to_viewport( + self, viewport_origin, viewport_up, look_at, focus + ) class Solid(Mixin3D[TopoDS_Solid]): diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 5c3e35c..279eb5c 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -172,7 +172,7 @@ T = TypeVar("T", Edge, Wire, "Face") class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" - project_to_viewport = Mixin1D.project_to_viewport + # project_to_viewport = Mixin1D.project_to_viewport # ---- Properties ---- @@ -407,13 +407,32 @@ class Mixin2D(ABC, Shape[TOPODS]): """Return a copy of self moved along the normal by amount""" return copy.deepcopy(self).moved(Location(self.normal_at() * amount)) - # def shell(self) -> Shell | None: - # """Return the Shell""" - # return Shape.get_single_shape(self, "Shell") + def project_to_viewport( + self, + viewport_origin: VectorLike, + viewport_up: VectorLike = (0, 0, 1), + look_at: VectorLike | None = None, + focus: float | None = None, + ) -> tuple[ShapeList[Edge], ShapeList[Edge]]: + """project_to_viewport - # def shells(self) -> ShapeList[Shell]: - # """shells - all the shells in this Shape""" - # return Shape.get_shape_list(self, "Shell") + Project a shape onto a viewport returning visible and hidden Edges. + + Args: + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike, optional): direction of the viewport y axis. + Defaults to (0, 0, 1). + look_at (VectorLike, optional): point to look at. + Defaults to None (center of shape). + focus (float, optional): the focal length for perspective projection + Defaults to None (orthographic projection) + + Returns: + tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges + """ + return Mixin1D.project_to_viewport( + self, viewport_origin, viewport_up, look_at, focus + ) def _wrap_edge( self, From d12ff82cea1f99eb13c963e9b2addad74c15f4b5 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Sat, 6 Dec 2025 13:19:33 -0500 Subject: [PATCH 084/105] Add formatter to Vector for f and g specs. Use in geometry classes --- src/build123d/geometry.py | 74 ++++++++++++++------------ src/build123d/topology/shape_core.py | 8 ++- tests/test_direct_api/test_assembly.py | 18 +++---- tests/test_direct_api/test_axis.py | 2 +- tests/test_direct_api/test_location.py | 4 +- tests/test_direct_api/test_plane.py | 2 +- tests/test_direct_api/test_vector.py | 4 ++ 7 files changed, 61 insertions(+), 51 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 299e416..2bdc9d4 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -442,20 +442,35 @@ class Vector: """intersect vector with other &""" return self.intersect(other) + def __format__(self, spec) -> str: + """Format Vector""" + + def trim_float(x: float, precision: int) -> float: + return round(x, precision) if abs(x) > TOLERANCE else 0.0 + + last_char = spec[-1] if spec else None + if last_char in ("f", "g"): + if "." in spec: + precision = int(spec[:-1].split(".")[-1]) + else: + precision = 6 if last_char == "f" else 6 + + x = trim_float(self.X, precision) + y = trim_float(self.Y, precision) + z = trim_float(self.Z, precision) + + return f"({x:{spec}}, {y:{spec}}, {z:{spec}})" + + return str(tuple(self)) + def __repr__(self) -> str: - """Represent vector""" - x = round(self.X, 13) if abs(self.X) > TOLERANCE else 0.0 - y = round(self.Y, 13) if abs(self.Y) > TOLERANCE else 0.0 - z = round(self.Z, 13) if abs(self.Z) > TOLERANCE else 0.0 - return f"{type(self).__name__}({x:.14g}, {y:.14g}, {z:.14g})" + """Represent Vector""" + return f"{type(self).__name__}{self:.13g}" def __str__(self) -> str: - """Display vector""" - strf = f".{TOL_DIGITS}g" - x = round(self.X, 13) if abs(self.X) > TOLERANCE else 0.0 - y = round(self.Y, 13) if abs(self.Y) > TOLERANCE else 0.0 - z = round(self.Z, 13) if abs(self.Z) > TOLERANCE else 0.0 - return f"{type(self).__name__}: (X={x:{strf}}, Y={y:{strf}}, Z={z:{strf}})" + """Display Vector""" + x, y, z = format(self, ".6g")[1:-1].split(", ") + return f"{type(self).__name__}: (X={x}, Y={y}, Z={z})" def __eq__(self, other: object) -> bool: """Vectors equal operator ==""" @@ -744,15 +759,15 @@ class Axis(metaclass=AxisMeta): ) def __repr__(self) -> str: - """Represent axis""" - return f"{type(self).__name__}({tuple(self.position)}, {tuple(self.direction)})" + """Represent Axis""" + return f"{type(self).__name__}({self.position:.13g}, {self.direction:.13g})" def __str__(self) -> str: - """Display axis""" - strf = f".{TOL_DIGITS}g" - position_str = ", ".join(f"{v:{strf}}" for v in tuple(self.position)) - direction_str = ", ".join(f"{v:{strf}}" for v in tuple(self.direction)) - return f"{type(self).__name__}: (position=({position_str}), direction=({direction_str}))" + """Display Axis""" + return ( + f"{type(self).__name__}: " + f"(position={self.position:.6g}, direction={self.direction:.6g})" + ) def __eq__(self, other: object) -> bool: if not isinstance(other, Axis): @@ -1917,19 +1932,16 @@ class Location: return rv_trans, rv_rot def __repr__(self) -> str: - """Represent location""" + """Represent Location""" return ( - f"{type(self).__name__}({tuple(self.position)}, {tuple(self.orientation)})" + f"{type(self).__name__}" f"({self.position:.13g}, {self.orientation:.13g})" ) def __str__(self) -> str: - """Display location""" - strf = f".{TOL_DIGITS}g" - position_str = ", ".join(f"{v:{strf}}" for v in tuple(self.position)) - orientation_str = ", ".join(f"{v:{strf}}" for v in tuple(self.orientation)) + """Display Location""" return ( f"{type(self).__name__}: " - f"(position=({position_str}), orientation=({orientation_str}))" + f"(position={self.position:.6g}, orientation={self.orientation:.6g})" ) @overload @@ -2852,21 +2864,17 @@ class Plane(metaclass=PlaneMeta): return self.intersect(other) def __repr__(self) -> str: - """Represent plane""" + """Represent Plane""" return ( f"{type(self).__name__}" - f"({tuple(self._origin)}, {tuple(self.x_dir)}, {tuple(self.z_dir)})" + f"({self.origin:.13g}, {self.x_dir:.13g}, {self.z_dir:.13g})" ) def __str__(self) -> str: - """Display plane""" - strf = f".{TOL_DIGITS}g" - origin_str = ", ".join(f"{v:{strf}}" for v in tuple(self.origin)) - x_dir_str = ", ".join(f"{v:{strf}}" for v in tuple(self.x_dir)) - z_dir_str = ", ".join(f"{v:{strf}}" for v in tuple(self.z_dir)) + """Display Plane""" return ( f"{type(self).__name__}: " - f"(origin=({origin_str}), x_dir=({x_dir_str}), z_dir=({z_dir_str}))" + f"(origin={self.origin:.6g}, x_dir={self.x_dir:.6g}, z_dir={self.z_dir:.6g})" ) def reverse(self) -> Plane: diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 24eb0fe..26b9421 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -725,17 +725,15 @@ class Shape(NodeMixin, Generic[TOPODS]): address = node.address name = "" loc = ( - "Center" + str(tuple(node.position)) + f"Center{node.position:.6g}" if show_center - else "Position" + str(tuple(node.position)) + else f"Position{node.position:.6g}" ) else: address = id(node) name = node.__class__.__name__.ljust(9) loc = ( - "Center" + str(tuple(node.center())) - if show_center - else repr(node.location) + f"Center{node.center():.6g}" if show_center else repr(node.location) ) result += f"{treestr}{name}at {address:#x}, {loc}\n" return result diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py index d746478..36a241d 100644 --- a/tests/test_direct_api/test_assembly.py +++ b/tests/test_direct_api/test_assembly.py @@ -70,18 +70,18 @@ class TestAssembly(unittest.TestCase): def test_show_topology_compound(self): assembly = TestAssembly.create_test_assembly() expected = [ - "assembly Compound at 0x7fced0fd1b50, Location((0.0, 0.0, 0.0), (-0.0, 0.0, -0.0))", - "├── box Solid at 0x7fced102d3a0, Location((0.0, 0.0, 0.0), (45.0, 45.0, -0.0))", - "└── sphere Solid at 0x7fced0fd1f10, Location((1.0, 2.0, 3.0), (-0.0, 0.0, -0.0))", + "assembly Compound at 0x7fced0fd1b50, Location((0, 0, 0), (0, 0, 0))", + "├── box Solid at 0x7fced102d3a0, Location((0, 0, 0), (45, 45, 0))", + "└── sphere Solid at 0x7fced0fd1f10, Location((1, 2, 3), (0, 0, 0))", ] self.assertTopoEqual(assembly.show_topology("Solid"), expected) def test_show_topology_shape_location(self): assembly = TestAssembly.create_test_assembly() expected = [ - "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)", - "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)", - " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)", + "Solid at 0x7f3754501530, Position(1, 2, 3)", + "└── Shell at 0x7f3754501a70, Position(1, 2, 3)", + " └── Face at 0x7f3754501030, Position(1, 2, 3)", ] self.assertTopoEqual( assembly.children[1].show_topology("Face", show_center=False), expected @@ -90,9 +90,9 @@ class TestAssembly(unittest.TestCase): def test_show_topology_shape(self): assembly = TestAssembly.create_test_assembly() expected = [ - "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)", - "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)", - " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)", + "Solid at 0x7f6279043ab0, Center(1, 2, 3)", + "└── Shell at 0x7f62790438f0, Center(1, 2, 3)", + " └── Face at 0x7f62790439f0, Center(1, 2, 3)", ] self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected) diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py index 0296e0d..b1c792a 100644 --- a/tests/test_direct_api/test_axis.py +++ b/tests/test_direct_api/test_axis.py @@ -85,7 +85,7 @@ class TestAxis(unittest.TestCase): self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5) def test_axis_repr_and_str(self): - self.assertEqual(repr(Axis.X), "Axis((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))") + self.assertEqual(repr(Axis.X), "Axis((0, 0, 0), (1, 0, 0))") self.assertEqual(str(Axis.Y), "Axis: (position=(0, 0, 0), direction=(0, 1, 0))") def test_axis_copy(self): diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index 6c3a134..4887fd6 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -228,11 +228,11 @@ class TestLocation(unittest.TestCase): def test_location_repr_and_str(self): self.assertEqual( - repr(Location()), "Location((0.0, 0.0, 0.0), (-0.0, 0.0, -0.0))" + repr(Location()), "Location((0, 0, 0), (0, 0, 0))" ) self.assertEqual( str(Location()), - "Location: (position=(0, 0, 0), orientation=(-0, 0, -0))", + "Location: (position=(0, 0, 0), orientation=(0, 0, 0))", ) loc = Location((1, 2, 3), (33, 45, 67)) self.assertEqual( diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py index 59b6d4e..9711c0f 100644 --- a/tests/test_direct_api/test_plane.py +++ b/tests/test_direct_api/test_plane.py @@ -335,7 +335,7 @@ class TestPlane(unittest.TestCase): def test_repr(self): self.assertEqual( repr(Plane.XY), - "Plane((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0))", + "Plane((0, 0, 0), (1, 0, 0), (0, 0, 1))", ) self.assertEqual( str(Plane.XY), diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py index b0bb0a9..69c9f17 100644 --- a/tests/test_direct_api/test_vector.py +++ b/tests/test_direct_api/test_vector.py @@ -220,6 +220,10 @@ class TestVector(unittest.TestCase): pass def test_vector_special_methods(self): + self.assertEqual(f"{Vector(1, 2, 3):.3f}", "(1.000, 2.000, 3.000)") + self.assertEqual(f"{Vector(1, 2, 3):.3g}", "(1, 2, 3)") + self.assertEqual(f"{Vector(1, 2, 3):.3t}", "(1.0, 2.0, 3.0)") + self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)") self.assertEqual(str(Vector(1, 2, 3)), "Vector: (X=1, Y=2, Z=3)") self.assertEqual( From 17b1bd838533f086a859adb6d205c0310959a971 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Sat, 6 Dec 2025 13:41:27 -0500 Subject: [PATCH 085/105] Fix docstring typo --- src/build123d/geometry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 2bdc9d4..adcc016 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1337,7 +1337,7 @@ class Color: return Color(*tuple(self)) def __str__(self) -> str: - """Display color""" + """Display Color""" rgb = self.wrapped.GetRGB() rgb = (rgb.Red(), rgb.Green(), rgb.Blue()) try: @@ -1351,7 +1351,7 @@ class Color: return f"{type(self).__name__}: {str(tuple(self))} {qualifier} {name.upper()!r}" def __repr__(self) -> str: - """Represent colr""" + """Represent Color""" return f"{type(self).__name__}{str(tuple(self))}" @staticmethod From 154b4f6a52cc0652e827d4f5b1d021d4c3f09950 Mon Sep 17 00:00:00 2001 From: dgies Date: Fri, 12 Dec 2025 21:33:01 -0800 Subject: [PATCH 086/105] Fix mislabeled bottom center circle on align example in docs --- docs/assets/align.svg | 146 +++++++++++++++++++++--------------------- docs/objects_2d.py | 4 +- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/docs/assets/align.svg b/docs/assets/align.svg index 415adcc..940cb94 100644 --- a/docs/assets/align.svg +++ b/docs/assets/align.svg @@ -2,79 +2,79 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/objects_2d.py b/docs/objects_2d.py index 9122fad..b2e2f07 100644 --- a/docs/objects_2d.py +++ b/docs/objects_2d.py @@ -268,9 +268,9 @@ with BuildSketch() as align: Text("MAX", font="FreeSerif", font_size=0.07) # Bottom Center: (CENTER, MAX) with Locations((0.0, -0.75 + 0.07 / 2)): - Text("MAX", font="FreeSerif", font_size=0.07) - with Locations((0.0, -0.75 - 0.07 / 2)): Text("CENTER", font="FreeSerif", font_size=0.07) + with Locations((0.0, -0.75 - 0.07 / 2)): + Text("MAX", font="FreeSerif", font_size=0.07) # Bottom Left: (MAx, MAX) with Locations((-0.75, -0.75)): Text("MAX\nMAX", font="FreeSerif", font_size=0.07) From d4d70cc85df198cabd196e6a2e885f80376b00e3 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Mon, 15 Dec 2025 17:04:22 -0600 Subject: [PATCH 087/105] pyproject.toml -> include [stubs] category in [all] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 22f6440..57a8c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ all = [ "build123d[ocp_vscode]", "build123d[development]", "build123d[docs]", - # "build123d[stubs]", # excluded for now as mypy fails + "build123d[stubs]", ] [tool.setuptools.packages.find] From 351ad9166243f8df32f91e15b53ec74453d2377e Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 17 Dec 2025 10:20:24 -0500 Subject: [PATCH 088/105] Removing commented out code --- src/build123d/topology/one_d.py | 25 ------------------------- src/build123d/topology/two_d.py | 2 -- 2 files changed, 27 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index d2a4913..692808e 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -684,31 +684,6 @@ class Mixin1D(Shape[TOPODS]): return derivative - # def edge(self) -> Edge | None: - # """Return the Edge""" - # return Shape.get_single_shape(self, "Edge") - - # def edges(self) -> ShapeList[Edge]: - # """edges - all the edges in this Shape""" - # if isinstance(self, Wire) and self.wrapped is not None: - # # The WireExplorer is a tool to explore the edges of a wire in a connection order. - # explorer = BRepTools_WireExplorer(self.wrapped) - - # edge_list: ShapeList[Edge] = ShapeList() - # while explorer.More(): - # next_edge = Edge(explorer.Current()) - # next_edge.topo_parent = ( - # self if self.topo_parent is None else self.topo_parent - # ) - # edge_list.append(next_edge) - # explorer.Next() - # return edge_list - - # edge_list = Shape.get_shape_list(self, "Edge") - # return edge_list.filter_by( - # lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True - # ) - def end_point(self) -> Vector: """The end point of this edge. diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 279eb5c..c841291 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -172,8 +172,6 @@ T = TypeVar("T", Edge, Wire, "Face") class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" - # project_to_viewport = Mixin1D.project_to_viewport - # ---- Properties ---- @property From c38cd45cbe8765ca204ef1dc2cd68a14d1a1813e Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 17 Dec 2025 23:35:29 -0500 Subject: [PATCH 089/105] Add __format__ to Axis, Plane, Location, TOL_DIGIT for precision both str and repr --- src/build123d/geometry.py | 43 +++++++++++++++++++------- tests/test_direct_api/test_axis.py | 13 +++++--- tests/test_direct_api/test_location.py | 9 +++++- tests/test_direct_api/test_plane.py | 12 +++++++ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index adcc016..6ebc6a5 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -453,7 +453,7 @@ class Vector: if "." in spec: precision = int(spec[:-1].split(".")[-1]) else: - precision = 6 if last_char == "f" else 6 + precision = 6 if last_char == "f" else 12 x = trim_float(self.X, precision) y = trim_float(self.Y, precision) @@ -758,15 +758,23 @@ class Axis(metaclass=AxisMeta): ) ) + def __format__(self, spec) -> str: + """Format Axis""" + last_char = spec[-1] if spec else None + if last_char in ("f", "g"): + return f"({self.position:{spec}}, {self.direction:{spec}})" + + return f"({tuple(self.position)}, {tuple(self.direction)})" + def __repr__(self) -> str: """Represent Axis""" - return f"{type(self).__name__}({self.position:.13g}, {self.direction:.13g})" + return f"{type(self).__name__}{self:.{TOL_DIGITS}g}" def __str__(self) -> str: """Display Axis""" return ( f"{type(self).__name__}: " - f"(position={self.position:.6g}, direction={self.direction:.6g})" + f"(position={self.position:.{TOL_DIGITS}g}, direction={self.direction:.{TOL_DIGITS}g})" ) def __eq__(self, other: object) -> bool: @@ -1931,17 +1939,23 @@ class Location: return rv_trans, rv_rot + def __format__(self, spec) -> str: + """Format Location""" + last_char = spec[-1] if spec else None + if last_char in ("f", "g"): + return f"({self.position:{spec}}, {self.orientation:{spec}})" + + return f"({tuple(self.position)}, {tuple(self.orientation)})" + def __repr__(self) -> str: """Represent Location""" - return ( - f"{type(self).__name__}" f"({self.position:.13g}, {self.orientation:.13g})" - ) + return f"{type(self).__name__}{self:.{TOL_DIGITS}g}" def __str__(self) -> str: """Display Location""" return ( f"{type(self).__name__}: " - f"(position={self.position:.6g}, orientation={self.orientation:.6g})" + f"(position={self.position:.{TOL_DIGITS}g}, orientation={self.orientation:.{TOL_DIGITS}g})" ) @overload @@ -2863,18 +2877,23 @@ class Plane(metaclass=PlaneMeta): """intersect plane with other &""" return self.intersect(other) + def __format__(self, spec) -> str: + """Format Plane""" + last_char = spec[-1] if spec else None + if last_char in ("f", "g"): + return f"({self.origin:{spec}}, {self.x_dir:{spec}}, {self.z_dir:{spec}})" + + return f"({tuple(self.origin)}, {tuple(self.x_dir)}, {tuple(self.z_dir)})" + def __repr__(self) -> str: """Represent Plane""" - return ( - f"{type(self).__name__}" - f"({self.origin:.13g}, {self.x_dir:.13g}, {self.z_dir:.13g})" - ) + return f"{type(self).__name__}{self:.{TOL_DIGITS}g}" def __str__(self) -> str: """Display Plane""" return ( f"{type(self).__name__}: " - f"(origin={self.origin:.6g}, x_dir={self.x_dir:.6g}, z_dir={self.z_dir:.6g})" + f"(origin={self.origin:.{TOL_DIGITS}g}, x_dir={self.x_dir:.{TOL_DIGITS}g}, z_dir={self.z_dir:.{TOL_DIGITS}g})" ) def reverse(self) -> Plane: diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py index b1c792a..ff5d06b 100644 --- a/tests/test_direct_api/test_axis.py +++ b/tests/test_direct_api/test_axis.py @@ -85,6 +85,15 @@ class TestAxis(unittest.TestCase): self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5) def test_axis_repr_and_str(self): + self.assertEqual( + f"{Axis((1, 2, 3), (4, 5, 6)):.2f}", + "((1.00, 2.00, 3.00), (0.46, 0.57, 0.68))", + ) + self.assertEqual( + f"{Axis((1, 2, 3), (4, 5, 6)):.2g}", "((1, 2, 3), (0.46, 0.57, 0.68))" + ) + self.assertIn("((1.0, 2.0, 3.0), ", f"{Axis((1, 2, 3), (4, 5, 6)):.2t}") + self.assertEqual(repr(Axis.X), "Axis((0, 0, 0), (1, 0, 0))") self.assertEqual(str(Axis.Y), "Axis: (position=(0, 0, 0), direction=(0, 1, 0))") @@ -136,10 +145,6 @@ class TestAxis(unittest.TestCase): self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0)))) self.assertFalse(Axis.X.is_parallel(Axis.Y)) - def test_axis_is_skew(self): - self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1)))) - self.assertFalse(Axis.X.is_skew(Axis.Y)) - def test_axis_is_skew(self): # Skew Axes self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1)))) diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index 4887fd6..ecb88bf 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -228,8 +228,15 @@ class TestLocation(unittest.TestCase): def test_location_repr_and_str(self): self.assertEqual( - repr(Location()), "Location((0, 0, 0), (0, 0, 0))" + f"{Location((1, 2, 3), (4, 5, 6)):.2f}", + "((1.00, 2.00, 3.00), (4.00, 5.00, 6.00))", ) + self.assertEqual( + f"{Location((1, 2, 3), (4, 5, 6)):.2g}", "((1, 2, 3), (4, 5, 6))" + ) + self.assertIn("((1.0, 2.0, 3.0), ", f"{Location((1, 2, 3), (4, 5, 6)):.2t}") + + self.assertEqual(repr(Location()), "Location((0, 0, 0), (0, 0, 0))") self.assertEqual( str(Location()), "Location: (position=(0, 0, 0), orientation=(0, 0, 0))", diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py index 9711c0f..474d05a 100644 --- a/tests/test_direct_api/test_plane.py +++ b/tests/test_direct_api/test_plane.py @@ -333,6 +333,18 @@ class TestPlane(unittest.TestCase): ) def test_repr(self): + self.assertEqual( + f"{Plane((1, 2, 3), (4, 5, 6), (7, 8, 9)):.2f}", + "((1.00, 2.00, 3.00), (0.46, 0.57, 0.68), (0.50, 0.57, 0.65))", + ) + self.assertEqual( + f"{Plane((1, 2, 3), (4, 5, 6), (7, 8, 9)):.2g}", + "((1, 2, 3), (0.46, 0.57, 0.68), (0.5, 0.57, 0.65))", + ) + self.assertIn( + "((1.0, 2.0, 3.0), ", f"{Plane((1, 2, 3), (4, 5, 6), (7, 8, 9)):.2t}" + ) + self.assertEqual( repr(Plane.XY), "Plane((0, 0, 0), (1, 0, 0), (0, 0, 1))", From 8eb3eed4f69ead8104a4da3d4145857d69907341 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Thu, 18 Dec 2025 11:09:29 -0500 Subject: [PATCH 090/105] Update DOI and builder statefulness, fix missing anchor, logo positioning, formatting --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index afb9320..c283ed0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

build123d logo

@@ -16,14 +16,14 @@ [![Downloads](https://pepy.tech/badge/build123d)](https://pepy.tech/project/build123d) [![Downloads/month](https://pepy.tech/badge/build123d/month)](https://pepy.tech/project/build123d) [![PyPI - Wheel](https://img.shields.io/pypi/wheel/build123d.svg)](https://pypi.org/project/build123d/) -[![DOI](https://zenodo.org/badge/510925389.svg)](https://doi.org/10.5281/zenodo.14872322) +[![DOI](https://zenodo.org/badge/510925389.svg)](https://zenodo.org/badge/latestdoi/510925389) [Documentation](https://build123d.readthedocs.io/en/latest/index.html) | [Cheat Sheet](https://build123d.readthedocs.io/en/latest/cheat_sheet.html) | [Discord](https://discord.com/invite/Bj9AQPsCfx) | [Discussions](https://github.com/gumyr/build123d/discussions) | [Issues](https://github.com/gumyr/build123d/issues ) | -[Contributing]() +[Contributing](#contributing) build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. @@ -73,7 +73,7 @@ line += PolarLine(line @ 1, 6, direction=line % 1) ### Upgrading to 2D and 3D -Faces, Shells (multiple connected Faces), and Sketches (a Compound of Faces and Shells) are the 2d Shapes available in build123d. The previous line is sufficiently defined to close the Wire and create a Face with `make_hull`: +Faces, Shells (multiple connected Faces), and Sketches (a Compound of Faces and Shells) are the 2D Shapes available in build123d. The previous line is sufficiently defined to close the Wire and create a Face with `make_hull`: ```py sketch = make_hull(line) @@ -87,7 +87,7 @@ part = extrude(sketch, amount= 2) ```
- upgrade 2d + upgrade 2D
### Adding to and modifying part @@ -124,7 +124,7 @@ part = chamfer(bore.edges(), .2) The previous construction is through the **Algebra Mode** interface, which follows a stateless paradigm where each object is explicitly tracked and mutated by algebraic operators. -**Builder Mode** is an alternative build123d interface where state is tracked and structured in a design history-like way where each dimension is distinct. Operations are aware pending faces and edges from Build contexts and location transformations are applied to all child objects in Build and Locations contexts. Builder mode also introduces the `mode` affordance to objects to specify how new Shapes are combined with the context: +**Builder Mode** is an alternative build123d interface where state is tracked and structured in a design history-like way where each dimension is distinct. Operations are aware pending faces and edges from Build contexts and location transformations are applied to all child objects in Build and Locations contexts. While each Build context tracks state, operations like `extrude` can still optionally take explicit Shape input instead of implicitly using pending Shapes. Builder mode also introduces the `mode` affordance to objects to specify how new Shapes are combined with the context: ```py with BuildPart() as part_context: @@ -193,7 +193,7 @@ for i, location in enumerate(GridLocations(5, 0, 4, 1)): ### Data interchange -build123d can import and export a number data formats for interchange with 2d and 3d design tools, 3D printing slicers, and traditional CAM: +build123d can import and export a number data formats for interchange with 2D and 3D design tools, 3D printing slicers, and traditional CAM: ```py svg = import_svg("spade.svg") @@ -225,7 +225,7 @@ If you receive errors about conflicting dependencies, retry the installation aft pip install --upgrade pip ``` -### Pre- release +### Pre-release build123d is under active development and up-to-date features are found in the development branch: From 50cbb3854abf72554602e92a492eb7944c773794 Mon Sep 17 00:00:00 2001 From: Anthony Sokolowski Date: Thu, 25 Dec 2025 16:55:10 +1100 Subject: [PATCH 091/105] Added project_line feature to ExtensionLine. --- docs/assets/stepper_drawing.svg | 1494 +++++++++++++++---------------- docs/technical_drawing.py | 8 +- src/build123d/drafting.py | 29 +- tests/test_drafting.py | 149 ++- 4 files changed, 919 insertions(+), 761 deletions(-) diff --git a/docs/assets/stepper_drawing.svg b/docs/assets/stepper_drawing.svg index 6504639..b994fbd 100644 --- a/docs/assets/stepper_drawing.svg +++ b/docs/assets/stepper_drawing.svg @@ -1,308 +1,308 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -319,453 +319,453 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/technical_drawing.py b/docs/technical_drawing.py index 55a6efc..e1a60da 100644 --- a/docs/technical_drawing.py +++ b/docs/technical_drawing.py @@ -162,9 +162,13 @@ vis, _ = project_to_2d( ) visible_lines.extend(vis) side_bbox = Curve(vis).bounding_box() -perimeter = Pos(*side_bbox.center()) * Rectangle(side_bbox.size.X, side_bbox.size.Y) +shaft_top_corner = vis.edges().sort_by(Axis.Y)[-1].vertices().sort_by(Axis.X)[-1] +body_bottom_corner = (side_bbox.max.X, side_bbox.min.Y) d4 = ExtensionLine( - border=perimeter.edges().sort_by(Axis.X)[-1], offset=1 * CM, draft=drafting_options + border=(shaft_top_corner, body_bottom_corner), + offset=-(side_bbox.max.X - shaft_top_corner.X) - 1 * CM, # offset to outside view. + project_line=(0, 1, 0), + draft=drafting_options, ) l3 = Text("Side Elevation", 6) l3.position = vis.group_by(Axis.Y)[0].sort_by(Edge.length)[-1].center() + (0, -5 * MM) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index 415d33e..121bd8d 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -504,7 +504,7 @@ class ExtensionLine(BaseSketchObject): label_angle (bool, optional): a flag indicating that instead of an extracted length value, the size of the circular arc extracted from the path should be displayed in degrees. Defaults to False. - project_line (Vector, optional): Vector line which to project dimension against. + project_line (Vector, optional): Vector line which to project dimension against. Offset start point is the position of the start of border. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -528,24 +528,39 @@ class ExtensionLine(BaseSketchObject): context = BuildSketch._get_context(self) if sketch is None and not (context is None or context.sketch is None): sketch = context.sketch - if project_line is not None: - raise NotImplementedError("project_line is currently unsupported") + if offset == 0: + raise ValueError("A dimension line should be used if offset is 0") # Create a wire modelling the path of the dimension lines from a variety of input types object_to_measure = Draft._process_path(border) + if object_to_measure.position_at(0) == object_to_measure.position_at(1): + raise ValueError("Start and end points of border must be different.") + + if project_line is not None: + if isinstance(project_line, Iterable): + project_line = Vector(project_line) + measure_object_span = object_to_measure.position_at( + 1 + ) - object_to_measure.position_at(0) + extent_along_wire = measure_object_span.project_to_line(project_line) + object_to_dimension = Edge.make_line( + object_to_measure.position_at(0), + object_to_measure.position_at(0) + extent_along_wire, + ) + else: + object_to_dimension = object_to_measure side_lut = {1: Side.RIGHT, -1: Side.LEFT} - if offset == 0: - raise ValueError("A dimension line should be used if offset is 0") - dimension_path = object_to_measure.offset_2d( + dimension_path = object_to_dimension.offset_2d( distance=offset, side=side_lut[int(copysign(1, offset))], closed=False ) dimension_label_str = ( label if label is not None - else draft._label_to_str(label, object_to_measure, label_angle, tolerance) + else draft._label_to_str(label, object_to_dimension, label_angle, tolerance) ) + extension_lines = [ Edge.make_line( object_to_measure.position_at(e), dimension_path.position_at(e) diff --git a/tests/test_drafting.py b/tests/test_drafting.py index 2f1b301..cec8075 100644 --- a/tests/test_drafting.py +++ b/tests/test_drafting.py @@ -37,7 +37,9 @@ from build123d import ( Axis, BuildLine, BuildSketch, + CenterOf, Color, + Compound, Edge, Face, FontStyle, @@ -50,6 +52,7 @@ from build123d import ( Rectangle, Sketch, Unit, + Vector, add, make_face, offset, @@ -292,14 +295,150 @@ class ExtensionLineTestCase(unittest.TestCase): self.assertAlmostEqual(bbox.size.X, 30 + metric.line_width, 5) self.assertAlmostEqual(bbox.size.Y, 10, 5) - def test_not_implemented(self): - shape, outer, inner = create_test_sketch() - with self.assertRaises(NotImplementedError): + def test_vectorlike_in_extension_function(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=-10, + draft=metric, + project_line=(0, 1, 0), + ) + self.assertIsNotNone(ext) + + def test_vertical_projection_with_dim_outside_shape(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=-10, + draft=metric, + project_line=Vector(0, 1, 0), + ) + self.assertIsNotNone(ext) + self.assertGreater( + Compound(children=[diagonal_line, ext]).bounding_box().size.X, + diagonal_line.bounding_box().size.X, + ) # dimension should be outside shape. + self.assertEqual( + diagonal_line.bounding_box().size.Y + 0.25, # plus line_width + ext.bounding_box().size.Y, + ) + self.assertEqual( + diagonal_line.center(CenterOf.BOUNDING_BOX).Y, + ext.center(CenterOf.BOUNDING_BOX).Y, + ) + self.assertEqual(ext.dimension, 100) + + def test_vertical_projection_with_dim_inside_shape(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=10, + draft=metric, + project_line=Vector(0, 1, 0), + ) + self.assertIsNotNone(ext) + self.assertEqual( + Compound(children=[diagonal_line, ext]).bounding_box().size.Y, + diagonal_line.bounding_box().size.Y + 0.25, + ) # plus line_width + self.assertEqual( + diagonal_line.center(CenterOf.BOUNDING_BOX).Y, + ext.center(CenterOf.BOUNDING_BOX).Y, + ) + self.assertEqual(ext.dimension, 100) + + def test_vertical_projection_with_dim_otherside(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + x_size = diagonal_line.bounding_box().size.X + ext = ExtensionLine( + border=diagonal_line, + offset=x_size + 10, + draft=metric, + project_line=Vector(0, 1, 0), + ) + self.assertIsNotNone(ext) + self.assertGreater( + Compound(children=[diagonal_line, ext]).bounding_box().size.Y, + diagonal_line.bounding_box().size.Y, + ) # plus line_width + self.assertEqual( + diagonal_line.center(CenterOf.BOUNDING_BOX).Y, + ext.center(CenterOf.BOUNDING_BOX).Y, + ) + self.assertEqual(ext.dimension, 100) + + def test_vertical_projection_with_vertical_line(self): + diagonal_line = Edge.make_line((100, 100), (100, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=10, + draft=metric, + project_line=Vector(0, 1, 0), + ) + self.assertIsNotNone(ext) + self.assertEqual( + diagonal_line.center(CenterOf.BOUNDING_BOX).Y, + ext.center(CenterOf.BOUNDING_BOX).Y, + ) + self.assertEqual(ext.dimension, 100) + + def test_horizontal_projection_with_dim_outside_shape(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=10, + draft=metric, + project_line=Vector(1, 0, 0), + ) + self.assertIsNotNone(ext) + self.assertGreater( + Compound(children=[diagonal_line, ext]).bounding_box().size.Y, + diagonal_line.bounding_box().size.Y, + ) # dimension should be outside shape. + self.assertEqual( + diagonal_line.bounding_box().size.X + 0.25, # plus line_width + ext.bounding_box().size.X, + ) + self.assertEqual( + diagonal_line.center(CenterOf.BOUNDING_BOX).X, + ext.center(CenterOf.BOUNDING_BOX).X, + ) + self.assertEqual(ext.dimension, 100) + + def test_angled_projection(self): + diagonal_line = Edge.make_line((100, 100), (200, 200)) + ext = ExtensionLine( + border=diagonal_line, + offset=10, + draft=metric, + project_line=Vector(1, 1, 0), + ) + self.assertIsNotNone(ext) + self.assertAlmostEqual(ext.dimension, 141.421, places=2) + + def test_half_circle(self): + half_circle = Edge.make_circle(50, start_angle=0, end_angle=180) + ext = ExtensionLine( + border=half_circle, + offset=-10, + draft=metric, + project_line=Vector(1, 0, 0), + ) + self.assertIsNotNone(ext) + self.assertEqual(ext.dimension, 100) + self.assertGreater( + Compound(children=[half_circle, ext]).bounding_box().size.Y, + half_circle.bounding_box().size.Y, + ) # dimension should be outside shape. + + def test_full_circle(self): + half_circle = Edge.make_circle(50) + with pytest.raises(ValueError): ExtensionLine( - outer.edges().sort_by(Axis.Y)[0], + border=half_circle, offset=10, - project_line=(1, 0, 0), draft=metric, + project_line=Vector(0, 1, 0), ) From 8f19b1acdc88b8fe5b3bfd8d98b928d13e5bad6f Mon Sep 17 00:00:00 2001 From: Daniel Weidmann Date: Fri, 26 Dec 2025 12:16:19 +0100 Subject: [PATCH 092/105] Fix link to class Rectangle --- docs/introductory_examples.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index 610fb6b..b0e319c 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -94,7 +94,7 @@ Build a prismatic solid using extrusion. * **Builder mode** This time we can first create a 2D :class:`~build_sketch.BuildSketch` adding a - :class:`~objects_sketch.Circle` and a subtracted :class:`~objects_sketch.Rectangle`` + :class:`~objects_sketch.Circle` and a subtracted :class:`~objects_sketch.Rectangle` and then use :class:`~build_part.BuildPart`'s :meth:`~operations_part.extrude` feature. .. literalinclude:: general_examples.py From 37b0c4871021d53eb25e779b92cd8027c6978932 Mon Sep 17 00:00:00 2001 From: Daniel Weidmann Date: Fri, 26 Dec 2025 12:17:04 +0100 Subject: [PATCH 093/105] Remove superfluous comma --- docs/introductory_examples.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index b0e319c..143b6b5 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -275,7 +275,7 @@ create the final profile. .. _ex 9: -9. Selectors, Fillets, and Chamfers +9. Selectors, Fillets and Chamfers --------------------------------------------------- This example introduces multiple useful and important concepts. Firstly :meth:`~operations_generic.chamfer` From 6783bc249f63f5c858d23aee7349004bddbaba81 Mon Sep 17 00:00:00 2001 From: Daniel Weidmann Date: Sun, 28 Dec 2025 13:04:38 +0100 Subject: [PATCH 094/105] Use Oxford commas in headings --- docs/introductory_examples.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index 143b6b5..6ad558b 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -275,7 +275,7 @@ create the final profile. .. _ex 9: -9. Selectors, Fillets and Chamfers +9. Selectors, Fillets, and Chamfers --------------------------------------------------- This example introduces multiple useful and important concepts. Firstly :meth:`~operations_generic.chamfer` @@ -412,7 +412,7 @@ edge that needs a complex profile. .. _ex 13: -13. CounterBoreHoles, CounterSinkHoles and PolarLocations +13. CounterBoreHoles, CounterSinkHoles, and PolarLocations ------------------------------------------------------------- Counter-sink and counter-bore holes are useful for creating recessed areas for fasteners. From cd0f25b7ec02ed19fa09c2821f9c2d25e6ac9456 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 30 Dec 2025 10:16:06 -0500 Subject: [PATCH 095/105] Improved setting origin in common_plane --- src/build123d/topology/one_d.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 692808e..7d6fa4c 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -473,7 +473,9 @@ class Mixin1D(Shape[TOPODS]): middle = self.bounding_box().center() return middle - def common_plane(self, *lines: Edge | Wire | None) -> None | Plane: + def common_plane( + self, *lines: Edge | Wire | None, tolerance: float = TOLERANCE + ) -> None | Plane: """common_plane Find the plane containing all the edges/wires (including self). If there @@ -482,6 +484,7 @@ class Mixin1D(Shape[TOPODS]): Args: lines (sequence of Edge | Wire): edges in common with self + tolerance (float): amount lines can deviate from plane. Defaults to TOLERANCE. Returns: None | Plane: Either the common plane or None @@ -505,8 +508,7 @@ class Mixin1D(Shape[TOPODS]): origin = as_axis[0].position x_dir = as_axis[0].direction z_dir = Plane(as_axis[0]).x_dir - c_plane = Plane(origin, z_dir=z_dir) - result = c_plane.shift_origin((0, 0)) + result = Plane(origin, z_dir=z_dir) if result is None: # not coaxial # Shorten any infinite lines (from converted Axis) @@ -537,16 +539,26 @@ class Mixin1D(Shape[TOPODS]): c_plane = Plane( origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir ) - c_plane = c_plane.shift_origin((0, 0)) except ValueError: # There is no valid common plane result = None else: # Are all of the points on the common plane - common = all(c_plane.contains(p) for p in points) + common = all(c_plane.contains(p, tolerance) for p in points) result = c_plane if common else None - return result + if result is None: + return result + + # Center the plane on the lines + global_center = sum( + [e.position_at(0.5) for e in all_lines], start=Vector(0, 0, 0) + ) / len(all_lines) + center_axis = Axis(global_center, result.z_dir) + plane_origin = result.intersect(center_axis) + assert isinstance(plane_origin, Vector) + + return result.shift_origin(plane_origin) def curvature_comb( self, count: int = 100, max_tooth_size: float | None = None From 4dca3b823f56f9b4a552e586e34dec80d20a6528 Mon Sep 17 00:00:00 2001 From: Anthony Sokolowski Date: Wed, 31 Dec 2025 08:01:55 +1100 Subject: [PATCH 096/105] project_line variable name changed to measurement_direction. Docstring type for this variable changed from Vector to VectorLike. --- docs/assets/stepper_drawing.svg | 12 ++++++------ docs/technical_drawing.py | 2 +- src/build123d/drafting.py | 15 +++++++++------ tests/test_drafting.py | 18 +++++++++--------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/docs/assets/stepper_drawing.svg b/docs/assets/stepper_drawing.svg index b994fbd..8b87d79 100644 --- a/docs/assets/stepper_drawing.svg +++ b/docs/assets/stepper_drawing.svg @@ -350,8 +350,8 @@ - + @@ -383,12 +383,12 @@ - + - + @@ -436,8 +436,8 @@ - + @@ -475,8 +475,8 @@ - + @@ -490,8 +490,8 @@ - + diff --git a/docs/technical_drawing.py b/docs/technical_drawing.py index e1a60da..0c45587 100644 --- a/docs/technical_drawing.py +++ b/docs/technical_drawing.py @@ -167,7 +167,7 @@ body_bottom_corner = (side_bbox.max.X, side_bbox.min.Y) d4 = ExtensionLine( border=(shaft_top_corner, body_bottom_corner), offset=-(side_bbox.max.X - shaft_top_corner.X) - 1 * CM, # offset to outside view. - project_line=(0, 1, 0), + measurement_direction=(0, 1, 0), draft=drafting_options, ) l3 = Text("Side Elevation", 6) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index 121bd8d..782c0d8 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -504,7 +504,8 @@ class ExtensionLine(BaseSketchObject): label_angle (bool, optional): a flag indicating that instead of an extracted length value, the size of the circular arc extracted from the path should be displayed in degrees. Defaults to False. - project_line (Vector, optional): Vector line which to project dimension against. Offset start point is the position of the start of border. + measurement_direction (VectorLike, optional): Vector line which to project the dimension + against. Offset start point is the position of the start of border. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -520,7 +521,7 @@ class ExtensionLine(BaseSketchObject): arrows: tuple[bool, bool] = (True, True), tolerance: float | tuple[float, float] | None = None, label_angle: bool = False, - project_line: VectorLike | None = None, + measurement_direction: VectorLike | None = None, mode: Mode = Mode.ADD, ): # pylint: disable=too-many-locals @@ -536,13 +537,15 @@ class ExtensionLine(BaseSketchObject): if object_to_measure.position_at(0) == object_to_measure.position_at(1): raise ValueError("Start and end points of border must be different.") - if project_line is not None: - if isinstance(project_line, Iterable): - project_line = Vector(project_line) + if measurement_direction is not None: + if isinstance(measurement_direction, Iterable): + measurement_direction = Vector(measurement_direction) measure_object_span = object_to_measure.position_at( 1 ) - object_to_measure.position_at(0) - extent_along_wire = measure_object_span.project_to_line(project_line) + extent_along_wire = measure_object_span.project_to_line( + measurement_direction + ) object_to_dimension = Edge.make_line( object_to_measure.position_at(0), object_to_measure.position_at(0) + extent_along_wire, diff --git a/tests/test_drafting.py b/tests/test_drafting.py index cec8075..6d4dae5 100644 --- a/tests/test_drafting.py +++ b/tests/test_drafting.py @@ -301,7 +301,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=-10, draft=metric, - project_line=(0, 1, 0), + measurement_direction=(0, 1, 0), ) self.assertIsNotNone(ext) @@ -311,7 +311,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=-10, draft=metric, - project_line=Vector(0, 1, 0), + measurement_direction=Vector(0, 1, 0), ) self.assertIsNotNone(ext) self.assertGreater( @@ -334,7 +334,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=10, draft=metric, - project_line=Vector(0, 1, 0), + measurement_direction=Vector(0, 1, 0), ) self.assertIsNotNone(ext) self.assertEqual( @@ -354,7 +354,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=x_size + 10, draft=metric, - project_line=Vector(0, 1, 0), + measurement_direction=Vector(0, 1, 0), ) self.assertIsNotNone(ext) self.assertGreater( @@ -373,7 +373,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=10, draft=metric, - project_line=Vector(0, 1, 0), + measurement_direction=Vector(0, 1, 0), ) self.assertIsNotNone(ext) self.assertEqual( @@ -388,7 +388,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=10, draft=metric, - project_line=Vector(1, 0, 0), + measurement_direction=Vector(1, 0, 0), ) self.assertIsNotNone(ext) self.assertGreater( @@ -411,7 +411,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=diagonal_line, offset=10, draft=metric, - project_line=Vector(1, 1, 0), + measurement_direction=Vector(1, 1, 0), ) self.assertIsNotNone(ext) self.assertAlmostEqual(ext.dimension, 141.421, places=2) @@ -422,7 +422,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=half_circle, offset=-10, draft=metric, - project_line=Vector(1, 0, 0), + measurement_direction=Vector(1, 0, 0), ) self.assertIsNotNone(ext) self.assertEqual(ext.dimension, 100) @@ -438,7 +438,7 @@ class ExtensionLineTestCase(unittest.TestCase): border=half_circle, offset=10, draft=metric, - project_line=Vector(0, 1, 0), + measurement_direction=Vector(0, 1, 0), ) From 4fc794327cd2b2f0875ddab105b5da51b66da48a Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 1 Jan 2026 13:42:42 -0500 Subject: [PATCH 097/105] fix: Color class corrected to use sRGB modified: src/build123d/geometry.py modified: tests/test_direct_api/test_color.py --- src/build123d/geometry.py | 22 ++++++++++++---------- tests/test_direct_api/test_color.py | 6 ++++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 3e3807f..ad04b0d 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -41,7 +41,7 @@ import json import logging import warnings from collections.abc import Callable, Iterable, Sequence -from math import degrees, isclose, log10, pi, radians, prod +from math import degrees, isclose, log10, pi, prod, radians from typing import TYPE_CHECKING, Any, TypeAlias, overload import numpy as np @@ -72,7 +72,7 @@ from OCP.gp import ( # properties used to store mass calculation result from OCP.GProp import GProp_GProps -from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA +from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA, Quantity_TypeOfColor from OCP.TopAbs import TopAbs_ShapeEnum from OCP.TopLoc import TopLoc_Location from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Vertex @@ -1200,7 +1200,7 @@ class Color: @overload def __init__(self, red: float, green: float, blue: float, alpha: float = 1.0): - """Color from RGBA and Alpha values + """Color from sRGB and Alpha values Args: red (float): 0.0 <= red <= 1.0 @@ -1312,7 +1312,10 @@ class Color: raise TypeError(f"Unsupported color definition: {color_format}") if not self.wrapped: - self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha) + the_color = Quantity_Color( + red, green, blue, Quantity_TypeOfColor.Quantity_TOC_sRGB + ) + self.wrapped = Quantity_ColorRGBA(the_color, alpha) def __iter__(self): """Initialize to beginning""" @@ -1321,14 +1324,14 @@ class Color: def __next__(self): """return the next value""" - rgb = self.wrapped.GetRGB() - rgb_tuple = (rgb.Red(), rgb.Green(), rgb.Blue(), self.wrapped.Alpha()) + r, g, b = self.wrapped.GetRGB().Values(Quantity_TypeOfColor.Quantity_TOC_sRGB) + rgb_tuple = (r, g, b, self.wrapped.Alpha()) if self.iter_index > 3: raise StopIteration value = rgb_tuple[self.iter_index] self.iter_index += 1 - return value + return round(value, 7) def __copy__(self) -> Color: """Return copy of self""" @@ -1340,10 +1343,9 @@ class Color: def __str__(self) -> str: """Generate string""" - rgb = self.wrapped.GetRGB() - rgb = (rgb.Red(), rgb.Green(), rgb.Blue()) + rgb = self.wrapped.GetRGB().Values(Quantity_TypeOfColor.Quantity_TOC_sRGB) try: - name = webcolors.rgb_to_name([int(c * 255) for c in rgb]) + name = webcolors.rgb_to_name([round(c * 255) for c in rgb]) qualifier = "is" except ValueError: # This still uses OCCT X11 colors instead of css3 diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 9e50a8a..bcf46fa 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -29,10 +29,11 @@ license: import colorsys import copy import math + import numpy as np import pytest - from OCP.Quantity import Quantity_ColorRGBA + from build123d.geometry import Color @@ -250,7 +251,7 @@ def test_str_repr_is(): def test_str_repr_near(): c = Color(1, 0.5, 0) - assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'" + assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKORANGE1'" assert repr(c) == "Color(1.0, 0.5, 0.0, 1.0)" @@ -319,3 +320,4 @@ class TestColorCategoricalSet: for c in colors: (_, _, _, a) = tuple(c) assert math.isclose(a, 1.0, rel_tol=1e-6) + assert math.isclose(a, 1.0, rel_tol=1e-6) From 822582adfded96e10787cc1a2ae3a427155c4d9c Mon Sep 17 00:00:00 2001 From: Fan Gong Date: Thu, 1 Jan 2026 14:00:48 -0500 Subject: [PATCH 098/105] fix: delete extra line at end --- tests/test_direct_api/test_color.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index bcf46fa..2813389 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -320,4 +320,3 @@ class TestColorCategoricalSet: for c in colors: (_, _, _, a) = tuple(c) assert math.isclose(a, 1.0, rel_tol=1e-6) - assert math.isclose(a, 1.0, rel_tol=1e-6) From 4a9a0995efbdcad3eee65039a4003f9f46549100 Mon Sep 17 00:00:00 2001 From: Kuravi H Date: Fri, 2 Jan 2026 00:30:42 -0500 Subject: [PATCH 099/105] Add tests for ParabolicCenterArc and HyperbolicCenterArc --- tests/test_build_line.py | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index ae7364a..6a12632 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -174,6 +174,53 @@ class BuildLineTests(unittest.TestCase): self.assertLessEqual(bbox.max.Y, 5) self.assertTrue(isinstance(e1, Edge)) + def test_parabolic_center_arc(self): + # General conic section equation: (1+K)x^2-2Rx+y^2=0 + # parabola (K = -1) => -2Rx+y^2=0 + center = (0, 0) + C = 1 + R = 1 / C + focal_length = R / 2 + with BuildLine() as el: + ParabolicCenterArc(center, focal_length, 0, 90, 0, AngularDirection.COUNTER_CLOCKWISE, Mode.ADD) + bbox = el.line.bounding_box() + self.assertGreaterEqual(bbox.min.X, -10) + self.assertGreaterEqual(bbox.min.Y, 0) + self.assertLessEqual(bbox.max.X, 10) + self.assertLessEqual(bbox.max.Y, 5) + + e1 = ParabolicCenterArc(center, focal_length, 0, 90, 0, AngularDirection.COUNTER_CLOCKWISE, Mode.ADD) + bbox = e1.bounding_box() + self.assertGreaterEqual(bbox.min.X, -10) + self.assertGreaterEqual(bbox.min.Y, 0) + self.assertLessEqual(bbox.max.X, 10) + self.assertLessEqual(bbox.max.Y, 5) + self.assertTrue(isinstance(e1, Edge)) + + def test_hyperbolic_center_arc(self): + # General conic section equation: (1+K)x^2-2Rx+y^2=0 + # hyperbola (K < -1) + center = (0, 0) + C = 1 + R = 1 / C + K = -2 # => -(x^2)-2Rx+y^2=0 + a, b = R / (-K - 1), R / sqrt(-K - 1) + with BuildLine() as el: + HyperbolicCenterArc(center, b, a, 0, 90, 0, AngularDirection.COUNTER_CLOCKWISE, Mode.ADD) + bbox = el.line.bounding_box() + self.assertGreaterEqual(bbox.min.X, -10) + self.assertGreaterEqual(bbox.min.Y, 0) + self.assertLessEqual(bbox.max.X, 10) + self.assertLessEqual(bbox.max.Y, 5) + + e1 = HyperbolicCenterArc(center, b, a, 0, 90, 0, AngularDirection.COUNTER_CLOCKWISE, Mode.ADD) + bbox = e1.bounding_box() + self.assertGreaterEqual(bbox.min.X, -10) + self.assertGreaterEqual(bbox.min.Y, 0) + self.assertLessEqual(bbox.max.X, 10) + self.assertLessEqual(bbox.max.Y, 5) + self.assertTrue(isinstance(e1, Edge)) + def test_filletpolyline(self): with BuildLine(Plane.YZ): p = FilletPolyline( From 2039ff64a7ad83bd64a21adc844b61af18df4bda Mon Sep 17 00:00:00 2001 From: James Keal Date: Sat, 3 Jan 2026 14:38:45 +1030 Subject: [PATCH 100/105] Update external.rst --- docs/external.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/external.rst b/docs/external.rst index 85f7723..03f0645 100644 --- a/docs/external.rst +++ b/docs/external.rst @@ -115,6 +115,13 @@ A gear generation framework that allows easy creation of a wide range of gears a See `gggears `_ +bd_vslot +================= + +A library of V-Slot linear rail components, including V-Slot rails. + +See: `bd_vslot `_ + ***** Tools ***** From bd0a3bab28bd1126a0a96cf2c41374b26e5c8b35 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 7 Jan 2026 10:36:56 -0500 Subject: [PATCH 101/105] Enhanced projected Edge/Wire by cleaning raw result to avoid fragmenting Edges --- src/build123d/topology/one_d.py | 26 +++++++++++++--------- tests/test_direct_api/test_projection.py | 28 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index d77f254..494d421 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -88,7 +88,12 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset 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, GC_MakeArcOfParabola, GC_MakeArcOfHyperbola +from OCP.GC import ( + GC_MakeArcOfCircle, + GC_MakeArcOfEllipse, + GC_MakeArcOfParabola, + GC_MakeArcOfHyperbola, +) from OCP.GCPnts import ( GCPnts_AbscissaPoint, GCPnts_QuasiUniformDeflection, @@ -2862,7 +2867,7 @@ class Edge(Mixin1D[TopoDS_Edge]): target_object: Shape, direction: VectorLike | None = None, center: VectorLike | None = None, - ) -> list[Edge]: + ) -> ShapeList[Edge]: """Project Edge Project an Edge onto a Shape generating new wires on the surfaces of the object @@ -2888,10 +2893,8 @@ class Edge(Mixin1D[TopoDS_Edge]): ValueError: Only one of direction or center must be provided """ - wire = Wire([self]) - projected_wires = wire.project_to_shape(target_object, direction, center) - projected_edges = [w.edges()[0] for w in projected_wires] - return projected_edges + projected_wires = Wire(self).project_to_shape(target_object, direction, center) + return projected_wires.edges() def reversed(self, reconstruct: bool = False) -> Edge: """reversed @@ -3880,7 +3883,7 @@ class Wire(Mixin1D[TopoDS_Wire]): target_object: Shape, direction: VectorLike | None = None, center: VectorLike | None = None, - ) -> list[Wire]: + ) -> ShapeList[Wire]: """Project Wire Project a Wire onto a Shape generating new wires on the surfaces of the object @@ -3934,7 +3937,7 @@ class Wire(Mixin1D[TopoDS_Wire]): ) # Generate a list of the projected wires with aligned orientation - output_wires = [] + output_wires = ShapeList() target_orientation = self.wrapped.Orientation() while projection_object.More(): projected_wire = projection_object.Current() @@ -3980,9 +3983,12 @@ class Wire(Mixin1D[TopoDS_Wire]): "projected, filtered and sorted wire list is of length %d", len(output_wires_distances), ) - output_wires = [w[0] for w in output_wires_distances] + output_wires = ShapeList([w[0] for w in output_wires_distances]) - return output_wires + # Clean the wires remove cases where projection artificially split edges + cleaned_wires = ShapeList([w.clean() for w in output_wires]) + + return cleaned_wires def stitch(self, other: Wire) -> Wire: """Attempt to stitch wires diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py index 8b0da03..ea72147 100644 --- a/tests/test_direct_api/test_projection.py +++ b/tests/test_direct_api/test_projection.py @@ -29,9 +29,9 @@ license: import unittest from build123d.build_enums import Align -from build123d.geometry import Axis, Plane, Pos, Vector +from build123d.geometry import Axis, Location, Plane, Pos, Vector from build123d.objects_part import Box -from build123d.topology import Compound, Edge, Solid, Wire +from build123d.topology import Compound, Edge, Face, Solid, Wire class TestProjection(unittest.TestCase): @@ -94,6 +94,30 @@ class TestProjection(unittest.TestCase): self.assertAlmostEqual(projection[0].position_at(0), (0, 1, 0), 5) self.assertAlmostEqual(projection[0].arc_center, (0, 0, 0), 5) + def test_project_clean_wire(self): + """Verify that the projected edge result is one edge - wire was cleaned""" + radii, width, opening_angle = (45, 30), 25, 80 + center_arc = Edge.make_ellipse( + *radii, + start_angle=270 + opening_angle / 2, + end_angle=270 - opening_angle / 2, + ) + center_surface = -Face.extrude(center_arc, (0, 0, 2 * width)).moved( + Location((0, 0, -width), (0, 0, 180)) + ) + tip_center_loc = -center_surface.location_at(center_arc @ 1, x_dir=(1, 0, 0)) + normal_at_tip_center = tip_center_loc.z_axis.direction + + planar_tip_arc = Edge.make_circle( + width / 2, start_angle=270, end_angle=450 + ).locate(tip_center_loc) + tip_arc = planar_tip_arc.project_to_shape( + center_surface, -normal_at_tip_center + )[0] + self.assertTrue(isinstance(tip_arc, Edge)) + self.assertAlmostEqual(tip_arc @ 0, planar_tip_arc @ 0, 5) + self.assertAlmostEqual(tip_arc @ 1, planar_tip_arc @ 1, 5) + if __name__ == "__main__": unittest.main() From a56163047672806ded331a5be006bc36c77715f4 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 7 Jan 2026 11:01:15 -0500 Subject: [PATCH 102/105] Making mypy happy --- src/build123d/topology/one_d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 494d421..eeac210 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -3937,7 +3937,7 @@ class Wire(Mixin1D[TopoDS_Wire]): ) # Generate a list of the projected wires with aligned orientation - output_wires = ShapeList() + output_wires: ShapeList[Wire] = ShapeList() target_orientation = self.wrapped.Orientation() while projection_object.More(): projected_wire = projection_object.Current() From b41692aea9dd558064a5107a12cc8e367fbbfba6 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 7 Jan 2026 11:08:32 -0500 Subject: [PATCH 103/105] Adding bracelet example to the docs --- docs/assets/examples/bracelet.png | Bin 0 -> 115102 bytes docs/examples_1.rst | 38 ++++++++ examples/bracelet.py | 138 ++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 docs/assets/examples/bracelet.png create mode 100644 examples/bracelet.py diff --git a/docs/assets/examples/bracelet.png b/docs/assets/examples/bracelet.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb5265d403c452f1bbf034503fab29752c5ecea GIT binary patch literal 115102 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
4z`u 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

bulged spline -> mirrored section) +# - guides define “along the tip” rails (start point -> projected 3D arc -> end point) +# +# Tangents are used to encourage smoothness where the tip joins the swept center section. +profile = Spline( + half_x_section @ 0, + tip_arc @ 0.5, + half_x_section @ 1, + tangents=(center_arc % 1, -(center_arc % 1)), +) +tip_surface = Face.make_gordon_surface( + profiles=[half_x_section, profile, half_x_section.mirror(Plane.XY)], + guides=[half_x_section @ 0, tip_arc, half_x_section @ 1], +) + +# Step 5: Close the tip surface into a watertight Solid. +# tip_surface is the outer “skin”; we create a side face from its boundary wire +# and make a shell, then a solid. +tip_side = Face(tip_surface.wire()) +tip = Solid(Shell([tip_side, tip_surface])) + +# Step 6: Sweep the *flat end face* of the tip around the center arc. +# This is the trick that makes the center section compatible with the freeform tip: +# the sweep profile is the same face that bounds the tip, so the join is naturally aligned. +center_section = sweep(tip_side, center_arc).solid() + +# Step 7: Assemble the bracelet from the center and two mirrored tips. +# Mirror across YZ to create the opposite end cap. +bracelet = Solid() + [tip, center_section, tip.mirror(Plane.YZ)] + +# Step 8: Add an embossed label. +# This is often the hardest operation for OCCT in this model: +# projecting text onto a doubly-curved surface can create many small faces/edges, +# and thickening them adds even more boolean complexity. +if label_str: + label = Text(label_str, font_size=width * 0.8, align=Align.CENTER) + + # Project the text onto the bracelet using a path-based placement along center_arc. + # The parameter offsets the label so it sits centered along arc-length. + p_labels = bracelet.project_faces( + label, center_arc, 0.5 - 0.5 * (label.bounding_box().size.X) / center_arc.length + ) + # Turn the projected faces into solids via thickening (embossing). + embossed_label = [Solid.thicken(f, 0.5) for f in p_labels.faces()] + bracelet += embossed_label + +# Step 9: Add alignment holes to aid assembly after 3D printing in two halves. +# These are placed at evenly spaced locations along the arc (including both ends). +# A small clearance (+0.15) is included for typical FDM tolerances. +alignment_holes = [ + Pos(p) * Cylinder(1.75 / 2 + 0.15, 8) + for p in [center_arc.position_at(i / 4) for i in range(5)] +] +bracelet -= alignment_holes + +show(bracelet) +# [End] From 11aa6c2b9906868e11d309bde9b328dc5bc1461e Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 7 Jan 2026 11:40:58 -0500 Subject: [PATCH 104/105] Removing special characters that cause problems in Windows --- examples/bracelet.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/bracelet.py b/examples/bracelet.py index fd03db1..ef8ad4f 100644 --- a/examples/bracelet.py +++ b/examples/bracelet.py @@ -6,17 +6,17 @@ by: Gumyr date: January 7, 2026 desc: - This model is a good “stress test” for OCCT because most of the final boundary + This model is a good "stress test" for OCCT because most of the final boundary surfaces are *freeform* (not analytic planes/cylinders/spheres). The geometry is assembled from: - a swept center section (using a curved solid end-face as the sweep profile) - - two freeform “tip caps” built as Gordon surfaces (network of curves) + - two freeform "tip caps" built as Gordon surfaces (network of curves) - an optional embossed text label projected onto a curved solid - alignment holes for splitting/printing/assembly Key techniques demonstrated: - using location_at/position_at/tangent (%) to extract local frames & tangents - - projecting curves onto a non-planar surface to create “true” 3D guide curves + - projecting curves onto a non-planar surface to create "true" 3D guide curves - Gordon surfaces to build high-quality doubly-curved skins - projecting faces (text) onto a complex solid and thickening them @@ -36,7 +36,7 @@ from ocp_vscode import show radii, width, thickness, opening_angle, label_str = (45, 30), 25, 5, 80, "build123d" # Step 1: Create an elliptical arc defining the *centerline* of the bracelet. -# The arc is truncated to leave an opening (the “gap” where the bracelet goes on). +# The arc is truncated to leave an opening (the "gap" where the bracelet goes on). # Angles are in degrees; 270° points downward, which keeps the opening centered at the bottom. center_arc = EllipticalCenterArc( (0, 0), *radii, 270 + opening_angle / 2, 270 - opening_angle / 2 @@ -47,13 +47,13 @@ center_arc = EllipticalCenterArc( # curve-network complexity when building the freeform tip. # # location_at(1) returns a local coordinate frame at the arc end (tangent-aware). -# x_dir is chosen so the section’s local “X” is well-defined and stable. +# x_dir is chosen so the section’s local "X" is well-defined and stable. end_center_arc = center_arc.location_at(1, x_dir=(0, 0, 1)) half_x_section = EllipticalCenterArc((0, 0), width / 2, thickness / 2, 90, 270).locate( end_center_arc ) -# Step 3: Create a doubly-curved “tip edge” curve. +# Step 3: Create a doubly-curved "tip edge" curve. # The tip edge must live in 3D and conform to the outside of the bracelet. # To do that, we: # 1) create a surface by extruding the center_arc into a sheet (a ribbon surface) @@ -75,12 +75,12 @@ tip_arc = planar_tip_arc.project_to_shape(center_surface, -normal_at_tip_center) # Step 4: Build the tip as a Gordon surface (a surface fit through a curve network). # Gordon surfaces are ideal when: # - you don’t have an obvious analytic surface -# - curvature changes in two directions (doubly-curved “cap”) +# - curvature changes in two directions (doubly-curved "cap") # - you can define a consistent set of profile curves + guide curves # # Here: -# - profiles define “across the tip” shape (section -> bulged spline -> mirrored section) -# - guides define “along the tip” rails (start point -> projected 3D arc -> end point) +# - profiles define "across the tip" shape (section -> bulged spline -> mirrored section) +# - guides define "along the tip" rails (start point -> projected 3D arc -> end point) # # Tangents are used to encourage smoothness where the tip joins the swept center section. profile = Spline( @@ -95,7 +95,7 @@ tip_surface = Face.make_gordon_surface( ) # Step 5: Close the tip surface into a watertight Solid. -# tip_surface is the outer “skin”; we create a side face from its boundary wire +# tip_surface is the outer "skin"; we create a side face from its boundary wire # and make a shell, then a solid. tip_side = Face(tip_surface.wire()) tip = Solid(Shell([tip_side, tip_surface])) From 7fb6e280f6142f03f7f8376531ffb7a15a071a89 Mon Sep 17 00:00:00 2001 From: gumyr Date: Thu, 8 Jan 2026 10:30:01 -0500 Subject: [PATCH 105/105] Added ConvexPolyhedron Issue #941 --- docs/assets/convex_polyhedron_example.svg | 57 ++++++++++++++++++++ docs/cheat_sheet.rst | 1 + docs/objects.rst | 8 +++ docs/objects_3d.py | 11 ++++ src/build123d/__init__.py | 7 +-- src/build123d/objects_part.py | 66 ++++++++++++++++++++++- tests/test_algebra.py | 9 ++++ tests/test_build_part.py | 15 +++++- 8 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 docs/assets/convex_polyhedron_example.svg diff --git a/docs/assets/convex_polyhedron_example.svg b/docs/assets/convex_polyhedron_example.svg new file mode 100644 index 0000000..881a78c --- /dev/null +++ b/docs/assets/convex_polyhedron_example.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index 3361f71..1995c2d 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -65,6 +65,7 @@ Cheat Sheet | :class:`~objects_part.Box` | :class:`~objects_part.Cone` + | :class:`~objects_part.ConvexPolyhedron` | :class:`~objects_part.CounterBoreHole` | :class:`~objects_part.CounterSinkHole` | :class:`~objects_part.Cylinder` diff --git a/docs/objects.rst b/docs/objects.rst index df6b0e2..9747ca2 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -455,6 +455,13 @@ Reference +++ Cone defined by radii and height + .. grid-item-card:: :class:`~objects_part.ConvexPolyhedron` + + .. image:: assets/convex_polyhedron_example.svg + + +++ + Convex Polyhedron defined by points + .. grid-item-card:: :class:`~objects_part.CounterBoreHole` .. image:: assets/counter_bore_hole_example.svg @@ -512,6 +519,7 @@ Reference .. autoclass:: BasePartObject .. autoclass:: Box .. autoclass:: Cone +.. autoclass:: ConvexPolyhedron .. autoclass:: CounterBoreHole .. autoclass:: CounterSinkHole .. autoclass:: Cylinder diff --git a/docs/objects_3d.py b/docs/objects_3d.py index 192a69a..b9c3c95 100644 --- a/docs/objects_3d.py +++ b/docs/objects_3d.py @@ -77,3 +77,14 @@ with BuildPart() as example_9: Wedge(1, 1, 1, 0, 0, 0.5, 0.5) # [Ex. 9] write_svg("wedge_example") + +# [Ex. 10] +with BuildPart() as example_10: + Box(30, 20, 20) + Box(20, 30, 20) + Box(20, 20, 30) + with Locations((-10, 0, 0)): + Box(40, 23, 23) + ConvexPolyhedron(example_10.vertices()) + # [Ex. 10] + write_svg("convex_polyhedron_example") diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index 69df5fe..b8f683d 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -129,12 +129,13 @@ __all__ = [ "Triangle", # 3D Part Objects "BasePartObject", - "CounterBoreHole", - "CounterSinkHole", - "Hole", "Box", "Cone", + "ConvexPolyhedron", + "CounterBoreHole", + "CounterSinkHole", "Cylinder", + "Hole", "Sphere", "Torus", "Wedge", diff --git a/src/build123d/objects_part.py b/src/build123d/objects_part.py index 7054d7a..bd0b238 100644 --- a/src/build123d/objects_part.py +++ b/src/build123d/objects_part.py @@ -28,13 +28,31 @@ license: from __future__ import annotations +from collections.abc import Iterable from math import radians, tan +from scipy.spatial import ConvexHull from build123d.build_common import LocationList, validate_inputs from build123d.build_enums import Align, Mode from build123d.build_part import BuildPart -from build123d.geometry import Location, Plane, Rotation, RotationLike -from build123d.topology import Compound, Part, ShapeList, Solid, tuplify +from build123d.geometry import ( + Location, + Plane, + Rotation, + RotationLike, + Vector, + VectorLike, +) +from build123d.topology import ( + Compound, + Face, + Part, + ShapeList, + Shell, + Solid, + Wire, + tuplify, +) class BasePartObject(Part): @@ -197,6 +215,50 @@ class Cone(BasePartObject): ) +class ConvexPolyhedron(BasePartObject): + """Part Object: ConvexPolyhedron + + Create a convex solid from the convex hull of the provided points. + + Args: + points (Iterable[VectorLike]): vertices of the polyhedron + rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0) + align (Align | tuple[Align, Align, Align] | None, optional): align MIN, CENTER, + or MAX of object. Defaults to Align.NONE + mode (Mode, optional): combine mode. Defaults to Mode.ADD + """ + + _applies_to = [BuildPart._tag] + + def __init__( + self, + points: Iterable[VectorLike], + rotation: RotationLike = (0, 0, 0), + align: Align | tuple[Align, Align, Align] | None = Align.NONE, + mode: Mode = Mode.ADD, + ): + context: BuildPart | None = BuildPart._get_context(self) + validate_inputs(context, self) + + pnts: list[tuple] = [tuple(Vector(p)) for p in points] + + # Create a convex hull from the vertices + convex_hull = ConvexHull(pnts).simplices.tolist() + + # Create faces from the vertex indices + polyhedron_faces = [] + for face_vertex_indices in convex_hull: + corner_vertices = [pnts[i] for i in face_vertex_indices] + polyhedron_faces.append(Face(Wire.make_polygon(corner_vertices))) + + # Create the solid from the Faces + polyhedron = Solid(Shell(polyhedron_faces)).clean() + + super().__init__( + part=polyhedron, rotation=rotation, align=tuplify(align, 3), mode=mode + ) + + class CounterBoreHole(BasePartObject): """Part Operation: Counter Bore Hole diff --git a/tests/test_algebra.py b/tests/test_algebra.py index 3d354a6..a25e0a9 100644 --- a/tests/test_algebra.py +++ b/tests/test_algebra.py @@ -175,6 +175,15 @@ class ObjectTests(unittest.TestCase): self.assertTupleAlmostEquals(s.bounding_box().min, (-0.5, -0.5, -0.5), 3) self.assertTupleAlmostEquals(s.bounding_box().max, (0.5, 0.5, 0.5), 3) + def test_convex_polyhedron(self): + base = Box(30, 20, 20) + base += Box(20, 30, 20) + base += Box(20, 20, 30) + base += Pos(10, 0, 0) * Box(40, 23, 23) + part = ConvexPolyhedron(base.vertices()) + self.assertAlmostEqual(part.volume, 33876.66666666667, 5) + self.assertEqual(len(part.faces()), 26) + def test_hole(self): obj = Box(10, 10, 10) obj -= Hole(3, 10) diff --git a/tests/test_build_part.py b/tests/test_build_part.py index d5dd6c7..1497e7c 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -295,7 +295,7 @@ class TestCounterSinkHole(unittest.TestCase): class TestCylinder(unittest.TestCase): - def test_simple_torus(self): + def test_simple_cylinder(self): with BuildPart() as test: Cylinder(2, 10) self.assertAlmostEqual(test.part.volume, pi * 2**2 * 10, 5) @@ -693,5 +693,18 @@ class TestWedge(unittest.TestCase): Wedge(1, 1, 0, 0, 0, 2, 5) +class TestConvexPolyhedron(unittest.TestCase): + def test_convex_polyhedron(self): + with BuildPart() as test: + Box(30, 20, 20) + Box(20, 30, 20) + Box(20, 20, 30) + with Locations((10, 0, 0)): + Box(40, 23, 23) + ConvexPolyhedron(test.vertices()) + self.assertAlmostEqual(test.part.volume, 33876.66666666667, 5) + self.assertEqual(len(test.faces()), 26) + + if __name__ == "__main__": unittest.main()