diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9b459bb..2af009a 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -8,10 +8,11 @@ runs: using: "composite" steps: - name: Setup Python - uses: actions/setup-python@v5 + uses: astral-sh/setup-uv@v5 with: + enable-cache: false python-version: ${{ inputs.python-version }} - name: Install Requirements shell: bash run: | - pip install .[development] + uv pip install .[development] diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 7332a56..ff389dc 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -3,7 +3,7 @@ name: benchmarks on: [push, pull_request, workflow_dispatch] jobs: - tests: + benchmarks: strategy: fail-fast: false matrix: @@ -12,7 +12,7 @@ jobs: # "3.11", "3.12", ] - os: [macos-13, macos-14, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -22,5 +22,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: benchmark run: | - pip install pytest-benchmark - python -m pytest --benchmark-only + python -m pytest --benchmark-only --benchmark-autosave + pytest-benchmark compare --csv="results.csv" + cat results.csv + - uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ matrix.os }} + path: results.csv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b636f04..0f9dabc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: # "3.12", "3.13", ] - os: [macos-13, macos-14, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -23,4 +23,4 @@ jobs: python-version: ${{ matrix.python-version }} - name: test run: | - python -m pytest + python -m pytest -n auto --benchmark-disable 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/.readthedocs.yaml b/.readthedocs.yaml index 9925d2f..44248cf 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,10 @@ build: python: "3.10" apt_packages: - graphviz + jobs: + post_checkout: + # necessary to ensure that the development builds get a correct version tag + - git fetch --unshallow || true # Build from the docs/ directory with Sphinx sphinx: @@ -21,8 +25,3 @@ python: path: . extra_requirements: - docs - -# Explicitly set the version of Python and its requirements -# python: -# install: -# - requirements: docs/requirements.txt diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..6db4126 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,16 @@ +cff-version: 1.2.0 +message: "If you use build123d in your research, please cite it using the following information." +title: "build123d: A Python-based parametric CAD library" +version: "0.9.1" +doi: "10.5281/zenodo.14872323" +authors: + - name: "Roger Maitland" + affiliation: "Independent Developer" +date-released: "2025-02-14" +repository-code: "https://github.com/gumyr/build123d" +license: "Apache-2.0" +keywords: + - CAD + - Python + - OpenCascade + - Parametric Design diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 115577a..78f6540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,10 @@ 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 pylint pytest mypy sphinx black` -- Install docs dependencies: `pip install -r docs/requirements.txt` (might need to comment out the build123d line in that file) +- 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` +- Run tests with: `python -m pytest -n auto` - Build docs with: `cd docs && make html` - Check added files' style with: `pylint ` - Check added files' type annotations with: `mypy ` diff --git a/Citation.md b/Citation.md new file mode 100644 index 0000000..bb4781a --- /dev/null +++ b/Citation.md @@ -0,0 +1,19 @@ +# Citation + +If you use **build123d** in your research, please cite: + +Roger Maitland. **"build123d: A Python-based parametric CAD library"**. Version 0.9.1, 2025. +DOI: [10.5281/zenodo.14872323](https://doi.org/10.5281/zenodo.14872323) +Source Code: [GitHub](https://github.com/gumyr/build123d) + +## BibTeX Entry + +```bibtex +@software{build123d, + author = {Roger Maitland}, + title = {build123d: A Python-based parametric CAD library}, + year = {2025}, + version = {0.9.1}, + doi = {10.5281/zenodo.14872323}, + url = {https://github.com/gumyr/build123d} +} \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2082252 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +build123d +Copyright (c) 2022–2025 The build123d Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at: + + http://www.apache.org/licenses/LICENSE-2.0 + +------------------------------------------------------------------------------- + +This project was originally derived from portions of the CadQuery codebase +(https://github.com/CadQuery/cadquery) but has since been extensively +refactored and restructured into an independent system. +CadQuery is licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 91d23f1..cb7c309 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- build123d logo + build123d logo

[![Documentation Status](https://readthedocs.org/projects/build123d/badge/?version=latest)](https://build123d.readthedocs.io/en/latest/?badge=latest) @@ -16,10 +16,20 @@ [![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) -Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks. -Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc. +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. + +Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers: +- Minimal or no internal state depending on mode, +- Explicit 1D, 2D, and 3D geometry classes with well-defined operations, +- Extensibility through subclassing and functional composition—no monkey patching, +- Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints, +- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)), +- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic. + +The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath. The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html). @@ -60,6 +70,10 @@ python3 -m pip install -e . Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html). +Attribution: + +Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system. + [BREP]: https://en.wikipedia.org/wiki/Boundary_representation [CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html [FreeCAD]: https://www.freecad.org/ diff --git a/docs/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/_static/spitfire_wing.glb b/docs/_static/spitfire_wing.glb new file mode 100644 index 0000000..93c275b Binary files /dev/null and b/docs/_static/spitfire_wing.glb differ diff --git a/docs/advantages.rst b/docs/advantages.rst index 690d871..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 @@ -100,13 +101,13 @@ prompt users for valid options without having to refer to documentation. Selectors replaced by Lists =========================== String based selectors have been replaced with standard python filters and -sorting which opens up the full functionality of python lists. To aid the +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_normal(Axis.Z)[-1] + top = rail.faces().filter_by(Axis.Z)[-1] ... outside_vertices = filter( lambda v: (v.Y == 0.0 or v.Y == height) and -width / 2 < v.X < width / 2, diff --git a/docs/algebra_definition.rst b/docs/algebra_definition.rst index ae98eb5..e87c42c 100644 --- a/docs/algebra_definition.rst +++ b/docs/algebra_definition.rst @@ -61,7 +61,7 @@ with :math:`B^3 \subset C^3, B^2 \subset C^2` and :math:`B^1 \subset C^1` * This definition also includes that neither ``-`` nor ``&`` are commutative. -Locations, planes and location arithmentic +Locations, planes and location arithmetic --------------------------------------------- **Set definitions:** 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/assets/double_tangent_line_example.svg b/docs/assets/double_tangent_line_example.svg index 61895bb..4c33f81 100644 --- a/docs/assets/double_tangent_line_example.svg +++ b/docs/assets/double_tangent_line_example.svg @@ -1,13 +1,13 @@ - + - - + + - - - + + + \ No newline at end of file diff --git a/docs/assets/example_airfoil.svg b/docs/assets/example_airfoil.svg new file mode 100644 index 0000000..47e2fbe --- /dev/null +++ b/docs/assets/example_airfoil.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/example_arc_arc_tangent_arc.svg b/docs/assets/example_arc_arc_tangent_arc.svg new file mode 100644 index 0000000..9d92be8 --- /dev/null +++ b/docs/assets/example_arc_arc_tangent_arc.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/example_arc_arc_tangent_line.svg b/docs/assets/example_arc_arc_tangent_line.svg new file mode 100644 index 0000000..0e52e00 --- /dev/null +++ b/docs/assets/example_arc_arc_tangent_line.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/example_blend_curve.svg b/docs/assets/example_blend_curve.svg new file mode 100644 index 0000000..d5e0ce6 --- /dev/null +++ b/docs/assets/example_blend_curve.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/example_point_arc_tangent_arc.svg b/docs/assets/example_point_arc_tangent_arc.svg new file mode 100644 index 0000000..ed3ef63 --- /dev/null +++ b/docs/assets/example_point_arc_tangent_arc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/example_point_arc_tangent_line.svg b/docs/assets/example_point_arc_tangent_line.svg new file mode 100644 index 0000000..54d9f48 --- /dev/null +++ b/docs/assets/example_point_arc_tangent_line.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/examples/bicycle_tire.png b/docs/assets/examples/bicycle_tire.png new file mode 100644 index 0000000..bba9acb Binary files /dev/null and b/docs/assets/examples/bicycle_tire.png differ diff --git a/docs/assets/examples/cast_bearing_unit.png b/docs/assets/examples/cast_bearing_unit.png new file mode 100644 index 0000000..e0bebf1 Binary files /dev/null and b/docs/assets/examples/cast_bearing_unit.png differ diff --git a/docs/assets/examples/fast_grid_holes.png b/docs/assets/examples/fast_grid_holes.png new file mode 100644 index 0000000..f402e15 Binary files /dev/null and b/docs/assets/examples/fast_grid_holes.png differ diff --git a/docs/assets/examples/toy_truck.png b/docs/assets/examples/toy_truck.png new file mode 100644 index 0000000..bc70175 Binary files /dev/null and b/docs/assets/examples/toy_truck.png differ diff --git a/docs/assets/examples/toy_truck_picture.jpg b/docs/assets/examples/toy_truck_picture.jpg new file mode 100644 index 0000000..ee075fd Binary files /dev/null and b/docs/assets/examples/toy_truck_picture.jpg differ diff --git a/docs/assets/objects/arcarctangentarc_keep_table.png b/docs/assets/objects/arcarctangentarc_keep_table.png new file mode 100644 index 0000000..c659e69 Binary files /dev/null and b/docs/assets/objects/arcarctangentarc_keep_table.png differ diff --git a/docs/assets/stepper_drawing.svg b/docs/assets/stepper_drawing.svg new file mode 100644 index 0000000..6504639 --- /dev/null +++ b/docs/assets/stepper_drawing.svg @@ -0,0 +1,771 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/surface_modeling/heart_token.png b/docs/assets/surface_modeling/heart_token.png new file mode 100644 index 0000000..24cfeb7 Binary files /dev/null and b/docs/assets/surface_modeling/heart_token.png differ diff --git a/docs/assets/surface_modeling/spitfire_wing.png b/docs/assets/surface_modeling/spitfire_wing.png new file mode 100644 index 0000000..1092426 Binary files /dev/null and b/docs/assets/surface_modeling/spitfire_wing.png differ diff --git a/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg b/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg new file mode 100644 index 0000000..2dfbd4c --- /dev/null +++ b/docs/assets/surface_modeling/spitfire_wing_profiles_guides.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/token_half_surface.png b/docs/assets/surface_modeling/token_half_surface.png similarity index 100% rename from docs/assets/token_half_surface.png rename to docs/assets/surface_modeling/token_half_surface.png diff --git a/docs/assets/token_heart_perimeter.png b/docs/assets/surface_modeling/token_heart_perimeter.png similarity index 100% rename from docs/assets/token_heart_perimeter.png rename to docs/assets/surface_modeling/token_heart_perimeter.png diff --git a/docs/assets/token_heart_solid.png b/docs/assets/surface_modeling/token_heart_solid.png similarity index 100% rename from docs/assets/token_heart_solid.png rename to docs/assets/surface_modeling/token_heart_solid.png diff --git a/docs/assets/token_sides.png b/docs/assets/surface_modeling/token_sides.png similarity index 100% rename from docs/assets/token_sides.png rename to docs/assets/surface_modeling/token_sides.png diff --git a/docs/assets/topology_selection/filter_all_edges_circle.png b/docs/assets/topology_selection/filter_all_edges_circle.png new file mode 100644 index 0000000..1d82993 Binary files /dev/null and b/docs/assets/topology_selection/filter_all_edges_circle.png differ diff --git a/docs/assets/topology_selection/filter_axisplane.png b/docs/assets/topology_selection/filter_axisplane.png new file mode 100644 index 0000000..88090f2 Binary files /dev/null and b/docs/assets/topology_selection/filter_axisplane.png differ diff --git a/docs/assets/topology_selection/filter_dot_axisplane.png b/docs/assets/topology_selection/filter_dot_axisplane.png new file mode 100644 index 0000000..639d990 Binary files /dev/null and b/docs/assets/topology_selection/filter_dot_axisplane.png differ diff --git a/docs/assets/topology_selection/filter_geomtype_cylinder.png b/docs/assets/topology_selection/filter_geomtype_cylinder.png new file mode 100644 index 0000000..7604e16 Binary files /dev/null and b/docs/assets/topology_selection/filter_geomtype_cylinder.png differ diff --git a/docs/assets/topology_selection/filter_geomtype_line.png b/docs/assets/topology_selection/filter_geomtype_line.png new file mode 100644 index 0000000..3450dae Binary files /dev/null and b/docs/assets/topology_selection/filter_geomtype_line.png differ diff --git a/docs/assets/topology_selection/filter_inner_wire_count.png b/docs/assets/topology_selection/filter_inner_wire_count.png new file mode 100644 index 0000000..af07141 Binary files /dev/null and b/docs/assets/topology_selection/filter_inner_wire_count.png differ diff --git a/docs/assets/topology_selection/filter_inner_wire_count_linear.png b/docs/assets/topology_selection/filter_inner_wire_count_linear.png new file mode 100644 index 0000000..379e807 Binary files /dev/null and b/docs/assets/topology_selection/filter_inner_wire_count_linear.png differ diff --git a/docs/assets/topology_selection/filter_nested.png b/docs/assets/topology_selection/filter_nested.png new file mode 100644 index 0000000..dc23c9d Binary files /dev/null and b/docs/assets/topology_selection/filter_nested.png differ diff --git a/docs/assets/topology_selection/filter_shape_properties.png b/docs/assets/topology_selection/filter_shape_properties.png new file mode 100644 index 0000000..937593d Binary files /dev/null and b/docs/assets/topology_selection/filter_shape_properties.png differ diff --git a/docs/assets/topology_selection/group_axis_with.png b/docs/assets/topology_selection/group_axis_with.png new file mode 100644 index 0000000..190b971 Binary files /dev/null and b/docs/assets/topology_selection/group_axis_with.png differ diff --git a/docs/assets/topology_selection/group_axis_without.png b/docs/assets/topology_selection/group_axis_without.png new file mode 100644 index 0000000..73869e4 Binary files /dev/null and b/docs/assets/topology_selection/group_axis_without.png differ diff --git a/docs/assets/topology_selection/group_hole_area.png b/docs/assets/topology_selection/group_hole_area.png new file mode 100644 index 0000000..753a063 Binary files /dev/null and b/docs/assets/topology_selection/group_hole_area.png differ diff --git a/docs/assets/topology_selection/group_length_key.png b/docs/assets/topology_selection/group_length_key.png new file mode 100644 index 0000000..c2bc7ba Binary files /dev/null and b/docs/assets/topology_selection/group_length_key.png differ diff --git a/docs/assets/topology_selection/group_radius_key.png b/docs/assets/topology_selection/group_radius_key.png new file mode 100644 index 0000000..e8cd5d9 Binary files /dev/null and b/docs/assets/topology_selection/group_radius_key.png differ diff --git a/docs/assets/topology_selection/operators_filter_z_normal.png b/docs/assets/topology_selection/operators_filter_z_normal.png new file mode 100644 index 0000000..ecde21e Binary files /dev/null and b/docs/assets/topology_selection/operators_filter_z_normal.png differ diff --git a/docs/assets/topology_selection/operators_group_area.png b/docs/assets/topology_selection/operators_group_area.png new file mode 100644 index 0000000..927b0ed Binary files /dev/null and b/docs/assets/topology_selection/operators_group_area.png differ diff --git a/docs/assets/topology_selection/operators_sort_x.png b/docs/assets/topology_selection/operators_sort_x.png new file mode 100644 index 0000000..264bdcb Binary files /dev/null and b/docs/assets/topology_selection/operators_sort_x.png differ diff --git a/docs/assets/topology_selection/selectors_new_edges.png b/docs/assets/topology_selection/selectors_new_edges.png new file mode 100644 index 0000000..b7c5fb0 Binary files /dev/null and b/docs/assets/topology_selection/selectors_new_edges.png differ diff --git a/docs/assets/topology_selection/selectors_select_all.png b/docs/assets/topology_selection/selectors_select_all.png new file mode 100644 index 0000000..285d980 Binary files /dev/null and b/docs/assets/topology_selection/selectors_select_all.png differ diff --git a/docs/assets/topology_selection/selectors_select_last.png b/docs/assets/topology_selection/selectors_select_last.png new file mode 100644 index 0000000..b005240 Binary files /dev/null and b/docs/assets/topology_selection/selectors_select_last.png differ diff --git a/docs/assets/topology_selection/selectors_select_new.png b/docs/assets/topology_selection/selectors_select_new.png new file mode 100644 index 0000000..d24aa93 Binary files /dev/null and b/docs/assets/topology_selection/selectors_select_new.png differ diff --git a/docs/assets/topology_selection/selectors_select_new_fillet.png b/docs/assets/topology_selection/selectors_select_new_fillet.png new file mode 100644 index 0000000..d8cced6 Binary files /dev/null and b/docs/assets/topology_selection/selectors_select_new_fillet.png differ diff --git a/docs/assets/topology_selection/selectors_select_new_none.png b/docs/assets/topology_selection/selectors_select_new_none.png new file mode 100644 index 0000000..92b46c1 Binary files /dev/null and b/docs/assets/topology_selection/selectors_select_new_none.png differ diff --git a/docs/assets/topology_selection/sort_along_wire.png b/docs/assets/topology_selection/sort_along_wire.png new file mode 100644 index 0000000..dbd19da Binary files /dev/null and b/docs/assets/topology_selection/sort_along_wire.png differ diff --git a/docs/assets/topology_selection/sort_axis.png b/docs/assets/topology_selection/sort_axis.png new file mode 100644 index 0000000..e0757d7 Binary files /dev/null and b/docs/assets/topology_selection/sort_axis.png differ diff --git a/docs/assets/topology_selection/sort_distance_from_largest.png b/docs/assets/topology_selection/sort_distance_from_largest.png new file mode 100644 index 0000000..2ea2a62 Binary files /dev/null and b/docs/assets/topology_selection/sort_distance_from_largest.png differ diff --git a/docs/assets/topology_selection/sort_distance_from_origin.png b/docs/assets/topology_selection/sort_distance_from_origin.png new file mode 100644 index 0000000..3c28535 Binary files /dev/null and b/docs/assets/topology_selection/sort_distance_from_origin.png differ diff --git a/docs/assets/topology_selection/sort_not_along_wire.png b/docs/assets/topology_selection/sort_not_along_wire.png new file mode 100644 index 0000000..95bee78 Binary files /dev/null and b/docs/assets/topology_selection/sort_not_along_wire.png differ diff --git a/docs/assets/topology_selection/sort_sortby_distance.png b/docs/assets/topology_selection/sort_sortby_distance.png new file mode 100644 index 0000000..335f33f Binary files /dev/null and b/docs/assets/topology_selection/sort_sortby_distance.png differ diff --git a/docs/assets/topology_selection/sort_sortby_length.png b/docs/assets/topology_selection/sort_sortby_length.png new file mode 100644 index 0000000..c703d50 Binary files /dev/null and b/docs/assets/topology_selection/sort_sortby_length.png differ diff --git a/docs/assets/topology_selection/thumb_filter_all_edges_circle.png b/docs/assets/topology_selection/thumb_filter_all_edges_circle.png new file mode 100644 index 0000000..4dc39b9 Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_all_edges_circle.png differ diff --git a/docs/assets/topology_selection/thumb_filter_axisplane.png b/docs/assets/topology_selection/thumb_filter_axisplane.png new file mode 100644 index 0000000..fcbc754 Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_axisplane.png differ diff --git a/docs/assets/topology_selection/thumb_filter_geomtype.png b/docs/assets/topology_selection/thumb_filter_geomtype.png new file mode 100644 index 0000000..4ea7ead Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_geomtype.png differ diff --git a/docs/assets/topology_selection/thumb_filter_inner_wire_count.png b/docs/assets/topology_selection/thumb_filter_inner_wire_count.png new file mode 100644 index 0000000..9417182 Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_inner_wire_count.png differ diff --git a/docs/assets/topology_selection/thumb_filter_nested.png b/docs/assets/topology_selection/thumb_filter_nested.png new file mode 100644 index 0000000..f4e6f32 Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_nested.png differ diff --git a/docs/assets/topology_selection/thumb_filter_shape_properties.png b/docs/assets/topology_selection/thumb_filter_shape_properties.png new file mode 100644 index 0000000..2d58d2e Binary files /dev/null and b/docs/assets/topology_selection/thumb_filter_shape_properties.png differ diff --git a/docs/assets/topology_selection/thumb_group_axis.png b/docs/assets/topology_selection/thumb_group_axis.png new file mode 100644 index 0000000..fd9637d Binary files /dev/null and b/docs/assets/topology_selection/thumb_group_axis.png differ diff --git a/docs/assets/topology_selection/thumb_group_hole_area.png b/docs/assets/topology_selection/thumb_group_hole_area.png new file mode 100644 index 0000000..30476d0 Binary files /dev/null and b/docs/assets/topology_selection/thumb_group_hole_area.png differ diff --git a/docs/assets/topology_selection/thumb_group_properties_with_keys.png b/docs/assets/topology_selection/thumb_group_properties_with_keys.png new file mode 100644 index 0000000..9cdacfd Binary files /dev/null and b/docs/assets/topology_selection/thumb_group_properties_with_keys.png differ diff --git a/docs/assets/topology_selection/thumb_sort_along_wire.png b/docs/assets/topology_selection/thumb_sort_along_wire.png new file mode 100644 index 0000000..07f22d6 Binary files /dev/null and b/docs/assets/topology_selection/thumb_sort_along_wire.png differ diff --git a/docs/assets/topology_selection/thumb_sort_axis.png b/docs/assets/topology_selection/thumb_sort_axis.png new file mode 100644 index 0000000..63500f7 Binary files /dev/null and b/docs/assets/topology_selection/thumb_sort_axis.png differ diff --git a/docs/assets/topology_selection/thumb_sort_distance.png b/docs/assets/topology_selection/thumb_sort_distance.png new file mode 100644 index 0000000..40daf5e Binary files /dev/null and b/docs/assets/topology_selection/thumb_sort_distance.png differ diff --git a/docs/assets/topology_selection/thumb_sort_sortby.png b/docs/assets/topology_selection/thumb_sort_sortby.png new file mode 100644 index 0000000..064f562 Binary files /dev/null and b/docs/assets/topology_selection/thumb_sort_sortby.png differ diff --git a/docs/assets/ttt/ttt-23-02-02-sm_hanger.py b/docs/assets/ttt/ttt-23-02-02-sm_hanger.py index b523175..309f61f 100644 --- a/docs/assets/ttt/ttt-23-02-02-sm_hanger.py +++ b/docs/assets/ttt/ttt-23-02-02-sm_hanger.py @@ -92,6 +92,13 @@ with BuildPart() as sm_hanger: mirror(about=Plane.YZ) mirror(about=Plane.XZ) -print(f"Mass: {sm_hanger.part.volume*7800*1e-6:0.1f} g") +got_mass = sm_hanger.part.volume*7800*1e-6 +want_mass = 1028 +tolerance = 10 +delta = abs(got_mass - want_mass) +print(f"Mass: {got_mass:0.1f} g") +assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}' + +assert abs(got_mass - 1028) < 10, f'{got_mass=}, want=1028, tolerance=10' show(sm_hanger) diff --git a/docs/assets/ttt/ttt-23-t-24-curved_support.py b/docs/assets/ttt/ttt-23-t-24-curved_support.py index 26b469b..d54f6ee 100644 --- a/docs/assets/ttt/ttt-23-t-24-curved_support.py +++ b/docs/assets/ttt/ttt-23-t-24-curved_support.py @@ -27,7 +27,7 @@ equations = [ (yl8 - 50) / (55 / 2 - xl8) - tan(radians(8)), # 8 degree slope ] # There are two solutions but we want the 2nd one -solution = sympy.solve(equations, dict=True)[1] +solution = {k: float(v) for k,v in sympy.solve(equations, dict=True)[1].items()} # Create the critical points c30 = Vector(x30, solution[y30]) @@ -58,5 +58,11 @@ with BuildPart() as curved_support: with Locations((0, 125)): Hole(20 / 2) -print(curved_support.part.volume * 7800e-6) +got_mass = curved_support.part.volume * 7800e-6 +want_mass = 1294 +delta = abs(got_mass - want_mass) +tolerance = 3 +print(f"Mass: {got_mass:0.1f} g") +assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}' + show(curved_support) diff --git a/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py b/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py index a54d77c..586e689 100644 --- a/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py +++ b/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py @@ -11,7 +11,10 @@ with BuildPart() as p: with BuildSketch(Plane.YZ) as yz: Trapezoid(2.5, 4, 90 - 6, align=(Align.CENTER, Align.MIN)) - _, arc_center, arc_radius = full_round(yz.edges().sort_by(SortBy.LENGTH)[0]) + full_round(yz.edges().sort_by(SortBy.LENGTH)[0]) + circle_edge = yz.edges().filter_by(GeomType.CIRCLE)[0] + arc_center = circle_edge.arc_center + arc_radius = circle_edge.radius extrude(amount=10, mode=Mode.INTERSECT) # To avoid OCCT problems, don't attempt to extend the top arc, remove instead @@ -45,6 +48,13 @@ with BuildPart() as p: mirror(about=Plane.YZ) part = scale(p.part, IN) -print(f"\npart weight = {part.volume*7800e-6/LB:0.2f} lbs") + + +got_mass = part.volume * 7800e-6 / LB +want_mass = 3.923 +tolerance = 0.02 +delta = abs(got_mass - want_mass) +print(f"Mass: {got_mass:0.1f} lbs") +assert delta < tolerance, f"{got_mass=}, {want_mass=}, {delta=}, {tolerance=}" show(p) diff --git a/docs/assets/ttt/ttt-ppp0101.py b/docs/assets/ttt/ttt-ppp0101.py index 8a78180..efd0a27 100644 --- a/docs/assets/ttt/ttt-ppp0101.py +++ b/docs/assets/ttt/ttt-ppp0101.py @@ -1,47 +1,54 @@ -""" -Too Tall Toby Party Pack 01-01 Bearing Bracket -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as p: - with BuildSketch() as s: - Rectangle(115, 50) - with Locations((5 / 2, 0)): - SlotOverall(90, 12, mode=Mode.SUBTRACT) - extrude(amount=15) - - with BuildSketch(Plane.XZ.offset(50 / 2)) as s3: - with Locations((-115 / 2 + 26, 15)): - SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90) - zz = extrude(amount=-12) - split(bisect_by=Plane.XY) - edgs = p.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2] - fillet(edgs, 9) - - with Locations(zz.faces().sort_by(Axis.Y)[0]): - with Locations((42 / 2 + 6, 0)): - CounterBoreHole(24 / 2, 34 / 2, 4) - mirror(about=Plane.XZ) - - with BuildSketch() as s4: - RectangleRounded(115, 50, 6) - extrude(amount=80, mode=Mode.INTERSECT) - # fillet does not work right, mode intersect is safer - - with BuildSketch(Plane.YZ) as s4: - with BuildLine() as bl: - l1 = Line((0, 0), (18 / 2, 0)) - l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL) - l3 = Line(l2 @ 1, (0, 8)) - mirror(about=Plane.YZ) - make_face() - extrude(amount=115/2, both=True, mode=Mode.SUBTRACT) - -show_object(p) -print(f"\npart mass = {p.part.volume*densa:0.2f}") +""" +Too Tall Toby Party Pack 01-01 Bearing Bracket +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +with BuildPart() as p: + with BuildSketch() as s: + Rectangle(115, 50) + with Locations((5 / 2, 0)): + SlotOverall(90, 12, mode=Mode.SUBTRACT) + extrude(amount=15) + + with BuildSketch(Plane.XZ.offset(50 / 2)) as s3: + with Locations((-115 / 2 + 26, 15)): + SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90) + zz = extrude(amount=-12) + split(bisect_by=Plane.XY) + edgs = p.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2] + fillet(edgs, 9) + + with Locations(zz.faces().sort_by(Axis.Y)[0]): + with Locations((42 / 2 + 6, 0)): + CounterBoreHole(24 / 2, 34 / 2, 4) + mirror(about=Plane.XZ) + + with BuildSketch() as s4: + RectangleRounded(115, 50, 6) + extrude(amount=80, mode=Mode.INTERSECT) + # fillet does not work right, mode intersect is safer + + with BuildSketch(Plane.YZ) as s4: + with BuildLine() as bl: + l1 = Line((0, 0), (18 / 2, 0)) + l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL) + l3 = Line(l2 @ 1, (0, 8)) + mirror(about=Plane.YZ) + make_face() + extrude(amount=115/2, both=True, mode=Mode.SUBTRACT) + +show_object(p) + + +got_mass = p.part.volume*densa +want_mass = 797.15 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0102.py b/docs/assets/ttt/ttt-ppp0102.py index df1df56..9670314 100644 --- a/docs/assets/ttt/ttt-ppp0102.py +++ b/docs/assets/ttt/ttt-ppp0102.py @@ -1,49 +1,57 @@ -""" -Too Tall Toby Party Pack 01-02 Post Cap -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - - -# TTT Party Pack 01: PPP0102, mass(abs) = 43.09g -with BuildPart() as p: - with BuildSketch(Plane.XZ) as sk1: - Rectangle(49, 48 - 8, align=(Align.CENTER, Align.MIN)) - Rectangle(9, 48, align=(Align.CENTER, Align.MIN)) - with Locations((9 / 2, 40)): - Ellipse(20, 8) - split(bisect_by=Plane.YZ) - revolve(axis=Axis.Z) - - with BuildSketch(Plane.YZ.offset(-15)) as xc1: - with Locations((0, 40 / 2 - 17)): - Ellipse(10 / 2, 4 / 2) - with BuildLine(Plane.XZ) as l1: - CenterArc((-15, 40 / 2), 17, 90, 180) - sweep(path=l1) - - fillet(p.edges().filter_by(GeomType.CIRCLE, reverse=True).group_by(Axis.X)[0], 1) - - with BuildLine(mode=Mode.PRIVATE) as lc1: - PolarLine( - (42 / 2, 0), 37, 94, length_mode=LengthMode.VERTICAL - ) # construction line - - pts = [ - (0, 0), - (42 / 2, 0), - ((lc1.line @ 1).X, (lc1.line @ 1).Y), - (0, (lc1.line @ 1).Y), - ] - with BuildSketch(Plane.XZ) as sk2: - Polygon(*pts, align=None) - fillet(sk2.vertices().group_by(Axis.X)[1], 3) - revolve(axis=Axis.Z, mode=Mode.SUBTRACT) - -show(p) -print(f"\npart mass = {p.part.volume*densa:0.2f}") +""" +Too Tall Toby Party Pack 01-02 Post Cap +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + + +# TTT Party Pack 01: PPP0102, mass(abs) = 43.09g +with BuildPart() as p: + with BuildSketch(Plane.XZ) as sk1: + Rectangle(49, 48 - 8, align=(Align.CENTER, Align.MIN)) + Rectangle(9, 48, align=(Align.CENTER, Align.MIN)) + with Locations((9 / 2, 40)): + Ellipse(20, 8) + split(bisect_by=Plane.YZ) + revolve(axis=Axis.Z) + + with BuildSketch(Plane.YZ.offset(-15)) as xc1: + with Locations((0, 40 / 2 - 17)): + Ellipse(10 / 2, 4 / 2) + with BuildLine(Plane.XZ) as l1: + CenterArc((-15, 40 / 2), 17, 90, 180) + sweep(path=l1) + + fillet(p.edges().filter_by(GeomType.CIRCLE, reverse=True).group_by(Axis.X)[0], 1) + + with BuildLine(mode=Mode.PRIVATE) as lc1: + PolarLine( + (42 / 2, 0), 37, 94, length_mode=LengthMode.VERTICAL + ) # construction line + + pts = [ + (0, 0), + (42 / 2, 0), + ((lc1.line @ 1).X, (lc1.line @ 1).Y), + (0, (lc1.line @ 1).Y), + ] + with BuildSketch(Plane.XZ) as sk2: + Polygon(*pts, align=None) + fillet(sk2.vertices().group_by(Axis.X)[1], 3) + revolve(axis=Axis.Z, mode=Mode.SUBTRACT) + +show(p) + + +got_mass = p.part.volume*densc +want_mass = 43.09 +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=}' + diff --git a/docs/assets/ttt/ttt-ppp0103.py b/docs/assets/ttt/ttt-ppp0103.py index b021b9b..1465232 100644 --- a/docs/assets/ttt/ttt-ppp0103.py +++ b/docs/assets/ttt/ttt-ppp0103.py @@ -1,34 +1,40 @@ -""" -Too Tall Toby Party Pack 01-03 C Clamp Base -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - - -with BuildPart() as ppp0103: - with BuildSketch() as sk1: - RectangleRounded(34 * 2, 95, 18) - with Locations((0, -2)): - RectangleRounded((34 - 16) * 2, 95 - 18 - 14, 7, mode=Mode.SUBTRACT) - with Locations((-34 / 2, 0)): - Rectangle(34, 95, 0, mode=Mode.SUBTRACT) - extrude(amount=16) - with BuildSketch(Plane.XZ.offset(-95 / 2)) as cyl1: - with Locations((0, 16 / 2)): - Circle(16 / 2) - extrude(amount=18) - with BuildSketch(Plane.XZ.offset(95 / 2 - 14)) as cyl2: - with Locations((0, 16 / 2)): - Circle(16 / 2) - extrude(amount=23) - with Locations(Plane.XZ.offset(95 / 2 + 9)): - with Locations((0, 16 / 2)): - CounterSinkHole(5.5 / 2, 11.2 / 2, None, 90) - -show(ppp0103) -print(f"\npart mass = {ppp0103.part.volume*densb:0.2f}") +""" +Too Tall Toby Party Pack 01-03 C Clamp Base +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + + +with BuildPart() as ppp0103: + with BuildSketch() as sk1: + RectangleRounded(34 * 2, 95, 18) + with Locations((0, -2)): + RectangleRounded((34 - 16) * 2, 95 - 18 - 14, 7, mode=Mode.SUBTRACT) + with Locations((-34 / 2, 0)): + Rectangle(34, 95, 0, mode=Mode.SUBTRACT) + extrude(amount=16) + with BuildSketch(Plane.XZ.offset(-95 / 2)) as cyl1: + with Locations((0, 16 / 2)): + Circle(16 / 2) + extrude(amount=18) + with BuildSketch(Plane.XZ.offset(95 / 2 - 14)) as cyl2: + with Locations((0, 16 / 2)): + Circle(16 / 2) + extrude(amount=23) + with Locations(Plane.XZ.offset(95 / 2 + 9)): + with Locations((0, 16 / 2)): + CounterSinkHole(5.5 / 2, 11.2 / 2, None, 90) + +show(ppp0103) + +got_mass = ppp0103.part.volume*densb +want_mass = 96.13 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0104.py b/docs/assets/ttt/ttt-ppp0104.py index 685b483..88361fa 100644 --- a/docs/assets/ttt/ttt-ppp0104.py +++ b/docs/assets/ttt/ttt-ppp0104.py @@ -1,57 +1,64 @@ -""" -Too Tall Toby Party Pack 01-04 Angle Bracket -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -d1, d2, d3 = 38, 26, 16 -h1, h2, h3, h4 = 20, 8, 7, 23 -w1, w2, w3 = 80, 10, 5 -f1, f2, f3 = 4, 10, 5 -sloth1, sloth2 = 18, 12 -slotw1, slotw2 = 17, 14 - -with BuildPart() as p: - with BuildSketch() as s: - Circle(d1 / 2) - extrude(amount=h1) - with BuildSketch(Plane.XY.offset(h1)) as s2: - Circle(d2 / 2) - extrude(amount=h2) - with BuildSketch(Plane.YZ) as s3: - Rectangle(d1 + 15, h3, align=(Align.CENTER, Align.MIN)) - extrude(amount=w1 - d1 / 2) - # fillet workaround \/ - ped = p.part.edges().group_by(Axis.Z)[2].filter_by(GeomType.CIRCLE) - fillet(ped, f1) - with BuildSketch(Plane.YZ) as s3a: - Rectangle(d1 + 15, 15, align=(Align.CENTER, Align.MIN)) - Rectangle(d1, 15, mode=Mode.SUBTRACT, align=(Align.CENTER, Align.MIN)) - extrude(amount=w1 - d1 / 2, mode=Mode.SUBTRACT) - # end fillet workaround /\ - with BuildSketch() as s4: - Circle(d3 / 2) - extrude(amount=h1 + h2, mode=Mode.SUBTRACT) - with BuildSketch() as s5: - with Locations((w1 - d1 / 2 - w2 / 2, 0)): - Rectangle(w2, d1) - extrude(amount=-h4) - fillet(p.part.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[-1], f2) - fillet(p.part.edges().group_by(Axis.X)[-4].sort_by(Axis.Z)[-2], f3) - pln = Plane.YZ.offset(w1 - d1 / 2) - with BuildSketch(pln) as s6: - with Locations((0, -h4)): - SlotOverall(slotw1 * 2, sloth1, 90) - extrude(amount=-w3, mode=Mode.SUBTRACT) - with BuildSketch(pln) as s6b: - with Locations((0, -h4)): - SlotOverall(slotw2 * 2, sloth2, 90) - extrude(amount=-w2, mode=Mode.SUBTRACT) - -show(p) -print(f"\npart mass = {p.part.volume*densa:0.2f}") +""" +Too Tall Toby Party Pack 01-04 Angle Bracket +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +d1, d2, d3 = 38, 26, 16 +h1, h2, h3, h4 = 20, 8, 7, 23 +w1, w2, w3 = 80, 10, 5 +f1, f2, f3 = 4, 10, 5 +sloth1, sloth2 = 18, 12 +slotw1, slotw2 = 17, 14 + +with BuildPart() as p: + with BuildSketch() as s: + Circle(d1 / 2) + extrude(amount=h1) + with BuildSketch(Plane.XY.offset(h1)) as s2: + Circle(d2 / 2) + extrude(amount=h2) + with BuildSketch(Plane.YZ) as s3: + Rectangle(d1 + 15, h3, align=(Align.CENTER, Align.MIN)) + extrude(amount=w1 - d1 / 2) + # fillet workaround \/ + ped = p.part.edges().group_by(Axis.Z)[2].filter_by(GeomType.CIRCLE) + fillet(ped, f1) + with BuildSketch(Plane.YZ) as s3a: + Rectangle(d1 + 15, 15, align=(Align.CENTER, Align.MIN)) + Rectangle(d1, 15, mode=Mode.SUBTRACT, align=(Align.CENTER, Align.MIN)) + extrude(amount=w1 - d1 / 2, mode=Mode.SUBTRACT) + # end fillet workaround /\ + with BuildSketch() as s4: + Circle(d3 / 2) + extrude(amount=h1 + h2, mode=Mode.SUBTRACT) + with BuildSketch() as s5: + with Locations((w1 - d1 / 2 - w2 / 2, 0)): + Rectangle(w2, d1) + extrude(amount=-h4) + fillet(p.part.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[-1], f2) + fillet(p.part.edges().group_by(Axis.X)[-4].sort_by(Axis.Z)[-2], f3) + pln = Plane.YZ.offset(w1 - d1 / 2) + with BuildSketch(pln) as s6: + with Locations((0, -h4)): + SlotOverall(slotw1 * 2, sloth1, 90) + extrude(amount=-w3, mode=Mode.SUBTRACT) + with BuildSketch(pln) as s6b: + with Locations((0, -h4)): + SlotOverall(slotw2 * 2, sloth2, 90) + extrude(amount=-w2, mode=Mode.SUBTRACT) + +show(p) + + +got_mass = p.part.volume*densa +want_mass = 310 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0105.py b/docs/assets/ttt/ttt-ppp0105.py index bf5c020..f599736 100644 --- a/docs/assets/ttt/ttt-ppp0105.py +++ b/docs/assets/ttt/ttt-ppp0105.py @@ -1,30 +1,38 @@ -""" -Too Tall Toby Party Pack 01-05 Paste Sleeve -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as p: - with BuildSketch() as s: - SlotOverall(45, 38) - offset(amount=3) - with BuildSketch(Plane.XY.offset(133 - 30)) as s2: - SlotOverall(60, 4) - offset(amount=3) - loft() - - with BuildSketch() as s3: - SlotOverall(45, 38) - with BuildSketch(Plane.XY.offset(133 - 30)) as s4: - SlotOverall(60, 4) - loft(mode=Mode.SUBTRACT) - - extrude(p.part.faces().sort_by(Axis.Z)[0], amount=30) - -show(p) -print(f"\npart mass = {p.part.volume*densc:0.2f}") +""" +Too Tall Toby Party Pack 01-05 Paste Sleeve +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +with BuildPart() as p: + with BuildSketch() as s: + SlotOverall(45, 38) + offset(amount=3) + with BuildSketch(Plane.XY.offset(133 - 30)) as s2: + SlotOverall(60, 4) + offset(amount=3) + loft() + + with BuildSketch() as s3: + SlotOverall(45, 38) + with BuildSketch(Plane.XY.offset(133 - 30)) as s4: + SlotOverall(60, 4) + loft(mode=Mode.SUBTRACT) + + extrude(p.part.faces().sort_by(Axis.Z)[0], amount=30) + +show(p) + + +got_mass = p.part.volume*densc +want_mass = 57.08 +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=}' + diff --git a/docs/assets/ttt/ttt-ppp0106.py b/docs/assets/ttt/ttt-ppp0106.py index 38aef2a..abd6751 100644 --- a/docs/assets/ttt/ttt-ppp0106.py +++ b/docs/assets/ttt/ttt-ppp0106.py @@ -1,52 +1,58 @@ -""" -Too Tall Toby Party Pack 01-06 Bearing Jig -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -r1, r2, r3, r4, r5 = 30 / 2, 13 / 2, 12 / 2, 10, 6 # radii used -x1 = 44 # lengths used -y1, y2, y3, y4, y_tot = 36, 36 - 22 / 2, 22 / 2, 42, 69 # widths used - -with BuildSketch(Location((0, -r1, y3))) as sk_body: - with BuildLine() as l: - c1 = Line((r1, 0), (r1, y_tot), mode=Mode.PRIVATE) # construction line - m1 = Line((0, y_tot), (x1 / 2, y_tot)) - m2 = JernArc(m1 @ 1, m1 % 1, r4, -90 - 45) - 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) - 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) - # Keyway - with Locations((0, r1)): - Circle(r3, mode=Mode.SUBTRACT) - Rectangle(4, 3 + 6, align=(Align.CENTER, Align.MIN), mode=Mode.SUBTRACT) - -with BuildPart() as p: - Box(200, 200, 22) # Oversized plate - # Cylinder underneath - Cylinder(r1, y2, align=(Align.CENTER, Align.CENTER, Align.MAX)) - fillet(p.edges(Select.NEW), r5) # Weld together - extrude(sk_body.sketch, amount=-y1, mode=Mode.INTERSECT) # Cut to shape - # Remove slot - with Locations((0, y_tot - r1 - y4, 0)): - Box( - y_tot, - y_tot, - 10, - align=(Align.CENTER, Align.MIN, Align.CENTER), - mode=Mode.SUBTRACT, - ) - -show(p) -print(f"\npart mass = {p.part.volume*densa:0.2f}") -print(p.part.bounding_box().size) +""" +Too Tall Toby Party Pack 01-06 Bearing Jig +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +r1, r2, r3, r4, r5 = 30 / 2, 13 / 2, 12 / 2, 10, 6 # radii used +x1 = 44 # lengths used +y1, y2, y3, y4, y_tot = 36, 36 - 22 / 2, 22 / 2, 42, 69 # widths used + +with BuildSketch(Location((0, -r1, y3))) as sk_body: + with BuildLine() as l: + c1 = Line((r1, 0), (r1, y_tot), mode=Mode.PRIVATE) # construction line + m1 = Line((0, y_tot), (x1 / 2, y_tot)) + m2 = JernArc(m1 @ 1, m1 % 1, r4, -90 - 45) + m3 = IntersectingLine(m2 @ 1, m2 % 1, c1) + m4 = Line(m3 @ 1, (r1, r1)) + m5 = JernArc(m4 @ 1, m4 % 1, r1, -90) + 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) + # Keyway + with Locations((0, r1)): + Circle(r3, mode=Mode.SUBTRACT) + Rectangle(4, 3 + 6, align=(Align.CENTER, Align.MIN), mode=Mode.SUBTRACT) + +with BuildPart() as p: + Box(200, 200, 22) # Oversized plate + # Cylinder underneath + Cylinder(r1, y2, align=(Align.CENTER, Align.CENTER, Align.MAX)) + fillet(p.edges(Select.NEW), r5) # Weld together + extrude(sk_body.sketch, amount=-y1, mode=Mode.INTERSECT) # Cut to shape + # Remove slot + with Locations((0, y_tot - r1 - y4, 0)): + Box( + y_tot, + y_tot, + 10, + align=(Align.CENTER, Align.MIN, Align.CENTER), + mode=Mode.SUBTRACT, + ) + +show(p) + + +got_mass = p.part.volume*densa +want_mass = 328.02 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0107.py b/docs/assets/ttt/ttt-ppp0107.py index ca13804..1ddc680 100644 --- a/docs/assets/ttt/ttt-ppp0107.py +++ b/docs/assets/ttt/ttt-ppp0107.py @@ -1,52 +1,59 @@ -""" -Too Tall Toby Party Pack 01-07 Flanged Hub -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as p: - with BuildSketch() as s: - Circle(130 / 2) - extrude(amount=8) - with BuildSketch(Plane.XY.offset(8)) as s2: - Circle(84 / 2) - extrude(amount=25 - 8) - with BuildSketch(Plane.XY.offset(25)) as s3: - Circle(35 / 2) - extrude(amount=52 - 25) - with BuildSketch() as s4: - Circle(73 / 2) - extrude(amount=18, mode=Mode.SUBTRACT) - pln2 = p.part.faces().sort_by(Axis.Z)[5] - with BuildSketch(Plane.XY.offset(52)) as s5: - Circle(20 / 2) - extrude(amount=-52, mode=Mode.SUBTRACT) - fillet( - p.part.edges() - .filter_by(GeomType.CIRCLE) - .sort_by(Axis.Z)[2:-2] - .sort_by(SortBy.RADIUS)[1:], - 3, - ) - pln = Plane(pln2) - pln.origin = pln.origin + Vector(20 / 2, 0, 0) - pln = pln.rotated((0, 45, 0)) - pln = pln.offset(-25 + 3 + 0.10) - with BuildSketch(pln) as s6: - Rectangle((73 - 35) / 2 * 1.414 + 5, 3) - zz = extrude(amount=15, taper=-20 / 2, mode=Mode.PRIVATE) - zz2 = split(zz, bisect_by=Plane.XY.offset(25), mode=Mode.PRIVATE) - zz3 = split(zz2, bisect_by=Plane.YZ.offset(35 / 2 - 1), mode=Mode.PRIVATE) - with PolarLocations(0, 3): - add(zz3) - with Locations(Plane.XY.offset(8)): - with PolarLocations(107.95 / 2, 6): - CounterBoreHole(6 / 2, 13 / 2, 4) - -show(p) -print(f"\npart mass = {p.part.volume*densb:0.2f}") +""" +Too Tall Toby Party Pack 01-07 Flanged Hub +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +with BuildPart() as p: + with BuildSketch() as s: + Circle(130 / 2) + extrude(amount=8) + with BuildSketch(Plane.XY.offset(8)) as s2: + Circle(84 / 2) + extrude(amount=25 - 8) + with BuildSketch(Plane.XY.offset(25)) as s3: + Circle(35 / 2) + extrude(amount=52 - 25) + with BuildSketch() as s4: + Circle(73 / 2) + extrude(amount=18, mode=Mode.SUBTRACT) + pln2 = p.part.faces().sort_by(Axis.Z)[5] + with BuildSketch(Plane.XY.offset(52)) as s5: + Circle(20 / 2) + extrude(amount=-52, mode=Mode.SUBTRACT) + fillet( + p.part.edges() + .filter_by(GeomType.CIRCLE) + .sort_by(Axis.Z)[2:-2] + .sort_by(SortBy.RADIUS)[1:], + 3, + ) + pln = Plane(pln2) + pln.origin = pln.origin + Vector(20 / 2, 0, 0) + pln = pln.rotated((0, 45, 0)) + pln = pln.offset(-25 + 3 + 0.10) + with BuildSketch(pln) as s6: + Rectangle((73 - 35) / 2 * 1.414 + 5, 3) + zz = extrude(amount=15, taper=-20 / 2, mode=Mode.PRIVATE) + zz2 = split(zz, bisect_by=Plane.XY.offset(25), mode=Mode.PRIVATE) + zz3 = split(zz2, bisect_by=Plane.YZ.offset(35 / 2 - 1), mode=Mode.PRIVATE) + with PolarLocations(0, 3): + add(zz3) + with Locations(Plane.XY.offset(8)): + with PolarLocations(107.95 / 2, 6): + CounterBoreHole(6 / 2, 13 / 2, 4) + +show(p) + + +got_mass = p.part.volume*densb +want_mass = 372.99 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0108.py b/docs/assets/ttt/ttt-ppp0108.py index e79d1f9..60280c5 100644 --- a/docs/assets/ttt/ttt-ppp0108.py +++ b/docs/assets/ttt/ttt-ppp0108.py @@ -1,47 +1,54 @@ -""" -Too Tall Toby Party Pack 01-08 Tie Plate -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as p: - with BuildSketch() as s1: - Rectangle(188 / 2 - 33, 162, align=(Align.MIN, Align.CENTER)) - with Locations((188 / 2 - 33, 0)): - SlotOverall(190, 33 * 2, rotation=90) - mirror(about=Plane.YZ) - with GridLocations(188 - 2 * 33, 190 - 2 * 33, 2, 2): - Circle(29 / 2, mode=Mode.SUBTRACT) - Circle(84 / 2, mode=Mode.SUBTRACT) - extrude(amount=16) - - with BuildPart() as p2: - with BuildSketch(Plane.XZ) as s2: - with BuildLine() as l1: - l1 = Polyline( - (222 / 2 + 14 - 40 - 40, 0), - (222 / 2 + 14 - 40, -35 + 16), - (222 / 2 + 14, -35 + 16), - (222 / 2 + 14, -35 + 16 + 30), - (222 / 2 + 14 - 40 - 40, -35 + 16 + 30), - close=True, - ) - make_face() - with Locations((222 / 2, -35 + 16 + 14)): - Circle(11 / 2, mode=Mode.SUBTRACT) - extrude(amount=20 / 2, both=True) - with BuildSketch() as s3: - with Locations(l1 @ 0): - Rectangle(40 + 40, 8, align=(Align.MIN, Align.CENTER)) - with Locations((40, 0)): - Rectangle(40, 20, align=(Align.MIN, Align.CENTER)) - extrude(amount=30, both=True, mode=Mode.INTERSECT) - mirror(about=Plane.YZ) - -show(p) -print(f"\npart mass = {p.part.volume*densa:0.2f}") +""" +Too Tall Toby Party Pack 01-08 Tie Plate +""" + +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +with BuildPart() as p: + with BuildSketch() as s1: + Rectangle(188 / 2 - 33, 162, align=(Align.MIN, Align.CENTER)) + with Locations((188 / 2 - 33, 0)): + SlotOverall(190, 33 * 2, rotation=90) + mirror(about=Plane.YZ) + with GridLocations(188 - 2 * 33, 190 - 2 * 33, 2, 2): + Circle(29 / 2, mode=Mode.SUBTRACT) + Circle(84 / 2, mode=Mode.SUBTRACT) + extrude(amount=16) + + with BuildPart() as p2: + with BuildSketch(Plane.XZ) as s2: + with BuildLine() as l1: + l1 = Polyline( + (222 / 2 + 14 - 40 - 40, 0), + (222 / 2 + 14 - 40, -35 + 16), + (222 / 2 + 14, -35 + 16), + (222 / 2 + 14, -35 + 16 + 30), + (222 / 2 + 14 - 40 - 40, -35 + 16 + 30), + close=True, + ) + make_face() + with Locations((222 / 2, -35 + 16 + 14)): + Circle(11 / 2, mode=Mode.SUBTRACT) + extrude(amount=20 / 2, both=True) + with BuildSketch() as s3: + with Locations(l1 @ 0): + Rectangle(40 + 40, 8, align=(Align.MIN, Align.CENTER)) + with Locations((40, 0)): + Rectangle(40, 20, align=(Align.MIN, Align.CENTER)) + extrude(amount=30, both=True, mode=Mode.INTERSECT) + mirror(about=Plane.YZ) + +show(p) + + +got_mass = p.part.volume*densa +want_mass = 3387.06 +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=}' diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py index 3426846..49863af 100644 --- a/docs/assets/ttt/ttt-ppp0109.py +++ b/docs/assets/ttt/ttt-ppp0109.py @@ -1,56 +1,63 @@ -""" -Too Tall Toby Party Pack 01-09 Corner Tie -""" - -from math import sqrt -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as ppp109: - with BuildSketch() as one: - Rectangle(69, 75, align=(Align.MAX, Align.CENTER)) - fillet(one.vertices().group_by(Axis.X)[0], 17) - extrude(amount=13) - centers = [ - arc.arc_center - for arc in ppp109.edges().filter_by(GeomType.CIRCLE).group_by(Axis.Z)[-1] - ] - with Locations(*centers): - CounterBoreHole(radius=8 / 2, counter_bore_radius=15 / 2, counter_bore_depth=4) - - with BuildSketch(Plane.YZ) as two: - with Locations((0, 45)): - Circle(15) - with BuildLine() as bl: - c = Line((75 / 2, 0), (75 / 2, 60), mode=Mode.PRIVATE) - u = two.edge().find_tangent(75 / 2 + 90)[0] # where is the slope 75/2? - l1 = IntersectingLine( - two.edge().position_at(u), -two.edge().tangent_at(u), other=c - ) - Line(l1 @ 0, (0, 45)) - Polyline((0, 0), c @ 0, l1 @ 1) - mirror(about=Plane.YZ) - make_face() - with Locations((0, 45)): - Circle(12 / 2, mode=Mode.SUBTRACT) - extrude(amount=-13) - - with BuildSketch(Plane((0, 0, 0), x_dir=(1, 0, 0), z_dir=(1, 0, 1))) as three: - Rectangle(45 * 2 / sqrt(2) - 37.5, 75, align=(Align.MIN, Align.CENTER)) - with Locations(three.edges().sort_by(Axis.X)[-1].center()): - Circle(37.5) - Circle(33 / 2, mode=Mode.SUBTRACT) - 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) - - -show(ppp109) -print(f"\npart mass = {ppp109.part.volume*densb:0.2f}") +""" +Too Tall Toby Party Pack 01-09 Corner Tie +""" + +from math import sqrt +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +with BuildPart() as ppp109: + with BuildSketch() as one: + Rectangle(69, 75, align=(Align.MAX, Align.CENTER)) + fillet(one.vertices().group_by(Axis.X)[0], 17) + extrude(amount=13) + centers = [ + arc.arc_center + for arc in ppp109.edges().filter_by(GeomType.CIRCLE).group_by(Axis.Z)[-1] + ] + with Locations(*centers): + CounterBoreHole(radius=8 / 2, counter_bore_radius=15 / 2, counter_bore_depth=4) + + with BuildSketch(Plane.YZ) as two: + with Locations((0, 45)): + Circle(15) + with BuildLine() as bl: + c = Line((75 / 2, 0), (75 / 2, 60), mode=Mode.PRIVATE) + u = two.edge().find_tangent(75 / 2 + 90)[0] # where is the slope 75/2? + l1 = IntersectingLine( + two.edge().position_at(u), -two.edge().tangent_at(u), other=c + ) + Line(l1 @ 0, (0, 45)) + Polyline((0, 0), c @ 0, l1 @ 1) + mirror(about=Plane.YZ) + make_face() + with Locations((0, 45)): + Circle(12 / 2, mode=Mode.SUBTRACT) + extrude(amount=-13) + + with BuildSketch(Plane((0, 0, 0), x_dir=(1, 0, 0), z_dir=(1, 0, 1))) as three: + Rectangle(45 * 2 / sqrt(2) - 37.5, 75, align=(Align.MIN, Align.CENTER)) + with Locations(three.edges().sort_by(Axis.X)[-1].center()): + Circle(37.5) + Circle(33 / 2, mode=Mode.SUBTRACT) + 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) + 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 +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=}" diff --git a/docs/assets/ttt/ttt-ppp0110.py b/docs/assets/ttt/ttt-ppp0110.py index f62c56f..9b3458f 100644 --- a/docs/assets/ttt/ttt-ppp0110.py +++ b/docs/assets/ttt/ttt-ppp0110.py @@ -1,52 +1,59 @@ -""" -Too Tall Toby Party Pack 01-10 Light Cap -""" - -from build123d import * -from ocp_vscode import * - -densa = 7800 / 1e6 # carbon steel density g/mm^3 -densb = 2700 / 1e6 # aluminum alloy -densc = 1020 / 1e6 # ABS - -with BuildPart() as p: - with BuildSketch(Plane.YZ.rotated((90, 0, 0))) as s: - with BuildLine() as l: - n2 = JernArc((0, 46), (1, 0), 40, -90) - n3 = Line(n2 @ 1, n2 @ 0) - make_face() - - with BuildLine() as l2: - m1 = Line((0, 0), (42, 0)) - m2 = Line((0, 0.01), (42, 0.01)) - m3 = Line(m1 @ 0, m2 @ 0) - m4 = Line(m1 @ 1, m2 @ 1) - make_face() - make_hull() - extrude(amount=100 / 2) - revolve(s.sketch, axis=Axis.Y.reverse(), revolution_arc=-90) - mirror(about=Plane(p.part.faces().sort_by(Axis.X)[-1])) - mirror(about=Plane.XY) - -with BuildPart() as p2: - add(p.part) - offset(amount=-8) - -with BuildPart() as pzzz: - add(p2.part) - split(bisect_by=Plane.XZ.offset(46 - 16), keep=Keep.BOTTOM) - fillet(pzzz.part.faces().filter_by(Axis.Y).sort_by(Axis.Y)[0].edges(), 12) - -with BuildPart() as p3: - with BuildSketch(Plane.XZ) as s2: - add(p.part.faces().sort_by(Axis.Y)[-1]) - offset(amount=-8) - loft([p2.part.faces().sort_by(Axis.Y)[-5], s2.sketch.faces()[0]]) - -with BuildPart() as ppp0110: - add(p.part) - add(pzzz.part, mode=Mode.SUBTRACT) - add(p3.part, mode=Mode.SUBTRACT) - -show(ppp0110) -print(f"\npart mass = {ppp0110.part.volume*densc:0.2f}") +""" +Too Tall Toby Party Pack 01-10 Light Cap +""" + +from math import sqrt, asin, pi +from build123d import * +from ocp_vscode import * + +densa = 7800 / 1e6 # carbon steel density g/mm^3 +densb = 2700 / 1e6 # aluminum alloy +densc = 1020 / 1e6 # ABS + +# The smaller cross-section is defined as having R40, height 46, +# and base width 84, so clearly it's not entirely a half-circle or +# similar; the base's extreme points need to connect via tangents +# to the R40 arc centered 6mm above the baseline. +# +# Compute the angle of the tangent line (working with the +# left/negativeX side, given symmetry) by observing the tangent +# point (T), the circle's center (O), and the baseline's edge (P) +# form a right triangle, so: + +OT=40 +OP=sqrt((-84/2)**2+(-6)**2) +TP=sqrt(OP**2-40**2) +OPT_degrees = asin(OT/OP) * 180/pi +# Correct for the fact that OP isn't horizontal. +OP_to_X_axis_degrees = asin(6/OP) * 180/pi +left_tangent_degrees = OPT_degrees + OP_to_X_axis_degrees +left_tangent_length = TP +with BuildPart() as outer: + with BuildSketch(Plane.XZ) as sk: + with BuildLine(): + l1 = PolarLine(start=(-84/2, 0), length=left_tangent_length, angle=left_tangent_degrees) + l2 = TangentArc(l1@1, (0, 46), tangent=l1%1) + l3 = offset(amount=-8, side=Side.RIGHT, closed=False, mode=Mode.ADD) + l4 = Line(l1@0, l3@1) + l5 = Line(l3@0, l2@1) + l6 = Line(l3@0, (0, 46-16)) + l7 = IntersectingLine(start=l6@1, direction=(-1,0), other=l3) + make_face() + revolve(axis=Axis.Z) +sk = sk.sketch & Plane.XZ*Rectangle(1000, 1000, align=[Align.CENTER, Align.MIN]) +positive_Z = Box(100, 100, 100, align=[Align.CENTER, Align.MIN, Align.MIN]) +p = outer.part & positive_Z +cross_section = sk + mirror(sk, about=Plane.YZ) +p += extrude(cross_section, amount=50) +p += mirror(p, about=Plane.XZ.offset(50)) +p += fillet(p.edges().filter_by(GeomType.LINE).filter_by(Axis.Y).group_by(Axis.Z)[-1], radius=8) +ppp0110 = p + +got_mass = ppp0110.volume*densc +want_mass = 211.30 +tolerance = 1 +delta = abs(got_mass - want_mass) +print(f"Mass: {got_mass:0.1f} g") +assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}' + +show(ppp0110) 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/cheat_sheet.rst b/docs/cheat_sheet.rst index b310a08..8bd0d86 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -15,7 +15,11 @@ Cheat Sheet .. grid-item-card:: 1D - BuildLine + | :class:`~objects_curve.Airfoil` + | :class:`~objects_curve.ArcArcTangentArc` + | :class:`~objects_curve.ArcArcTangentLine` | :class:`~objects_curve.Bezier` + | :class:`~objects_curve.BlendCurve` | :class:`~objects_curve.CenterArc` | :class:`~objects_curve.DoubleTangentArc` | :class:`~objects_curve.EllipticalCenterArc` @@ -24,6 +28,8 @@ Cheat Sheet | :class:`~objects_curve.IntersectingLine` | :class:`~objects_curve.JernArc` | :class:`~objects_curve.Line` + | :class:`~objects_curve.PointArcTangentArc` + | :class:`~objects_curve.PointArcTangentLine` | :class:`~objects_curve.PolarLine` | :class:`~objects_curve.Polyline` | :class:`~objects_curve.RadiusArc` @@ -75,6 +81,7 @@ Cheat Sheet | :func:`~operations_generic.bounding_box` | :func:`~operations_generic.mirror` | :func:`~operations_generic.offset` + | :func:`~operations_generic.project` | :func:`~operations_generic.scale` | :func:`~operations_generic.split` @@ -88,6 +95,7 @@ Cheat Sheet | :func:`~operations_sketch.make_hull` | :func:`~operations_generic.mirror` | :func:`~operations_generic.offset` + | :func:`~operations_generic.project` | :func:`~operations_generic.scale` | :func:`~operations_generic.split` | :func:`~operations_generic.sweep` @@ -97,12 +105,14 @@ Cheat Sheet | :func:`~operations_generic.add` | :func:`~operations_generic.chamfer` + | :func:`~operations_part.draft` | :func:`~operations_part.extrude` | :func:`~operations_generic.fillet` | :func:`~operations_part.loft` | :func:`~operations_part.make_brake_formed` | :func:`~operations_generic.mirror` | :func:`~operations_generic.offset` + | :func:`~operations_generic.project` | :func:`~operations_part.revolve` | :func:`~operations_generic.scale` | :func:`~operations_part.section` @@ -223,15 +233,19 @@ Cheat Sheet +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.CenterOf` | GEOMETRY, MASS, BOUNDING_BOX | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.FontStyle` | REGULAR, BOLD, ITALIC | + | :class:`~build_enums.Extrinsic` | XYZ, XZY, YZX, YXZ, ZXY, ZYX, XYX, XZX, YZY, YXY, ZXZ, ZYZ | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.FontStyle` | REGULAR, BOLD, BOLDITALIC, ITALIC | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.FrameMethod` | CORRECTED, FRENET | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.GeomType` | BEZIER, BSPLINE, CIRCLE, CONE, CYLINDER, ELLIPSE, EXTRUSION, HYPERBOLA, LINE, OFFSET, OTHER, PARABOLA, PLANE, REVOLUTION, SPHERE, TORUS | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Intrinsic` | XYZ, XZY, YZX, YXZ, ZXY, ZYX, XYX, XZX, YZY, YXY, ZXZ, ZYZ | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.HeadType` | CURVED, FILLETED, STRAIGHT | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Keep` | TOP, BOTTOM, BOTH, INSIDE, OUTSIDE | + | :class:`~build_enums.Keep` | ALL, TOP, BOTTOM, BOTH, INSIDE, OUTSIDE | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.Kind` | ARC, INTERSECTION, TANGENT | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ @@ -249,12 +263,14 @@ Cheat Sheet +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.PrecisionMode` | LEAST, AVERAGE, GREATEST, SESSION | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Select` | ALL, LAST | + | :class:`~build_enums.Select` | ALL, LAST, NEW | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.Side` | BOTH, LEFT, RIGHT | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.SortBy` | LENGTH, RADIUS, AREA, VOLUME, DISTANCE | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.TextAlign` | BOTTOM, CENTER, LEFT, RIGHT, TOP, TOPFIRSTLINE | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.Transition` | RIGHT, ROUND, TRANSFORMED | +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ | :class:`~build_enums.Unit` | MC, MM, CM, M, IN, FT | 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 55c7cd2..32cc77d 100644 --- a/docs/examples_1.rst +++ b/docs/examples_1.rst @@ -1,9 +1,9 @@ ####################### The build123d Examples ####################### -.. |siren| replace:: 🚨 +.. |siren| replace:: 🚨 .. |Builder| replace:: 🔨 -.. |Algebra| replace:: ✏️ +.. |Algebra| replace:: ✏️ Overview -------------------------------- @@ -24,21 +24,36 @@ Most of the examples show the builder and algebra modes. :link: examples-benchy :link-type: ref - .. grid-item-card:: Circuit Board With Holes |Builder| |Algebra| - :img-top: assets/examples/thumbnail_circuit_board_01.png + .. grid-item-card:: Bicycle Tire |Builder| + :img-top: assets/examples/bicycle_tire.png + :link: examples-bicycle_tire + :link-type: ref + + .. grid-item-card:: Canadian Flag Blowing in The Wind |Builder| |Algebra| + :img-top: assets/examples/example_canadian_flag_01.png :link: examples-canadian_flag :link-type: ref - - .. grid-item-card:: Canadian Flag Blowing in The Wind |Builder| |Algebra| - :img-top: assets/examples/example_canadian_flag_01.png + + .. grid-item-card:: Cast Bearing Unit |Builder| + :img-top: assets/examples/cast_bearing_unit.png + :link: examples-cast_bearing_unit + :link-type: ref + + .. grid-item-card:: Circuit Board With Holes |Builder| |Algebra| + :img-top: assets/examples/thumbnail_circuit_board_01.png :link: examples-circuit_board :link-type: ref - .. grid-item-card:: Clock Face |Builder| |Algebra| + .. grid-item-card:: Clock Face |Builder| |Algebra| :img-top: assets/examples/clock_face.png :link: clock_face :link-type: ref + .. grid-item-card:: Fast Grid Holes |Algebra| + :img-top: assets/examples/fast_grid_holes.png + :link: fast_grid_holes + :link-type: ref + .. grid-item-card:: Handle |Builder| |Algebra| :img-top: assets/examples/handle.png :link: handle @@ -58,43 +73,48 @@ Most of the examples show the builder and algebra modes. :img-top: assets/examples/thumbnail_build123d_logo_01.png :link: examples-build123d_logo :link-type: ref - - .. grid-item-card:: Maker Coin |Builder| + + .. grid-item-card:: Maker Coin |Builder| :img-top: assets/examples/maker_coin.png :link: maker_coin :link-type: ref - .. grid-item-card:: Multi-Sketch Loft |Builder| |Algebra| + .. grid-item-card:: Multi-Sketch Loft |Builder| |Algebra| :img-top: assets/examples/loft.png :link: multi_sketch_loft :link-type: ref - .. grid-item-card:: Peg Board J Hook |Builder| |Algebra| + .. grid-item-card:: Peg Board J Hook |Builder| |Algebra| :img-top: assets/examples/peg_board_hook.png :link: peg_board_hook :link-type: ref - .. grid-item-card:: Platonic Solids |Algebra| + .. grid-item-card:: Platonic Solids |Algebra| :img-top: assets/examples/platonic_solids.png :link: platonic_solids :link-type: ref - .. grid-item-card:: Playing Cards |Builder| + .. grid-item-card:: Playing Cards |Builder| :img-top: assets/examples/playing_cards.png :link: playing_cards :link-type: ref - .. grid-item-card:: Stud Wall |Algebra| + .. grid-item-card:: Stud Wall |Algebra| :img-top: assets/examples/stud_wall.png :link: stud_wall :link-type: ref - .. grid-item-card:: Tea Cup |Builder| |Algebra| + .. grid-item-card:: Tea Cup |Builder| |Algebra| :img-top: assets/examples/tea_cup.png :link: tea_cup :link-type: ref - .. grid-item-card:: Vase |Builder| |Algebra| + .. grid-item-card:: Toy Truck |Builder| + :img-top: assets/examples/toy_truck.png + :link: toy_truck + :link-type: ref + + .. grid-item-card:: Vase |Builder| |Algebra| :img-top: assets/examples/vase.png :link: vase :link-type: ref @@ -106,7 +126,7 @@ Most of the examples show the builder and algebra modes. :img-top: assets/examples/thumbnail_{name-of-your-example}_01.{extension} :link: examples-{name-of-your-example} :link-type: ref - + .. ---------------------------------------------------------------------------------------------- .. Details Section .. ---------------------------------------------------------------------------------------------- @@ -119,10 +139,13 @@ Benchy :align: center -The Benchy examples shows how to import a STL model as a `Solid` object with the class `Mesher` and +The Benchy examples shows how to import a STL model as a `Solid` object with the class `Mesher` and modify it by replacing chimney with a BREP version. -.. note +- Benchy STL model: :download:`low_poly_benchy.stl <../examples/low_poly_benchy.stl>` + + +.. note *Attribution:* The low-poly-benchy used in this example is by `reddaugherty`, see @@ -138,14 +161,34 @@ modify it by replacing chimney with a BREP version. .. image:: assets/examples/example_benchy_03.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/benchy.py + :language: build123d :start-after: [Code] :end-before: [End] .. ---------------------------------------------------------------------------------------------- +.. _examples-bicycle_tire: + +Bicycle Tire +-------------------------------- +.. image:: assets/examples/bicycle_tire.png + :align: center + +This example demonstrates how to model a realistic bicycle tire with a +patterned tread using build123d. The key concept showcased here is the +use of wrap_faces to project 2D planar geometry onto a curved 3D +surface. + +.. dropdown:: |Builder| Reference Implementation (Builder Mode) + + .. literalinclude:: ../examples/bicycle_tire.py + :language: build123d + :start-after: [Code] + :end-before: [End] + .. _examples-build123d_logo: Former build123d Logo @@ -156,23 +199,43 @@ Former build123d Logo This example creates the former build123d logo (new logo was created in the end of 2023). -Using text and lines to create the first build123d logo. +Using text and lines to create the first build123d logo. The builder mode example also generates the SVG file `logo.svg`. -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) + +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/build123d_logo_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] +.. _examples-cast_bearing_unit: + +Cast Bearing Unit +----------------- +.. image:: assets/examples/cast_bearing_unit.png + :align: center + +This example demonstrates the creation of a castable flanged bearing housing +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] + .. _examples-canadian_flag: Canadian Flag Blowing in The Wind @@ -196,18 +259,20 @@ This example also demonstrates building complex lines that snap to existing feat :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) + +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/canadian_flag_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] - + .. _examples-circuit_board: @@ -232,15 +297,17 @@ This example demonstrates placing holes around a part. :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) + +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/circuit_board_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -252,26 +319,55 @@ Clock Face .. image:: assets/examples/clock_face.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/clock.py + :language: build123d :start-after: [Code] :end-before: [End] -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/clock_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] -The Python code utilizes the build123d library to create a 3D model of a clock face. -It defines a minute indicator with arcs and lines, applying fillets, and then -integrates it into the clock face sketch. The clock face includes a circular outline, -hour labels, and slots at specified positions. The resulting 3D model represents +The Python code utilizes the build123d library to create a 3D model of a clock face. +It defines a minute indicator with arcs and lines, applying fillets, and then +integrates it into the clock face sketch. The clock face includes a circular outline, +hour labels, and slots at specified positions. The resulting 3D model represents a detailed and visually appealing clock design. :class:`~build_common.PolarLocations` are used to position features on the clock face. +.. _fast_grid_holes: + +Fast Grid Holes +--------------- +.. image:: assets/examples/fast_grid_holes.png + :align: center + +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) + + .. literalinclude:: ../examples/fast_grid_holes.py + :language: build123d + :start-after: [Code] + :end-before: [End] + +This example demonstrates an efficient approach to creating a large number of holes +(625 in this case) in a planar part using build123d. + +Instead of modeling and subtracting 3D solids for each hole—which is computationally +expensive—this method constructs a 2D Face from an outer perimeter wire and a list of +hole wires. The entire face is then extruded in a single operation to form the final +3D object. This approach significantly reduces modeling time and complexity. + +The hexagonal hole pattern is generated using HexLocations, and each location is +populated with a hexagonal wire. These wires are passed directly to the Face constructor +as holes. On a typical Linux laptop, this script completes in approximately 1.02 seconds, +compared to substantially longer runtimes for boolean subtraction of individual holes in 3D. + .. _handle: @@ -280,15 +376,17 @@ Handle .. image:: assets/examples/handle.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/handle.py + :language: build123d :start-after: [Code] :end-before: [End] -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/handle_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -301,15 +399,17 @@ Heat Exchanger .. image:: assets/examples/heat_exchanger.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/heat_exchanger_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -325,15 +425,17 @@ Key Cap .. image:: assets/examples/key_cap.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/key_cap_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -350,16 +452,17 @@ Maker Coin This example creates the maker coin as defined by Angus on the Maker's Muse YouTube channel. There are two key features: -#. the use of :class:`~objects_curve.DoubleTangentArc` to create a smooth +#. the use of :class:`~objects_curve.DoubleTangentArc` to create a smooth transition from the central dish to the outside arc, and #. embossing the text into the top of the coin not just as a simple extrude but from a projection which results in text with even depth. -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/maker_coin.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -375,15 +478,17 @@ Multi-Sketch Loft This example demonstrates lofting a set of sketches, selecting the top and bottom by type, and shelling. -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/loft.py + :language: build123d :start-after: [Code] :end-before: [End] -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/loft_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -395,22 +500,24 @@ Peg Board Hook .. image:: assets/examples/peg_board_hook.png :align: center -This script creates a a J-shaped pegboard hook. These hooks are commonly used for -organizing tools in garages, workshops, or other spaces where tools and equipment +This script creates a a J-shaped pegboard hook. These hooks are commonly used for +organizing tools in garages, workshops, or other spaces where tools and equipment need to be stored neatly and accessibly. The hook is created by defining a complex path and then sweeping it to define the hook. The sides of the hook are flattened to aid 3D printing. -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/pegboard_j_hook.py + :language: build123d :start-after: [Code] :end-before: [End] -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/pegboard_j_hook_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -424,19 +531,20 @@ Platonic Solids This example creates a custom Part object PlatonicSolid. -Platonic solids are five three-dimensional shapes that are highly symmetrical, -known since antiquity and named after the ancient Greek philosopher Plato. -These solids are unique because their faces are congruent regular polygons, -with the same number of faces meeting at each vertex. The five Platonic solids -are the tetrahedron (4 triangular faces), cube (6 square faces), octahedron -(8 triangular faces), dodecahedron (12 pentagonal faces), and icosahedron -(20 triangular faces). Each solid represents a unique way in which identical -polygons can be arranged in three dimensions to form a convex polyhedron, +Platonic solids are five three-dimensional shapes that are highly symmetrical, +known since antiquity and named after the ancient Greek philosopher Plato. +These solids are unique because their faces are congruent regular polygons, +with the same number of faces meeting at each vertex. The five Platonic solids +are the tetrahedron (4 triangular faces), cube (6 square faces), octahedron +(8 triangular faces), dodecahedron (12 pentagonal faces), and icosahedron +(20 triangular faces). Each solid represents a unique way in which identical +polygons can be arranged in three dimensions to form a convex polyhedron, embodying ideals of symmetry and balance. -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/platonic_solids.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -447,14 +555,15 @@ Playing Cards .. image:: assets/examples/playing_cards.png :align: center -This example creates a customs Sketch objects: Club, Spade, Heart, Diamond, -and PlayingCard in addition to a two part playing card box which has suit -cutouts in the lid. The four suits are created with Bézier curves that were -imported as code from an SVG file and modified to the code found here. +This example creates a customs Sketch objects: Club, Spade, Heart, Diamond, +and PlayingCard in addition to a two part playing card box which has suit +cutouts in the lid. The four suits are created with Bézier curves that were +imported as code from an SVG file and modified to the code found here. -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/playing_cards.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -465,18 +574,19 @@ Stud Wall .. image:: assets/examples/stud_wall.png :align: center -This example demonstrates creatings custom `Part` objects and putting them into +This example demonstrates creating custom `Part` objects and putting them into assemblies. The custom object is a `Stud` used in the building industry while the assembly is a `StudWall` created from copies of `Stud` objects for efficiency. Both the `Stud` and `StudWall` objects use `RigidJoints` to define snap points which -are used to position all of objects. +are used to position all of objects. -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/stud_wall.py + :language: build123d :start-after: [Code] :end-before: [End] - + .. _tea_cup: Tea Cup @@ -484,34 +594,61 @@ Tea Cup .. image:: assets/examples/tea_cup.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. 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) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/tea_cup_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] -This example demonstrates the creation a tea cup, which serves as an example of +This example demonstrates the creation a tea cup, which serves as an example of constructing complex, non-flat geometrical shapes programmatically. The tea cup model involves several CAD techniques, such as: -* Revolve Operations: There is 1 occurrence of a revolve operation. This is used - to create the main body of the tea cup by revolving a profile around an axis, +* Revolve Operations: There is 1 occurrence of a revolve operation. This is used + to create the main body of the tea cup by revolving a profile around an axis, a common technique for generating symmetrical objects like cups. * Sweep Operations: There are 2 occurrences of sweep operations. The handle are created by sweeping a profile along a path to generate non-planar surfaces. * Offset/Shell Operations: the bowl of the cup is hollowed out with the offset - operation leaving the top open. -* Fillet Operations: There is 1 occurrence of a fillet operation which is used to - round the edges for aesthetic improvement and to mimic real-world objects more + operation leaving the top open. +* Fillet Operations: There is 1 occurrence of a fillet operation which is used to + round the edges for aesthetic improvement and to mimic real-world objects more closely. + +.. _toy_truck: + +Toy Truck +--------- +.. image:: assets/examples/toy_truck.png + :align: center + +.. image:: assets/examples/toy_truck_picture.jpg + :align: center + +.. dropdown:: |Builder| Reference Implementation (Builder Mode) + + .. literalinclude:: ../examples/toy_truck.py + :language: build123d + :start-after: [Code] + :end-before: [End] + +This example demonstrates how to design a toy truck using BuildPart and +BuildSketch in Builder mode. The model includes a detailed body, cab, grill, +and bumper, showcasing techniques like sketch reuse, symmetry, tapered +extrusions, selective filleting, and the use of joints for part assembly. +Ideal for learning complex part construction and hierarchical modeling in +build123d. + .. _vase: Vase @@ -519,37 +656,39 @@ Vase .. image:: assets/examples/vase.png :align: center -.. dropdown:: |Builder| Reference Implementation (Builder Mode) +.. dropdown:: |Builder| Reference Implementation (Builder Mode) .. literalinclude:: ../examples/vase.py + :language: build123d :start-after: [Code] :end-before: [End] -.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) +.. dropdown:: |Algebra| Reference Implementation (Algebra Mode) .. literalinclude:: ../examples/vase_algebra.py + :language: build123d :start-after: [Code] :end-before: [End] -This example demonstrates the build123d techniques involving the creation of a vase. -Specifically, it showcases the processes of revolving a sketch, shelling -(creating a hollow object by removing material from its interior), and -selecting edges by position range and type for the application of fillets +This example demonstrates the build123d techniques involving the creation of a vase. +Specifically, it showcases the processes of revolving a sketch, shelling +(creating a hollow object by removing material from its interior), and +selecting edges by position range and type for the application of fillets (rounding off the edges). -* Sketching: Drawing a 2D profile or outline that represents the side view of +* Sketching: Drawing a 2D profile or outline that represents the side view of the vase. -* Revolving: Rotating the sketch around an axis to create a 3D object. This +* Revolving: Rotating the sketch around an axis to create a 3D object. This step transforms the 2D profile into a 3D vase shape. -* Offset/Shelling: Removing material from the interior of the solid vase to +* Offset/Shelling: Removing material from the interior of the solid vase to create a hollow space, making it resemble a real vase more closely. -* Edge Filleting: Selecting specific edges of the vase for filleting, which +* Edge Filleting: Selecting specific edges of the vase for filleting, which involves rounding those edges. The edges are selected based on their position and type. .. NOTE 02: insert new example thumbnails above this line - + .. TODO: Copy this block to add your example details here .. _examples-{name-of-your-example}: @@ -564,16 +703,18 @@ selecting edges by position range and type for the application of fillets .. dropdown:: info - TODO: add more information about your example + TODO: add more information about your example - .. dropdown:: |Builder| Reference Implementation (Builder Mode) + .. 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) + .. 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/external.rst b/docs/external.rst index f1b9480..85f7723 100644 --- a/docs/external.rst +++ b/docs/external.rst @@ -31,15 +31,16 @@ GUI editor based on PyQT. This fork has changes from jdegenstein to allow easier See: `jdegenstein's fork of cq-editor `_ -yet-another-cad-viewer +Yet Another CAD Viewer ====================== -A CAD viewer capable of displaying OCP models (CadQuery/Build123d) in a -web browser. Mainly intended for deployment of finished models as a static -website. It also works for developing models with hot reloading, though -this feature may not be as mature as in ocp-vscode. +A web-based CAD viewer for OCP models (CadQuery/build123d) that runs in any modern browser and supports +static site deployment. Features include interactive inspection of faces, edges, and vertices, +measurement tools, per-model clipping planes, transparency control, and hot reloading via ``yacv-server``. +It also has a build123d playground for editing and sharing models directly in the browser +(`demo `_). -See: `yet-another-cad-viewer `_ +See: `Yet Another CAD Viewer `_ PartCAD VS Code extension ========================= @@ -71,6 +72,13 @@ Parts available include: See: `bd_warehouse `_ +bd_beams_and_bars +================= + +2D sections and 3D beams generation (UPN, IPN, UPE, flat bars, ...) + +See: `bd_beams_and_bars `_ + Superellipses & Superellipsoids =============================== @@ -153,3 +161,13 @@ Library that helps perform `topology optimization `_-based CAD models (`CadQuery `_/`Build123d `_/...) using the `dl4to `_ library. + +See: `dl4to4ocp `_ + +OCP.wasm +======== + +This project ports the low-level dependencies required for build123d to run in a browser. +For a fully featured frontend, check out ``Yet Another CAD Viewer`` (see above). + +See: `OCP.wasm `_ diff --git a/docs/heart_token.py b/docs/heart_token.py new file mode 100644 index 0000000..da11e68 --- /dev/null +++ b/docs/heart_token.py @@ -0,0 +1,68 @@ +# [Code] +from build123d import * +from ocp_vscode import show + +# Create the edges of one half the heart surface +l1 = JernArc((0, 0), (1, 1.4), 40, -17) +l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175) +l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20))) +l4 = ThreePointArc(l3 @ 1, (0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0) +heart_half = Wire([l1, l2, l3, l4]) +# [SurfaceEdges] + +# Create a point elevated off the center +surface_pnt = l2.arc_center + (0, 0, 1.5) +# [SurfacePoint] + +# Create the surface from the edges and point +top_right_surface = Pos(Z=0.5) * -Face.make_surface(heart_half, [surface_pnt]) +# [Surface] + +# Use the mirror method to create the other top and bottom surfaces +top_left_surface = top_right_surface.mirror(Plane.YZ) +bottom_right_surface = top_right_surface.mirror(Plane.XY) +bottom_left_surface = -top_left_surface.mirror(Plane.XY) +# [Surfaces] + +# Create the left and right sides +left_wire = Wire([l3, l2, l1]) +left_side = Pos(Z=-0.5) * Shell.extrude(left_wire, (0, 0, 1)) +right_side = left_side.mirror(Plane.YZ) +# [Sides] + +# Put all of the faces together into a Shell/Solid +heart = Solid( + Shell( + [ + top_right_surface, + top_left_surface, + bottom_right_surface, + bottom_left_surface, + left_side, + right_side, + ] + ) +) +# [Solid] + +# Build a frame around the heart +with BuildPart() as heart_token: + with BuildSketch() as outline: + with BuildLine(): + add(l1) + add(l2) + add(l3) + Line(l3 @ 1, l1 @ 0) + make_face() + mirror(about=Plane.YZ) + center = outline.sketch + offset(amount=2, kind=Kind.INTERSECTION) + add(center, mode=Mode.SUBTRACT) + extrude(amount=2, both=True) + add(heart) + +heart_token.part.color = "Red" + +show(heart_token) +# [End] +# export_gltf(heart_token.part, "heart_token.glb", binary=True) diff --git a/docs/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 beec8ec..6b23c03 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,60 +29,38 @@ :align: center :alt: build123d logo -Build123d is a python-based, parametric, boundary representation (BREP) modeling -framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and -allows for the creation of complex models using a simple and intuitive python -syntax. Build123d can be used to create models for 3D printing, CNC machining, -laser cutting, and other manufacturing processes. Models can be exported to a -wide variety of popular CAD tools such as FreeCAD and SolidWorks. - -Build123d could be considered as an evolution of -`CadQuery `_ where the -somewhat restrictive Fluent API (method chaining) is replaced with stateful -context managers - i.e. `with` blocks - thus enabling the full python toolbox: -for loops, references to objects, object sorting and filtering, etc. - -Note that this documentation is available in -`pdf `_ and -`epub `_ formats -for reference while offline. - ######## -Overview +About ######## -build123d uses the standard python context manager - i.e. the ``with`` statement often used when -working with files - as a builder of the object under construction. Once the object is complete -it can be extracted from the builders and used in other ways: for example exported as a STEP -file or used in an Assembly. There are three builders available: +Build123d is a Python-based, parametric (BREP) modeling framework for 2D and 3D CAD. +Built on the Open Cascade geometric kernel, it provides a clean, fully Pythonic interface +for creating precise models suitable for 3D printing, CNC machining, laser cutting, and +other manufacturing processes. Models can be exported to popular CAD tools such as FreeCAD +and SolidWorks. -* **BuildLine**: a builder of one dimensional objects - those with the property - of length but not of area or volume - typically used - to create complex lines used in sketches or paths. -* **BuildSketch**: a builder of planar two dimensional objects - those with the property - of area but not of volume - typically used to create 2D drawings that are extruded into 3D parts. -* **BuildPart**: a builder of three dimensional objects - those with the property of volume - - used to create individual parts. +Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with +expressive, algebraic modeling. It offers: -The three builders work together in a hierarchy as follows: +* Minimal or no internal state depending on mode +* Explicit 1D, 2D, and 3D geometry classes with well-defined operations +* Extensibility through subclassing and functional composition—no monkey patching +* Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints +* Deep Python integration—selectors as lists, locations as iterables, and natural + conversions (``Solid(shell)``, ``tuple(Vector)``) +* Operator-driven modeling (``obj += sub_obj``, ``Plane.XZ * Pos(X=5) * Rectangle(1, 1)``) + for algebraic, readable, and composable design logic -.. code-block:: python +.. code-block:: build123d - with BuildPart() as my_part: - ... - with BuildSketch() as my_sketch: - ... - with BuildLine() as my_line: - ... - ... - ... -where ``my_line`` will be added to ``my_sketch`` once the line is complete and ``my_sketch`` will be -added to ``my_part`` once the sketch is complete. +With build123d, intricate parametric models can be created in just a few lines of readable +Python code—as demonstrated by the tea cup example below. -As an example, consider the design of a tea cup: +.. dropdown:: Teacup Example .. literalinclude:: ../examples/tea_cup.py + :language: build123d :start-after: [Code] :end-before: [End] @@ -91,6 +69,14 @@ As an example, consider the design of a tea cup: +.. note:: + + + This documentation is available in + `pdf `_ and + `epub `_ formats + for reference while offline. + .. note:: There is a `Discord `_ server (shared with CadQuery) where @@ -114,6 +100,7 @@ Table Of Contents tutorials.rst objects.rst operations.rst + topology_selection.rst builders.rst joints.rst assemblies.rst diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index 1399a80..610fb6b 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 +14. 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,14 @@ 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 3ffaaab..3b117b0 100644 --- a/docs/joints.rst +++ b/docs/joints.rst @@ -25,7 +25,7 @@ in pairs - a :class:`~topology.Joint` can only be connected to another :class:`~ Objects may have many joints bound to them each with an identifying label. All :class:`~topology.Joint` objects have a ``symbol`` property that can be displayed to help visualize -their position and orientation (the `ocp-vscode `_ viewer +their position and orientation (the `ocp-vscode `_ viewer has built-in support for displaying joints). .. note:: @@ -41,19 +41,19 @@ The following sections provide more detail on the available joints and describes Rigid Joint *********** -A rigid joint positions two components relative to each another with no freedom of movement. When a +A rigid joint positions two components relative to each another with no freedom of movement. When a :class:`~joints.RigidJoint` is instantiated it's assigned a ``label``, a part to bind to (``to_part``), -and a ``joint_location`` which defines both the position and orientation of the joint (see +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 +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,11 +70,12 @@ 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 and how the ``-`` negate operator is used to reverse the direction of the location without changing its -poosition. Also note that the ``WeldNeckFlange`` class predefines two joints, one at the pipe end and +position. Also note that the ``WeldNeckFlange`` class predefines two joints, one at the pipe end and one at the face end - both of which are shown in the above image (generated by ocp-vscode with the ``render_joints=True`` flag set in the ``show`` function). @@ -105,7 +106,7 @@ Revolute Joint Component rotates around axis like a hinge. The :ref:`joint_tutorial` covers Revolute Joints in detail. -During instantiation of a :class:`~joints.RevoluteJoint` there are three parameters not present with +During instantiation of a :class:`~joints.RevoluteJoint` there are three parameters not present with Rigid Joints: ``axis``, ``angle_reference``, and ``range`` that allow the circular motion to be fully defined. @@ -114,7 +115,7 @@ which allows one to change the relative position of joined parts by changing a s .. autoclass:: RevoluteJoint -.. +.. :exclude-members: connect_to .. method:: connect_to(other: RigidJoint, *, angle: float = None) @@ -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 @@ -151,7 +153,7 @@ of the limits will raise an exception. .. autoclass:: LinearJoint -.. +.. :exclude-members: connect_to .. method:: connect_to(other: RevoluteJoint, *, position: float = None, angle: float = None) @@ -164,10 +166,10 @@ of the limits will raise an exception. Cylindrical Joint ***************** -A :class:`~joints.CylindricalJoint` allows a component to rotate around and moves along a single axis -like a screw combining the functionality of a :class:`~joints.LinearJoint` and a -:class:`~joints.RevoluteJoint` joint. The ``connect_to`` for these joints have both ``position`` and -``angle`` parameters as shown below extracted from the joint tutorial. +A :class:`~joints.CylindricalJoint` allows a component to rotate around and moves along a single axis +like a screw combining the functionality of a :class:`~joints.LinearJoint` and a +:class:`~joints.RevoluteJoint` joint. The ``connect_to`` for these joints have both ``position`` and +``angle`` parameters as shown below extracted from the joint tutorial. .. code-block::python @@ -176,7 +178,7 @@ like a screw combining the functionality of a :class:`~joints.LinearJoint` and a .. autoclass:: CylindricalJoint -.. +.. :exclude-members: connect_to .. method:: connect_to(other: RigidJoint, *, position: float = None, angle: float = None) @@ -193,15 +195,16 @@ 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 -within the rod end does not interfer with the rod end itself. The ``connect_to`` sets the three angles +Note how limits are defined during the instantiation of the ball joint when ensures that the pin or bolt +within the rod end does not interfere with the rod end itself. The ``connect_to`` sets the three angles (only two are significant in this example). .. autoclass:: BallJoint -.. +.. :exclude-members: connect_to .. method:: connect_to(other: RigidJoint, *, angles: RotationLike = None) diff --git a/docs/key_concepts_algebra.rst b/docs/key_concepts_algebra.rst index b76a1df..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,15 +54,15 @@ 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 2. Placement on the ``plane`` and then moved relative to the ``plane`` by ``location`` -(the location is relative to the local corrdinate system of the plane). +(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 8f68a50..23a0ad8 100644 --- a/docs/location_arithmetic.rst +++ b/docs/location_arithmetic.rst @@ -3,145 +3,146 @@ Location arithmetic for algebra mode ====================================== - Position a shape relative to the XY plane --------------------------------------------- For the following use the helper function: -.. code-block:: python +.. code-block:: build123d - def location_symbol(self, l=1) -> Compound: - return Compound.make_triad(axes_scale=l).locate(self) + def location_symbol(location: Location, scale: float = 1) -> Compound: + return Compound.make_triad(axes_scale=scale).locate(location) + def plane_symbol(plane: Plane, scale: float = 1) -> Compound: + triad = Compound.make_triad(axes_scale=scale) + circle = Circle(scale * .8).edge() + return (triad + circle).locate(plane.location) 1. **Positioning at a location** - .. code-block:: python + .. code-block:: build123d - 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:: build123d - 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:: build123d - 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:: build123d - 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:: build123d - 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:: build123d - 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:: build123d - 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)`` 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 2c1eff1..85204bf 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) @@ -76,6 +76,13 @@ The following objects all can be used in BuildLine contexts. Note that .. grid:: 3 + .. grid-item-card:: :class:`~objects_curve.Airfoil` + + .. image:: assets/example_airfoil.svg + + +++ + Airfoil described by 4 digit NACA profile + .. grid-item-card:: :class:`~objects_curve.Bezier` .. image:: assets/bezier_curve_example.svg @@ -83,6 +90,13 @@ The following objects all can be used in BuildLine contexts. Note that +++ Curve defined by control points and weights + .. grid-item-card:: :class:`~objects_curve.BlendCurve` + + .. image:: assets/example_blend_curve.svg + + +++ + Curve blending curvature of two curves + .. grid-item-card:: :class:`~objects_curve.CenterArc` .. image:: assets/center_arc_example.svg @@ -158,14 +172,14 @@ The following objects all can be used in BuildLine contexts. Note that .. image:: assets/radius_arc_example.svg +++ - Arc define by two points and a radius + Arc defined by two points and a radius .. grid-item-card:: :class:`~objects_curve.SagittaArc` .. image:: assets/sagitta_arc_example.svg +++ - Arc define by two points and a sagitta + Arc defined by two points and a sagitta .. grid-item-card:: :class:`~objects_curve.Spline` @@ -179,22 +193,51 @@ The following objects all can be used in BuildLine contexts. Note that .. image:: assets/tangent_arc_example.svg +++ - Curve define by two points and a tangent + Arc defined by two points and a tangent .. grid-item-card:: :class:`~objects_curve.ThreePointArc` .. image:: assets/three_point_arc_example.svg +++ - Curve define by three points + Arc defined by three points + .. grid-item-card:: :class:`~objects_curve.ArcArcTangentLine` + + .. image:: assets/example_arc_arc_tangent_line.svg + + +++ + Line tangent defined by two arcs + + .. grid-item-card:: :class:`~objects_curve.ArcArcTangentArc` + + .. image:: assets/example_arc_arc_tangent_arc.svg + + +++ + Arc tangent defined by two arcs + + .. grid-item-card:: :class:`~objects_curve.PointArcTangentLine` + + .. image:: assets/example_point_arc_tangent_line.svg + + +++ + Line tangent defined by a point and arc + + .. grid-item-card:: :class:`~objects_curve.PointArcTangentArc` + + .. image:: assets/example_point_arc_tangent_arc.svg + + +++ + Arc tangent defined by a point, direction, and arc Reference ^^^^^^^^^ .. py:module:: objects_curve .. autoclass:: BaseLineObject +.. autoclass:: Airfoil .. autoclass:: Bezier +.. autoclass:: BlendCurve .. autoclass:: CenterArc .. autoclass:: DoubleTangentArc .. autoclass:: EllipticalCenterArc @@ -210,6 +253,14 @@ Reference .. autoclass:: Spline .. autoclass:: TangentArc .. autoclass:: ThreePointArc +.. autoclass:: ArcArcTangentLine +.. autoclass:: ArcArcTangentArc +.. image:: assets/objects/arcarctangentarc_keep_table.png + :alt: ArcArcTangentArc keep table + :align: center + +.. autoclass:: PointArcTangentLine +.. autoclass:: PointArcTangentArc 2D Objects ---------- @@ -468,6 +519,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/objects_1d.py b/docs/objects_1d.py index 7d029cd..1d72359 100644 --- a/docs/objects_1d.py +++ b/docs/objects_1d.py @@ -176,7 +176,6 @@ svg.write("assets/polyline_example.svg") with BuildLine(Plane.YZ) as filletpolyline: FilletPolyline((0, 0, 0), (0, 10, 2), (0, 10, 10), (5, 20, 10), radius=2) -show(filletpolyline) scene = Compound(filletpolyline.line) + Compound.make_triad(2) visible, _hidden = scene.project_to_viewport((0, 0, 1), (0, 1, 0)) s = 100 / max(*Compound(children=visible).bounding_box().size) @@ -248,17 +247,71 @@ svg.add_shape(dot.moved(Location(Vector((1, 0))))) svg.write("assets/intersecting_line_example.svg") with BuildLine() as double_tangent: - l2 = JernArc(start=(0, 20), tangent=(0, 1), radius=5, arc_size=-300) - l3 = DoubleTangentArc((6, 0), tangent=(0, 1), other=l2) + p1 = (6, 0) + d1 = (0, 1) + l2 = Spline((0, 10), (3, 8), (7, 7), (10, 10)) + show_object([p1, l2]) + l3 = DoubleTangentArc(p1, tangent=d1, other=l2) s = 100 / max(*double_tangent.line.bounding_box().size) svg = ExportSVG(scale=s) svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) svg.add_shape(l2, "dashed") svg.add_shape(l3) -svg.add_shape(dot.scale(5).moved(Pos(6, 0))) -svg.add_shape(Edge.make_line((6, 0), (6, 5)), "dashed") +svg.add_shape(dot.scale(5).moved(Pos(p1))) +svg.add_shape(PolarLine(p1, 1, direction=d1), "dashed") svg.write("assets/double_tangent_line_example.svg") +with BuildLine() as point_arc_tangent_line: + p1 = (10, 3) + l1 = CenterArc((0, 5), 5, -90, 180) + l2 = PointArcTangentLine(p1, l1, Side.RIGHT) +s = 100 / max(*point_arc_tangent_line.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l1, "dashed") +svg.add_shape(l2) +svg.add_shape(dot.scale(5).moved(Pos(p1))) +svg.write("assets/example_point_arc_tangent_line.svg") + +with BuildLine() as point_arc_tangent_arc: + p1 = (10, 3) + d1 = (-3, 1) + l1 = CenterArc((0, 5), 5, -90, 180) + l2 = PointArcTangentArc(p1, d1, l1, Side.RIGHT) +s = 100 / max(*point_arc_tangent_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l1, "dashed") +svg.add_shape(l2) +svg.add_shape(dot.scale(5).moved(Pos(p1))) +svg.add_shape(PolarLine(p1, 1, direction=d1), "dashed") +svg.write("assets/example_point_arc_tangent_arc.svg") + +with BuildLine() as arc_arc_tangent_line: + l1 = CenterArc((7, 3), 3, 0, 360) + l2 = CenterArc((0, 8), 2, -90, 180) + l3 = ArcArcTangentLine(l1, l2, Side.RIGHT, Keep.OUTSIDE) +s = 100 / max(*arc_arc_tangent_line.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l1, "dashed") +svg.add_shape(l2, "dashed") +svg.add_shape(l3) +svg.write("assets/example_arc_arc_tangent_line.svg") + +with BuildLine() as arc_arc_tangent_arc: + l1 = CenterArc((7, 3), 3, 0, 360) + l2 = CenterArc((0, 8), 2, -90, 180) + radius = 12 + l3 = ArcArcTangentArc(l1, l2, radius, Side.LEFT, (Keep.INSIDE, Keep.OUTSIDE)) +s = 100 / max(*arc_arc_tangent_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l1, "dashed") +svg.add_shape(l2, "dashed") +svg.add_shape(l3) +svg.write("assets/example_arc_arc_tangent_arc.svg") + # show_object(example_1.line, name="Ex. 1") # show_object(example_2.line, name="Ex. 2") # show_object(example_3.line, name="Ex. 3") diff --git a/docs/objects_1d_blend_curve.py b/docs/objects_1d_blend_curve.py new file mode 100644 index 0000000..e7ea991 --- /dev/null +++ b/docs/objects_1d_blend_curve.py @@ -0,0 +1,18 @@ +from build123d import * + +# from ocp_vscode import show_all, set_defaults, Camera + +# set_defaults(reset_camera=Camera.KEEP) + +with BuildLine() as blend_curve: + l1 = CenterArc((0, 0), 5, 135, -135) + l2 = Spline((0, -5), (-3, -8), (0, -11)) + l3 = BlendCurve(l1, l2, tangent_scalars=(2, 5)) +s = 100 / max(*blend_curve.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l1, "dashed") +svg.add_shape(l2, "dashed") +svg.add_shape(l3) +svg.write("assets/example_blend_curve.svg") +# show_all() diff --git a/docs/operations.rst b/docs/operations.rst index 924a0c7..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) @@ -21,51 +21,53 @@ The following table summarizes all of the available operations. Operations marke applicable to BuildLine and Algebra Curve, 2D to BuildSketch and Algebra Sketch, 3D to BuildPart and Algebra Part. -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| Operation | Description | 0D | 1D | 2D | 3D | Example | -+==============================================+====================================+====+====+====+====+========================+ -| :func:`~operations_generic.add` | Add object to builder | | ✓ | ✓ | ✓ | :ref:`16 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.bounding_box` | Add bounding box as Shape | | ✓ | ✓ | ✓ | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.chamfer` | Bevel Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.extrude` | Draw 2D Shape into 3D | | | | ✓ | :ref:`3 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.fillet` | Radius Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_sketch.full_round` | Round-off Face along given Edge | | | ✓ | | :ref:`ttt-24-spo-06` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.loft` | Create 3D Shape from sections | | | | ✓ | :ref:`24 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.make_brake_formed` | Create sheet metal parts | | | | ✓ | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_sketch.make_face` | Create a Face from Edges | | | ✓ | | :ref:`4 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_sketch.make_hull` | Create Convex Hull from Edges | | | ✓ | | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.mirror` | Mirror about Plane | | ✓ | ✓ | ✓ | :ref:`15 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.offset` | Inset or outset Shape | | ✓ | ✓ | ✓ | :ref:`25 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.project` | Project points, lines or Faces | ✓ | ✓ | ✓ | | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.project_workplane` | Create workplane for projection | | | | | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.revolve` | Swing 2D Shape about Axis | | | | ✓ | :ref:`23 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.scale` | Change size of Shape | | ✓ | ✓ | ✓ | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.section` | Generate 2D slices from 3D Shape | | | | ✓ | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.split` | Divide object by Plane | | ✓ | ✓ | ✓ | :ref:`27 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_generic.sweep` | Extrude 1/2D section(s) along path | | | ✓ | ✓ | :ref:`14 ` | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_part.thicken` | Expand 2D section(s) | | | | ✓ | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ -| :func:`~operations_sketch.trace` | Convert lines to faces | | | ✓ | | | -+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| Operation | Description | 0D | 1D | 2D | 3D | Example | ++==============================================+====================================+====+====+====+====+===================================+ +| :func:`~operations_generic.add` | Add object to builder | | ✓ | ✓ | ✓ | :ref:`16 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.bounding_box` | Add bounding box as Shape | | ✓ | ✓ | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.chamfer` | Bevel Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.draft` | Add a draft taper to a part | | | | ✓ | :ref:`examples-cast_bearing_unit` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.extrude` | Draw 2D Shape into 3D | | | | ✓ | :ref:`3 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.fillet` | Radius Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_sketch.full_round` | Round-off Face along given Edge | | | ✓ | | :ref:`ttt-24-spo-06` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.loft` | Create 3D Shape from sections | | | | ✓ | :ref:`24 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.make_brake_formed` | Create sheet metal parts | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_sketch.make_face` | Create a Face from Edges | | | ✓ | | :ref:`4 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_sketch.make_hull` | Create Convex Hull from Edges | | | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.mirror` | Mirror about Plane | | ✓ | ✓ | ✓ | :ref:`15 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.offset` | Inset or outset Shape | | ✓ | ✓ | ✓ | :ref:`25 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.project` | Project points, lines or Faces | ✓ | ✓ | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.project_workplane` | Create workplane for projection | | | | | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.revolve` | Swing 2D Shape about Axis | | | | ✓ | :ref:`23 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.scale` | Change size of Shape | | ✓ | ✓ | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.section` | Generate 2D slices from 3D Shape | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.split` | Divide object by Plane | | ✓ | ✓ | ✓ | :ref:`27 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_generic.sweep` | Extrude 1/2D section(s) along path | | | ✓ | ✓ | :ref:`14 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_part.thicken` | Expand 2D section(s) | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ +| :func:`~operations_sketch.trace` | Convert lines to faces | | | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+-----------------------------------+ The following table summarizes all of the selectors that can be used within the scope of a Builder. Note that they will extract objects from the builder that is @@ -104,6 +106,7 @@ Reference .. autofunction:: operations_generic.add .. autofunction:: operations_generic.bounding_box .. autofunction:: operations_generic.chamfer +.. autofunction:: operations_part.draft .. autofunction:: operations_part.extrude .. autofunction:: operations_generic.fillet .. autofunction:: operations_sketch.full_round diff --git a/docs/rod_end.py b/docs/rod_end.py index e0a4364..335743f 100644 --- a/docs/rod_end.py +++ b/docs/rod_end.py @@ -3,9 +3,7 @@ from bd_warehouse.thread import IsoThread from ocp_vscode import * # Create the thread so the min radius is available below -thread = IsoThread( - major_diameter=8, pitch=1.25, length=20, end_finishes=("fade", "raw") -) +thread = IsoThread(major_diameter=6, pitch=1, length=20, end_finishes=("fade", "raw")) inner_radius = 15.89 / 2 inner_gap = 0.2 @@ -52,4 +50,4 @@ with BuildPart() as ball: rod_end.part.joints["socket"].connect_to(ball.part.joints["ball"], angles=(5, 10, 0)) -show(rod_end.part, ball.part) +show(rod_end.part, ball.part, s2) 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/spitfire_wing_gordon.py b/docs/spitfire_wing_gordon.py new file mode 100644 index 0000000..8b41a0c --- /dev/null +++ b/docs/spitfire_wing_gordon.py @@ -0,0 +1,77 @@ +""" +Supermarine Spitfire Wing +""" + +# [Code] + +from build123d import * +from ocp_vscode import show + +wing_span = 36 * FT + 10 * IN +wing_leading = 2.5 * FT +wing_trailing = wing_span / 4 - wing_leading +wing_leading_fraction = wing_leading / (wing_leading + wing_trailing) +wing_tip_section = wing_span / 2 - 1 * IN # distance from root to last section + +# Create leading and trailing edges +leading_edge = EllipticalCenterArc( + (0, 0), wing_span / 2, wing_leading, start_angle=270, end_angle=360 +) +trailing_edge = EllipticalCenterArc( + (0, 0), wing_span / 2, wing_trailing, start_angle=0, end_angle=90 +) + +# [AirfoilSizes] +# Calculate the airfoil sizes from the leading/trailing edges +airfoil_sizes = [] +for i in [0, 1]: + tip_axis = Axis(i * (wing_tip_section, 0, 0), (0, 1, 0)) + leading_pnt = leading_edge.intersect(tip_axis)[0] + trailing_pnt = trailing_edge.intersect(tip_axis)[0] + airfoil_sizes.append(trailing_pnt.Y - leading_pnt.Y) + +# [Airfoils] +# Create the root and tip airfoils - note that they are different NACA profiles +airfoil_root = Plane.YZ * scale( + Airfoil("2213").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[0] +) +airfoil_tip = ( + Plane.YZ + * Pos(Z=wing_tip_section) + * scale(Airfoil("2205").translate((-wing_leading_fraction, 0, 0)), airfoil_sizes[1]) +) + +# [Profiles] +# Create the Gordon surface profiles and guides +profiles = airfoil_root.edges() + airfoil_tip.edges() +profiles.append(leading_edge @ 1) # wing tip +guides = [leading_edge, trailing_edge] +# Create the wing surface as a Gordon Surface +wing_surface = -Face.make_gordon_surface(profiles, guides) +# Create the root of the wing +wing_root = -Face(Wire(wing_surface.edges().filter_by(Edge.is_closed))) + +# [Solid] +# Create the wing Solid +wing = Solid(Shell([wing_surface, wing_root])) +wing.color = 0x99A3B9 # Azure Blue + +show(wing) +# [End] +# Documentation artifact generation +# wing_control_edges = Curve( +# [airfoil_root, airfoil_tip, Vertex(leading_edge @ 1), leading_edge, trailing_edge] +# ) +# visible, _ = wing_control_edges.project_to_viewport((50 * FT, -50 * FT, 50 * FT)) +# max_dimension = max(*Compound(children=visible).bounding_box().size) +# svg = ExportSVG(scale=100 / max_dimension) +# svg.add_shape(visible) +# svg.write("assets/surface_modeling/spitfire_wing_profiles_guides.svg") + +# export_gltf( +# wing, +# "assets/surface_modeling/spitfire_wing.glb", +# binary=True, +# linear_deflection=0.1, +# angular_deflection=1, +# ) diff --git a/docs/tech_drawing_tutorial.rst b/docs/tech_drawing_tutorial.rst new file mode 100644 index 0000000..b4f9db6 --- /dev/null +++ b/docs/tech_drawing_tutorial.rst @@ -0,0 +1,73 @@ +.. _tech_drawing_tutorial: + +########################## +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 +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. +These include: + +- **Plan (Top View)** – as seen from directly above (Z-axis down) +- **Front Elevation** – looking at the object head-on (Y-axis forward) +- **Side Elevation (Right Side)** – viewed from the right (X-axis) +- **Isometric Projection** – a 3D perspective view to help visualize depth + +Each view is aligned to a position on the page and optionally scaled or annotated. + +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) +and places the result onto a virtual drawing sheet. + +The steps involved are: + +1. Load or construct a 3D part (in this case, a stepper motor). +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 + and style. + +Result +------ + +.. image:: /assets/stepper_drawing.svg + :alt: Stepper motor technical drawing + :class: align-center + :width: 80% + +Try It Yourself +--------------- + +You can modify the script to: + +- Replace the part with your own `Part` model +- Adjust camera angles and scale +- Add other views (bottom, rear) +- Enhance with more labels and dimensions + +Code +---- + +.. literalinclude:: technical_drawing.py + :language: build123d + :start-after: [code] + :end-before: [end] + +Dependencies +------------ + +This example depends on the following packages: + +- `build123d` +- `bd_warehouse` (for the `StepperMotor` part) +- `ocp_vscode` (for local preview) diff --git a/docs/technical_drawing.py b/docs/technical_drawing.py new file mode 100644 index 0000000..55a6efc --- /dev/null +++ b/docs/technical_drawing.py @@ -0,0 +1,188 @@ +""" + +name: technical_drawing.py +by: gumyr +date: May 23, 2025 + +desc: + + Generate a multi-view technical drawing of a part, including isometric and + orthographic projections. + + This module demonstrates how to create a standard technical drawing using + `build123d`. It includes: + - Projection of a 3D part to 2D views (plan, front, side, isometric) + - Drawing borders and dimensioning using extension lines + - SVG export of visible and hidden geometry + - Example part: Nema 23 stepper motor from `bd_warehouse.open_builds` + + The following standard views are generated: + - Plan View (Top) + - Front Elevation + - Side Elevation (Right Side) + - Isometric Projection + + The resulting drawing is exported as an SVG and can be previewed using + the `ocp_vscode` viewer. + +license: + + Copyright 2025 gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +# [code] +from datetime import date + +from bd_warehouse.open_builds import StepperMotor +from build123d import * +from ocp_vscode import show + + +def project_to_2d( + part: Part, + viewport_origin: VectorLike, + viewport_up: VectorLike, + page_origin: VectorLike, + scale_factor: float = 1.0, +) -> tuple[ShapeList[Edge], ShapeList[Edge]]: + """project_to_2d + + Helper function to generate 2d views translated on the 2d page. + + Args: + part (Part): 3d object + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike): direction of the viewport Y axis + page_origin (VectorLike): center of 2d object on page + scale_factor (float, optional): part scalar. Defaults to 1.0. + + Returns: + tuple[ShapeList[Edge], ShapeList[Edge]]: visible & hidden edges + """ + scaled_part = part if scale_factor == 1.0 else scale(part, scale_factor) + visible, hidden = scaled_part.project_to_viewport( + viewport_origin, viewport_up, look_at=(0, 0, 0) + ) + visible = [Pos(*page_origin) * e for e in visible] + hidden = [Pos(*page_origin) * e for e in hidden] + + return ShapeList(visible), ShapeList(hidden) + + +# The object that appearing in the drawing +stepper: Part = StepperMotor("Nema23") + +# Create a standard technical drawing border on A4 paper +border = TechnicalDrawing( + designed_by="build123d", + design_date=date.fromisoformat("2025-05-23"), + page_size=PageSize.A4, + title="Nema 23 Stepper", + sub_title="Units: mm", + drawing_number="BD-1", + sheet_number=1, + drawing_scale=1, +) +page_size = border.bounding_box().size + +# Specify the drafting options for extension lines +drafting_options = Draft(font_size=3.5, decimal_precision=1, display_units=False) + +# Lists used to store the 2d visible and hidden lines +visible_lines, hidden_lines = [], [] + +# Isometric Projection - A 3D view where the part is rotated to reveal three +# dimensions equally. +iso_v, iso_h = project_to_2d( + stepper, + (100, 100, 100), + (0, 0, 1), + page_size * 0.3, + 0.75, +) +visible_lines.extend(iso_v) +hidden_lines.extend(iso_h) + +# Plan View (Top) - The view from directly above the part (looking down along +# the Z-axis). +vis, _ = project_to_2d( + stepper, + (0, 0, 100), + (0, 1, 0), + (page_size.X * -0.3, page_size.Y * 0.25), +) +visible_lines.extend(vis) + +# Dimension the top of the stepper +top_bbox = Curve(vis).bounding_box() +perimeter = Pos(*top_bbox.center()) * Rectangle(top_bbox.size.X, top_bbox.size.Y) +d1 = ExtensionLine( + border=perimeter.edges().sort_by(Axis.X)[-1], offset=1 * CM, draft=drafting_options +) +d2 = ExtensionLine( + border=perimeter.edges().sort_by(Axis.Y)[0], offset=1 * CM, draft=drafting_options +) +# Add a label +l1 = Text("Plan View", 6) +l1.position = vis.sort_by(Axis.Y)[-1].center() + (0, 5 * MM) + +# Front Elevation - The primary view, typically looking along the Y-axis, +# showing the height. +vis, _ = project_to_2d( + stepper, + (0, -100, 0), + (0, 0, 1), + (page_size.X * -0.3, page_size.Y * -0.125), +) +visible_lines.extend(vis) +d3 = ExtensionLine( + border=vis.sort_by(Axis.Y)[-1], offset=-5 * MM, draft=drafting_options +) +l2 = Text("Front Elevation", 6) +l2.position = vis.group_by(Axis.Y)[0].sort_by(Edge.length)[-1].center() + (0, -5 * MM) + +# Side Elevation - Often refers to the Right Side View, looking along the X-axis. +vis, _ = project_to_2d( + stepper, + (100, 0, 0), + (0, 0, 1), + (0, page_size.Y * 0.15), +) +visible_lines.extend(vis) +side_bbox = Curve(vis).bounding_box() +perimeter = Pos(*side_bbox.center()) * Rectangle(side_bbox.size.X, side_bbox.size.Y) +d4 = ExtensionLine( + border=perimeter.edges().sort_by(Axis.X)[-1], offset=1 * CM, 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) + + +# Initialize the SVG exporter +exporter = ExportSVG(unit=Unit.MM) +# Define visible and hidden line layers +exporter.add_layer("Visible") +exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) +# Add the objects to the appropriate layer +exporter.add_shape(visible_lines, layer="Visible") +exporter.add_shape(hidden_lines, layer="Hidden") +exporter.add_shape(border, layer="Visible") +exporter.add_shape([d1, d2, d3, d4], layer="Visible") +exporter.add_shape([l1, l2, l3], layer="Visible") +# Write the file +exporter.write(f"assets/stepper_drawing.svg") + +show(border, visible_lines, d1, d2, d3, d4, l1, l2, l3) +# [end] diff --git a/docs/tips.rst b/docs/tips.rst index 8bd9d7f..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): @@ -271,7 +271,7 @@ reoriented, all ``BuildLine`` instances within the scope of ``BuildSketch`` shou on the default ``Plane.XY``. *************************************************************** -Don't Builders inherit workplane/coordinate sytems when nested +Don't Builders inherit workplane/coordinate systems when nested *************************************************************** Some users expect that nested Builders will inherit the workplane or coordinate system from diff --git a/docs/topology_selection.rst b/docs/topology_selection.rst new file mode 100644 index 0000000..694c75f --- /dev/null +++ b/docs/topology_selection.rst @@ -0,0 +1,432 @@ +##################################### +Topology Selection and Exploration +##################################### + +:ref:`topology` is the structure of build123d geometric features and traversing the +topology of a part is often required to specify objects for an operation or to locate a +CAD feature. :ref:`selectors` allow selection of topology objects into a |ShapeList|. +:ref:`operators` are powerful methods further explore and refine a |ShapeList| for +subsequent operations. + +.. _selectors: + +********* +Selectors +********* + +Selectors provide methods to extract all or a subset of a feature type in the referenced +object. These methods select Edges, Faces, Solids, Vertices, or Wires in Builder objects +or from Shape objects themselves. All of these methods return a |ShapeList|, +which is a subclass of ``list`` and may be sorted, grouped, or filtered by +:ref:`operators`. + +Overview +======== + ++--------------+----------------+-----------------------------------------------+-----------------------+ +| Selector | Criteria | Applicability | Description | ++==============+================+===============================================+=======================+ +| |vertices| | ALL, LAST | ``BuildLine``, ``BuildSketch``, ``BuildPart`` | ``Vertex`` extraction | ++--------------+----------------+-----------------------------------------------+-----------------------+ +| |edges| | ALL, LAST, NEW | ``BuildLine``, ``BuildSketch``, ``BuildPart`` | ``Edge`` extraction | ++--------------+----------------+-----------------------------------------------+-----------------------+ +| |wires| | ALL, LAST | ``BuildLine``, ``BuildSketch``, ``BuildPart`` | ``Wire`` extraction | ++--------------+----------------+-----------------------------------------------+-----------------------+ +| |faces| | ALL, LAST | ``BuildSketch``, ``BuildPart`` | ``Face`` extraction | ++--------------+----------------+-----------------------------------------------+-----------------------+ +| |solids| | ALL, LAST | ``BuildPart`` | ``Solid`` extraction | ++--------------+----------------+-----------------------------------------------+-----------------------+ + +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:: build123d + + # In context + with BuildSketch() as context: + Rectangle(1, 1) + context.edges() + + # Build context implicitly has access to the selector + edges() + + # Taking the sketch out of context + context.sketch.edges() + + # Create sketch out of context + Rectangle(1, 1).edges() + +Select In Build Context +======================== + +Build contexts track the last operation and their selector methods can take +:class:`~build_enums.Select` as criteria to specify a subset of +features to extract. By default, a selector will select ``ALL`` of a feature, while +``LAST`` selects features created or altered by the most recent operation. |edges| can +uniquely specify ``NEW`` to only select edges created in the last operation which neither +existed in the referenced object before the last operation, nor the modifying object. + +.. important:: + + :class:`~build_enums.Select` as selector criteria is only valid for builder objects! + + .. code-block:: build123d + + # In context + with BuildPart() as context: + Box(2, 2, 1) + Cylinder(1, 2) + context.edges(Select.LAST) + + # Does not work out of context! + context.part.edges(Select.LAST) + (Box(2, 2, 1) + Cylinder(1, 2)).edges(Select.LAST) + +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:: build123d + + with BuildPart() as part: + Box(5, 5, 1) + Cylinder(1, 5) + + part.vertices() + part.edges() + part.faces() + + # Is the same as + part.vertices(Select.ALL) + part.edges(Select.ALL) + part.faces(Select.ALL) + +.. figure:: assets/topology_selection/selectors_select_all.png + :align: center + + The default ``Select.ALL`` features + +Select features changed in the last operation with criteria ``Select.LAST``. + +.. code-block:: build123d + + with BuildPart() as part: + Box(5, 5, 1) + Cylinder(1, 5) + + part.vertices(Select.LAST) + part.edges(Select.LAST) + part.faces(Select.LAST) + +.. figure:: assets/topology_selection/selectors_select_last.png + :align: center + + ``Select.LAST`` features + +Select only new edges from the last operation with ``Select.NEW``. This option is only +available for a ``ShapeList`` of edges! + +.. code-block:: build123d + + with BuildPart() as part: + Box(5, 5, 1) + Cylinder(1, 5) + + part.edges(Select.NEW) + +.. figure:: assets/topology_selection/selectors_select_new.png + :align: center + + ``Select.NEW`` edges where box and cylinder intersect + +This only returns new edges which are not reused from Box or Cylinder, in this case where +the objects `intersect`. But what happens if the objects don't intersect and all the +edges are reused? + +.. code-block:: build123d + + with BuildPart() as part: + Box(5, 5, 1, align=(Align.CENTER, Align.CENTER, Align.MAX)) + Cylinder(2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)) + + part.edges(Select.NEW) + +.. figure:: assets/topology_selection/selectors_select_new_none.png + :align: center + + ``Select.NEW`` edges when box and cylinder don't intersect + +No edges are selected! Unlike the previous example, the Edge between the Box and Cylinder +objects is an edge reused from the Cylinder. Think of ``Select.NEW`` as a way to select +only completely new edges created by the operation. + +.. note:: + + Chamfer and fillet modify the current object, but do not have new edges via + ``Select.NEW``. + + .. code-block:: build123d + + with BuildPart() as part: + Box(5, 5, 1) + Cylinder(1, 5) + edges = part.edges().filter_by(lambda a: a.length == 1) + fillet(edges, 1) + + part.edges(Select.NEW) + + .. figure:: assets/topology_selection/selectors_select_new_fillet.png + :align: center + + Left, ``Select.NEW`` returns no edges after fillet. Right, ``Select.LAST`` + +Select New Edges In Algebra Mode +================================ + +The utility method ``new_edges`` compares one or more shape objects to a +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:: build123d + + box = Box(5, 5, 1) + circle = Cylinder(2, 5) + part = box + circle + edges = new_edges(box, circle, combined=part) + +.. figure:: assets/topology_selection/selectors_new_edges.png + :align: center + +``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:: build123d + + box = Box(5, 5, 1) + circle = Cylinder(2, 5) + part_before = box + circle + edges = part_before.edges().filter_by(lambda a: a.length == 1) + part = fillet(edges, 1) + edges = new_edges(part_before, combined=part) + +.. figure:: assets/topology_selection/operators_group_area.png + :align: center + +.. _operators: + +********* +Operators +********* + +Operators provide methods refine a ``ShapeList`` of features isolated by a *selector* to +further specify feature(s). These methods can sort, group, or filter ``ShapeList`` +objects and return a modified ``ShapeList``, or in the case of |group_by|, ``GroupBy``, +a list of ``ShapeList`` objects accessible by index or key. + +Overview +======== + ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ +| Method | Criteria | Description | ++======================+==================================================================+=======================================================+ +| |sort_by| | ``Axis``, ``Edge``, ``Wire``, ``SortBy``, callable, property | Sort ``ShapeList`` by criteria | ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ +| |sort_by_distance| | ``Shape``, ``VectorLike`` | Sort ``ShapeList`` by distance from criteria | ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ +| |group_by| | ``Axis``, ``Edge``, ``Wire``, ``SortBy``, callable, property | Group ``ShapeList`` by criteria | ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ +| |filter_by| | ``Axis``, ``Plane``, ``GeomType``, ``ShapePredicate``, property | Filter ``ShapeList`` by criteria | ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ +| |filter_by_position| | ``Axis`` | Filter ``ShapeList`` by ``Axis`` & mix / max values | ++----------------------+------------------------------------------------------------------+-------------------------------------------------------+ + +Operator methods take criteria to refine ``ShapeList``. Broadly speaking, the criteria +fall into the following categories, though not all operators take all criteria: + +- Geometric objects: ``Axis``, ``Plane`` +- Topological objects: ``Edge``, ``Wire`` +- Enums: :class:`~build_enums.SortBy`, :class:`~build_enums.GeomType` +- Properties, eg: ``Face.area``, ``Edge.length`` +- ``ShapePredicate``, eg: ``lambda e: e.is_interior == 1``, ``lambda f: lf.edges() >= 3`` +- Callable eg: ``Vertex().distance`` + +Sort +======= + +A ``ShapeList`` can be sorted with the |sort_by| and |sort_by_distance| +methods based on a sorting criteria. Sorting is a critical step when isolating individual +features as a ``ShapeList`` from a selector is typically unordered. + +Here we want to capture some vertices from the object furthest along ``X``: All the +vertices are first captured with the |vertices| selector, then sort by ``Axis.X``. +Finally, the vertices can be captured with a list slice for the last 4 list items, as the +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:: build123d + + part.vertices().sort_by(Axis.X)[-4:] + +.. figure:: assets/topology_selection/operators_sort_x.png + :align: center + +| + +Examples +-------- + +.. toctree:: + :maxdepth: 2 + :hidden: + + topology_selection/sort_examples + +.. grid:: 3 + :gutter: 3 + + .. grid-item-card:: SortBy + :img-top: assets/topology_selection/thumb_sort_sortby.png + :link: sort_sortby + :link-type: ref + + .. grid-item-card:: Along Wire + :img-top: assets/topology_selection/thumb_sort_along_wire.png + :link: sort_along_wire + :link-type: ref + + .. grid-item-card:: Axis + :img-top: assets/topology_selection/thumb_sort_axis.png + :link: sort_axis + :link-type: ref + + .. grid-item-card:: Distance From + :img-top: assets/topology_selection/thumb_sort_distance.png + :link: sort_distance_from + :link-type: ref + +Group +======== + +A ShapeList can be grouped and sorted with the |group_by| method based on a grouping +criteria. Grouping can be a great way to organize features without knowing the values of +specific feature properties. Rather than returning a ``ShapeList``, |group_by| returns +a ``GroupBy``, a list of ``ShapeList`` objects sorted by the grouping criteria. +``GroupBy`` can be printed to view the members of each group, indexed like a list to +retrieve a ``ShapeList``, and be accessed using a key with the ``group`` method. If the +group keys are unknown they can be discovered with ``key_to_group_index``. + +If we want only the edges from the smallest faces by area we can get the faces, then +group by ``SortBy.AREA``. The ``ShapeList`` of smallest faces is available from the first +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:: build123d + + part.faces().group_by(SortBy.AREA)[0].edges()) + +.. figure:: assets/topology_selection/operators_group_area.png + :align: center + +| + +Examples +-------- + +.. toctree:: + :maxdepth: 2 + :hidden: + + topology_selection/group_examples + +.. grid:: 3 + :gutter: 3 + + .. grid-item-card:: Axis and Length + :img-top: assets/topology_selection/thumb_group_axis.png + :link: group_axis + :link-type: ref + + .. grid-item-card:: Hole Area + :img-top: assets/topology_selection/thumb_group_hole_area.png + :link: group_hole_area + :link-type: ref + + .. grid-item-card:: Properties with Keys + :img-top: assets/topology_selection/thumb_group_properties_with_keys.png + :link: group_properties_with_keys + :link-type: ref + +Filter +========= + +A ``ShapeList`` can be filtered with the |filter_by| and |filter_by_position| methods based +on a filtering criteria. Filters are flexible way to isolate (or exclude) features based +on known criteria. + +Lets say we need all the faces with a normal in the ``+Z`` direction. One way to do this +might be with a list comprehension, however |filter_by| has the capability to take a +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:: build123d + + part.faces().filter_by(lambda f: f.normal_at() == Vector(0, 0, 1)) + +.. figure:: assets/topology_selection/operators_filter_z_normal.png + :align: center + +| + +Examples +-------- + +.. toctree:: + :maxdepth: 2 + :hidden: + + topology_selection/filter_examples + +.. grid:: 3 + :gutter: 3 + + .. grid-item-card:: GeomType + :img-top: assets/topology_selection/thumb_filter_geomtype.png + :link: filter_geomtype + :link-type: ref + + .. grid-item-card:: All Edges Circle + :img-top: assets/topology_selection/thumb_filter_all_edges_circle.png + :link: filter_all_edges_circle + :link-type: ref + + .. grid-item-card:: Axis and Plane + :img-top: assets/topology_selection/thumb_filter_axisplane.png + :link: filter_axis_plane + :link-type: ref + + .. grid-item-card:: Inner Wire Count + :img-top: assets/topology_selection/thumb_filter_inner_wire_count.png + :link: filter_inner_wire_count + :link-type: ref + + .. grid-item-card:: Nested Filters + :img-top: assets/topology_selection/thumb_filter_nested.png + :link: filter_nested + :link-type: ref + + .. grid-item-card:: Shape Properties + :img-top: assets/topology_selection/thumb_filter_shape_properties.png + :link: filter_shape_properties + :link-type: ref + +.. |vertices| replace:: :meth:`~topology.Shape.vertices` +.. |edges| replace:: :meth:`~topology.Shape.edges` +.. |wires| replace:: :meth:`~topology.Shape.wires` +.. |faces| replace:: :meth:`~topology.Shape.faces` +.. |solids| replace:: :meth:`~topology.Shape.solids` +.. |sort_by| replace:: :meth:`~topology.ShapeList.sort_by` +.. |sort_by_distance| replace:: :meth:`~topology.ShapeList.sort_by_distance` +.. |group_by| replace:: :meth:`~topology.ShapeList.group_by` +.. |filter_by| replace:: :meth:`~topology.ShapeList.filter_by` +.. |filter_by_position| replace:: :meth:`~topology.ShapeList.filter_by_position` +.. |ShapeList| replace:: :class:`~topology.ShapeList` diff --git a/docs/topology_selection/examples/filter_all_edges_circle.py b/docs/topology_selection/examples/filter_all_edges_circle.py new file mode 100644 index 0000000..2531158 --- /dev/null +++ b/docs/topology_selection/examples/filter_all_edges_circle.py @@ -0,0 +1,50 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + with BuildSketch() as s: + Rectangle(115, 50) + with Locations((5 / 2, 0)): + SlotOverall(90, 12, mode=Mode.SUBTRACT) + extrude(amount=15) + + with BuildSketch(Plane.XZ.offset(50 / 2)) as s3: + with Locations((-115 / 2 + 26, 15)): + SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90) + zz = extrude(amount=-12) + split(bisect_by=Plane.XY) + edgs = part.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2] + fillet(edgs, 9) + + with Locations(zz.faces().sort_by(Axis.Y)[0]): + with Locations((42 / 2 + 6, 0)): + CounterBoreHole(24 / 2, 34 / 2, 4) + mirror(about=Plane.XZ) + + with BuildSketch() as s4: + RectangleRounded(115, 50, 6) + extrude(amount=80, mode=Mode.INTERSECT) + # fillet does not work right, mode intersect is safer + + with BuildSketch(Plane.YZ) as s4: + with BuildLine() as bl: + l1 = Line((0, 0), (18 / 2, 0)) + l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL) + l3 = Line(l2 @ 1, (0, 8)) + mirror(about=Plane.YZ) + make_face() + extrude(amount=115 / 2, both=True, mode=Mode.SUBTRACT) + + faces = part.faces().filter_by( + lambda f: all(e.geom_type == GeomType.CIRCLE for e in f.edges()) + ) + for i, f in enumerate(faces): + RigidJoint(f"bearing_bore_{i}", joint_location=f.center_location) + +show(part, [f.translate(f.normal_at() * 0.01) for f in faces], render_joints=True) +save_screenshot(os.path.join(filedir, "filter_all_edges_circle.png")) diff --git a/docs/topology_selection/examples/filter_axisplane.py b/docs/topology_selection/examples/filter_axisplane.py new file mode 100644 index 0000000..3bb08ff --- /dev/null +++ b/docs/topology_selection/examples/filter_axisplane.py @@ -0,0 +1,47 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +axis = Axis.Z +plane = Plane.XY +with BuildPart() as part: + with BuildSketch(Plane.XY.shift_origin((1, 1))) as plane_rep: + Rectangle(2, 2) + with Locations((-.9, -.9)): + Text("Plane.XY", .2, align=(Align.MIN, Align.MIN), mode=Mode.SUBTRACT) + plane_rep = plane_rep.sketch + plane_rep.color = Color(0, .55, .55, .1) + + with Locations((-1, -1, 0)): + b = Box(1, 1, 1) + f = b.faces() + res = f.filter_by(axis) + axis_rep = [Axis(f.center(), f.normal_at()) for f in res] + show_object([b, res, axis_rep]) + + with Locations((1, 1, 0)): + b = Box(1, 1, 1) + f = b.faces() + res = f.filter_by(plane) + show_object([b, res, plane_rep]) + + save_screenshot(os.path.join(filedir, "filter_axisplane.png")) + reset_show() + + with Locations((-1, -1, 0)): + b = Box(1, 1, 1) + f = b.faces() + res = f.filter_by(lambda f: abs(f.normal_at().dot(axis.direction)) < 1e-6) + show_object([b, res, axis_rep]) + + with Locations((1, 1, 0)): + b = Box(1, 1, 1) + f = b.faces() + res = f.filter_by(lambda f: abs(f.normal_at().dot(plane.z_dir)) < 1e-6) + show_object([b, res, plane_rep]) + + save_screenshot(os.path.join(filedir, "filter_dot_axisplane.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/filter_geomtype.py b/docs/topology_selection/examples/filter_geomtype.py new file mode 100644 index 0000000..cb791cd --- /dev/null +++ b/docs/topology_selection/examples/filter_geomtype.py @@ -0,0 +1,23 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + Box(5, 5, 1) + Cylinder(2, 5) + edges = part.edges().filter_by(lambda a: a.length == 1) + fillet(edges, 1) + +part.edges().filter_by(GeomType.LINE) + +part.faces().filter_by(GeomType.CYLINDER) + +show(part, part.edges().filter_by(GeomType.LINE)) +save_screenshot(os.path.join(filedir, "filter_geomtype_line.png")) + +show(part, part.faces().filter_by(GeomType.CYLINDER)) +save_screenshot(os.path.join(filedir, "filter_geomtype_cylinder.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/filter_inner_wire_count.py b/docs/topology_selection/examples/filter_inner_wire_count.py new file mode 100644 index 0000000..7f8ef68 --- /dev/null +++ b/docs/topology_selection/examples/filter_inner_wire_count.py @@ -0,0 +1,38 @@ +from copy import copy +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +bracket = import_step(os.path.join(working_path, "nema-17-bracket.step")) +faces = bracket.faces() + +motor_mounts = faces.filter_by(GeomType.CYLINDER).filter_by(lambda f: f.radius == 3.3/2) +for i, f in enumerate(motor_mounts): + location = f.axis_of_rotation.location + RigidJoint(f"motor_m3_{i}", bracket, joint_location=location) + +motor_face = faces.filter_by(lambda f: len(f.inner_wires()) == 5).sort_by(Axis.X)[-1] +motor_bore = motor_face.inner_wires().edges().filter_by(lambda e: e.radius == 16).edge() +location = Location(motor_bore.arc_center, motor_bore.normal() * 90, Intrinsic.YXZ) +RigidJoint(f"motor", bracket, joint_location=location) + +before_linear = copy(bracket) + +mount_face = faces.filter_by(lambda f: len(f.inner_wires()) == 6).sort_by(Axis.Z)[-1] +mount_slots = mount_face.inner_wires().edges().filter_by(GeomType.CIRCLE) +joint_edges = [ + Line(mount_slots[i].arc_center, mount_slots[i + 1].arc_center) + for i in range(0, len(mount_slots), 2) +] +for i, e in enumerate(joint_edges): + LinearJoint(f"mount_m4_{i}", bracket, axis=Axis(e), linear_range=(0, e.length / 2)) + +show(before_linear, render_joints=True) +save_screenshot(os.path.join(filedir, "filter_inner_wire_count.png")) + +show(bracket, render_joints=True) +save_screenshot(os.path.join(filedir, "filter_inner_wire_count_linear.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/filter_nested.py b/docs/topology_selection/examples/filter_nested.py new file mode 100644 index 0000000..dba8293 --- /dev/null +++ b/docs/topology_selection/examples/filter_nested.py @@ -0,0 +1,39 @@ +from copy import copy +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + Cylinder(15, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)) + with BuildSketch(): + RectangleRounded(10, 10, 2.5) + extrude(amount=15) + + with BuildSketch(): + Circle(2.5) + Rectangle(4, 5, mode=Mode.INTERSECT) + extrude(amount=15, mode=Mode.SUBTRACT) + + with GridLocations(20, 0, 2, 1): + Hole(3.5 / 2) + + before = copy(part) + + faces = part.faces().filter_by( + lambda f: len(f.inner_wires().edges().filter_by(GeomType.LINE)) == 2 + ) + wires = faces.wires().filter_by( + lambda w: any(e.geom_type == GeomType.LINE for e in w.edges()) + ) + chamfer(wires.edges(), 0.5) + +location = Location((-25, -25)) +b = before.part.moved(location) +f = [f.moved(location) for f in faces] + +show(b, f, part) +save_screenshot(os.path.join(filedir, "filter_nested.png")) diff --git a/docs/topology_selection/examples/filter_shape_properties.py b/docs/topology_selection/examples/filter_shape_properties.py new file mode 100644 index 0000000..7a1f39c --- /dev/null +++ b/docs/topology_selection/examples/filter_shape_properties.py @@ -0,0 +1,25 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as open_box_builder: + Box(20, 20, 5) + offset(amount=-2, openings=open_box_builder.faces().sort_by(Axis.Z)[-1]) + inside_edges = open_box_builder.edges().filter_by(Edge.is_interior) + fillet(inside_edges, 1.5) + outside_edges = open_box_builder.edges().filter_by(Edge.is_interior, reverse=True) + fillet(outside_edges, 0.5) + +open_box = open_box_builder.part +open_box.color = Color(0xEDAE49) +outside_fillets = Compound(open_box.faces().filter_by(Face.is_circular_convex)) +outside_fillets.color = Color(0xD1495B) +inside_fillets = Compound(open_box.faces().filter_by(Face.is_circular_concave)) +inside_fillets.color = Color(0x00798C) + +show(open_box, inside_fillets, outside_fillets) +save_screenshot(os.path.join(filedir, "filter_shape_properties.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/group_axis.py b/docs/topology_selection/examples/group_axis.py new file mode 100644 index 0000000..4e95757 --- /dev/null +++ b/docs/topology_selection/examples/group_axis.py @@ -0,0 +1,28 @@ +import os +from copy import copy + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as fins: + with GridLocations(4, 6, 4, 4): + Box(2, 3, 10, align=(Align.CENTER, Align.CENTER, Align.MIN)) + +with BuildPart() as part: + Box(34, 48, 5, align=(Align.CENTER, Align.CENTER, Align.MAX)) + with GridLocations(20, 27, 2, 2): + add(fins) + + without = copy(part) + + target = part.edges().group_by(Axis.Z)[-1].group_by(Edge.length)[-1] + fillet(target, .75) + +show(without) +save_screenshot(os.path.join(filedir, "group_axis_without.png")) + +show(part) +save_screenshot(os.path.join(filedir, "group_axis_with.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/group_hole_area.py b/docs/topology_selection/examples/group_hole_area.py new file mode 100644 index 0000000..43cd53c --- /dev/null +++ b/docs/topology_selection/examples/group_hole_area.py @@ -0,0 +1,31 @@ +from copy import copy +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + Cylinder(10, 30, rotation=(90, 0, 0)) + Cylinder(8, 40, rotation=(90, 0, 0), align=(Align.CENTER, Align.CENTER, Align.MAX)) + Cylinder(8, 23, rotation=(90, 0, 0), align=(Align.CENTER, Align.CENTER, Align.MIN)) + Cylinder(5, 40, rotation=(90, 0, 0), align=(Align.CENTER, Align.CENTER, Align.MIN)) + with BuildSketch(Plane.XY.offset(8)) as s: + SlotCenterPoint((0, 38), (0, 48), 5) + extrude(amount=2.5, both=True, mode=Mode.SUBTRACT) + + before = copy(part) + + faces = part.faces().group_by( + lambda f: Face(f.inner_wires()[0]).area if f.inner_wires() else 0 + ) + chamfer([f.outer_wire().edges() for f in faces[-1]], 0.5) + +show( + before, + [f.translate(f.normal_at() * 0.01) for f in faces], + part.part.translate((40, 40)), +) +save_screenshot(os.path.join(filedir, "group_hole_area.png")) diff --git a/docs/topology_selection/examples/group_properties_with_keys.py b/docs/topology_selection/examples/group_properties_with_keys.py new file mode 100644 index 0000000..85c4eaf --- /dev/null +++ b/docs/topology_selection/examples/group_properties_with_keys.py @@ -0,0 +1,61 @@ +import os +from copy import copy + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + with BuildSketch(Plane.XZ) as sketch: + with BuildLine(): + CenterArc((-6, 12), 10, 0, 360) + Line((-16, 0), (16, 0)) + make_hull() + Rectangle(50, 5, align=(Align.CENTER, Align.MAX)) + + extrude(amount=12) + + Box(38, 6, 22, align=(Align.CENTER, Align.MAX, Align.MIN), mode=Mode.SUBTRACT) + + circle = part.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Y)[0] + with Locations(Plane(circle.arc_center, z_dir=circle.normal())): + CounterBoreHole(13 / 2, 16 / 2, 4) + + mirror(about=Plane.XZ) + + before_fillet = copy(part) + + length_groups = part.edges().group_by(Edge.length) + fillet(length_groups.group(6) + length_groups.group(5), 4) + + after_fillet = copy(part) + + with BuildSketch() as pins: + with Locations((-21, 0)): + Circle(3 / 2) + with Locations((21, 0)): + SlotCenterToCenter(1, 3) + extrude(amount=-12, mode=Mode.SUBTRACT) + + with GridLocations(42, 16, 2, 2): + CounterBoreHole(3.5 / 2, 3.5, 0) + + after_holes = copy(part) + + radius_groups = part.edges().filter_by(GeomType.CIRCLE).group_by(Edge.radius) + bearing_edges = radius_groups.group(8).group_by(SortBy.DISTANCE)[-1] + pin_edges = radius_groups.group(1.5).filter_by_position(Axis.Z, -5, -5) + chamfer([pin_edges, bearing_edges], .5) + +location = Location((-20, -20)) +items = [before_fillet.part] + length_groups.group(6) + length_groups.group(5) +before = Compound(items).move(location) +show(before, after_fillet.part.move(Location((20, 20)))) +save_screenshot(os.path.join(filedir, "group_length_key.png")) + +location = Location((-20, -20), (180, 0, 0)) +after = Compound([after_holes.part] + pin_edges + bearing_edges).move(location) +show(after, part.part.move(Location((20, 20), (180, 0, 0)))) +save_screenshot(os.path.join(filedir, "group_radius_key.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/nema-17-bracket.step b/docs/topology_selection/examples/nema-17-bracket.step new file mode 100644 index 0000000..c950780 --- /dev/null +++ b/docs/topology_selection/examples/nema-17-bracket.step @@ -0,0 +1,4030 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('Open CASCADE Model'),'2;1'); +FILE_NAME('nema-17-bracket','2025-04-01T21:12:35',('Author'),( + 'Open CASCADE'),'Open CASCADE STEP processor 7.8','build123d', + 'Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('nema-17-bracket','nema-17-bracket','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#3331); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#348,#513,#567,#616,#739,#788,#815,#869,#923, + #977,#1031,#1085,#1907,#1956,#2625,#2649,#2676,#2683,#2730,#2757, + #2784,#2791,#2838,#2865,#2892,#2899,#2946,#2973,#3000,#3007,#3054, + #3081,#3108,#3115,#3162,#3189,#3216,#3223,#3270,#3297,#3324)); +#17 = ADVANCED_FACE('',(#18,#193,#224,#255,#286,#317),#32,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#55,#83,#111,#139,#167)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(-4.440892098501E-16,-20.5,3.)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(0.,-20.5,49.)); +#26 = SURFACE_CURVE('',#27,(#31,#43),.PCURVE_S1.); +#27 = LINE('',#28,#29); +#28 = CARTESIAN_POINT('',(0.,-20.5,0.)); +#29 = VECTOR('',#30,1.); +#30 = DIRECTION('',(0.,0.,1.)); +#31 = PCURVE('',#32,#37); +#32 = PLANE('',#33); +#33 = AXIS2_PLACEMENT_3D('',#34,#35,#36); +#34 = CARTESIAN_POINT('',(0.,-20.5,0.)); +#35 = DIRECTION('',(1.,0.,0.)); +#36 = DIRECTION('',(0.,0.,1.)); +#37 = DEFINITIONAL_REPRESENTATION('',(#38),#42); +#38 = LINE('',#39,#40); +#39 = CARTESIAN_POINT('',(0.,0.)); +#40 = VECTOR('',#41,1.); +#41 = DIRECTION('',(1.,0.)); +#42 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#43 = PCURVE('',#44,#49); +#44 = PLANE('',#45); +#45 = AXIS2_PLACEMENT_3D('',#46,#47,#48); +#46 = CARTESIAN_POINT('',(0.,-20.5,0.)); +#47 = DIRECTION('',(0.,1.,0.)); +#48 = DIRECTION('',(0.,0.,1.)); +#49 = DEFINITIONAL_REPRESENTATION('',(#50),#54); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(0.,0.)); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(1.,0.)); +#54 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#55 = ORIENTED_EDGE('',*,*,#56,.T.); +#56 = EDGE_CURVE('',#22,#57,#59,.T.); +#57 = VERTEX_POINT('',#58); +#58 = CARTESIAN_POINT('',(-4.440892098501E-16,20.5,3.)); +#59 = SURFACE_CURVE('',#60,(#64,#71),.PCURVE_S1.); +#60 = LINE('',#61,#62); +#61 = CARTESIAN_POINT('',(-4.440892098501E-16,-20.5,3.)); +#62 = VECTOR('',#63,1.); +#63 = DIRECTION('',(0.,1.,0.)); +#64 = PCURVE('',#32,#65); +#65 = DEFINITIONAL_REPRESENTATION('',(#66),#70); +#66 = LINE('',#67,#68); +#67 = CARTESIAN_POINT('',(3.,0.)); +#68 = VECTOR('',#69,1.); +#69 = DIRECTION('',(0.,-1.)); +#70 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#71 = PCURVE('',#72,#77); +#72 = CYLINDRICAL_SURFACE('',#73,3.); +#73 = AXIS2_PLACEMENT_3D('',#74,#75,#76); +#74 = CARTESIAN_POINT('',(3.,-20.5,3.)); +#75 = DIRECTION('',(0.,1.,0.)); +#76 = DIRECTION('',(-1.,0.,0.)); +#77 = DEFINITIONAL_REPRESENTATION('',(#78),#82); +#78 = LINE('',#79,#80); +#79 = CARTESIAN_POINT('',(-0.,0.)); +#80 = VECTOR('',#81,1.); +#81 = DIRECTION('',(-0.,1.)); +#82 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#83 = ORIENTED_EDGE('',*,*,#84,.F.); +#84 = EDGE_CURVE('',#85,#57,#87,.T.); +#85 = VERTEX_POINT('',#86); +#86 = CARTESIAN_POINT('',(0.,20.5,49.)); +#87 = SURFACE_CURVE('',#88,(#92,#99),.PCURVE_S1.); +#88 = LINE('',#89,#90); +#89 = CARTESIAN_POINT('',(0.,20.5,51.)); +#90 = VECTOR('',#91,1.); +#91 = DIRECTION('',(0.,0.,-1.)); +#92 = PCURVE('',#32,#93); +#93 = DEFINITIONAL_REPRESENTATION('',(#94),#98); +#94 = LINE('',#95,#96); +#95 = CARTESIAN_POINT('',(51.,-41.)); +#96 = VECTOR('',#97,1.); +#97 = DIRECTION('',(-1.,0.)); +#98 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#99 = PCURVE('',#100,#105); +#100 = PLANE('',#101); +#101 = AXIS2_PLACEMENT_3D('',#102,#103,#104); +#102 = CARTESIAN_POINT('',(0.,20.5,0.)); +#103 = DIRECTION('',(0.,1.,0.)); +#104 = DIRECTION('',(0.,0.,1.)); +#105 = DEFINITIONAL_REPRESENTATION('',(#106),#110); +#106 = LINE('',#107,#108); +#107 = CARTESIAN_POINT('',(51.,0.)); +#108 = VECTOR('',#109,1.); +#109 = DIRECTION('',(-1.,0.)); +#110 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#111 = ORIENTED_EDGE('',*,*,#112,.T.); +#112 = EDGE_CURVE('',#85,#113,#115,.T.); +#113 = VERTEX_POINT('',#114); +#114 = CARTESIAN_POINT('',(0.,18.5,51.)); +#115 = SURFACE_CURVE('',#116,(#120,#127),.PCURVE_S1.); +#116 = LINE('',#117,#118); +#117 = CARTESIAN_POINT('',(0.,22.,47.5)); +#118 = VECTOR('',#119,1.); +#119 = DIRECTION('',(0.,-0.707106781187,0.707106781187)); +#120 = PCURVE('',#32,#121); +#121 = DEFINITIONAL_REPRESENTATION('',(#122),#126); +#122 = LINE('',#123,#124); +#123 = CARTESIAN_POINT('',(47.5,-42.5)); +#124 = VECTOR('',#125,1.); +#125 = DIRECTION('',(0.707106781187,0.707106781187)); +#126 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#127 = PCURVE('',#128,#133); +#128 = PLANE('',#129); +#129 = AXIS2_PLACEMENT_3D('',#130,#131,#132); +#130 = CARTESIAN_POINT('',(0.,19.5,50.)); +#131 = DIRECTION('',(0.,0.707106781187,0.707106781187)); +#132 = DIRECTION('',(-1.,-0.,0.)); +#133 = DEFINITIONAL_REPRESENTATION('',(#134),#138); +#134 = LINE('',#135,#136); +#135 = CARTESIAN_POINT('',(-0.,-3.535533905933)); +#136 = VECTOR('',#137,1.); +#137 = DIRECTION('',(-0.,1.)); +#138 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#139 = ORIENTED_EDGE('',*,*,#140,.F.); +#140 = EDGE_CURVE('',#141,#113,#143,.T.); +#141 = VERTEX_POINT('',#142); +#142 = CARTESIAN_POINT('',(0.,-18.5,51.)); +#143 = SURFACE_CURVE('',#144,(#148,#155),.PCURVE_S1.); +#144 = LINE('',#145,#146); +#145 = CARTESIAN_POINT('',(0.,-20.5,51.)); +#146 = VECTOR('',#147,1.); +#147 = DIRECTION('',(0.,1.,0.)); +#148 = PCURVE('',#32,#149); +#149 = DEFINITIONAL_REPRESENTATION('',(#150),#154); +#150 = LINE('',#151,#152); +#151 = CARTESIAN_POINT('',(51.,0.)); +#152 = VECTOR('',#153,1.); +#153 = DIRECTION('',(0.,-1.)); +#154 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#155 = PCURVE('',#156,#161); +#156 = PLANE('',#157); +#157 = AXIS2_PLACEMENT_3D('',#158,#159,#160); +#158 = CARTESIAN_POINT('',(0.,-20.5,51.)); +#159 = DIRECTION('',(0.,0.,1.)); +#160 = DIRECTION('',(1.,0.,0.)); +#161 = DEFINITIONAL_REPRESENTATION('',(#162),#166); +#162 = LINE('',#163,#164); +#163 = CARTESIAN_POINT('',(0.,0.)); +#164 = VECTOR('',#165,1.); +#165 = DIRECTION('',(0.,1.)); +#166 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#167 = ORIENTED_EDGE('',*,*,#168,.F.); +#168 = EDGE_CURVE('',#24,#141,#169,.T.); +#169 = SURFACE_CURVE('',#170,(#174,#181),.PCURVE_S1.); +#170 = LINE('',#171,#172); +#171 = CARTESIAN_POINT('',(0.,-32.25,37.25)); +#172 = VECTOR('',#173,1.); +#173 = DIRECTION('',(-0.,0.707106781187,0.707106781187)); +#174 = PCURVE('',#32,#175); +#175 = DEFINITIONAL_REPRESENTATION('',(#176),#180); +#176 = LINE('',#177,#178); +#177 = CARTESIAN_POINT('',(37.25,11.75)); +#178 = VECTOR('',#179,1.); +#179 = DIRECTION('',(0.707106781187,-0.707106781187)); +#180 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#181 = PCURVE('',#182,#187); +#182 = PLANE('',#183); +#183 = AXIS2_PLACEMENT_3D('',#184,#185,#186); +#184 = CARTESIAN_POINT('',(0.,-19.5,50.)); +#185 = DIRECTION('',(0.,0.707106781187,-0.707106781187)); +#186 = DIRECTION('',(-1.,-0.,-0.)); +#187 = DEFINITIONAL_REPRESENTATION('',(#188),#192); +#188 = LINE('',#189,#190); +#189 = CARTESIAN_POINT('',(-0.,-18.03122292025)); +#190 = VECTOR('',#191,1.); +#191 = DIRECTION('',(-0.,1.)); +#192 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#193 = FACE_BOUND('',#194,.F.); +#194 = EDGE_LOOP('',(#195)); +#195 = ORIENTED_EDGE('',*,*,#196,.F.); +#196 = EDGE_CURVE('',#197,#197,#199,.T.); +#197 = VERTEX_POINT('',#198); +#198 = CARTESIAN_POINT('',(0.,17.15,14.5)); +#199 = SURFACE_CURVE('',#200,(#205,#212),.PCURVE_S1.); +#200 = CIRCLE('',#201,1.65); +#201 = AXIS2_PLACEMENT_3D('',#202,#203,#204); +#202 = CARTESIAN_POINT('',(0.,15.5,14.5)); +#203 = DIRECTION('',(1.,0.,0.)); +#204 = DIRECTION('',(0.,1.,0.)); +#205 = PCURVE('',#32,#206); +#206 = DEFINITIONAL_REPRESENTATION('',(#207),#211); +#207 = CIRCLE('',#208,1.65); +#208 = AXIS2_PLACEMENT_2D('',#209,#210); +#209 = CARTESIAN_POINT('',(14.5,-36.)); +#210 = DIRECTION('',(0.,-1.)); +#211 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#212 = PCURVE('',#213,#218); +#213 = CYLINDRICAL_SURFACE('',#214,1.65); +#214 = AXIS2_PLACEMENT_3D('',#215,#216,#217); +#215 = CARTESIAN_POINT('',(0.,15.5,14.5)); +#216 = DIRECTION('',(-1.,-0.,-0.)); +#217 = DIRECTION('',(0.,1.,0.)); +#218 = DEFINITIONAL_REPRESENTATION('',(#219),#223); +#219 = LINE('',#220,#221); +#220 = CARTESIAN_POINT('',(-0.,0.)); +#221 = VECTOR('',#222,1.); +#222 = DIRECTION('',(-1.,0.)); +#223 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#224 = FACE_BOUND('',#225,.F.); +#225 = EDGE_LOOP('',(#226)); +#226 = ORIENTED_EDGE('',*,*,#227,.F.); +#227 = EDGE_CURVE('',#228,#228,#230,.T.); +#228 = VERTEX_POINT('',#229); +#229 = CARTESIAN_POINT('',(0.,17.15,45.5)); +#230 = SURFACE_CURVE('',#231,(#236,#243),.PCURVE_S1.); +#231 = CIRCLE('',#232,1.65); +#232 = AXIS2_PLACEMENT_3D('',#233,#234,#235); +#233 = CARTESIAN_POINT('',(0.,15.5,45.5)); +#234 = DIRECTION('',(1.,0.,0.)); +#235 = DIRECTION('',(0.,1.,0.)); +#236 = PCURVE('',#32,#237); +#237 = DEFINITIONAL_REPRESENTATION('',(#238),#242); +#238 = CIRCLE('',#239,1.65); +#239 = AXIS2_PLACEMENT_2D('',#240,#241); +#240 = CARTESIAN_POINT('',(45.5,-36.)); +#241 = DIRECTION('',(0.,-1.)); +#242 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#243 = PCURVE('',#244,#249); +#244 = CYLINDRICAL_SURFACE('',#245,1.65); +#245 = AXIS2_PLACEMENT_3D('',#246,#247,#248); +#246 = CARTESIAN_POINT('',(0.,15.5,45.5)); +#247 = DIRECTION('',(-1.,-0.,-0.)); +#248 = DIRECTION('',(0.,1.,0.)); +#249 = DEFINITIONAL_REPRESENTATION('',(#250),#254); +#250 = LINE('',#251,#252); +#251 = CARTESIAN_POINT('',(-0.,0.)); +#252 = VECTOR('',#253,1.); +#253 = DIRECTION('',(-1.,0.)); +#254 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#255 = FACE_BOUND('',#256,.F.); +#256 = EDGE_LOOP('',(#257)); +#257 = ORIENTED_EDGE('',*,*,#258,.F.); +#258 = EDGE_CURVE('',#259,#259,#261,.T.); +#259 = VERTEX_POINT('',#260); +#260 = CARTESIAN_POINT('',(0.,-13.85,14.5)); +#261 = SURFACE_CURVE('',#262,(#267,#274),.PCURVE_S1.); +#262 = CIRCLE('',#263,1.65); +#263 = AXIS2_PLACEMENT_3D('',#264,#265,#266); +#264 = CARTESIAN_POINT('',(0.,-15.5,14.5)); +#265 = DIRECTION('',(1.,0.,0.)); +#266 = DIRECTION('',(0.,1.,0.)); +#267 = PCURVE('',#32,#268); +#268 = DEFINITIONAL_REPRESENTATION('',(#269),#273); +#269 = CIRCLE('',#270,1.65); +#270 = AXIS2_PLACEMENT_2D('',#271,#272); +#271 = CARTESIAN_POINT('',(14.5,-5.)); +#272 = DIRECTION('',(0.,-1.)); +#273 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#274 = PCURVE('',#275,#280); +#275 = CYLINDRICAL_SURFACE('',#276,1.65); +#276 = AXIS2_PLACEMENT_3D('',#277,#278,#279); +#277 = CARTESIAN_POINT('',(0.,-15.5,14.5)); +#278 = DIRECTION('',(-1.,-0.,-0.)); +#279 = DIRECTION('',(0.,1.,0.)); +#280 = DEFINITIONAL_REPRESENTATION('',(#281),#285); +#281 = LINE('',#282,#283); +#282 = CARTESIAN_POINT('',(-0.,0.)); +#283 = VECTOR('',#284,1.); +#284 = DIRECTION('',(-1.,0.)); +#285 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#286 = FACE_BOUND('',#287,.F.); +#287 = EDGE_LOOP('',(#288)); +#288 = ORIENTED_EDGE('',*,*,#289,.F.); +#289 = EDGE_CURVE('',#290,#290,#292,.T.); +#290 = VERTEX_POINT('',#291); +#291 = CARTESIAN_POINT('',(0.,16.,30.)); +#292 = SURFACE_CURVE('',#293,(#298,#305),.PCURVE_S1.); +#293 = CIRCLE('',#294,16.); +#294 = AXIS2_PLACEMENT_3D('',#295,#296,#297); +#295 = CARTESIAN_POINT('',(0.,0.,30.)); +#296 = DIRECTION('',(1.,0.,0.)); +#297 = DIRECTION('',(0.,1.,0.)); +#298 = PCURVE('',#32,#299); +#299 = DEFINITIONAL_REPRESENTATION('',(#300),#304); +#300 = CIRCLE('',#301,16.); +#301 = AXIS2_PLACEMENT_2D('',#302,#303); +#302 = CARTESIAN_POINT('',(30.,-20.5)); +#303 = DIRECTION('',(0.,-1.)); +#304 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#305 = PCURVE('',#306,#311); +#306 = CYLINDRICAL_SURFACE('',#307,16.); +#307 = AXIS2_PLACEMENT_3D('',#308,#309,#310); +#308 = CARTESIAN_POINT('',(0.,0.,30.)); +#309 = DIRECTION('',(-1.,-0.,-0.)); +#310 = DIRECTION('',(0.,1.,0.)); +#311 = DEFINITIONAL_REPRESENTATION('',(#312),#316); +#312 = LINE('',#313,#314); +#313 = CARTESIAN_POINT('',(-0.,0.)); +#314 = VECTOR('',#315,1.); +#315 = DIRECTION('',(-1.,0.)); +#316 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#317 = FACE_BOUND('',#318,.F.); +#318 = EDGE_LOOP('',(#319)); +#319 = ORIENTED_EDGE('',*,*,#320,.F.); +#320 = EDGE_CURVE('',#321,#321,#323,.T.); +#321 = VERTEX_POINT('',#322); +#322 = CARTESIAN_POINT('',(0.,-13.85,45.5)); +#323 = SURFACE_CURVE('',#324,(#329,#336),.PCURVE_S1.); +#324 = CIRCLE('',#325,1.65); +#325 = AXIS2_PLACEMENT_3D('',#326,#327,#328); +#326 = CARTESIAN_POINT('',(0.,-15.5,45.5)); +#327 = DIRECTION('',(1.,0.,0.)); +#328 = DIRECTION('',(0.,1.,0.)); +#329 = PCURVE('',#32,#330); +#330 = DEFINITIONAL_REPRESENTATION('',(#331),#335); +#331 = CIRCLE('',#332,1.65); +#332 = AXIS2_PLACEMENT_2D('',#333,#334); +#333 = CARTESIAN_POINT('',(45.5,-5.)); +#334 = DIRECTION('',(0.,-1.)); +#335 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#336 = PCURVE('',#337,#342); +#337 = CYLINDRICAL_SURFACE('',#338,1.65); +#338 = AXIS2_PLACEMENT_3D('',#339,#340,#341); +#339 = CARTESIAN_POINT('',(0.,-15.5,45.5)); +#340 = DIRECTION('',(-1.,-0.,-0.)); +#341 = DIRECTION('',(0.,1.,0.)); +#342 = DEFINITIONAL_REPRESENTATION('',(#343),#347); +#343 = LINE('',#344,#345); +#344 = CARTESIAN_POINT('',(-0.,0.)); +#345 = VECTOR('',#346,1.); +#346 = DIRECTION('',(-1.,0.)); +#347 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#348 = ADVANCED_FACE('',(#349),#44,.F.); +#349 = FACE_BOUND('',#350,.F.); +#350 = EDGE_LOOP('',(#351,#381,#407,#408,#431,#459,#487)); +#351 = ORIENTED_EDGE('',*,*,#352,.F.); +#352 = EDGE_CURVE('',#353,#355,#357,.T.); +#353 = VERTEX_POINT('',#354); +#354 = CARTESIAN_POINT('',(3.,-20.5,-4.440892098501E-16)); +#355 = VERTEX_POINT('',#356); +#356 = CARTESIAN_POINT('',(33.,-20.5,0.)); +#357 = SURFACE_CURVE('',#358,(#362,#369),.PCURVE_S1.); +#358 = LINE('',#359,#360); +#359 = CARTESIAN_POINT('',(0.,-20.5,0.)); +#360 = VECTOR('',#361,1.); +#361 = DIRECTION('',(1.,0.,0.)); +#362 = PCURVE('',#44,#363); +#363 = DEFINITIONAL_REPRESENTATION('',(#364),#368); +#364 = LINE('',#365,#366); +#365 = CARTESIAN_POINT('',(0.,0.)); +#366 = VECTOR('',#367,1.); +#367 = DIRECTION('',(0.,1.)); +#368 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#369 = PCURVE('',#370,#375); +#370 = PLANE('',#371); +#371 = AXIS2_PLACEMENT_3D('',#372,#373,#374); +#372 = CARTESIAN_POINT('',(0.,-20.5,0.)); +#373 = DIRECTION('',(0.,0.,1.)); +#374 = DIRECTION('',(1.,0.,0.)); +#375 = DEFINITIONAL_REPRESENTATION('',(#376),#380); +#376 = LINE('',#377,#378); +#377 = CARTESIAN_POINT('',(0.,0.)); +#378 = VECTOR('',#379,1.); +#379 = DIRECTION('',(1.,0.)); +#380 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#381 = ORIENTED_EDGE('',*,*,#382,.F.); +#382 = EDGE_CURVE('',#22,#353,#383,.T.); +#383 = SURFACE_CURVE('',#384,(#389,#400),.PCURVE_S1.); +#384 = CIRCLE('',#385,3.); +#385 = AXIS2_PLACEMENT_3D('',#386,#387,#388); +#386 = CARTESIAN_POINT('',(3.,-20.5,3.)); +#387 = DIRECTION('',(-0.,-1.,0.)); +#388 = DIRECTION('',(0.,-0.,1.)); +#389 = PCURVE('',#44,#390); +#390 = DEFINITIONAL_REPRESENTATION('',(#391),#399); +#391 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#392,#393,#394,#395,#396,#397 +,#398),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),( + -2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718, +8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() +RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM( + '') ); +#392 = CARTESIAN_POINT('',(6.,3.)); +#393 = CARTESIAN_POINT('',(6.,-2.196152422707)); +#394 = CARTESIAN_POINT('',(1.5,0.401923788647)); +#395 = CARTESIAN_POINT('',(-3.,3.)); +#396 = CARTESIAN_POINT('',(1.5,5.598076211353)); +#397 = CARTESIAN_POINT('',(6.,8.196152422707)); +#398 = CARTESIAN_POINT('',(6.,3.)); +#399 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#400 = PCURVE('',#72,#401); +#401 = DEFINITIONAL_REPRESENTATION('',(#402),#406); +#402 = LINE('',#403,#404); +#403 = CARTESIAN_POINT('',(1.570796326795,-0.)); +#404 = VECTOR('',#405,1.); +#405 = DIRECTION('',(-1.,0.)); +#406 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#407 = ORIENTED_EDGE('',*,*,#21,.T.); +#408 = ORIENTED_EDGE('',*,*,#409,.T.); +#409 = EDGE_CURVE('',#24,#410,#412,.T.); +#410 = VERTEX_POINT('',#411); +#411 = CARTESIAN_POINT('',(3.,-20.5,49.)); +#412 = SURFACE_CURVE('',#413,(#417,#424),.PCURVE_S1.); +#413 = LINE('',#414,#415); +#414 = CARTESIAN_POINT('',(0.,-20.5,49.)); +#415 = VECTOR('',#416,1.); +#416 = DIRECTION('',(1.,0.,0.)); +#417 = PCURVE('',#44,#418); +#418 = DEFINITIONAL_REPRESENTATION('',(#419),#423); +#419 = LINE('',#420,#421); +#420 = CARTESIAN_POINT('',(49.,0.)); +#421 = VECTOR('',#422,1.); +#422 = DIRECTION('',(0.,1.)); +#423 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#424 = PCURVE('',#182,#425); +#425 = DEFINITIONAL_REPRESENTATION('',(#426),#430); +#426 = LINE('',#427,#428); +#427 = CARTESIAN_POINT('',(-0.,-1.414213562373)); +#428 = VECTOR('',#429,1.); +#429 = DIRECTION('',(-1.,0.)); +#430 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#431 = ORIENTED_EDGE('',*,*,#432,.F.); +#432 = EDGE_CURVE('',#433,#410,#435,.T.); +#433 = VERTEX_POINT('',#434); +#434 = CARTESIAN_POINT('',(3.,-20.5,3.)); +#435 = SURFACE_CURVE('',#436,(#440,#447),.PCURVE_S1.); +#436 = LINE('',#437,#438); +#437 = CARTESIAN_POINT('',(3.,-20.5,0.)); +#438 = VECTOR('',#439,1.); +#439 = DIRECTION('',(0.,0.,1.)); +#440 = PCURVE('',#44,#441); +#441 = DEFINITIONAL_REPRESENTATION('',(#442),#446); +#442 = LINE('',#443,#444); +#443 = CARTESIAN_POINT('',(0.,3.)); +#444 = VECTOR('',#445,1.); +#445 = DIRECTION('',(1.,0.)); +#446 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#447 = PCURVE('',#448,#453); +#448 = PLANE('',#449); +#449 = AXIS2_PLACEMENT_3D('',#450,#451,#452); +#450 = CARTESIAN_POINT('',(3.,-20.5,0.)); +#451 = DIRECTION('',(1.,0.,0.)); +#452 = DIRECTION('',(0.,0.,1.)); +#453 = DEFINITIONAL_REPRESENTATION('',(#454),#458); +#454 = LINE('',#455,#456); +#455 = CARTESIAN_POINT('',(0.,0.)); +#456 = VECTOR('',#457,1.); +#457 = DIRECTION('',(1.,0.)); +#458 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#459 = ORIENTED_EDGE('',*,*,#460,.T.); +#460 = EDGE_CURVE('',#433,#461,#463,.T.); +#461 = VERTEX_POINT('',#462); +#462 = CARTESIAN_POINT('',(33.,-20.5,3.)); +#463 = SURFACE_CURVE('',#464,(#468,#475),.PCURVE_S1.); +#464 = LINE('',#465,#466); +#465 = CARTESIAN_POINT('',(0.,-20.5,3.)); +#466 = VECTOR('',#467,1.); +#467 = DIRECTION('',(1.,0.,0.)); +#468 = PCURVE('',#44,#469); +#469 = DEFINITIONAL_REPRESENTATION('',(#470),#474); +#470 = LINE('',#471,#472); +#471 = CARTESIAN_POINT('',(3.,0.)); +#472 = VECTOR('',#473,1.); +#473 = DIRECTION('',(0.,1.)); +#474 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#475 = PCURVE('',#476,#481); +#476 = PLANE('',#477); +#477 = AXIS2_PLACEMENT_3D('',#478,#479,#480); +#478 = CARTESIAN_POINT('',(0.,-20.5,3.)); +#479 = DIRECTION('',(0.,0.,1.)); +#480 = DIRECTION('',(1.,0.,0.)); +#481 = DEFINITIONAL_REPRESENTATION('',(#482),#486); +#482 = LINE('',#483,#484); +#483 = CARTESIAN_POINT('',(0.,0.)); +#484 = VECTOR('',#485,1.); +#485 = DIRECTION('',(1.,0.)); +#486 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#487 = ORIENTED_EDGE('',*,*,#488,.F.); +#488 = EDGE_CURVE('',#355,#461,#489,.T.); +#489 = SURFACE_CURVE('',#490,(#494,#501),.PCURVE_S1.); +#490 = LINE('',#491,#492); +#491 = CARTESIAN_POINT('',(33.,-20.5,0.)); +#492 = VECTOR('',#493,1.); +#493 = DIRECTION('',(0.,0.,1.)); +#494 = PCURVE('',#44,#495); +#495 = DEFINITIONAL_REPRESENTATION('',(#496),#500); +#496 = LINE('',#497,#498); +#497 = CARTESIAN_POINT('',(0.,33.)); +#498 = VECTOR('',#499,1.); +#499 = DIRECTION('',(1.,0.)); +#500 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#501 = PCURVE('',#502,#507); +#502 = PLANE('',#503); +#503 = AXIS2_PLACEMENT_3D('',#504,#505,#506); +#504 = CARTESIAN_POINT('',(34.,-19.5,0.)); +#505 = DIRECTION('',(-0.707106781187,0.707106781187,0.)); +#506 = DIRECTION('',(0.,0.,1.)); +#507 = DEFINITIONAL_REPRESENTATION('',(#508),#512); +#508 = LINE('',#509,#510); +#509 = CARTESIAN_POINT('',(0.,-1.414213562373)); +#510 = VECTOR('',#511,1.); +#511 = DIRECTION('',(1.,0.)); +#512 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#513 = ADVANCED_FACE('',(#514),#72,.T.); +#514 = FACE_BOUND('',#515,.F.); +#515 = EDGE_LOOP('',(#516,#517,#540,#566)); +#516 = ORIENTED_EDGE('',*,*,#382,.T.); +#517 = ORIENTED_EDGE('',*,*,#518,.T.); +#518 = EDGE_CURVE('',#353,#519,#521,.T.); +#519 = VERTEX_POINT('',#520); +#520 = CARTESIAN_POINT('',(3.,20.5,-4.440892098501E-16)); +#521 = SURFACE_CURVE('',#522,(#526,#533),.PCURVE_S1.); +#522 = LINE('',#523,#524); +#523 = CARTESIAN_POINT('',(3.,-20.5,-4.440892098501E-16)); +#524 = VECTOR('',#525,1.); +#525 = DIRECTION('',(0.,1.,0.)); +#526 = PCURVE('',#72,#527); +#527 = DEFINITIONAL_REPRESENTATION('',(#528),#532); +#528 = LINE('',#529,#530); +#529 = CARTESIAN_POINT('',(-1.570796326795,0.)); +#530 = VECTOR('',#531,1.); +#531 = DIRECTION('',(-0.,1.)); +#532 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#533 = PCURVE('',#370,#534); +#534 = DEFINITIONAL_REPRESENTATION('',(#535),#539); +#535 = LINE('',#536,#537); +#536 = CARTESIAN_POINT('',(3.,0.)); +#537 = VECTOR('',#538,1.); +#538 = DIRECTION('',(0.,1.)); +#539 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#540 = ORIENTED_EDGE('',*,*,#541,.F.); +#541 = EDGE_CURVE('',#57,#519,#542,.T.); +#542 = SURFACE_CURVE('',#543,(#548,#555),.PCURVE_S1.); +#543 = CIRCLE('',#544,3.); +#544 = AXIS2_PLACEMENT_3D('',#545,#546,#547); +#545 = CARTESIAN_POINT('',(3.,20.5,3.)); +#546 = DIRECTION('',(-0.,-1.,0.)); +#547 = DIRECTION('',(0.,-0.,1.)); +#548 = PCURVE('',#72,#549); +#549 = DEFINITIONAL_REPRESENTATION('',(#550),#554); +#550 = LINE('',#551,#552); +#551 = CARTESIAN_POINT('',(1.570796326795,41.)); +#552 = VECTOR('',#553,1.); +#553 = DIRECTION('',(-1.,0.)); +#554 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#555 = PCURVE('',#100,#556); +#556 = DEFINITIONAL_REPRESENTATION('',(#557),#565); +#557 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#558,#559,#560,#561,#562,#563 +,#564),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),( + -2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718, +8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() +RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM( + '') ); +#558 = CARTESIAN_POINT('',(6.,3.)); +#559 = CARTESIAN_POINT('',(6.,-2.196152422707)); +#560 = CARTESIAN_POINT('',(1.5,0.401923788647)); +#561 = CARTESIAN_POINT('',(-3.,3.)); +#562 = CARTESIAN_POINT('',(1.5,5.598076211353)); +#563 = CARTESIAN_POINT('',(6.,8.196152422707)); +#564 = CARTESIAN_POINT('',(6.,3.)); +#565 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#566 = ORIENTED_EDGE('',*,*,#56,.F.); +#567 = ADVANCED_FACE('',(#568),#182,.F.); +#568 = FACE_BOUND('',#569,.T.); +#569 = EDGE_LOOP('',(#570,#571,#572,#595)); +#570 = ORIENTED_EDGE('',*,*,#168,.F.); +#571 = ORIENTED_EDGE('',*,*,#409,.T.); +#572 = ORIENTED_EDGE('',*,*,#573,.T.); +#573 = EDGE_CURVE('',#410,#574,#576,.T.); +#574 = VERTEX_POINT('',#575); +#575 = CARTESIAN_POINT('',(3.,-18.5,51.)); +#576 = SURFACE_CURVE('',#577,(#581,#588),.PCURVE_S1.); +#577 = LINE('',#578,#579); +#578 = CARTESIAN_POINT('',(3.,-32.25,37.25)); +#579 = VECTOR('',#580,1.); +#580 = DIRECTION('',(-0.,0.707106781187,0.707106781187)); +#581 = PCURVE('',#182,#582); +#582 = DEFINITIONAL_REPRESENTATION('',(#583),#587); +#583 = LINE('',#584,#585); +#584 = CARTESIAN_POINT('',(-3.,-18.03122292025)); +#585 = VECTOR('',#586,1.); +#586 = DIRECTION('',(-0.,1.)); +#587 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#588 = PCURVE('',#448,#589); +#589 = DEFINITIONAL_REPRESENTATION('',(#590),#594); +#590 = LINE('',#591,#592); +#591 = CARTESIAN_POINT('',(37.25,11.75)); +#592 = VECTOR('',#593,1.); +#593 = DIRECTION('',(0.707106781187,-0.707106781187)); +#594 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#595 = ORIENTED_EDGE('',*,*,#596,.F.); +#596 = EDGE_CURVE('',#141,#574,#597,.T.); +#597 = SURFACE_CURVE('',#598,(#602,#609),.PCURVE_S1.); +#598 = LINE('',#599,#600); +#599 = CARTESIAN_POINT('',(0.,-18.5,51.)); +#600 = VECTOR('',#601,1.); +#601 = DIRECTION('',(1.,0.,0.)); +#602 = PCURVE('',#182,#603); +#603 = DEFINITIONAL_REPRESENTATION('',(#604),#608); +#604 = LINE('',#605,#606); +#605 = CARTESIAN_POINT('',(-0.,1.414213562373)); +#606 = VECTOR('',#607,1.); +#607 = DIRECTION('',(-1.,0.)); +#608 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#609 = PCURVE('',#156,#610); +#610 = DEFINITIONAL_REPRESENTATION('',(#611),#615); +#611 = LINE('',#612,#613); +#612 = CARTESIAN_POINT('',(0.,2.)); +#613 = VECTOR('',#614,1.); +#614 = DIRECTION('',(1.,0.)); +#615 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#616 = ADVANCED_FACE('',(#617),#100,.T.); +#617 = FACE_BOUND('',#618,.T.); +#618 = EDGE_LOOP('',(#619,#620,#643,#666,#689,#717,#738)); +#619 = ORIENTED_EDGE('',*,*,#84,.F.); +#620 = ORIENTED_EDGE('',*,*,#621,.T.); +#621 = EDGE_CURVE('',#85,#622,#624,.T.); +#622 = VERTEX_POINT('',#623); +#623 = CARTESIAN_POINT('',(3.,20.5,49.)); +#624 = SURFACE_CURVE('',#625,(#629,#636),.PCURVE_S1.); +#625 = LINE('',#626,#627); +#626 = CARTESIAN_POINT('',(0.,20.5,49.)); +#627 = VECTOR('',#628,1.); +#628 = DIRECTION('',(1.,0.,0.)); +#629 = PCURVE('',#100,#630); +#630 = DEFINITIONAL_REPRESENTATION('',(#631),#635); +#631 = LINE('',#632,#633); +#632 = CARTESIAN_POINT('',(49.,0.)); +#633 = VECTOR('',#634,1.); +#634 = DIRECTION('',(0.,1.)); +#635 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#636 = PCURVE('',#128,#637); +#637 = DEFINITIONAL_REPRESENTATION('',(#638),#642); +#638 = LINE('',#639,#640); +#639 = CARTESIAN_POINT('',(-0.,-1.414213562373)); +#640 = VECTOR('',#641,1.); +#641 = DIRECTION('',(-1.,0.)); +#642 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#643 = ORIENTED_EDGE('',*,*,#644,.F.); +#644 = EDGE_CURVE('',#645,#622,#647,.T.); +#645 = VERTEX_POINT('',#646); +#646 = CARTESIAN_POINT('',(3.,20.5,3.)); +#647 = SURFACE_CURVE('',#648,(#652,#659),.PCURVE_S1.); +#648 = LINE('',#649,#650); +#649 = CARTESIAN_POINT('',(3.,20.5,0.)); +#650 = VECTOR('',#651,1.); +#651 = DIRECTION('',(0.,0.,1.)); +#652 = PCURVE('',#100,#653); +#653 = DEFINITIONAL_REPRESENTATION('',(#654),#658); +#654 = LINE('',#655,#656); +#655 = CARTESIAN_POINT('',(0.,3.)); +#656 = VECTOR('',#657,1.); +#657 = DIRECTION('',(1.,0.)); +#658 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#659 = PCURVE('',#448,#660); +#660 = DEFINITIONAL_REPRESENTATION('',(#661),#665); +#661 = LINE('',#662,#663); +#662 = CARTESIAN_POINT('',(0.,-41.)); +#663 = VECTOR('',#664,1.); +#664 = DIRECTION('',(1.,0.)); +#665 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#666 = ORIENTED_EDGE('',*,*,#667,.T.); +#667 = EDGE_CURVE('',#645,#668,#670,.T.); +#668 = VERTEX_POINT('',#669); +#669 = CARTESIAN_POINT('',(33.,20.5,3.)); +#670 = SURFACE_CURVE('',#671,(#675,#682),.PCURVE_S1.); +#671 = LINE('',#672,#673); +#672 = CARTESIAN_POINT('',(0.,20.5,3.)); +#673 = VECTOR('',#674,1.); +#674 = DIRECTION('',(1.,0.,0.)); +#675 = PCURVE('',#100,#676); +#676 = DEFINITIONAL_REPRESENTATION('',(#677),#681); +#677 = LINE('',#678,#679); +#678 = CARTESIAN_POINT('',(3.,0.)); +#679 = VECTOR('',#680,1.); +#680 = DIRECTION('',(0.,1.)); +#681 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#682 = PCURVE('',#476,#683); +#683 = DEFINITIONAL_REPRESENTATION('',(#684),#688); +#684 = LINE('',#685,#686); +#685 = CARTESIAN_POINT('',(0.,41.)); +#686 = VECTOR('',#687,1.); +#687 = DIRECTION('',(1.,0.)); +#688 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#689 = ORIENTED_EDGE('',*,*,#690,.F.); +#690 = EDGE_CURVE('',#691,#668,#693,.T.); +#691 = VERTEX_POINT('',#692); +#692 = CARTESIAN_POINT('',(33.,20.5,0.)); +#693 = SURFACE_CURVE('',#694,(#698,#705),.PCURVE_S1.); +#694 = LINE('',#695,#696); +#695 = CARTESIAN_POINT('',(33.,20.5,0.)); +#696 = VECTOR('',#697,1.); +#697 = DIRECTION('',(0.,0.,1.)); +#698 = PCURVE('',#100,#699); +#699 = DEFINITIONAL_REPRESENTATION('',(#700),#704); +#700 = LINE('',#701,#702); +#701 = CARTESIAN_POINT('',(0.,33.)); +#702 = VECTOR('',#703,1.); +#703 = DIRECTION('',(1.,0.)); +#704 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#705 = PCURVE('',#706,#711); +#706 = PLANE('',#707); +#707 = AXIS2_PLACEMENT_3D('',#708,#709,#710); +#708 = CARTESIAN_POINT('',(34.,19.5,0.)); +#709 = DIRECTION('',(0.707106781187,0.707106781187,0.)); +#710 = DIRECTION('',(0.,-0.,1.)); +#711 = DEFINITIONAL_REPRESENTATION('',(#712),#716); +#712 = LINE('',#713,#714); +#713 = CARTESIAN_POINT('',(0.,-1.414213562373)); +#714 = VECTOR('',#715,1.); +#715 = DIRECTION('',(1.,0.)); +#716 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#717 = ORIENTED_EDGE('',*,*,#718,.T.); +#718 = EDGE_CURVE('',#691,#519,#719,.T.); +#719 = SURFACE_CURVE('',#720,(#724,#731),.PCURVE_S1.); +#720 = LINE('',#721,#722); +#721 = CARTESIAN_POINT('',(35.,20.5,0.)); +#722 = VECTOR('',#723,1.); +#723 = DIRECTION('',(-1.,0.,0.)); +#724 = PCURVE('',#100,#725); +#725 = DEFINITIONAL_REPRESENTATION('',(#726),#730); +#726 = LINE('',#727,#728); +#727 = CARTESIAN_POINT('',(0.,35.)); +#728 = VECTOR('',#729,1.); +#729 = DIRECTION('',(0.,-1.)); +#730 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#731 = PCURVE('',#370,#732); +#732 = DEFINITIONAL_REPRESENTATION('',(#733),#737); +#733 = LINE('',#734,#735); +#734 = CARTESIAN_POINT('',(35.,41.)); +#735 = VECTOR('',#736,1.); +#736 = DIRECTION('',(-1.,0.)); +#737 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#738 = ORIENTED_EDGE('',*,*,#541,.F.); +#739 = ADVANCED_FACE('',(#740),#156,.T.); +#740 = FACE_BOUND('',#741,.T.); +#741 = EDGE_LOOP('',(#742,#743,#744,#767)); +#742 = ORIENTED_EDGE('',*,*,#140,.F.); +#743 = ORIENTED_EDGE('',*,*,#596,.T.); +#744 = ORIENTED_EDGE('',*,*,#745,.T.); +#745 = EDGE_CURVE('',#574,#746,#748,.T.); +#746 = VERTEX_POINT('',#747); +#747 = CARTESIAN_POINT('',(3.,18.5,51.)); +#748 = SURFACE_CURVE('',#749,(#753,#760),.PCURVE_S1.); +#749 = LINE('',#750,#751); +#750 = CARTESIAN_POINT('',(3.,-20.5,51.)); +#751 = VECTOR('',#752,1.); +#752 = DIRECTION('',(0.,1.,0.)); +#753 = PCURVE('',#156,#754); +#754 = DEFINITIONAL_REPRESENTATION('',(#755),#759); +#755 = LINE('',#756,#757); +#756 = CARTESIAN_POINT('',(3.,0.)); +#757 = VECTOR('',#758,1.); +#758 = DIRECTION('',(0.,1.)); +#759 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#760 = PCURVE('',#448,#761); +#761 = DEFINITIONAL_REPRESENTATION('',(#762),#766); +#762 = LINE('',#763,#764); +#763 = CARTESIAN_POINT('',(51.,0.)); +#764 = VECTOR('',#765,1.); +#765 = DIRECTION('',(0.,-1.)); +#766 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#767 = ORIENTED_EDGE('',*,*,#768,.F.); +#768 = EDGE_CURVE('',#113,#746,#769,.T.); +#769 = SURFACE_CURVE('',#770,(#774,#781),.PCURVE_S1.); +#770 = LINE('',#771,#772); +#771 = CARTESIAN_POINT('',(0.,18.5,51.)); +#772 = VECTOR('',#773,1.); +#773 = DIRECTION('',(1.,0.,0.)); +#774 = PCURVE('',#156,#775); +#775 = DEFINITIONAL_REPRESENTATION('',(#776),#780); +#776 = LINE('',#777,#778); +#777 = CARTESIAN_POINT('',(0.,39.)); +#778 = VECTOR('',#779,1.); +#779 = DIRECTION('',(1.,0.)); +#780 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#781 = PCURVE('',#128,#782); +#782 = DEFINITIONAL_REPRESENTATION('',(#783),#787); +#783 = LINE('',#784,#785); +#784 = CARTESIAN_POINT('',(-0.,1.414213562373)); +#785 = VECTOR('',#786,1.); +#786 = DIRECTION('',(-1.,0.)); +#787 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#788 = ADVANCED_FACE('',(#789),#128,.T.); +#789 = FACE_BOUND('',#790,.F.); +#790 = EDGE_LOOP('',(#791,#792,#793,#814)); +#791 = ORIENTED_EDGE('',*,*,#112,.F.); +#792 = ORIENTED_EDGE('',*,*,#621,.T.); +#793 = ORIENTED_EDGE('',*,*,#794,.T.); +#794 = EDGE_CURVE('',#622,#746,#795,.T.); +#795 = SURFACE_CURVE('',#796,(#800,#807),.PCURVE_S1.); +#796 = LINE('',#797,#798); +#797 = CARTESIAN_POINT('',(3.,22.,47.5)); +#798 = VECTOR('',#799,1.); +#799 = DIRECTION('',(0.,-0.707106781187,0.707106781187)); +#800 = PCURVE('',#128,#801); +#801 = DEFINITIONAL_REPRESENTATION('',(#802),#806); +#802 = LINE('',#803,#804); +#803 = CARTESIAN_POINT('',(-3.,-3.535533905933)); +#804 = VECTOR('',#805,1.); +#805 = DIRECTION('',(-0.,1.)); +#806 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#807 = PCURVE('',#448,#808); +#808 = DEFINITIONAL_REPRESENTATION('',(#809),#813); +#809 = LINE('',#810,#811); +#810 = CARTESIAN_POINT('',(47.5,-42.5)); +#811 = VECTOR('',#812,1.); +#812 = DIRECTION('',(0.707106781187,0.707106781187)); +#813 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#814 = ORIENTED_EDGE('',*,*,#768,.F.); +#815 = ADVANCED_FACE('',(#816),#213,.F.); +#816 = FACE_BOUND('',#817,.T.); +#817 = EDGE_LOOP('',(#818,#841,#842,#843)); +#818 = ORIENTED_EDGE('',*,*,#819,.F.); +#819 = EDGE_CURVE('',#197,#820,#822,.T.); +#820 = VERTEX_POINT('',#821); +#821 = CARTESIAN_POINT('',(3.,17.15,14.5)); +#822 = SEAM_CURVE('',#823,(#827,#834),.PCURVE_S1.); +#823 = LINE('',#824,#825); +#824 = CARTESIAN_POINT('',(0.,17.15,14.5)); +#825 = VECTOR('',#826,1.); +#826 = DIRECTION('',(1.,0.,0.)); +#827 = PCURVE('',#213,#828); +#828 = DEFINITIONAL_REPRESENTATION('',(#829),#833); +#829 = LINE('',#830,#831); +#830 = CARTESIAN_POINT('',(-6.28318530718,0.)); +#831 = VECTOR('',#832,1.); +#832 = DIRECTION('',(-0.,-1.)); +#833 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#834 = PCURVE('',#213,#835); +#835 = DEFINITIONAL_REPRESENTATION('',(#836),#840); +#836 = LINE('',#837,#838); +#837 = CARTESIAN_POINT('',(-0.,0.)); +#838 = VECTOR('',#839,1.); +#839 = DIRECTION('',(-0.,-1.)); +#840 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#841 = ORIENTED_EDGE('',*,*,#196,.F.); +#842 = ORIENTED_EDGE('',*,*,#819,.T.); +#843 = ORIENTED_EDGE('',*,*,#844,.F.); +#844 = EDGE_CURVE('',#820,#820,#845,.T.); +#845 = SURFACE_CURVE('',#846,(#851,#858),.PCURVE_S1.); +#846 = CIRCLE('',#847,1.65); +#847 = AXIS2_PLACEMENT_3D('',#848,#849,#850); +#848 = CARTESIAN_POINT('',(3.,15.5,14.5)); +#849 = DIRECTION('',(-1.,0.,0.)); +#850 = DIRECTION('',(0.,1.,0.)); +#851 = PCURVE('',#213,#852); +#852 = DEFINITIONAL_REPRESENTATION('',(#853),#857); +#853 = LINE('',#854,#855); +#854 = CARTESIAN_POINT('',(-6.28318530718,-3.)); +#855 = VECTOR('',#856,1.); +#856 = DIRECTION('',(1.,-0.)); +#857 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#858 = PCURVE('',#448,#859); +#859 = DEFINITIONAL_REPRESENTATION('',(#860),#868); +#860 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#861,#862,#863,#864,#865,#866 +,#867),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),( + -2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718, +8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() +RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM( + '') ); +#861 = CARTESIAN_POINT('',(14.5,-37.65)); +#862 = CARTESIAN_POINT('',(11.642116167511,-37.65)); +#863 = CARTESIAN_POINT('',(13.071058083756,-35.175)); +#864 = CARTESIAN_POINT('',(14.5,-32.7)); +#865 = CARTESIAN_POINT('',(15.928941916244,-35.175)); +#866 = CARTESIAN_POINT('',(17.357883832489,-37.65)); +#867 = CARTESIAN_POINT('',(14.5,-37.65)); +#868 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#869 = ADVANCED_FACE('',(#870),#244,.F.); +#870 = FACE_BOUND('',#871,.T.); +#871 = EDGE_LOOP('',(#872,#895,#896,#897)); +#872 = ORIENTED_EDGE('',*,*,#873,.F.); +#873 = EDGE_CURVE('',#228,#874,#876,.T.); +#874 = VERTEX_POINT('',#875); +#875 = CARTESIAN_POINT('',(3.,17.15,45.5)); +#876 = SEAM_CURVE('',#877,(#881,#888),.PCURVE_S1.); +#877 = LINE('',#878,#879); +#878 = CARTESIAN_POINT('',(0.,17.15,45.5)); +#879 = VECTOR('',#880,1.); +#880 = DIRECTION('',(1.,0.,0.)); +#881 = PCURVE('',#244,#882); +#882 = DEFINITIONAL_REPRESENTATION('',(#883),#887); +#883 = LINE('',#884,#885); +#884 = CARTESIAN_POINT('',(-6.28318530718,0.)); +#885 = VECTOR('',#886,1.); +#886 = DIRECTION('',(-0.,-1.)); +#887 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#888 = PCURVE('',#244,#889); +#889 = DEFINITIONAL_REPRESENTATION('',(#890),#894); +#890 = LINE('',#891,#892); +#891 = CARTESIAN_POINT('',(-0.,0.)); +#892 = VECTOR('',#893,1.); +#893 = DIRECTION('',(-0.,-1.)); +#894 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#895 = ORIENTED_EDGE('',*,*,#227,.F.); +#896 = ORIENTED_EDGE('',*,*,#873,.T.); +#897 = ORIENTED_EDGE('',*,*,#898,.F.); +#898 = EDGE_CURVE('',#874,#874,#899,.T.); +#899 = SURFACE_CURVE('',#900,(#905,#912),.PCURVE_S1.); +#900 = CIRCLE('',#901,1.65); +#901 = AXIS2_PLACEMENT_3D('',#902,#903,#904); +#902 = CARTESIAN_POINT('',(3.,15.5,45.5)); +#903 = DIRECTION('',(-1.,0.,0.)); +#904 = DIRECTION('',(0.,1.,0.)); +#905 = PCURVE('',#244,#906); +#906 = DEFINITIONAL_REPRESENTATION('',(#907),#911); +#907 = LINE('',#908,#909); +#908 = CARTESIAN_POINT('',(-6.28318530718,-3.)); +#909 = VECTOR('',#910,1.); +#910 = DIRECTION('',(1.,-0.)); +#911 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#912 = PCURVE('',#448,#913); +#913 = DEFINITIONAL_REPRESENTATION('',(#914),#922); +#914 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#915,#916,#917,#918,#919,#920 +,#921),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),( + -2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718, +8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() +RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM( + '') ); +#915 = CARTESIAN_POINT('',(45.5,-37.65)); +#916 = CARTESIAN_POINT('',(42.642116167511,-37.65)); +#917 = CARTESIAN_POINT('',(44.071058083756,-35.175)); +#918 = CARTESIAN_POINT('',(45.5,-32.7)); +#919 = CARTESIAN_POINT('',(46.928941916244,-35.175)); +#920 = CARTESIAN_POINT('',(48.357883832489,-37.65)); +#921 = CARTESIAN_POINT('',(45.5,-37.65)); +#922 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#923 = ADVANCED_FACE('',(#924),#275,.F.); +#924 = FACE_BOUND('',#925,.T.); +#925 = EDGE_LOOP('',(#926,#949,#950,#951)); +#926 = ORIENTED_EDGE('',*,*,#927,.F.); +#927 = EDGE_CURVE('',#259,#928,#930,.T.); +#928 = VERTEX_POINT('',#929); +#929 = CARTESIAN_POINT('',(3.,-13.85,14.5)); +#930 = SEAM_CURVE('',#931,(#935,#942),.PCURVE_S1.); +#931 = LINE('',#932,#933); +#932 = CARTESIAN_POINT('',(0.,-13.85,14.5)); +#933 = VECTOR('',#934,1.); +#934 = DIRECTION('',(1.,0.,0.)); +#935 = PCURVE('',#275,#936); +#936 = DEFINITIONAL_REPRESENTATION('',(#937),#941); +#937 = LINE('',#938,#939); +#938 = CARTESIAN_POINT('',(-6.28318530718,0.)); +#939 = VECTOR('',#940,1.); +#940 = DIRECTION('',(-0.,-1.)); +#941 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#942 = PCURVE('',#275,#943); +#943 = DEFINITIONAL_REPRESENTATION('',(#944),#948); +#944 = LINE('',#945,#946); +#945 = CARTESIAN_POINT('',(-0.,0.)); +#946 = VECTOR('',#947,1.); +#947 = DIRECTION('',(-0.,-1.)); +#948 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#949 = ORIENTED_EDGE('',*,*,#258,.F.); +#950 = ORIENTED_EDGE('',*,*,#927,.T.); +#951 = ORIENTED_EDGE('',*,*,#952,.F.); +#952 = EDGE_CURVE('',#928,#928,#953,.T.); +#953 = SURFACE_CURVE('',#954,(#959,#966),.PCURVE_S1.); +#954 = CIRCLE('',#955,1.65); +#955 = AXIS2_PLACEMENT_3D('',#956,#957,#958); +#956 = CARTESIAN_POINT('',(3.,-15.5,14.5)); +#957 = DIRECTION('',(-1.,0.,0.)); +#958 = DIRECTION('',(0.,1.,0.)); +#959 = PCURVE('',#275,#960); +#960 = DEFINITIONAL_REPRESENTATION('',(#961),#965); +#961 = LINE('',#962,#963); +#962 = CARTESIAN_POINT('',(-6.28318530718,-3.)); +#963 = VECTOR('',#964,1.); +#964 = DIRECTION('',(1.,-0.)); +#965 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#966 = PCURVE('',#448,#967); +#967 = DEFINITIONAL_REPRESENTATION('',(#968),#976); +#968 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#969,#970,#971,#972,#973,#974 +,#975),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),( + -2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718, +8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() +RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM( + '') ); +#969 = CARTESIAN_POINT('',(14.5,-6.65)); +#970 = CARTESIAN_POINT('',(11.642116167511,-6.65)); +#971 = CARTESIAN_POINT('',(13.071058083756,-4.175)); +#972 = CARTESIAN_POINT('',(14.5,-1.7)); +#973 = CARTESIAN_POINT('',(15.928941916244,-4.175)); +#974 = CARTESIAN_POINT('',(17.357883832489,-6.65)); +#975 = CARTESIAN_POINT('',(14.5,-6.65)); +#976 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#977 = ADVANCED_FACE('',(#978),#306,.F.); +#978 = FACE_BOUND('',#979,.T.); +#979 = EDGE_LOOP('',(#980,#1003,#1004,#1005)); +#980 = ORIENTED_EDGE('',*,*,#981,.F.); +#981 = EDGE_CURVE('',#290,#982,#984,.T.); +#982 = VERTEX_POINT('',#983); +#983 = CARTESIAN_POINT('',(3.,16.,30.)); +#984 = SEAM_CURVE('',#985,(#989,#996),.PCURVE_S1.); +#985 = LINE('',#986,#987); +#986 = CARTESIAN_POINT('',(0.,16.,30.)); +#987 = VECTOR('',#988,1.); +#988 = DIRECTION('',(1.,0.,0.)); +#989 = PCURVE('',#306,#990); +#990 = DEFINITIONAL_REPRESENTATION('',(#991),#995); +#991 = LINE('',#992,#993); +#992 = CARTESIAN_POINT('',(-6.28318530718,0.)); +#993 = VECTOR('',#994,1.); +#994 = DIRECTION('',(-0.,-1.)); +#995 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#996 = PCURVE('',#306,#997); +#997 = DEFINITIONAL_REPRESENTATION('',(#998),#1002); +#998 = LINE('',#999,#1000); +#999 = CARTESIAN_POINT('',(-0.,0.)); +#1000 = VECTOR('',#1001,1.); +#1001 = DIRECTION('',(-0.,-1.)); +#1002 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1003 = ORIENTED_EDGE('',*,*,#289,.F.); +#1004 = ORIENTED_EDGE('',*,*,#981,.T.); +#1005 = ORIENTED_EDGE('',*,*,#1006,.F.); +#1006 = EDGE_CURVE('',#982,#982,#1007,.T.); +#1007 = SURFACE_CURVE('',#1008,(#1013,#1020),.PCURVE_S1.); +#1008 = CIRCLE('',#1009,16.); +#1009 = AXIS2_PLACEMENT_3D('',#1010,#1011,#1012); +#1010 = CARTESIAN_POINT('',(3.,0.,30.)); +#1011 = DIRECTION('',(-1.,0.,0.)); +#1012 = DIRECTION('',(0.,1.,0.)); +#1013 = PCURVE('',#306,#1014); +#1014 = DEFINITIONAL_REPRESENTATION('',(#1015),#1019); +#1015 = LINE('',#1016,#1017); +#1016 = CARTESIAN_POINT('',(-6.28318530718,-3.)); +#1017 = VECTOR('',#1018,1.); +#1018 = DIRECTION('',(1.,-0.)); +#1019 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1020 = PCURVE('',#448,#1021); +#1021 = DEFINITIONAL_REPRESENTATION('',(#1022),#1030); +#1022 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1023,#1024,#1025,#1026, +#1027,#1028,#1029),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1023 = CARTESIAN_POINT('',(30.,-36.5)); +#1024 = CARTESIAN_POINT('',(2.287187078898,-36.5)); +#1025 = CARTESIAN_POINT('',(16.143593539449,-12.5)); +#1026 = CARTESIAN_POINT('',(30.,11.5)); +#1027 = CARTESIAN_POINT('',(43.856406460551,-12.5)); +#1028 = CARTESIAN_POINT('',(57.712812921102,-36.5)); +#1029 = CARTESIAN_POINT('',(30.,-36.5)); +#1030 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1031 = ADVANCED_FACE('',(#1032),#337,.F.); +#1032 = FACE_BOUND('',#1033,.T.); +#1033 = EDGE_LOOP('',(#1034,#1057,#1058,#1059)); +#1034 = ORIENTED_EDGE('',*,*,#1035,.F.); +#1035 = EDGE_CURVE('',#321,#1036,#1038,.T.); +#1036 = VERTEX_POINT('',#1037); +#1037 = CARTESIAN_POINT('',(3.,-13.85,45.5)); +#1038 = SEAM_CURVE('',#1039,(#1043,#1050),.PCURVE_S1.); +#1039 = LINE('',#1040,#1041); +#1040 = CARTESIAN_POINT('',(0.,-13.85,45.5)); +#1041 = VECTOR('',#1042,1.); +#1042 = DIRECTION('',(1.,0.,0.)); +#1043 = PCURVE('',#337,#1044); +#1044 = DEFINITIONAL_REPRESENTATION('',(#1045),#1049); +#1045 = LINE('',#1046,#1047); +#1046 = CARTESIAN_POINT('',(-6.28318530718,0.)); +#1047 = VECTOR('',#1048,1.); +#1048 = DIRECTION('',(-0.,-1.)); +#1049 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1050 = PCURVE('',#337,#1051); +#1051 = DEFINITIONAL_REPRESENTATION('',(#1052),#1056); +#1052 = LINE('',#1053,#1054); +#1053 = CARTESIAN_POINT('',(-0.,0.)); +#1054 = VECTOR('',#1055,1.); +#1055 = DIRECTION('',(-0.,-1.)); +#1056 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1057 = ORIENTED_EDGE('',*,*,#320,.F.); +#1058 = ORIENTED_EDGE('',*,*,#1035,.T.); +#1059 = ORIENTED_EDGE('',*,*,#1060,.F.); +#1060 = EDGE_CURVE('',#1036,#1036,#1061,.T.); +#1061 = SURFACE_CURVE('',#1062,(#1067,#1074),.PCURVE_S1.); +#1062 = CIRCLE('',#1063,1.65); +#1063 = AXIS2_PLACEMENT_3D('',#1064,#1065,#1066); +#1064 = CARTESIAN_POINT('',(3.,-15.5,45.5)); +#1065 = DIRECTION('',(-1.,0.,0.)); +#1066 = DIRECTION('',(0.,1.,0.)); +#1067 = PCURVE('',#337,#1068); +#1068 = DEFINITIONAL_REPRESENTATION('',(#1069),#1073); +#1069 = LINE('',#1070,#1071); +#1070 = CARTESIAN_POINT('',(-6.28318530718,-3.)); +#1071 = VECTOR('',#1072,1.); +#1072 = DIRECTION('',(1.,-0.)); +#1073 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1074 = PCURVE('',#448,#1075); +#1075 = DEFINITIONAL_REPRESENTATION('',(#1076),#1084); +#1076 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1077,#1078,#1079,#1080, +#1081,#1082,#1083),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1077 = CARTESIAN_POINT('',(45.5,-6.65)); +#1078 = CARTESIAN_POINT('',(42.642116167511,-6.65)); +#1079 = CARTESIAN_POINT('',(44.071058083756,-4.175)); +#1080 = CARTESIAN_POINT('',(45.5,-1.7)); +#1081 = CARTESIAN_POINT('',(46.928941916244,-4.175)); +#1082 = CARTESIAN_POINT('',(48.357883832489,-6.65)); +#1083 = CARTESIAN_POINT('',(45.5,-6.65)); +#1084 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1085 = ADVANCED_FACE('',(#1086,#1163,#1287,#1411,#1535,#1659,#1783), + #370,.F.); +#1086 = FACE_BOUND('',#1087,.F.); +#1087 = EDGE_LOOP('',(#1088,#1089,#1112,#1140,#1161,#1162)); +#1088 = ORIENTED_EDGE('',*,*,#352,.T.); +#1089 = ORIENTED_EDGE('',*,*,#1090,.T.); +#1090 = EDGE_CURVE('',#355,#1091,#1093,.T.); +#1091 = VERTEX_POINT('',#1092); +#1092 = CARTESIAN_POINT('',(35.,-18.5,0.)); +#1093 = SURFACE_CURVE('',#1094,(#1098,#1105),.PCURVE_S1.); +#1094 = LINE('',#1095,#1096); +#1095 = CARTESIAN_POINT('',(25.25,-28.25,0.)); +#1096 = VECTOR('',#1097,1.); +#1097 = DIRECTION('',(0.707106781187,0.707106781187,-0.)); +#1098 = PCURVE('',#370,#1099); +#1099 = DEFINITIONAL_REPRESENTATION('',(#1100),#1104); +#1100 = LINE('',#1101,#1102); +#1101 = CARTESIAN_POINT('',(25.25,-7.75)); +#1102 = VECTOR('',#1103,1.); +#1103 = DIRECTION('',(0.707106781187,0.707106781187)); +#1104 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1105 = PCURVE('',#502,#1106); +#1106 = DEFINITIONAL_REPRESENTATION('',(#1107),#1111); +#1107 = LINE('',#1108,#1109); +#1108 = CARTESIAN_POINT('',(0.,-12.37436867076)); +#1109 = VECTOR('',#1110,1.); +#1110 = DIRECTION('',(0.,1.)); +#1111 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1112 = ORIENTED_EDGE('',*,*,#1113,.T.); +#1113 = EDGE_CURVE('',#1091,#1114,#1116,.T.); +#1114 = VERTEX_POINT('',#1115); +#1115 = CARTESIAN_POINT('',(35.,18.5,0.)); +#1116 = SURFACE_CURVE('',#1117,(#1121,#1128),.PCURVE_S1.); +#1117 = LINE('',#1118,#1119); +#1118 = CARTESIAN_POINT('',(35.,-20.5,0.)); +#1119 = VECTOR('',#1120,1.); +#1120 = DIRECTION('',(0.,1.,0.)); +#1121 = PCURVE('',#370,#1122); +#1122 = DEFINITIONAL_REPRESENTATION('',(#1123),#1127); +#1123 = LINE('',#1124,#1125); +#1124 = CARTESIAN_POINT('',(35.,0.)); +#1125 = VECTOR('',#1126,1.); +#1126 = DIRECTION('',(0.,1.)); +#1127 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1128 = PCURVE('',#1129,#1134); +#1129 = PLANE('',#1130); +#1130 = AXIS2_PLACEMENT_3D('',#1131,#1132,#1133); +#1131 = CARTESIAN_POINT('',(35.,-20.5,0.)); +#1132 = DIRECTION('',(1.,0.,0.)); +#1133 = DIRECTION('',(0.,0.,1.)); +#1134 = DEFINITIONAL_REPRESENTATION('',(#1135),#1139); +#1135 = LINE('',#1136,#1137); +#1136 = CARTESIAN_POINT('',(0.,0.)); +#1137 = VECTOR('',#1138,1.); +#1138 = DIRECTION('',(0.,-1.)); +#1139 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1140 = ORIENTED_EDGE('',*,*,#1141,.F.); +#1141 = EDGE_CURVE('',#691,#1114,#1142,.T.); +#1142 = SURFACE_CURVE('',#1143,(#1147,#1154),.PCURVE_S1.); +#1143 = LINE('',#1144,#1145); +#1144 = CARTESIAN_POINT('',(35.5,18.,0.)); +#1145 = VECTOR('',#1146,1.); +#1146 = DIRECTION('',(0.707106781187,-0.707106781187,0.)); +#1147 = PCURVE('',#370,#1148); +#1148 = DEFINITIONAL_REPRESENTATION('',(#1149),#1153); +#1149 = LINE('',#1150,#1151); +#1150 = CARTESIAN_POINT('',(35.5,38.5)); +#1151 = VECTOR('',#1152,1.); +#1152 = DIRECTION('',(0.707106781187,-0.707106781187)); +#1153 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1154 = PCURVE('',#706,#1155); +#1155 = DEFINITIONAL_REPRESENTATION('',(#1156),#1160); +#1156 = LINE('',#1157,#1158); +#1157 = CARTESIAN_POINT('',(0.,2.12132034356)); +#1158 = VECTOR('',#1159,1.); +#1159 = DIRECTION('',(0.,1.)); +#1160 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1161 = ORIENTED_EDGE('',*,*,#718,.T.); +#1162 = ORIENTED_EDGE('',*,*,#518,.F.); +#1163 = FACE_BOUND('',#1164,.F.); +#1164 = EDGE_LOOP('',(#1165,#1195,#1228,#1256)); +#1165 = ORIENTED_EDGE('',*,*,#1166,.F.); +#1166 = EDGE_CURVE('',#1167,#1169,#1171,.T.); +#1167 = VERTEX_POINT('',#1168); +#1168 = CARTESIAN_POINT('',(7.75,-12.5,0.)); +#1169 = VERTEX_POINT('',#1170); +#1170 = CARTESIAN_POINT('',(7.75,-15.5,0.)); +#1171 = SURFACE_CURVE('',#1172,(#1176,#1183),.PCURVE_S1.); +#1172 = LINE('',#1173,#1174); +#1173 = CARTESIAN_POINT('',(7.75,-12.5,0.)); +#1174 = VECTOR('',#1175,1.); +#1175 = DIRECTION('',(0.,-1.,0.)); +#1176 = PCURVE('',#370,#1177); +#1177 = DEFINITIONAL_REPRESENTATION('',(#1178),#1182); +#1178 = LINE('',#1179,#1180); +#1179 = CARTESIAN_POINT('',(7.75,8.)); +#1180 = VECTOR('',#1181,1.); +#1181 = DIRECTION('',(0.,-1.)); +#1182 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1183 = PCURVE('',#1184,#1189); +#1184 = PLANE('',#1185); +#1185 = AXIS2_PLACEMENT_3D('',#1186,#1187,#1188); +#1186 = CARTESIAN_POINT('',(7.75,-12.5,0.)); +#1187 = DIRECTION('',(1.,0.,-0.)); +#1188 = DIRECTION('',(0.,-1.,0.)); +#1189 = DEFINITIONAL_REPRESENTATION('',(#1190),#1194); +#1190 = LINE('',#1191,#1192); +#1191 = CARTESIAN_POINT('',(0.,0.)); +#1192 = VECTOR('',#1193,1.); +#1193 = DIRECTION('',(1.,0.)); +#1194 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1195 = ORIENTED_EDGE('',*,*,#1196,.T.); +#1196 = EDGE_CURVE('',#1167,#1197,#1199,.T.); +#1197 = VERTEX_POINT('',#1198); +#1198 = CARTESIAN_POINT('',(12.25,-12.5,0.)); +#1199 = SURFACE_CURVE('',#1200,(#1205,#1216),.PCURVE_S1.); +#1200 = CIRCLE('',#1201,2.25); +#1201 = AXIS2_PLACEMENT_3D('',#1202,#1203,#1204); +#1202 = CARTESIAN_POINT('',(10.,-12.5,0.)); +#1203 = DIRECTION('',(-0.,-0.,-1.)); +#1204 = DIRECTION('',(0.,-1.,0.)); +#1205 = PCURVE('',#370,#1206); +#1206 = DEFINITIONAL_REPRESENTATION('',(#1207),#1215); +#1207 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1208,#1209,#1210,#1211, +#1212,#1213,#1214),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1208 = CARTESIAN_POINT('',(10.,5.75)); +#1209 = CARTESIAN_POINT('',(6.10288568297,5.75)); +#1210 = CARTESIAN_POINT('',(8.051442841485,9.125)); +#1211 = CARTESIAN_POINT('',(10.,12.5)); +#1212 = CARTESIAN_POINT('',(11.948557158515,9.125)); +#1213 = CARTESIAN_POINT('',(13.89711431703,5.75)); +#1214 = CARTESIAN_POINT('',(10.,5.75)); +#1215 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1216 = PCURVE('',#1217,#1222); +#1217 = CYLINDRICAL_SURFACE('',#1218,2.25); +#1218 = AXIS2_PLACEMENT_3D('',#1219,#1220,#1221); +#1219 = CARTESIAN_POINT('',(10.,-12.5,0.)); +#1220 = DIRECTION('',(-0.,-0.,-1.)); +#1221 = DIRECTION('',(0.,-1.,0.)); +#1222 = DEFINITIONAL_REPRESENTATION('',(#1223),#1227); +#1223 = LINE('',#1224,#1225); +#1224 = CARTESIAN_POINT('',(0.,0.)); +#1225 = VECTOR('',#1226,1.); +#1226 = DIRECTION('',(1.,0.)); +#1227 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1228 = ORIENTED_EDGE('',*,*,#1229,.F.); +#1229 = EDGE_CURVE('',#1230,#1197,#1232,.T.); +#1230 = VERTEX_POINT('',#1231); +#1231 = CARTESIAN_POINT('',(12.25,-15.5,0.)); +#1232 = SURFACE_CURVE('',#1233,(#1237,#1244),.PCURVE_S1.); +#1233 = LINE('',#1234,#1235); +#1234 = CARTESIAN_POINT('',(12.25,-15.5,0.)); +#1235 = VECTOR('',#1236,1.); +#1236 = DIRECTION('',(0.,1.,0.)); +#1237 = PCURVE('',#370,#1238); +#1238 = DEFINITIONAL_REPRESENTATION('',(#1239),#1243); +#1239 = LINE('',#1240,#1241); +#1240 = CARTESIAN_POINT('',(12.25,5.)); +#1241 = VECTOR('',#1242,1.); +#1242 = DIRECTION('',(0.,1.)); +#1243 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1244 = PCURVE('',#1245,#1250); +#1245 = PLANE('',#1246); +#1246 = AXIS2_PLACEMENT_3D('',#1247,#1248,#1249); +#1247 = CARTESIAN_POINT('',(12.25,-15.5,0.)); +#1248 = DIRECTION('',(-1.,0.,0.)); +#1249 = DIRECTION('',(0.,1.,0.)); +#1250 = DEFINITIONAL_REPRESENTATION('',(#1251),#1255); +#1251 = LINE('',#1252,#1253); +#1252 = CARTESIAN_POINT('',(0.,0.)); +#1253 = VECTOR('',#1254,1.); +#1254 = DIRECTION('',(1.,0.)); +#1255 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1256 = ORIENTED_EDGE('',*,*,#1257,.T.); +#1257 = EDGE_CURVE('',#1230,#1169,#1258,.T.); +#1258 = SURFACE_CURVE('',#1259,(#1264,#1275),.PCURVE_S1.); +#1259 = CIRCLE('',#1260,2.25); +#1260 = AXIS2_PLACEMENT_3D('',#1261,#1262,#1263); +#1261 = CARTESIAN_POINT('',(10.,-15.5,0.)); +#1262 = DIRECTION('',(0.,0.,-1.)); +#1263 = DIRECTION('',(0.,1.,0.)); +#1264 = PCURVE('',#370,#1265); +#1265 = DEFINITIONAL_REPRESENTATION('',(#1266),#1274); +#1266 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1267,#1268,#1269,#1270, +#1271,#1272,#1273),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1267 = CARTESIAN_POINT('',(10.,7.25)); +#1268 = CARTESIAN_POINT('',(13.89711431703,7.25)); +#1269 = CARTESIAN_POINT('',(11.948557158515,3.875)); +#1270 = CARTESIAN_POINT('',(10.,0.5)); +#1271 = CARTESIAN_POINT('',(8.051442841485,3.875)); +#1272 = CARTESIAN_POINT('',(6.10288568297,7.25)); +#1273 = CARTESIAN_POINT('',(10.,7.25)); +#1274 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1275 = PCURVE('',#1276,#1281); +#1276 = CYLINDRICAL_SURFACE('',#1277,2.25); +#1277 = AXIS2_PLACEMENT_3D('',#1278,#1279,#1280); +#1278 = CARTESIAN_POINT('',(10.,-15.5,0.)); +#1279 = DIRECTION('',(0.,0.,-1.)); +#1280 = DIRECTION('',(0.,1.,0.)); +#1281 = DEFINITIONAL_REPRESENTATION('',(#1282),#1286); +#1282 = LINE('',#1283,#1284); +#1283 = CARTESIAN_POINT('',(0.,0.)); +#1284 = VECTOR('',#1285,1.); +#1285 = DIRECTION('',(1.,0.)); +#1286 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1287 = FACE_BOUND('',#1288,.F.); +#1288 = EDGE_LOOP('',(#1289,#1319,#1352,#1380)); +#1289 = ORIENTED_EDGE('',*,*,#1290,.F.); +#1290 = EDGE_CURVE('',#1291,#1293,#1295,.T.); +#1291 = VERTEX_POINT('',#1292); +#1292 = CARTESIAN_POINT('',(21.5,-13.25,0.)); +#1293 = VERTEX_POINT('',#1294); +#1294 = CARTESIAN_POINT('',(18.5,-13.25,0.)); +#1295 = SURFACE_CURVE('',#1296,(#1300,#1307),.PCURVE_S1.); +#1296 = LINE('',#1297,#1298); +#1297 = CARTESIAN_POINT('',(21.5,-13.25,0.)); +#1298 = VECTOR('',#1299,1.); +#1299 = DIRECTION('',(-1.,0.,0.)); +#1300 = PCURVE('',#370,#1301); +#1301 = DEFINITIONAL_REPRESENTATION('',(#1302),#1306); +#1302 = LINE('',#1303,#1304); +#1303 = CARTESIAN_POINT('',(21.5,7.25)); +#1304 = VECTOR('',#1305,1.); +#1305 = DIRECTION('',(-1.,0.)); +#1306 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1307 = PCURVE('',#1308,#1313); +#1308 = PLANE('',#1309); +#1309 = AXIS2_PLACEMENT_3D('',#1310,#1311,#1312); +#1310 = CARTESIAN_POINT('',(21.5,-13.25,0.)); +#1311 = DIRECTION('',(0.,-1.,0.)); +#1312 = DIRECTION('',(-1.,0.,0.)); +#1313 = DEFINITIONAL_REPRESENTATION('',(#1314),#1318); +#1314 = LINE('',#1315,#1316); +#1315 = CARTESIAN_POINT('',(0.,-0.)); +#1316 = VECTOR('',#1317,1.); +#1317 = DIRECTION('',(1.,0.)); +#1318 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1319 = ORIENTED_EDGE('',*,*,#1320,.T.); +#1320 = EDGE_CURVE('',#1291,#1321,#1323,.T.); +#1321 = VERTEX_POINT('',#1322); +#1322 = CARTESIAN_POINT('',(21.5,-17.75,0.)); +#1323 = SURFACE_CURVE('',#1324,(#1329,#1340),.PCURVE_S1.); +#1324 = CIRCLE('',#1325,2.25); +#1325 = AXIS2_PLACEMENT_3D('',#1326,#1327,#1328); +#1326 = CARTESIAN_POINT('',(21.5,-15.5,0.)); +#1327 = DIRECTION('',(0.,0.,-1.)); +#1328 = DIRECTION('',(-1.,0.,0.)); +#1329 = PCURVE('',#370,#1330); +#1330 = DEFINITIONAL_REPRESENTATION('',(#1331),#1339); +#1331 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1332,#1333,#1334,#1335, +#1336,#1337,#1338),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1332 = CARTESIAN_POINT('',(19.25,5.)); +#1333 = CARTESIAN_POINT('',(19.25,8.89711431703)); +#1334 = CARTESIAN_POINT('',(22.625,6.948557158515)); +#1335 = CARTESIAN_POINT('',(26.,5.)); +#1336 = CARTESIAN_POINT('',(22.625,3.051442841485)); +#1337 = CARTESIAN_POINT('',(19.25,1.10288568297)); +#1338 = CARTESIAN_POINT('',(19.25,5.)); +#1339 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1340 = PCURVE('',#1341,#1346); +#1341 = CYLINDRICAL_SURFACE('',#1342,2.25); +#1342 = AXIS2_PLACEMENT_3D('',#1343,#1344,#1345); +#1343 = CARTESIAN_POINT('',(21.5,-15.5,0.)); +#1344 = DIRECTION('',(0.,0.,-1.)); +#1345 = DIRECTION('',(-1.,0.,0.)); +#1346 = DEFINITIONAL_REPRESENTATION('',(#1347),#1351); +#1347 = LINE('',#1348,#1349); +#1348 = CARTESIAN_POINT('',(0.,0.)); +#1349 = VECTOR('',#1350,1.); +#1350 = DIRECTION('',(1.,0.)); +#1351 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1352 = ORIENTED_EDGE('',*,*,#1353,.F.); +#1353 = EDGE_CURVE('',#1354,#1321,#1356,.T.); +#1354 = VERTEX_POINT('',#1355); +#1355 = CARTESIAN_POINT('',(18.5,-17.75,0.)); +#1356 = SURFACE_CURVE('',#1357,(#1361,#1368),.PCURVE_S1.); +#1357 = LINE('',#1358,#1359); +#1358 = CARTESIAN_POINT('',(18.5,-17.75,0.)); +#1359 = VECTOR('',#1360,1.); +#1360 = DIRECTION('',(1.,0.,0.)); +#1361 = PCURVE('',#370,#1362); +#1362 = DEFINITIONAL_REPRESENTATION('',(#1363),#1367); +#1363 = LINE('',#1364,#1365); +#1364 = CARTESIAN_POINT('',(18.5,2.75)); +#1365 = VECTOR('',#1366,1.); +#1366 = DIRECTION('',(1.,0.)); +#1367 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1368 = PCURVE('',#1369,#1374); +#1369 = PLANE('',#1370); +#1370 = AXIS2_PLACEMENT_3D('',#1371,#1372,#1373); +#1371 = CARTESIAN_POINT('',(18.5,-17.75,0.)); +#1372 = DIRECTION('',(0.,1.,0.)); +#1373 = DIRECTION('',(1.,0.,0.)); +#1374 = DEFINITIONAL_REPRESENTATION('',(#1375),#1379); +#1375 = LINE('',#1376,#1377); +#1376 = CARTESIAN_POINT('',(0.,0.)); +#1377 = VECTOR('',#1378,1.); +#1378 = DIRECTION('',(1.,0.)); +#1379 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1380 = ORIENTED_EDGE('',*,*,#1381,.T.); +#1381 = EDGE_CURVE('',#1354,#1293,#1382,.T.); +#1382 = SURFACE_CURVE('',#1383,(#1388,#1399),.PCURVE_S1.); +#1383 = CIRCLE('',#1384,2.25); +#1384 = AXIS2_PLACEMENT_3D('',#1385,#1386,#1387); +#1385 = CARTESIAN_POINT('',(18.5,-15.5,0.)); +#1386 = DIRECTION('',(0.,0.,-1.)); +#1387 = DIRECTION('',(1.,0.,0.)); +#1388 = PCURVE('',#370,#1389); +#1389 = DEFINITIONAL_REPRESENTATION('',(#1390),#1398); +#1390 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1391,#1392,#1393,#1394, +#1395,#1396,#1397),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1391 = CARTESIAN_POINT('',(20.75,5.)); +#1392 = CARTESIAN_POINT('',(20.75,1.10288568297)); +#1393 = CARTESIAN_POINT('',(17.375,3.051442841485)); +#1394 = CARTESIAN_POINT('',(14.,5.)); +#1395 = CARTESIAN_POINT('',(17.375,6.948557158515)); +#1396 = CARTESIAN_POINT('',(20.75,8.89711431703)); +#1397 = CARTESIAN_POINT('',(20.75,5.)); +#1398 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1399 = PCURVE('',#1400,#1405); +#1400 = CYLINDRICAL_SURFACE('',#1401,2.25); +#1401 = AXIS2_PLACEMENT_3D('',#1402,#1403,#1404); +#1402 = CARTESIAN_POINT('',(18.5,-15.5,0.)); +#1403 = DIRECTION('',(0.,0.,-1.)); +#1404 = DIRECTION('',(1.,0.,0.)); +#1405 = DEFINITIONAL_REPRESENTATION('',(#1406),#1410); +#1406 = LINE('',#1407,#1408); +#1407 = CARTESIAN_POINT('',(0.,0.)); +#1408 = VECTOR('',#1409,1.); +#1409 = DIRECTION('',(1.,0.)); +#1410 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1411 = FACE_BOUND('',#1412,.F.); +#1412 = EDGE_LOOP('',(#1413,#1443,#1476,#1504)); +#1413 = ORIENTED_EDGE('',*,*,#1414,.F.); +#1414 = EDGE_CURVE('',#1415,#1417,#1419,.T.); +#1415 = VERTEX_POINT('',#1416); +#1416 = CARTESIAN_POINT('',(27.75,-12.5,0.)); +#1417 = VERTEX_POINT('',#1418); +#1418 = CARTESIAN_POINT('',(27.75,-15.5,0.)); +#1419 = SURFACE_CURVE('',#1420,(#1424,#1431),.PCURVE_S1.); +#1420 = LINE('',#1421,#1422); +#1421 = CARTESIAN_POINT('',(27.75,-12.5,0.)); +#1422 = VECTOR('',#1423,1.); +#1423 = DIRECTION('',(0.,-1.,0.)); +#1424 = PCURVE('',#370,#1425); +#1425 = DEFINITIONAL_REPRESENTATION('',(#1426),#1430); +#1426 = LINE('',#1427,#1428); +#1427 = CARTESIAN_POINT('',(27.75,8.)); +#1428 = VECTOR('',#1429,1.); +#1429 = DIRECTION('',(0.,-1.)); +#1430 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1431 = PCURVE('',#1432,#1437); +#1432 = PLANE('',#1433); +#1433 = AXIS2_PLACEMENT_3D('',#1434,#1435,#1436); +#1434 = CARTESIAN_POINT('',(27.75,-12.5,0.)); +#1435 = DIRECTION('',(1.,0.,-0.)); +#1436 = DIRECTION('',(0.,-1.,0.)); +#1437 = DEFINITIONAL_REPRESENTATION('',(#1438),#1442); +#1438 = LINE('',#1439,#1440); +#1439 = CARTESIAN_POINT('',(0.,0.)); +#1440 = VECTOR('',#1441,1.); +#1441 = DIRECTION('',(1.,0.)); +#1442 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1443 = ORIENTED_EDGE('',*,*,#1444,.T.); +#1444 = EDGE_CURVE('',#1415,#1445,#1447,.T.); +#1445 = VERTEX_POINT('',#1446); +#1446 = CARTESIAN_POINT('',(32.25,-12.5,0.)); +#1447 = SURFACE_CURVE('',#1448,(#1453,#1464),.PCURVE_S1.); +#1448 = CIRCLE('',#1449,2.25); +#1449 = AXIS2_PLACEMENT_3D('',#1450,#1451,#1452); +#1450 = CARTESIAN_POINT('',(30.,-12.5,0.)); +#1451 = DIRECTION('',(-0.,-0.,-1.)); +#1452 = DIRECTION('',(0.,-1.,0.)); +#1453 = PCURVE('',#370,#1454); +#1454 = DEFINITIONAL_REPRESENTATION('',(#1455),#1463); +#1455 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1456,#1457,#1458,#1459, +#1460,#1461,#1462),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1456 = CARTESIAN_POINT('',(30.,5.75)); +#1457 = CARTESIAN_POINT('',(26.10288568297,5.75)); +#1458 = CARTESIAN_POINT('',(28.051442841485,9.125)); +#1459 = CARTESIAN_POINT('',(30.,12.5)); +#1460 = CARTESIAN_POINT('',(31.948557158515,9.125)); +#1461 = CARTESIAN_POINT('',(33.89711431703,5.75)); +#1462 = CARTESIAN_POINT('',(30.,5.75)); +#1463 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1464 = PCURVE('',#1465,#1470); +#1465 = CYLINDRICAL_SURFACE('',#1466,2.25); +#1466 = AXIS2_PLACEMENT_3D('',#1467,#1468,#1469); +#1467 = CARTESIAN_POINT('',(30.,-12.5,0.)); +#1468 = DIRECTION('',(-0.,-0.,-1.)); +#1469 = DIRECTION('',(0.,-1.,0.)); +#1470 = DEFINITIONAL_REPRESENTATION('',(#1471),#1475); +#1471 = LINE('',#1472,#1473); +#1472 = CARTESIAN_POINT('',(0.,0.)); +#1473 = VECTOR('',#1474,1.); +#1474 = DIRECTION('',(1.,0.)); +#1475 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1476 = ORIENTED_EDGE('',*,*,#1477,.F.); +#1477 = EDGE_CURVE('',#1478,#1445,#1480,.T.); +#1478 = VERTEX_POINT('',#1479); +#1479 = CARTESIAN_POINT('',(32.25,-15.5,0.)); +#1480 = SURFACE_CURVE('',#1481,(#1485,#1492),.PCURVE_S1.); +#1481 = LINE('',#1482,#1483); +#1482 = CARTESIAN_POINT('',(32.25,-15.5,0.)); +#1483 = VECTOR('',#1484,1.); +#1484 = DIRECTION('',(0.,1.,0.)); +#1485 = PCURVE('',#370,#1486); +#1486 = DEFINITIONAL_REPRESENTATION('',(#1487),#1491); +#1487 = LINE('',#1488,#1489); +#1488 = CARTESIAN_POINT('',(32.25,5.)); +#1489 = VECTOR('',#1490,1.); +#1490 = DIRECTION('',(0.,1.)); +#1491 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1492 = PCURVE('',#1493,#1498); +#1493 = PLANE('',#1494); +#1494 = AXIS2_PLACEMENT_3D('',#1495,#1496,#1497); +#1495 = CARTESIAN_POINT('',(32.25,-15.5,0.)); +#1496 = DIRECTION('',(-1.,0.,0.)); +#1497 = DIRECTION('',(0.,1.,0.)); +#1498 = DEFINITIONAL_REPRESENTATION('',(#1499),#1503); +#1499 = LINE('',#1500,#1501); +#1500 = CARTESIAN_POINT('',(0.,0.)); +#1501 = VECTOR('',#1502,1.); +#1502 = DIRECTION('',(1.,0.)); +#1503 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1504 = ORIENTED_EDGE('',*,*,#1505,.T.); +#1505 = EDGE_CURVE('',#1478,#1417,#1506,.T.); +#1506 = SURFACE_CURVE('',#1507,(#1512,#1523),.PCURVE_S1.); +#1507 = CIRCLE('',#1508,2.25); +#1508 = AXIS2_PLACEMENT_3D('',#1509,#1510,#1511); +#1509 = CARTESIAN_POINT('',(30.,-15.5,0.)); +#1510 = DIRECTION('',(0.,0.,-1.)); +#1511 = DIRECTION('',(0.,1.,0.)); +#1512 = PCURVE('',#370,#1513); +#1513 = DEFINITIONAL_REPRESENTATION('',(#1514),#1522); +#1514 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1515,#1516,#1517,#1518, +#1519,#1520,#1521),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1515 = CARTESIAN_POINT('',(30.,7.25)); +#1516 = CARTESIAN_POINT('',(33.89711431703,7.25)); +#1517 = CARTESIAN_POINT('',(31.948557158515,3.875)); +#1518 = CARTESIAN_POINT('',(30.,0.5)); +#1519 = CARTESIAN_POINT('',(28.051442841485,3.875)); +#1520 = CARTESIAN_POINT('',(26.10288568297,7.25)); +#1521 = CARTESIAN_POINT('',(30.,7.25)); +#1522 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1523 = PCURVE('',#1524,#1529); +#1524 = CYLINDRICAL_SURFACE('',#1525,2.25); +#1525 = AXIS2_PLACEMENT_3D('',#1526,#1527,#1528); +#1526 = CARTESIAN_POINT('',(30.,-15.5,0.)); +#1527 = DIRECTION('',(0.,0.,-1.)); +#1528 = DIRECTION('',(0.,1.,0.)); +#1529 = DEFINITIONAL_REPRESENTATION('',(#1530),#1534); +#1530 = LINE('',#1531,#1532); +#1531 = CARTESIAN_POINT('',(0.,0.)); +#1532 = VECTOR('',#1533,1.); +#1533 = DIRECTION('',(1.,0.)); +#1534 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1535 = FACE_BOUND('',#1536,.F.); +#1536 = EDGE_LOOP('',(#1537,#1567,#1600,#1628)); +#1537 = ORIENTED_EDGE('',*,*,#1538,.F.); +#1538 = EDGE_CURVE('',#1539,#1541,#1543,.T.); +#1539 = VERTEX_POINT('',#1540); +#1540 = CARTESIAN_POINT('',(7.75,15.5,0.)); +#1541 = VERTEX_POINT('',#1542); +#1542 = CARTESIAN_POINT('',(7.75,12.5,0.)); +#1543 = SURFACE_CURVE('',#1544,(#1548,#1555),.PCURVE_S1.); +#1544 = LINE('',#1545,#1546); +#1545 = CARTESIAN_POINT('',(7.75,15.5,0.)); +#1546 = VECTOR('',#1547,1.); +#1547 = DIRECTION('',(0.,-1.,0.)); +#1548 = PCURVE('',#370,#1549); +#1549 = DEFINITIONAL_REPRESENTATION('',(#1550),#1554); +#1550 = LINE('',#1551,#1552); +#1551 = CARTESIAN_POINT('',(7.75,36.)); +#1552 = VECTOR('',#1553,1.); +#1553 = DIRECTION('',(0.,-1.)); +#1554 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1555 = PCURVE('',#1556,#1561); +#1556 = PLANE('',#1557); +#1557 = AXIS2_PLACEMENT_3D('',#1558,#1559,#1560); +#1558 = CARTESIAN_POINT('',(7.75,15.5,0.)); +#1559 = DIRECTION('',(1.,0.,-0.)); +#1560 = DIRECTION('',(0.,-1.,0.)); +#1561 = DEFINITIONAL_REPRESENTATION('',(#1562),#1566); +#1562 = LINE('',#1563,#1564); +#1563 = CARTESIAN_POINT('',(0.,0.)); +#1564 = VECTOR('',#1565,1.); +#1565 = DIRECTION('',(1.,0.)); +#1566 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1567 = ORIENTED_EDGE('',*,*,#1568,.T.); +#1568 = EDGE_CURVE('',#1539,#1569,#1571,.T.); +#1569 = VERTEX_POINT('',#1570); +#1570 = CARTESIAN_POINT('',(12.25,15.5,0.)); +#1571 = SURFACE_CURVE('',#1572,(#1577,#1588),.PCURVE_S1.); +#1572 = CIRCLE('',#1573,2.25); +#1573 = AXIS2_PLACEMENT_3D('',#1574,#1575,#1576); +#1574 = CARTESIAN_POINT('',(10.,15.5,0.)); +#1575 = DIRECTION('',(-0.,-0.,-1.)); +#1576 = DIRECTION('',(0.,-1.,0.)); +#1577 = PCURVE('',#370,#1578); +#1578 = DEFINITIONAL_REPRESENTATION('',(#1579),#1587); +#1579 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1580,#1581,#1582,#1583, +#1584,#1585,#1586),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1580 = CARTESIAN_POINT('',(10.,33.75)); +#1581 = CARTESIAN_POINT('',(6.10288568297,33.75)); +#1582 = CARTESIAN_POINT('',(8.051442841485,37.125)); +#1583 = CARTESIAN_POINT('',(10.,40.5)); +#1584 = CARTESIAN_POINT('',(11.948557158515,37.125)); +#1585 = CARTESIAN_POINT('',(13.89711431703,33.75)); +#1586 = CARTESIAN_POINT('',(10.,33.75)); +#1587 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1588 = PCURVE('',#1589,#1594); +#1589 = CYLINDRICAL_SURFACE('',#1590,2.25); +#1590 = AXIS2_PLACEMENT_3D('',#1591,#1592,#1593); +#1591 = CARTESIAN_POINT('',(10.,15.5,0.)); +#1592 = DIRECTION('',(-0.,-0.,-1.)); +#1593 = DIRECTION('',(0.,-1.,0.)); +#1594 = DEFINITIONAL_REPRESENTATION('',(#1595),#1599); +#1595 = LINE('',#1596,#1597); +#1596 = CARTESIAN_POINT('',(0.,0.)); +#1597 = VECTOR('',#1598,1.); +#1598 = DIRECTION('',(1.,0.)); +#1599 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1600 = ORIENTED_EDGE('',*,*,#1601,.F.); +#1601 = EDGE_CURVE('',#1602,#1569,#1604,.T.); +#1602 = VERTEX_POINT('',#1603); +#1603 = CARTESIAN_POINT('',(12.25,12.5,0.)); +#1604 = SURFACE_CURVE('',#1605,(#1609,#1616),.PCURVE_S1.); +#1605 = LINE('',#1606,#1607); +#1606 = CARTESIAN_POINT('',(12.25,12.5,0.)); +#1607 = VECTOR('',#1608,1.); +#1608 = DIRECTION('',(0.,1.,0.)); +#1609 = PCURVE('',#370,#1610); +#1610 = DEFINITIONAL_REPRESENTATION('',(#1611),#1615); +#1611 = LINE('',#1612,#1613); +#1612 = CARTESIAN_POINT('',(12.25,33.)); +#1613 = VECTOR('',#1614,1.); +#1614 = DIRECTION('',(0.,1.)); +#1615 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1616 = PCURVE('',#1617,#1622); +#1617 = PLANE('',#1618); +#1618 = AXIS2_PLACEMENT_3D('',#1619,#1620,#1621); +#1619 = CARTESIAN_POINT('',(12.25,12.5,0.)); +#1620 = DIRECTION('',(-1.,0.,0.)); +#1621 = DIRECTION('',(0.,1.,0.)); +#1622 = DEFINITIONAL_REPRESENTATION('',(#1623),#1627); +#1623 = LINE('',#1624,#1625); +#1624 = CARTESIAN_POINT('',(0.,0.)); +#1625 = VECTOR('',#1626,1.); +#1626 = DIRECTION('',(1.,0.)); +#1627 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1628 = ORIENTED_EDGE('',*,*,#1629,.T.); +#1629 = EDGE_CURVE('',#1602,#1541,#1630,.T.); +#1630 = SURFACE_CURVE('',#1631,(#1636,#1647),.PCURVE_S1.); +#1631 = CIRCLE('',#1632,2.25); +#1632 = AXIS2_PLACEMENT_3D('',#1633,#1634,#1635); +#1633 = CARTESIAN_POINT('',(10.,12.5,0.)); +#1634 = DIRECTION('',(0.,0.,-1.)); +#1635 = DIRECTION('',(0.,1.,0.)); +#1636 = PCURVE('',#370,#1637); +#1637 = DEFINITIONAL_REPRESENTATION('',(#1638),#1646); +#1638 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1639,#1640,#1641,#1642, +#1643,#1644,#1645),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1639 = CARTESIAN_POINT('',(10.,35.25)); +#1640 = CARTESIAN_POINT('',(13.89711431703,35.25)); +#1641 = CARTESIAN_POINT('',(11.948557158515,31.875)); +#1642 = CARTESIAN_POINT('',(10.,28.5)); +#1643 = CARTESIAN_POINT('',(8.051442841485,31.875)); +#1644 = CARTESIAN_POINT('',(6.10288568297,35.25)); +#1645 = CARTESIAN_POINT('',(10.,35.25)); +#1646 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1647 = PCURVE('',#1648,#1653); +#1648 = CYLINDRICAL_SURFACE('',#1649,2.25); +#1649 = AXIS2_PLACEMENT_3D('',#1650,#1651,#1652); +#1650 = CARTESIAN_POINT('',(10.,12.5,0.)); +#1651 = DIRECTION('',(0.,0.,-1.)); +#1652 = DIRECTION('',(0.,1.,0.)); +#1653 = DEFINITIONAL_REPRESENTATION('',(#1654),#1658); +#1654 = LINE('',#1655,#1656); +#1655 = CARTESIAN_POINT('',(0.,0.)); +#1656 = VECTOR('',#1657,1.); +#1657 = DIRECTION('',(1.,0.)); +#1658 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1659 = FACE_BOUND('',#1660,.F.); +#1660 = EDGE_LOOP('',(#1661,#1691,#1724,#1752)); +#1661 = ORIENTED_EDGE('',*,*,#1662,.F.); +#1662 = EDGE_CURVE('',#1663,#1665,#1667,.T.); +#1663 = VERTEX_POINT('',#1664); +#1664 = CARTESIAN_POINT('',(21.5,17.75,0.)); +#1665 = VERTEX_POINT('',#1666); +#1666 = CARTESIAN_POINT('',(18.5,17.75,0.)); +#1667 = SURFACE_CURVE('',#1668,(#1672,#1679),.PCURVE_S1.); +#1668 = LINE('',#1669,#1670); +#1669 = CARTESIAN_POINT('',(21.5,17.75,0.)); +#1670 = VECTOR('',#1671,1.); +#1671 = DIRECTION('',(-1.,0.,0.)); +#1672 = PCURVE('',#370,#1673); +#1673 = DEFINITIONAL_REPRESENTATION('',(#1674),#1678); +#1674 = LINE('',#1675,#1676); +#1675 = CARTESIAN_POINT('',(21.5,38.25)); +#1676 = VECTOR('',#1677,1.); +#1677 = DIRECTION('',(-1.,0.)); +#1678 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1679 = PCURVE('',#1680,#1685); +#1680 = PLANE('',#1681); +#1681 = AXIS2_PLACEMENT_3D('',#1682,#1683,#1684); +#1682 = CARTESIAN_POINT('',(21.5,17.75,0.)); +#1683 = DIRECTION('',(0.,-1.,0.)); +#1684 = DIRECTION('',(-1.,0.,0.)); +#1685 = DEFINITIONAL_REPRESENTATION('',(#1686),#1690); +#1686 = LINE('',#1687,#1688); +#1687 = CARTESIAN_POINT('',(0.,-0.)); +#1688 = VECTOR('',#1689,1.); +#1689 = DIRECTION('',(1.,0.)); +#1690 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1691 = ORIENTED_EDGE('',*,*,#1692,.T.); +#1692 = EDGE_CURVE('',#1663,#1693,#1695,.T.); +#1693 = VERTEX_POINT('',#1694); +#1694 = CARTESIAN_POINT('',(21.5,13.25,0.)); +#1695 = SURFACE_CURVE('',#1696,(#1701,#1712),.PCURVE_S1.); +#1696 = CIRCLE('',#1697,2.25); +#1697 = AXIS2_PLACEMENT_3D('',#1698,#1699,#1700); +#1698 = CARTESIAN_POINT('',(21.5,15.5,0.)); +#1699 = DIRECTION('',(0.,0.,-1.)); +#1700 = DIRECTION('',(-1.,0.,0.)); +#1701 = PCURVE('',#370,#1702); +#1702 = DEFINITIONAL_REPRESENTATION('',(#1703),#1711); +#1703 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1704,#1705,#1706,#1707, +#1708,#1709,#1710),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1704 = CARTESIAN_POINT('',(19.25,36.)); +#1705 = CARTESIAN_POINT('',(19.25,39.89711431703)); +#1706 = CARTESIAN_POINT('',(22.625,37.948557158515)); +#1707 = CARTESIAN_POINT('',(26.,36.)); +#1708 = CARTESIAN_POINT('',(22.625,34.051442841485)); +#1709 = CARTESIAN_POINT('',(19.25,32.10288568297)); +#1710 = CARTESIAN_POINT('',(19.25,36.)); +#1711 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1712 = PCURVE('',#1713,#1718); +#1713 = CYLINDRICAL_SURFACE('',#1714,2.25); +#1714 = AXIS2_PLACEMENT_3D('',#1715,#1716,#1717); +#1715 = CARTESIAN_POINT('',(21.5,15.5,0.)); +#1716 = DIRECTION('',(0.,0.,-1.)); +#1717 = DIRECTION('',(-1.,0.,0.)); +#1718 = DEFINITIONAL_REPRESENTATION('',(#1719),#1723); +#1719 = LINE('',#1720,#1721); +#1720 = CARTESIAN_POINT('',(0.,0.)); +#1721 = VECTOR('',#1722,1.); +#1722 = DIRECTION('',(1.,0.)); +#1723 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1724 = ORIENTED_EDGE('',*,*,#1725,.F.); +#1725 = EDGE_CURVE('',#1726,#1693,#1728,.T.); +#1726 = VERTEX_POINT('',#1727); +#1727 = CARTESIAN_POINT('',(18.5,13.25,0.)); +#1728 = SURFACE_CURVE('',#1729,(#1733,#1740),.PCURVE_S1.); +#1729 = LINE('',#1730,#1731); +#1730 = CARTESIAN_POINT('',(18.5,13.25,0.)); +#1731 = VECTOR('',#1732,1.); +#1732 = DIRECTION('',(1.,0.,0.)); +#1733 = PCURVE('',#370,#1734); +#1734 = DEFINITIONAL_REPRESENTATION('',(#1735),#1739); +#1735 = LINE('',#1736,#1737); +#1736 = CARTESIAN_POINT('',(18.5,33.75)); +#1737 = VECTOR('',#1738,1.); +#1738 = DIRECTION('',(1.,0.)); +#1739 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1740 = PCURVE('',#1741,#1746); +#1741 = PLANE('',#1742); +#1742 = AXIS2_PLACEMENT_3D('',#1743,#1744,#1745); +#1743 = CARTESIAN_POINT('',(18.5,13.25,0.)); +#1744 = DIRECTION('',(0.,1.,0.)); +#1745 = DIRECTION('',(1.,0.,0.)); +#1746 = DEFINITIONAL_REPRESENTATION('',(#1747),#1751); +#1747 = LINE('',#1748,#1749); +#1748 = CARTESIAN_POINT('',(0.,0.)); +#1749 = VECTOR('',#1750,1.); +#1750 = DIRECTION('',(1.,0.)); +#1751 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1752 = ORIENTED_EDGE('',*,*,#1753,.T.); +#1753 = EDGE_CURVE('',#1726,#1665,#1754,.T.); +#1754 = SURFACE_CURVE('',#1755,(#1760,#1771),.PCURVE_S1.); +#1755 = CIRCLE('',#1756,2.25); +#1756 = AXIS2_PLACEMENT_3D('',#1757,#1758,#1759); +#1757 = CARTESIAN_POINT('',(18.5,15.5,0.)); +#1758 = DIRECTION('',(0.,0.,-1.)); +#1759 = DIRECTION('',(1.,0.,0.)); +#1760 = PCURVE('',#370,#1761); +#1761 = DEFINITIONAL_REPRESENTATION('',(#1762),#1770); +#1762 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1763,#1764,#1765,#1766, +#1767,#1768,#1769),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1763 = CARTESIAN_POINT('',(20.75,36.)); +#1764 = CARTESIAN_POINT('',(20.75,32.10288568297)); +#1765 = CARTESIAN_POINT('',(17.375,34.051442841485)); +#1766 = CARTESIAN_POINT('',(14.,36.)); +#1767 = CARTESIAN_POINT('',(17.375,37.948557158515)); +#1768 = CARTESIAN_POINT('',(20.75,39.89711431703)); +#1769 = CARTESIAN_POINT('',(20.75,36.)); +#1770 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1771 = PCURVE('',#1772,#1777); +#1772 = CYLINDRICAL_SURFACE('',#1773,2.25); +#1773 = AXIS2_PLACEMENT_3D('',#1774,#1775,#1776); +#1774 = CARTESIAN_POINT('',(18.5,15.5,0.)); +#1775 = DIRECTION('',(0.,0.,-1.)); +#1776 = DIRECTION('',(1.,0.,0.)); +#1777 = DEFINITIONAL_REPRESENTATION('',(#1778),#1782); +#1778 = LINE('',#1779,#1780); +#1779 = CARTESIAN_POINT('',(0.,0.)); +#1780 = VECTOR('',#1781,1.); +#1781 = DIRECTION('',(1.,0.)); +#1782 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1783 = FACE_BOUND('',#1784,.F.); +#1784 = EDGE_LOOP('',(#1785,#1815,#1848,#1876)); +#1785 = ORIENTED_EDGE('',*,*,#1786,.F.); +#1786 = EDGE_CURVE('',#1787,#1789,#1791,.T.); +#1787 = VERTEX_POINT('',#1788); +#1788 = CARTESIAN_POINT('',(27.75,15.5,0.)); +#1789 = VERTEX_POINT('',#1790); +#1790 = CARTESIAN_POINT('',(27.75,12.5,0.)); +#1791 = SURFACE_CURVE('',#1792,(#1796,#1803),.PCURVE_S1.); +#1792 = LINE('',#1793,#1794); +#1793 = CARTESIAN_POINT('',(27.75,15.5,0.)); +#1794 = VECTOR('',#1795,1.); +#1795 = DIRECTION('',(0.,-1.,0.)); +#1796 = PCURVE('',#370,#1797); +#1797 = DEFINITIONAL_REPRESENTATION('',(#1798),#1802); +#1798 = LINE('',#1799,#1800); +#1799 = CARTESIAN_POINT('',(27.75,36.)); +#1800 = VECTOR('',#1801,1.); +#1801 = DIRECTION('',(0.,-1.)); +#1802 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1803 = PCURVE('',#1804,#1809); +#1804 = PLANE('',#1805); +#1805 = AXIS2_PLACEMENT_3D('',#1806,#1807,#1808); +#1806 = CARTESIAN_POINT('',(27.75,15.5,0.)); +#1807 = DIRECTION('',(1.,0.,-0.)); +#1808 = DIRECTION('',(0.,-1.,0.)); +#1809 = DEFINITIONAL_REPRESENTATION('',(#1810),#1814); +#1810 = LINE('',#1811,#1812); +#1811 = CARTESIAN_POINT('',(0.,0.)); +#1812 = VECTOR('',#1813,1.); +#1813 = DIRECTION('',(1.,0.)); +#1814 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1815 = ORIENTED_EDGE('',*,*,#1816,.T.); +#1816 = EDGE_CURVE('',#1787,#1817,#1819,.T.); +#1817 = VERTEX_POINT('',#1818); +#1818 = CARTESIAN_POINT('',(32.25,15.5,0.)); +#1819 = SURFACE_CURVE('',#1820,(#1825,#1836),.PCURVE_S1.); +#1820 = CIRCLE('',#1821,2.25); +#1821 = AXIS2_PLACEMENT_3D('',#1822,#1823,#1824); +#1822 = CARTESIAN_POINT('',(30.,15.5,0.)); +#1823 = DIRECTION('',(-0.,-0.,-1.)); +#1824 = DIRECTION('',(0.,-1.,0.)); +#1825 = PCURVE('',#370,#1826); +#1826 = DEFINITIONAL_REPRESENTATION('',(#1827),#1835); +#1827 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1828,#1829,#1830,#1831, +#1832,#1833,#1834),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1828 = CARTESIAN_POINT('',(30.,33.75)); +#1829 = CARTESIAN_POINT('',(26.10288568297,33.75)); +#1830 = CARTESIAN_POINT('',(28.051442841485,37.125)); +#1831 = CARTESIAN_POINT('',(30.,40.5)); +#1832 = CARTESIAN_POINT('',(31.948557158515,37.125)); +#1833 = CARTESIAN_POINT('',(33.89711431703,33.75)); +#1834 = CARTESIAN_POINT('',(30.,33.75)); +#1835 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1836 = PCURVE('',#1837,#1842); +#1837 = CYLINDRICAL_SURFACE('',#1838,2.25); +#1838 = AXIS2_PLACEMENT_3D('',#1839,#1840,#1841); +#1839 = CARTESIAN_POINT('',(30.,15.5,0.)); +#1840 = DIRECTION('',(-0.,-0.,-1.)); +#1841 = DIRECTION('',(0.,-1.,0.)); +#1842 = DEFINITIONAL_REPRESENTATION('',(#1843),#1847); +#1843 = LINE('',#1844,#1845); +#1844 = CARTESIAN_POINT('',(0.,0.)); +#1845 = VECTOR('',#1846,1.); +#1846 = DIRECTION('',(1.,0.)); +#1847 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1848 = ORIENTED_EDGE('',*,*,#1849,.F.); +#1849 = EDGE_CURVE('',#1850,#1817,#1852,.T.); +#1850 = VERTEX_POINT('',#1851); +#1851 = CARTESIAN_POINT('',(32.25,12.5,0.)); +#1852 = SURFACE_CURVE('',#1853,(#1857,#1864),.PCURVE_S1.); +#1853 = LINE('',#1854,#1855); +#1854 = CARTESIAN_POINT('',(32.25,12.5,0.)); +#1855 = VECTOR('',#1856,1.); +#1856 = DIRECTION('',(0.,1.,0.)); +#1857 = PCURVE('',#370,#1858); +#1858 = DEFINITIONAL_REPRESENTATION('',(#1859),#1863); +#1859 = LINE('',#1860,#1861); +#1860 = CARTESIAN_POINT('',(32.25,33.)); +#1861 = VECTOR('',#1862,1.); +#1862 = DIRECTION('',(0.,1.)); +#1863 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1864 = PCURVE('',#1865,#1870); +#1865 = PLANE('',#1866); +#1866 = AXIS2_PLACEMENT_3D('',#1867,#1868,#1869); +#1867 = CARTESIAN_POINT('',(32.25,12.5,0.)); +#1868 = DIRECTION('',(-1.,0.,0.)); +#1869 = DIRECTION('',(0.,1.,0.)); +#1870 = DEFINITIONAL_REPRESENTATION('',(#1871),#1875); +#1871 = LINE('',#1872,#1873); +#1872 = CARTESIAN_POINT('',(0.,0.)); +#1873 = VECTOR('',#1874,1.); +#1874 = DIRECTION('',(1.,0.)); +#1875 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1876 = ORIENTED_EDGE('',*,*,#1877,.T.); +#1877 = EDGE_CURVE('',#1850,#1789,#1878,.T.); +#1878 = SURFACE_CURVE('',#1879,(#1884,#1895),.PCURVE_S1.); +#1879 = CIRCLE('',#1880,2.25); +#1880 = AXIS2_PLACEMENT_3D('',#1881,#1882,#1883); +#1881 = CARTESIAN_POINT('',(30.,12.5,0.)); +#1882 = DIRECTION('',(0.,0.,-1.)); +#1883 = DIRECTION('',(0.,1.,0.)); +#1884 = PCURVE('',#370,#1885); +#1885 = DEFINITIONAL_REPRESENTATION('',(#1886),#1894); +#1886 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#1887,#1888,#1889,#1890, +#1891,#1892,#1893),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#1887 = CARTESIAN_POINT('',(30.,35.25)); +#1888 = CARTESIAN_POINT('',(33.89711431703,35.25)); +#1889 = CARTESIAN_POINT('',(31.948557158515,31.875)); +#1890 = CARTESIAN_POINT('',(30.,28.5)); +#1891 = CARTESIAN_POINT('',(28.051442841485,31.875)); +#1892 = CARTESIAN_POINT('',(26.10288568297,35.25)); +#1893 = CARTESIAN_POINT('',(30.,35.25)); +#1894 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1895 = PCURVE('',#1896,#1901); +#1896 = CYLINDRICAL_SURFACE('',#1897,2.25); +#1897 = AXIS2_PLACEMENT_3D('',#1898,#1899,#1900); +#1898 = CARTESIAN_POINT('',(30.,12.5,0.)); +#1899 = DIRECTION('',(0.,0.,-1.)); +#1900 = DIRECTION('',(0.,1.,0.)); +#1901 = DEFINITIONAL_REPRESENTATION('',(#1902),#1906); +#1902 = LINE('',#1903,#1904); +#1903 = CARTESIAN_POINT('',(0.,0.)); +#1904 = VECTOR('',#1905,1.); +#1905 = DIRECTION('',(1.,0.)); +#1906 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1907 = ADVANCED_FACE('',(#1908),#502,.F.); +#1908 = FACE_BOUND('',#1909,.F.); +#1909 = EDGE_LOOP('',(#1910,#1911,#1912,#1935)); +#1910 = ORIENTED_EDGE('',*,*,#1090,.F.); +#1911 = ORIENTED_EDGE('',*,*,#488,.T.); +#1912 = ORIENTED_EDGE('',*,*,#1913,.T.); +#1913 = EDGE_CURVE('',#461,#1914,#1916,.T.); +#1914 = VERTEX_POINT('',#1915); +#1915 = CARTESIAN_POINT('',(35.,-18.5,3.)); +#1916 = SURFACE_CURVE('',#1917,(#1921,#1928),.PCURVE_S1.); +#1917 = LINE('',#1918,#1919); +#1918 = CARTESIAN_POINT('',(25.25,-28.25,3.)); +#1919 = VECTOR('',#1920,1.); +#1920 = DIRECTION('',(0.707106781187,0.707106781187,-0.)); +#1921 = PCURVE('',#502,#1922); +#1922 = DEFINITIONAL_REPRESENTATION('',(#1923),#1927); +#1923 = LINE('',#1924,#1925); +#1924 = CARTESIAN_POINT('',(3.,-12.37436867076)); +#1925 = VECTOR('',#1926,1.); +#1926 = DIRECTION('',(0.,1.)); +#1927 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1928 = PCURVE('',#476,#1929); +#1929 = DEFINITIONAL_REPRESENTATION('',(#1930),#1934); +#1930 = LINE('',#1931,#1932); +#1931 = CARTESIAN_POINT('',(25.25,-7.75)); +#1932 = VECTOR('',#1933,1.); +#1933 = DIRECTION('',(0.707106781187,0.707106781187)); +#1934 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1935 = ORIENTED_EDGE('',*,*,#1936,.F.); +#1936 = EDGE_CURVE('',#1091,#1914,#1937,.T.); +#1937 = SURFACE_CURVE('',#1938,(#1942,#1949),.PCURVE_S1.); +#1938 = LINE('',#1939,#1940); +#1939 = CARTESIAN_POINT('',(35.,-18.5,0.)); +#1940 = VECTOR('',#1941,1.); +#1941 = DIRECTION('',(0.,0.,1.)); +#1942 = PCURVE('',#502,#1943); +#1943 = DEFINITIONAL_REPRESENTATION('',(#1944),#1948); +#1944 = LINE('',#1945,#1946); +#1945 = CARTESIAN_POINT('',(0.,1.414213562373)); +#1946 = VECTOR('',#1947,1.); +#1947 = DIRECTION('',(1.,0.)); +#1948 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1949 = PCURVE('',#1129,#1950); +#1950 = DEFINITIONAL_REPRESENTATION('',(#1951),#1955); +#1951 = LINE('',#1952,#1953); +#1952 = CARTESIAN_POINT('',(0.,-2.)); +#1953 = VECTOR('',#1954,1.); +#1954 = DIRECTION('',(1.,0.)); +#1955 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1956 = ADVANCED_FACE('',(#1957,#2025,#2125,#2225,#2325,#2425,#2525), + #476,.T.); +#1957 = FACE_BOUND('',#1958,.T.); +#1958 = EDGE_LOOP('',(#1959,#1960,#1979,#1980,#1981,#2004)); +#1959 = ORIENTED_EDGE('',*,*,#667,.F.); +#1960 = ORIENTED_EDGE('',*,*,#1961,.F.); +#1961 = EDGE_CURVE('',#433,#645,#1962,.T.); +#1962 = SURFACE_CURVE('',#1963,(#1967,#1973),.PCURVE_S1.); +#1963 = LINE('',#1964,#1965); +#1964 = CARTESIAN_POINT('',(3.,-20.5,3.)); +#1965 = VECTOR('',#1966,1.); +#1966 = DIRECTION('',(0.,1.,0.)); +#1967 = PCURVE('',#476,#1968); +#1968 = DEFINITIONAL_REPRESENTATION('',(#1969),#1972); +#1969 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#1970,#1971),.UNSPECIFIED.,.F., + .F.,(2,2),(0.,41.),.PIECEWISE_BEZIER_KNOTS.); +#1970 = CARTESIAN_POINT('',(3.,0.)); +#1971 = CARTESIAN_POINT('',(3.,41.)); +#1972 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1973 = PCURVE('',#448,#1974); +#1974 = DEFINITIONAL_REPRESENTATION('',(#1975),#1978); +#1975 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#1976,#1977),.UNSPECIFIED.,.F., + .F.,(2,2),(0.,41.),.PIECEWISE_BEZIER_KNOTS.); +#1976 = CARTESIAN_POINT('',(3.,0.)); +#1977 = CARTESIAN_POINT('',(3.,-41.)); +#1978 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1979 = ORIENTED_EDGE('',*,*,#460,.T.); +#1980 = ORIENTED_EDGE('',*,*,#1913,.T.); +#1981 = ORIENTED_EDGE('',*,*,#1982,.T.); +#1982 = EDGE_CURVE('',#1914,#1983,#1985,.T.); +#1983 = VERTEX_POINT('',#1984); +#1984 = CARTESIAN_POINT('',(35.,18.5,3.)); +#1985 = SURFACE_CURVE('',#1986,(#1990,#1997),.PCURVE_S1.); +#1986 = LINE('',#1987,#1988); +#1987 = CARTESIAN_POINT('',(35.,-20.5,3.)); +#1988 = VECTOR('',#1989,1.); +#1989 = DIRECTION('',(0.,1.,0.)); +#1990 = PCURVE('',#476,#1991); +#1991 = DEFINITIONAL_REPRESENTATION('',(#1992),#1996); +#1992 = LINE('',#1993,#1994); +#1993 = CARTESIAN_POINT('',(35.,0.)); +#1994 = VECTOR('',#1995,1.); +#1995 = DIRECTION('',(0.,1.)); +#1996 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1997 = PCURVE('',#1129,#1998); +#1998 = DEFINITIONAL_REPRESENTATION('',(#1999),#2003); +#1999 = LINE('',#2000,#2001); +#2000 = CARTESIAN_POINT('',(3.,0.)); +#2001 = VECTOR('',#2002,1.); +#2002 = DIRECTION('',(0.,-1.)); +#2003 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2004 = ORIENTED_EDGE('',*,*,#2005,.F.); +#2005 = EDGE_CURVE('',#668,#1983,#2006,.T.); +#2006 = SURFACE_CURVE('',#2007,(#2011,#2018),.PCURVE_S1.); +#2007 = LINE('',#2008,#2009); +#2008 = CARTESIAN_POINT('',(35.5,18.,3.)); +#2009 = VECTOR('',#2010,1.); +#2010 = DIRECTION('',(0.707106781187,-0.707106781187,0.)); +#2011 = PCURVE('',#476,#2012); +#2012 = DEFINITIONAL_REPRESENTATION('',(#2013),#2017); +#2013 = LINE('',#2014,#2015); +#2014 = CARTESIAN_POINT('',(35.5,38.5)); +#2015 = VECTOR('',#2016,1.); +#2016 = DIRECTION('',(0.707106781187,-0.707106781187)); +#2017 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2018 = PCURVE('',#706,#2019); +#2019 = DEFINITIONAL_REPRESENTATION('',(#2020),#2024); +#2020 = LINE('',#2021,#2022); +#2021 = CARTESIAN_POINT('',(3.,2.12132034356)); +#2022 = VECTOR('',#2023,1.); +#2023 = DIRECTION('',(0.,1.)); +#2024 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2025 = FACE_BOUND('',#2026,.T.); +#2026 = EDGE_LOOP('',(#2027,#2050,#2078,#2099)); +#2027 = ORIENTED_EDGE('',*,*,#2028,.F.); +#2028 = EDGE_CURVE('',#2029,#2031,#2033,.T.); +#2029 = VERTEX_POINT('',#2030); +#2030 = CARTESIAN_POINT('',(7.75,-12.5,3.)); +#2031 = VERTEX_POINT('',#2032); +#2032 = CARTESIAN_POINT('',(7.75,-15.5,3.)); +#2033 = SURFACE_CURVE('',#2034,(#2038,#2044),.PCURVE_S1.); +#2034 = LINE('',#2035,#2036); +#2035 = CARTESIAN_POINT('',(7.75,-16.5,3.)); +#2036 = VECTOR('',#2037,1.); +#2037 = DIRECTION('',(0.,-1.,0.)); +#2038 = PCURVE('',#476,#2039); +#2039 = DEFINITIONAL_REPRESENTATION('',(#2040),#2043); +#2040 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2041,#2042),.UNSPECIFIED.,.F., + .F.,(2,2),(-4.,-1.),.PIECEWISE_BEZIER_KNOTS.); +#2041 = CARTESIAN_POINT('',(7.75,8.)); +#2042 = CARTESIAN_POINT('',(7.75,5.)); +#2043 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2044 = PCURVE('',#1184,#2045); +#2045 = DEFINITIONAL_REPRESENTATION('',(#2046),#2049); +#2046 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2047,#2048),.UNSPECIFIED.,.F., + .F.,(2,2),(-4.,-1.),.PIECEWISE_BEZIER_KNOTS.); +#2047 = CARTESIAN_POINT('',(0.,-3.)); +#2048 = CARTESIAN_POINT('',(3.,-3.)); +#2049 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2050 = ORIENTED_EDGE('',*,*,#2051,.T.); +#2051 = EDGE_CURVE('',#2029,#2052,#2054,.T.); +#2052 = VERTEX_POINT('',#2053); +#2053 = CARTESIAN_POINT('',(12.25,-12.5,3.)); +#2054 = SURFACE_CURVE('',#2055,(#2060,#2071),.PCURVE_S1.); +#2055 = CIRCLE('',#2056,2.25); +#2056 = AXIS2_PLACEMENT_3D('',#2057,#2058,#2059); +#2057 = CARTESIAN_POINT('',(10.,-12.5,3.)); +#2058 = DIRECTION('',(-0.,-0.,-1.)); +#2059 = DIRECTION('',(0.,-1.,0.)); +#2060 = PCURVE('',#476,#2061); +#2061 = DEFINITIONAL_REPRESENTATION('',(#2062),#2070); +#2062 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2063,#2064,#2065,#2066, +#2067,#2068,#2069),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2063 = CARTESIAN_POINT('',(10.,5.75)); +#2064 = CARTESIAN_POINT('',(6.10288568297,5.75)); +#2065 = CARTESIAN_POINT('',(8.051442841485,9.125)); +#2066 = CARTESIAN_POINT('',(10.,12.5)); +#2067 = CARTESIAN_POINT('',(11.948557158515,9.125)); +#2068 = CARTESIAN_POINT('',(13.89711431703,5.75)); +#2069 = CARTESIAN_POINT('',(10.,5.75)); +#2070 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2071 = PCURVE('',#1217,#2072); +#2072 = DEFINITIONAL_REPRESENTATION('',(#2073),#2077); +#2073 = LINE('',#2074,#2075); +#2074 = CARTESIAN_POINT('',(0.,-3.)); +#2075 = VECTOR('',#2076,1.); +#2076 = DIRECTION('',(1.,0.)); +#2077 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2078 = ORIENTED_EDGE('',*,*,#2079,.F.); +#2079 = EDGE_CURVE('',#2080,#2052,#2082,.T.); +#2080 = VERTEX_POINT('',#2081); +#2081 = CARTESIAN_POINT('',(12.25,-15.5,3.)); +#2082 = SURFACE_CURVE('',#2083,(#2087,#2093),.PCURVE_S1.); +#2083 = LINE('',#2084,#2085); +#2084 = CARTESIAN_POINT('',(12.25,-18.,3.)); +#2085 = VECTOR('',#2086,1.); +#2086 = DIRECTION('',(0.,1.,-0.)); +#2087 = PCURVE('',#476,#2088); +#2088 = DEFINITIONAL_REPRESENTATION('',(#2089),#2092); +#2089 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2090,#2091),.UNSPECIFIED.,.F., + .F.,(2,2),(2.5,5.5),.PIECEWISE_BEZIER_KNOTS.); +#2090 = CARTESIAN_POINT('',(12.25,5.)); +#2091 = CARTESIAN_POINT('',(12.25,8.)); +#2092 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2093 = PCURVE('',#1245,#2094); +#2094 = DEFINITIONAL_REPRESENTATION('',(#2095),#2098); +#2095 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2096,#2097),.UNSPECIFIED.,.F., + .F.,(2,2),(2.5,5.5),.PIECEWISE_BEZIER_KNOTS.); +#2096 = CARTESIAN_POINT('',(0.,-3.)); +#2097 = CARTESIAN_POINT('',(3.,-3.)); +#2098 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2099 = ORIENTED_EDGE('',*,*,#2100,.T.); +#2100 = EDGE_CURVE('',#2080,#2031,#2101,.T.); +#2101 = SURFACE_CURVE('',#2102,(#2107,#2118),.PCURVE_S1.); +#2102 = CIRCLE('',#2103,2.25); +#2103 = AXIS2_PLACEMENT_3D('',#2104,#2105,#2106); +#2104 = CARTESIAN_POINT('',(10.,-15.5,3.)); +#2105 = DIRECTION('',(0.,0.,-1.)); +#2106 = DIRECTION('',(0.,1.,0.)); +#2107 = PCURVE('',#476,#2108); +#2108 = DEFINITIONAL_REPRESENTATION('',(#2109),#2117); +#2109 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2110,#2111,#2112,#2113, +#2114,#2115,#2116),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2110 = CARTESIAN_POINT('',(10.,7.25)); +#2111 = CARTESIAN_POINT('',(13.89711431703,7.25)); +#2112 = CARTESIAN_POINT('',(11.948557158515,3.875)); +#2113 = CARTESIAN_POINT('',(10.,0.5)); +#2114 = CARTESIAN_POINT('',(8.051442841485,3.875)); +#2115 = CARTESIAN_POINT('',(6.10288568297,7.25)); +#2116 = CARTESIAN_POINT('',(10.,7.25)); +#2117 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2118 = PCURVE('',#1276,#2119); +#2119 = DEFINITIONAL_REPRESENTATION('',(#2120),#2124); +#2120 = LINE('',#2121,#2122); +#2121 = CARTESIAN_POINT('',(0.,-3.)); +#2122 = VECTOR('',#2123,1.); +#2123 = DIRECTION('',(1.,0.)); +#2124 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2125 = FACE_BOUND('',#2126,.T.); +#2126 = EDGE_LOOP('',(#2127,#2150,#2178,#2199)); +#2127 = ORIENTED_EDGE('',*,*,#2128,.F.); +#2128 = EDGE_CURVE('',#2129,#2131,#2133,.T.); +#2129 = VERTEX_POINT('',#2130); +#2130 = CARTESIAN_POINT('',(21.5,-13.25,3.)); +#2131 = VERTEX_POINT('',#2132); +#2132 = CARTESIAN_POINT('',(18.5,-13.25,3.)); +#2133 = SURFACE_CURVE('',#2134,(#2138,#2144),.PCURVE_S1.); +#2134 = LINE('',#2135,#2136); +#2135 = CARTESIAN_POINT('',(10.75,-13.25,3.)); +#2136 = VECTOR('',#2137,1.); +#2137 = DIRECTION('',(-1.,0.,0.)); +#2138 = PCURVE('',#476,#2139); +#2139 = DEFINITIONAL_REPRESENTATION('',(#2140),#2143); +#2140 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2141,#2142),.UNSPECIFIED.,.F., + .F.,(2,2),(-10.75,-7.75),.PIECEWISE_BEZIER_KNOTS.); +#2141 = CARTESIAN_POINT('',(21.5,7.25)); +#2142 = CARTESIAN_POINT('',(18.5,7.25)); +#2143 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2144 = PCURVE('',#1308,#2145); +#2145 = DEFINITIONAL_REPRESENTATION('',(#2146),#2149); +#2146 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2147,#2148),.UNSPECIFIED.,.F., + .F.,(2,2),(-10.75,-7.75),.PIECEWISE_BEZIER_KNOTS.); +#2147 = CARTESIAN_POINT('',(0.,-3.)); +#2148 = CARTESIAN_POINT('',(3.,-3.)); +#2149 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2150 = ORIENTED_EDGE('',*,*,#2151,.T.); +#2151 = EDGE_CURVE('',#2129,#2152,#2154,.T.); +#2152 = VERTEX_POINT('',#2153); +#2153 = CARTESIAN_POINT('',(21.5,-17.75,3.)); +#2154 = SURFACE_CURVE('',#2155,(#2160,#2171),.PCURVE_S1.); +#2155 = CIRCLE('',#2156,2.25); +#2156 = AXIS2_PLACEMENT_3D('',#2157,#2158,#2159); +#2157 = CARTESIAN_POINT('',(21.5,-15.5,3.)); +#2158 = DIRECTION('',(0.,0.,-1.)); +#2159 = DIRECTION('',(-1.,0.,0.)); +#2160 = PCURVE('',#476,#2161); +#2161 = DEFINITIONAL_REPRESENTATION('',(#2162),#2170); +#2162 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2163,#2164,#2165,#2166, +#2167,#2168,#2169),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2163 = CARTESIAN_POINT('',(19.25,5.)); +#2164 = CARTESIAN_POINT('',(19.25,8.89711431703)); +#2165 = CARTESIAN_POINT('',(22.625,6.948557158515)); +#2166 = CARTESIAN_POINT('',(26.,5.)); +#2167 = CARTESIAN_POINT('',(22.625,3.051442841485)); +#2168 = CARTESIAN_POINT('',(19.25,1.10288568297)); +#2169 = CARTESIAN_POINT('',(19.25,5.)); +#2170 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2171 = PCURVE('',#1341,#2172); +#2172 = DEFINITIONAL_REPRESENTATION('',(#2173),#2177); +#2173 = LINE('',#2174,#2175); +#2174 = CARTESIAN_POINT('',(0.,-3.)); +#2175 = VECTOR('',#2176,1.); +#2176 = DIRECTION('',(1.,0.)); +#2177 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2178 = ORIENTED_EDGE('',*,*,#2179,.F.); +#2179 = EDGE_CURVE('',#2180,#2152,#2182,.T.); +#2180 = VERTEX_POINT('',#2181); +#2181 = CARTESIAN_POINT('',(18.5,-17.75,3.)); +#2182 = SURFACE_CURVE('',#2183,(#2187,#2193),.PCURVE_S1.); +#2183 = LINE('',#2184,#2185); +#2184 = CARTESIAN_POINT('',(9.25,-17.75,3.)); +#2185 = VECTOR('',#2186,1.); +#2186 = DIRECTION('',(1.,0.,0.)); +#2187 = PCURVE('',#476,#2188); +#2188 = DEFINITIONAL_REPRESENTATION('',(#2189),#2192); +#2189 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2190,#2191),.UNSPECIFIED.,.F., + .F.,(2,2),(9.25,12.25),.PIECEWISE_BEZIER_KNOTS.); +#2190 = CARTESIAN_POINT('',(18.5,2.75)); +#2191 = CARTESIAN_POINT('',(21.5,2.75)); +#2192 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2193 = PCURVE('',#1369,#2194); +#2194 = DEFINITIONAL_REPRESENTATION('',(#2195),#2198); +#2195 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2196,#2197),.UNSPECIFIED.,.F., + .F.,(2,2),(9.25,12.25),.PIECEWISE_BEZIER_KNOTS.); +#2196 = CARTESIAN_POINT('',(0.,-3.)); +#2197 = CARTESIAN_POINT('',(3.,-3.)); +#2198 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2199 = ORIENTED_EDGE('',*,*,#2200,.T.); +#2200 = EDGE_CURVE('',#2180,#2131,#2201,.T.); +#2201 = SURFACE_CURVE('',#2202,(#2207,#2218),.PCURVE_S1.); +#2202 = CIRCLE('',#2203,2.25); +#2203 = AXIS2_PLACEMENT_3D('',#2204,#2205,#2206); +#2204 = CARTESIAN_POINT('',(18.5,-15.5,3.)); +#2205 = DIRECTION('',(0.,0.,-1.)); +#2206 = DIRECTION('',(1.,0.,0.)); +#2207 = PCURVE('',#476,#2208); +#2208 = DEFINITIONAL_REPRESENTATION('',(#2209),#2217); +#2209 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2210,#2211,#2212,#2213, +#2214,#2215,#2216),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2210 = CARTESIAN_POINT('',(20.75,5.)); +#2211 = CARTESIAN_POINT('',(20.75,1.10288568297)); +#2212 = CARTESIAN_POINT('',(17.375,3.051442841485)); +#2213 = CARTESIAN_POINT('',(14.,5.)); +#2214 = CARTESIAN_POINT('',(17.375,6.948557158515)); +#2215 = CARTESIAN_POINT('',(20.75,8.89711431703)); +#2216 = CARTESIAN_POINT('',(20.75,5.)); +#2217 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2218 = PCURVE('',#1400,#2219); +#2219 = DEFINITIONAL_REPRESENTATION('',(#2220),#2224); +#2220 = LINE('',#2221,#2222); +#2221 = CARTESIAN_POINT('',(0.,-3.)); +#2222 = VECTOR('',#2223,1.); +#2223 = DIRECTION('',(1.,0.)); +#2224 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2225 = FACE_BOUND('',#2226,.T.); +#2226 = EDGE_LOOP('',(#2227,#2250,#2278,#2299)); +#2227 = ORIENTED_EDGE('',*,*,#2228,.F.); +#2228 = EDGE_CURVE('',#2229,#2231,#2233,.T.); +#2229 = VERTEX_POINT('',#2230); +#2230 = CARTESIAN_POINT('',(27.75,-12.5,3.)); +#2231 = VERTEX_POINT('',#2232); +#2232 = CARTESIAN_POINT('',(27.75,-15.5,3.)); +#2233 = SURFACE_CURVE('',#2234,(#2238,#2244),.PCURVE_S1.); +#2234 = LINE('',#2235,#2236); +#2235 = CARTESIAN_POINT('',(27.75,-16.5,3.)); +#2236 = VECTOR('',#2237,1.); +#2237 = DIRECTION('',(0.,-1.,0.)); +#2238 = PCURVE('',#476,#2239); +#2239 = DEFINITIONAL_REPRESENTATION('',(#2240),#2243); +#2240 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2241,#2242),.UNSPECIFIED.,.F., + .F.,(2,2),(-4.,-1.),.PIECEWISE_BEZIER_KNOTS.); +#2241 = CARTESIAN_POINT('',(27.75,8.)); +#2242 = CARTESIAN_POINT('',(27.75,5.)); +#2243 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2244 = PCURVE('',#1432,#2245); +#2245 = DEFINITIONAL_REPRESENTATION('',(#2246),#2249); +#2246 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2247,#2248),.UNSPECIFIED.,.F., + .F.,(2,2),(-4.,-1.),.PIECEWISE_BEZIER_KNOTS.); +#2247 = CARTESIAN_POINT('',(0.,-3.)); +#2248 = CARTESIAN_POINT('',(3.,-3.)); +#2249 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2250 = ORIENTED_EDGE('',*,*,#2251,.T.); +#2251 = EDGE_CURVE('',#2229,#2252,#2254,.T.); +#2252 = VERTEX_POINT('',#2253); +#2253 = CARTESIAN_POINT('',(32.25,-12.5,3.)); +#2254 = SURFACE_CURVE('',#2255,(#2260,#2271),.PCURVE_S1.); +#2255 = CIRCLE('',#2256,2.25); +#2256 = AXIS2_PLACEMENT_3D('',#2257,#2258,#2259); +#2257 = CARTESIAN_POINT('',(30.,-12.5,3.)); +#2258 = DIRECTION('',(-0.,-0.,-1.)); +#2259 = DIRECTION('',(0.,-1.,0.)); +#2260 = PCURVE('',#476,#2261); +#2261 = DEFINITIONAL_REPRESENTATION('',(#2262),#2270); +#2262 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2263,#2264,#2265,#2266, +#2267,#2268,#2269),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2263 = CARTESIAN_POINT('',(30.,5.75)); +#2264 = CARTESIAN_POINT('',(26.10288568297,5.75)); +#2265 = CARTESIAN_POINT('',(28.051442841485,9.125)); +#2266 = CARTESIAN_POINT('',(30.,12.5)); +#2267 = CARTESIAN_POINT('',(31.948557158515,9.125)); +#2268 = CARTESIAN_POINT('',(33.89711431703,5.75)); +#2269 = CARTESIAN_POINT('',(30.,5.75)); +#2270 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2271 = PCURVE('',#1465,#2272); +#2272 = DEFINITIONAL_REPRESENTATION('',(#2273),#2277); +#2273 = LINE('',#2274,#2275); +#2274 = CARTESIAN_POINT('',(0.,-3.)); +#2275 = VECTOR('',#2276,1.); +#2276 = DIRECTION('',(1.,0.)); +#2277 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2278 = ORIENTED_EDGE('',*,*,#2279,.F.); +#2279 = EDGE_CURVE('',#2280,#2252,#2282,.T.); +#2280 = VERTEX_POINT('',#2281); +#2281 = CARTESIAN_POINT('',(32.25,-15.5,3.)); +#2282 = SURFACE_CURVE('',#2283,(#2287,#2293),.PCURVE_S1.); +#2283 = LINE('',#2284,#2285); +#2284 = CARTESIAN_POINT('',(32.25,-18.,3.)); +#2285 = VECTOR('',#2286,1.); +#2286 = DIRECTION('',(0.,1.,-0.)); +#2287 = PCURVE('',#476,#2288); +#2288 = DEFINITIONAL_REPRESENTATION('',(#2289),#2292); +#2289 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2290,#2291),.UNSPECIFIED.,.F., + .F.,(2,2),(2.5,5.5),.PIECEWISE_BEZIER_KNOTS.); +#2290 = CARTESIAN_POINT('',(32.25,5.)); +#2291 = CARTESIAN_POINT('',(32.25,8.)); +#2292 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2293 = PCURVE('',#1493,#2294); +#2294 = DEFINITIONAL_REPRESENTATION('',(#2295),#2298); +#2295 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2296,#2297),.UNSPECIFIED.,.F., + .F.,(2,2),(2.5,5.5),.PIECEWISE_BEZIER_KNOTS.); +#2296 = CARTESIAN_POINT('',(0.,-3.)); +#2297 = CARTESIAN_POINT('',(3.,-3.)); +#2298 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2299 = ORIENTED_EDGE('',*,*,#2300,.T.); +#2300 = EDGE_CURVE('',#2280,#2231,#2301,.T.); +#2301 = SURFACE_CURVE('',#2302,(#2307,#2318),.PCURVE_S1.); +#2302 = CIRCLE('',#2303,2.25); +#2303 = AXIS2_PLACEMENT_3D('',#2304,#2305,#2306); +#2304 = CARTESIAN_POINT('',(30.,-15.5,3.)); +#2305 = DIRECTION('',(0.,0.,-1.)); +#2306 = DIRECTION('',(0.,1.,0.)); +#2307 = PCURVE('',#476,#2308); +#2308 = DEFINITIONAL_REPRESENTATION('',(#2309),#2317); +#2309 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2310,#2311,#2312,#2313, +#2314,#2315,#2316),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2310 = CARTESIAN_POINT('',(30.,7.25)); +#2311 = CARTESIAN_POINT('',(33.89711431703,7.25)); +#2312 = CARTESIAN_POINT('',(31.948557158515,3.875)); +#2313 = CARTESIAN_POINT('',(30.,0.5)); +#2314 = CARTESIAN_POINT('',(28.051442841485,3.875)); +#2315 = CARTESIAN_POINT('',(26.10288568297,7.25)); +#2316 = CARTESIAN_POINT('',(30.,7.25)); +#2317 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2318 = PCURVE('',#1524,#2319); +#2319 = DEFINITIONAL_REPRESENTATION('',(#2320),#2324); +#2320 = LINE('',#2321,#2322); +#2321 = CARTESIAN_POINT('',(0.,-3.)); +#2322 = VECTOR('',#2323,1.); +#2323 = DIRECTION('',(1.,0.)); +#2324 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2325 = FACE_BOUND('',#2326,.T.); +#2326 = EDGE_LOOP('',(#2327,#2350,#2378,#2399)); +#2327 = ORIENTED_EDGE('',*,*,#2328,.F.); +#2328 = EDGE_CURVE('',#2329,#2331,#2333,.T.); +#2329 = VERTEX_POINT('',#2330); +#2330 = CARTESIAN_POINT('',(7.75,15.5,3.)); +#2331 = VERTEX_POINT('',#2332); +#2332 = CARTESIAN_POINT('',(7.75,12.5,3.)); +#2333 = SURFACE_CURVE('',#2334,(#2338,#2344),.PCURVE_S1.); +#2334 = LINE('',#2335,#2336); +#2335 = CARTESIAN_POINT('',(7.75,-2.5,3.)); +#2336 = VECTOR('',#2337,1.); +#2337 = DIRECTION('',(0.,-1.,0.)); +#2338 = PCURVE('',#476,#2339); +#2339 = DEFINITIONAL_REPRESENTATION('',(#2340),#2343); +#2340 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2341,#2342),.UNSPECIFIED.,.F., + .F.,(2,2),(-18.,-15.),.PIECEWISE_BEZIER_KNOTS.); +#2341 = CARTESIAN_POINT('',(7.75,36.)); +#2342 = CARTESIAN_POINT('',(7.75,33.)); +#2343 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2344 = PCURVE('',#1556,#2345); +#2345 = DEFINITIONAL_REPRESENTATION('',(#2346),#2349); +#2346 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2347,#2348),.UNSPECIFIED.,.F., + .F.,(2,2),(-18.,-15.),.PIECEWISE_BEZIER_KNOTS.); +#2347 = CARTESIAN_POINT('',(0.,-3.)); +#2348 = CARTESIAN_POINT('',(3.,-3.)); +#2349 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2350 = ORIENTED_EDGE('',*,*,#2351,.T.); +#2351 = EDGE_CURVE('',#2329,#2352,#2354,.T.); +#2352 = VERTEX_POINT('',#2353); +#2353 = CARTESIAN_POINT('',(12.25,15.5,3.)); +#2354 = SURFACE_CURVE('',#2355,(#2360,#2371),.PCURVE_S1.); +#2355 = CIRCLE('',#2356,2.25); +#2356 = AXIS2_PLACEMENT_3D('',#2357,#2358,#2359); +#2357 = CARTESIAN_POINT('',(10.,15.5,3.)); +#2358 = DIRECTION('',(-0.,-0.,-1.)); +#2359 = DIRECTION('',(0.,-1.,0.)); +#2360 = PCURVE('',#476,#2361); +#2361 = DEFINITIONAL_REPRESENTATION('',(#2362),#2370); +#2362 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2363,#2364,#2365,#2366, +#2367,#2368,#2369),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2363 = CARTESIAN_POINT('',(10.,33.75)); +#2364 = CARTESIAN_POINT('',(6.10288568297,33.75)); +#2365 = CARTESIAN_POINT('',(8.051442841485,37.125)); +#2366 = CARTESIAN_POINT('',(10.,40.5)); +#2367 = CARTESIAN_POINT('',(11.948557158515,37.125)); +#2368 = CARTESIAN_POINT('',(13.89711431703,33.75)); +#2369 = CARTESIAN_POINT('',(10.,33.75)); +#2370 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2371 = PCURVE('',#1589,#2372); +#2372 = DEFINITIONAL_REPRESENTATION('',(#2373),#2377); +#2373 = LINE('',#2374,#2375); +#2374 = CARTESIAN_POINT('',(0.,-3.)); +#2375 = VECTOR('',#2376,1.); +#2376 = DIRECTION('',(1.,0.)); +#2377 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2378 = ORIENTED_EDGE('',*,*,#2379,.F.); +#2379 = EDGE_CURVE('',#2380,#2352,#2382,.T.); +#2380 = VERTEX_POINT('',#2381); +#2381 = CARTESIAN_POINT('',(12.25,12.5,3.)); +#2382 = SURFACE_CURVE('',#2383,(#2387,#2393),.PCURVE_S1.); +#2383 = LINE('',#2384,#2385); +#2384 = CARTESIAN_POINT('',(12.25,-4.,3.)); +#2385 = VECTOR('',#2386,1.); +#2386 = DIRECTION('',(0.,1.,-0.)); +#2387 = PCURVE('',#476,#2388); +#2388 = DEFINITIONAL_REPRESENTATION('',(#2389),#2392); +#2389 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2390,#2391),.UNSPECIFIED.,.F., + .F.,(2,2),(16.5,19.5),.PIECEWISE_BEZIER_KNOTS.); +#2390 = CARTESIAN_POINT('',(12.25,33.)); +#2391 = CARTESIAN_POINT('',(12.25,36.)); +#2392 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2393 = PCURVE('',#1617,#2394); +#2394 = DEFINITIONAL_REPRESENTATION('',(#2395),#2398); +#2395 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2396,#2397),.UNSPECIFIED.,.F., + .F.,(2,2),(16.5,19.5),.PIECEWISE_BEZIER_KNOTS.); +#2396 = CARTESIAN_POINT('',(0.,-3.)); +#2397 = CARTESIAN_POINT('',(3.,-3.)); +#2398 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2399 = ORIENTED_EDGE('',*,*,#2400,.T.); +#2400 = EDGE_CURVE('',#2380,#2331,#2401,.T.); +#2401 = SURFACE_CURVE('',#2402,(#2407,#2418),.PCURVE_S1.); +#2402 = CIRCLE('',#2403,2.25); +#2403 = AXIS2_PLACEMENT_3D('',#2404,#2405,#2406); +#2404 = CARTESIAN_POINT('',(10.,12.5,3.)); +#2405 = DIRECTION('',(0.,0.,-1.)); +#2406 = DIRECTION('',(0.,1.,0.)); +#2407 = PCURVE('',#476,#2408); +#2408 = DEFINITIONAL_REPRESENTATION('',(#2409),#2417); +#2409 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2410,#2411,#2412,#2413, +#2414,#2415,#2416),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2410 = CARTESIAN_POINT('',(10.,35.25)); +#2411 = CARTESIAN_POINT('',(13.89711431703,35.25)); +#2412 = CARTESIAN_POINT('',(11.948557158515,31.875)); +#2413 = CARTESIAN_POINT('',(10.,28.5)); +#2414 = CARTESIAN_POINT('',(8.051442841485,31.875)); +#2415 = CARTESIAN_POINT('',(6.10288568297,35.25)); +#2416 = CARTESIAN_POINT('',(10.,35.25)); +#2417 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2418 = PCURVE('',#1648,#2419); +#2419 = DEFINITIONAL_REPRESENTATION('',(#2420),#2424); +#2420 = LINE('',#2421,#2422); +#2421 = CARTESIAN_POINT('',(0.,-3.)); +#2422 = VECTOR('',#2423,1.); +#2423 = DIRECTION('',(1.,0.)); +#2424 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2425 = FACE_BOUND('',#2426,.T.); +#2426 = EDGE_LOOP('',(#2427,#2450,#2478,#2499)); +#2427 = ORIENTED_EDGE('',*,*,#2428,.F.); +#2428 = EDGE_CURVE('',#2429,#2431,#2433,.T.); +#2429 = VERTEX_POINT('',#2430); +#2430 = CARTESIAN_POINT('',(21.5,17.75,3.)); +#2431 = VERTEX_POINT('',#2432); +#2432 = CARTESIAN_POINT('',(18.5,17.75,3.)); +#2433 = SURFACE_CURVE('',#2434,(#2438,#2444),.PCURVE_S1.); +#2434 = LINE('',#2435,#2436); +#2435 = CARTESIAN_POINT('',(10.75,17.75,3.)); +#2436 = VECTOR('',#2437,1.); +#2437 = DIRECTION('',(-1.,0.,0.)); +#2438 = PCURVE('',#476,#2439); +#2439 = DEFINITIONAL_REPRESENTATION('',(#2440),#2443); +#2440 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2441,#2442),.UNSPECIFIED.,.F., + .F.,(2,2),(-10.75,-7.75),.PIECEWISE_BEZIER_KNOTS.); +#2441 = CARTESIAN_POINT('',(21.5,38.25)); +#2442 = CARTESIAN_POINT('',(18.5,38.25)); +#2443 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2444 = PCURVE('',#1680,#2445); +#2445 = DEFINITIONAL_REPRESENTATION('',(#2446),#2449); +#2446 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2447,#2448),.UNSPECIFIED.,.F., + .F.,(2,2),(-10.75,-7.75),.PIECEWISE_BEZIER_KNOTS.); +#2447 = CARTESIAN_POINT('',(0.,-3.)); +#2448 = CARTESIAN_POINT('',(3.,-3.)); +#2449 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2450 = ORIENTED_EDGE('',*,*,#2451,.T.); +#2451 = EDGE_CURVE('',#2429,#2452,#2454,.T.); +#2452 = VERTEX_POINT('',#2453); +#2453 = CARTESIAN_POINT('',(21.5,13.25,3.)); +#2454 = SURFACE_CURVE('',#2455,(#2460,#2471),.PCURVE_S1.); +#2455 = CIRCLE('',#2456,2.25); +#2456 = AXIS2_PLACEMENT_3D('',#2457,#2458,#2459); +#2457 = CARTESIAN_POINT('',(21.5,15.5,3.)); +#2458 = DIRECTION('',(0.,0.,-1.)); +#2459 = DIRECTION('',(-1.,0.,0.)); +#2460 = PCURVE('',#476,#2461); +#2461 = DEFINITIONAL_REPRESENTATION('',(#2462),#2470); +#2462 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2463,#2464,#2465,#2466, +#2467,#2468,#2469),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2463 = CARTESIAN_POINT('',(19.25,36.)); +#2464 = CARTESIAN_POINT('',(19.25,39.89711431703)); +#2465 = CARTESIAN_POINT('',(22.625,37.948557158515)); +#2466 = CARTESIAN_POINT('',(26.,36.)); +#2467 = CARTESIAN_POINT('',(22.625,34.051442841485)); +#2468 = CARTESIAN_POINT('',(19.25,32.10288568297)); +#2469 = CARTESIAN_POINT('',(19.25,36.)); +#2470 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2471 = PCURVE('',#1713,#2472); +#2472 = DEFINITIONAL_REPRESENTATION('',(#2473),#2477); +#2473 = LINE('',#2474,#2475); +#2474 = CARTESIAN_POINT('',(0.,-3.)); +#2475 = VECTOR('',#2476,1.); +#2476 = DIRECTION('',(1.,0.)); +#2477 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2478 = ORIENTED_EDGE('',*,*,#2479,.F.); +#2479 = EDGE_CURVE('',#2480,#2452,#2482,.T.); +#2480 = VERTEX_POINT('',#2481); +#2481 = CARTESIAN_POINT('',(18.5,13.25,3.)); +#2482 = SURFACE_CURVE('',#2483,(#2487,#2493),.PCURVE_S1.); +#2483 = LINE('',#2484,#2485); +#2484 = CARTESIAN_POINT('',(9.25,13.25,3.)); +#2485 = VECTOR('',#2486,1.); +#2486 = DIRECTION('',(1.,0.,0.)); +#2487 = PCURVE('',#476,#2488); +#2488 = DEFINITIONAL_REPRESENTATION('',(#2489),#2492); +#2489 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2490,#2491),.UNSPECIFIED.,.F., + .F.,(2,2),(9.25,12.25),.PIECEWISE_BEZIER_KNOTS.); +#2490 = CARTESIAN_POINT('',(18.5,33.75)); +#2491 = CARTESIAN_POINT('',(21.5,33.75)); +#2492 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2493 = PCURVE('',#1741,#2494); +#2494 = DEFINITIONAL_REPRESENTATION('',(#2495),#2498); +#2495 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2496,#2497),.UNSPECIFIED.,.F., + .F.,(2,2),(9.25,12.25),.PIECEWISE_BEZIER_KNOTS.); +#2496 = CARTESIAN_POINT('',(0.,-3.)); +#2497 = CARTESIAN_POINT('',(3.,-3.)); +#2498 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2499 = ORIENTED_EDGE('',*,*,#2500,.T.); +#2500 = EDGE_CURVE('',#2480,#2431,#2501,.T.); +#2501 = SURFACE_CURVE('',#2502,(#2507,#2518),.PCURVE_S1.); +#2502 = CIRCLE('',#2503,2.25); +#2503 = AXIS2_PLACEMENT_3D('',#2504,#2505,#2506); +#2504 = CARTESIAN_POINT('',(18.5,15.5,3.)); +#2505 = DIRECTION('',(0.,0.,-1.)); +#2506 = DIRECTION('',(1.,0.,0.)); +#2507 = PCURVE('',#476,#2508); +#2508 = DEFINITIONAL_REPRESENTATION('',(#2509),#2517); +#2509 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2510,#2511,#2512,#2513, +#2514,#2515,#2516),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2510 = CARTESIAN_POINT('',(20.75,36.)); +#2511 = CARTESIAN_POINT('',(20.75,32.10288568297)); +#2512 = CARTESIAN_POINT('',(17.375,34.051442841485)); +#2513 = CARTESIAN_POINT('',(14.,36.)); +#2514 = CARTESIAN_POINT('',(17.375,37.948557158515)); +#2515 = CARTESIAN_POINT('',(20.75,39.89711431703)); +#2516 = CARTESIAN_POINT('',(20.75,36.)); +#2517 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2518 = PCURVE('',#1772,#2519); +#2519 = DEFINITIONAL_REPRESENTATION('',(#2520),#2524); +#2520 = LINE('',#2521,#2522); +#2521 = CARTESIAN_POINT('',(0.,-3.)); +#2522 = VECTOR('',#2523,1.); +#2523 = DIRECTION('',(1.,0.)); +#2524 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2525 = FACE_BOUND('',#2526,.T.); +#2526 = EDGE_LOOP('',(#2527,#2550,#2578,#2599)); +#2527 = ORIENTED_EDGE('',*,*,#2528,.F.); +#2528 = EDGE_CURVE('',#2529,#2531,#2533,.T.); +#2529 = VERTEX_POINT('',#2530); +#2530 = CARTESIAN_POINT('',(27.75,15.5,3.)); +#2531 = VERTEX_POINT('',#2532); +#2532 = CARTESIAN_POINT('',(27.75,12.5,3.)); +#2533 = SURFACE_CURVE('',#2534,(#2538,#2544),.PCURVE_S1.); +#2534 = LINE('',#2535,#2536); +#2535 = CARTESIAN_POINT('',(27.75,-2.5,3.)); +#2536 = VECTOR('',#2537,1.); +#2537 = DIRECTION('',(0.,-1.,0.)); +#2538 = PCURVE('',#476,#2539); +#2539 = DEFINITIONAL_REPRESENTATION('',(#2540),#2543); +#2540 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2541,#2542),.UNSPECIFIED.,.F., + .F.,(2,2),(-18.,-15.),.PIECEWISE_BEZIER_KNOTS.); +#2541 = CARTESIAN_POINT('',(27.75,36.)); +#2542 = CARTESIAN_POINT('',(27.75,33.)); +#2543 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2544 = PCURVE('',#1804,#2545); +#2545 = DEFINITIONAL_REPRESENTATION('',(#2546),#2549); +#2546 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2547,#2548),.UNSPECIFIED.,.F., + .F.,(2,2),(-18.,-15.),.PIECEWISE_BEZIER_KNOTS.); +#2547 = CARTESIAN_POINT('',(0.,-3.)); +#2548 = CARTESIAN_POINT('',(3.,-3.)); +#2549 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2550 = ORIENTED_EDGE('',*,*,#2551,.T.); +#2551 = EDGE_CURVE('',#2529,#2552,#2554,.T.); +#2552 = VERTEX_POINT('',#2553); +#2553 = CARTESIAN_POINT('',(32.25,15.5,3.)); +#2554 = SURFACE_CURVE('',#2555,(#2560,#2571),.PCURVE_S1.); +#2555 = CIRCLE('',#2556,2.25); +#2556 = AXIS2_PLACEMENT_3D('',#2557,#2558,#2559); +#2557 = CARTESIAN_POINT('',(30.,15.5,3.)); +#2558 = DIRECTION('',(-0.,-0.,-1.)); +#2559 = DIRECTION('',(0.,-1.,0.)); +#2560 = PCURVE('',#476,#2561); +#2561 = DEFINITIONAL_REPRESENTATION('',(#2562),#2570); +#2562 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2563,#2564,#2565,#2566, +#2567,#2568,#2569),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2563 = CARTESIAN_POINT('',(30.,33.75)); +#2564 = CARTESIAN_POINT('',(26.10288568297,33.75)); +#2565 = CARTESIAN_POINT('',(28.051442841485,37.125)); +#2566 = CARTESIAN_POINT('',(30.,40.5)); +#2567 = CARTESIAN_POINT('',(31.948557158515,37.125)); +#2568 = CARTESIAN_POINT('',(33.89711431703,33.75)); +#2569 = CARTESIAN_POINT('',(30.,33.75)); +#2570 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2571 = PCURVE('',#1837,#2572); +#2572 = DEFINITIONAL_REPRESENTATION('',(#2573),#2577); +#2573 = LINE('',#2574,#2575); +#2574 = CARTESIAN_POINT('',(0.,-3.)); +#2575 = VECTOR('',#2576,1.); +#2576 = DIRECTION('',(1.,0.)); +#2577 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2578 = ORIENTED_EDGE('',*,*,#2579,.F.); +#2579 = EDGE_CURVE('',#2580,#2552,#2582,.T.); +#2580 = VERTEX_POINT('',#2581); +#2581 = CARTESIAN_POINT('',(32.25,12.5,3.)); +#2582 = SURFACE_CURVE('',#2583,(#2587,#2593),.PCURVE_S1.); +#2583 = LINE('',#2584,#2585); +#2584 = CARTESIAN_POINT('',(32.25,-4.,3.)); +#2585 = VECTOR('',#2586,1.); +#2586 = DIRECTION('',(0.,1.,-0.)); +#2587 = PCURVE('',#476,#2588); +#2588 = DEFINITIONAL_REPRESENTATION('',(#2589),#2592); +#2589 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2590,#2591),.UNSPECIFIED.,.F., + .F.,(2,2),(16.5,19.5),.PIECEWISE_BEZIER_KNOTS.); +#2590 = CARTESIAN_POINT('',(32.25,33.)); +#2591 = CARTESIAN_POINT('',(32.25,36.)); +#2592 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2593 = PCURVE('',#1865,#2594); +#2594 = DEFINITIONAL_REPRESENTATION('',(#2595),#2598); +#2595 = B_SPLINE_CURVE_WITH_KNOTS('',1,(#2596,#2597),.UNSPECIFIED.,.F., + .F.,(2,2),(16.5,19.5),.PIECEWISE_BEZIER_KNOTS.); +#2596 = CARTESIAN_POINT('',(0.,-3.)); +#2597 = CARTESIAN_POINT('',(3.,-3.)); +#2598 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2599 = ORIENTED_EDGE('',*,*,#2600,.T.); +#2600 = EDGE_CURVE('',#2580,#2531,#2601,.T.); +#2601 = SURFACE_CURVE('',#2602,(#2607,#2618),.PCURVE_S1.); +#2602 = CIRCLE('',#2603,2.25); +#2603 = AXIS2_PLACEMENT_3D('',#2604,#2605,#2606); +#2604 = CARTESIAN_POINT('',(30.,12.5,3.)); +#2605 = DIRECTION('',(0.,0.,-1.)); +#2606 = DIRECTION('',(0.,1.,0.)); +#2607 = PCURVE('',#476,#2608); +#2608 = DEFINITIONAL_REPRESENTATION('',(#2609),#2617); +#2609 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#2610,#2611,#2612,#2613, +#2614,#2615,#2616),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2 + ,2,2,2,1),(-2.094395102393,0.,2.094395102393,4.188790204786, +6.28318530718,8.377580409573),.UNSPECIFIED.) CURVE() +GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5, +1.,0.5,1.)) REPRESENTATION_ITEM('') ); +#2610 = CARTESIAN_POINT('',(30.,35.25)); +#2611 = CARTESIAN_POINT('',(33.89711431703,35.25)); +#2612 = CARTESIAN_POINT('',(31.948557158515,31.875)); +#2613 = CARTESIAN_POINT('',(30.,28.5)); +#2614 = CARTESIAN_POINT('',(28.051442841485,31.875)); +#2615 = CARTESIAN_POINT('',(26.10288568297,35.25)); +#2616 = CARTESIAN_POINT('',(30.,35.25)); +#2617 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2618 = PCURVE('',#1896,#2619); +#2619 = DEFINITIONAL_REPRESENTATION('',(#2620),#2624); +#2620 = LINE('',#2621,#2622); +#2621 = CARTESIAN_POINT('',(0.,-3.)); +#2622 = VECTOR('',#2623,1.); +#2623 = DIRECTION('',(1.,0.)); +#2624 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2625 = ADVANCED_FACE('',(#2626,#2634,#2637,#2640,#2643,#2646),#448,.T. + ); +#2626 = FACE_BOUND('',#2627,.T.); +#2627 = EDGE_LOOP('',(#2628,#2629,#2630,#2631,#2632,#2633)); +#2628 = ORIENTED_EDGE('',*,*,#432,.F.); +#2629 = ORIENTED_EDGE('',*,*,#1961,.T.); +#2630 = ORIENTED_EDGE('',*,*,#644,.T.); +#2631 = ORIENTED_EDGE('',*,*,#794,.T.); +#2632 = ORIENTED_EDGE('',*,*,#745,.F.); +#2633 = ORIENTED_EDGE('',*,*,#573,.F.); +#2634 = FACE_BOUND('',#2635,.T.); +#2635 = EDGE_LOOP('',(#2636)); +#2636 = ORIENTED_EDGE('',*,*,#844,.T.); +#2637 = FACE_BOUND('',#2638,.T.); +#2638 = EDGE_LOOP('',(#2639)); +#2639 = ORIENTED_EDGE('',*,*,#898,.T.); +#2640 = FACE_BOUND('',#2641,.T.); +#2641 = EDGE_LOOP('',(#2642)); +#2642 = ORIENTED_EDGE('',*,*,#952,.T.); +#2643 = FACE_BOUND('',#2644,.T.); +#2644 = EDGE_LOOP('',(#2645)); +#2645 = ORIENTED_EDGE('',*,*,#1006,.T.); +#2646 = FACE_BOUND('',#2647,.T.); +#2647 = EDGE_LOOP('',(#2648)); +#2648 = ORIENTED_EDGE('',*,*,#1060,.T.); +#2649 = ADVANCED_FACE('',(#2650),#706,.T.); +#2650 = FACE_BOUND('',#2651,.T.); +#2651 = EDGE_LOOP('',(#2652,#2653,#2654,#2655)); +#2652 = ORIENTED_EDGE('',*,*,#1141,.F.); +#2653 = ORIENTED_EDGE('',*,*,#690,.T.); +#2654 = ORIENTED_EDGE('',*,*,#2005,.T.); +#2655 = ORIENTED_EDGE('',*,*,#2656,.F.); +#2656 = EDGE_CURVE('',#1114,#1983,#2657,.T.); +#2657 = SURFACE_CURVE('',#2658,(#2662,#2669),.PCURVE_S1.); +#2658 = LINE('',#2659,#2660); +#2659 = CARTESIAN_POINT('',(35.,18.5,0.)); +#2660 = VECTOR('',#2661,1.); +#2661 = DIRECTION('',(0.,0.,1.)); +#2662 = PCURVE('',#706,#2663); +#2663 = DEFINITIONAL_REPRESENTATION('',(#2664),#2668); +#2664 = LINE('',#2665,#2666); +#2665 = CARTESIAN_POINT('',(0.,1.414213562373)); +#2666 = VECTOR('',#2667,1.); +#2667 = DIRECTION('',(1.,0.)); +#2668 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2669 = PCURVE('',#1129,#2670); +#2670 = DEFINITIONAL_REPRESENTATION('',(#2671),#2675); +#2671 = LINE('',#2672,#2673); +#2672 = CARTESIAN_POINT('',(0.,-39.)); +#2673 = VECTOR('',#2674,1.); +#2674 = DIRECTION('',(1.,0.)); +#2675 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2676 = ADVANCED_FACE('',(#2677),#1129,.T.); +#2677 = FACE_BOUND('',#2678,.T.); +#2678 = EDGE_LOOP('',(#2679,#2680,#2681,#2682)); +#2679 = ORIENTED_EDGE('',*,*,#1982,.F.); +#2680 = ORIENTED_EDGE('',*,*,#1936,.F.); +#2681 = ORIENTED_EDGE('',*,*,#1113,.T.); +#2682 = ORIENTED_EDGE('',*,*,#2656,.T.); +#2683 = ADVANCED_FACE('',(#2684),#1184,.T.); +#2684 = FACE_BOUND('',#2685,.T.); +#2685 = EDGE_LOOP('',(#2686,#2687,#2708,#2709)); +#2686 = ORIENTED_EDGE('',*,*,#1166,.F.); +#2687 = ORIENTED_EDGE('',*,*,#2688,.T.); +#2688 = EDGE_CURVE('',#1167,#2029,#2689,.T.); +#2689 = SURFACE_CURVE('',#2690,(#2694,#2701),.PCURVE_S1.); +#2690 = LINE('',#2691,#2692); +#2691 = CARTESIAN_POINT('',(7.75,-12.5,0.)); +#2692 = VECTOR('',#2693,1.); +#2693 = DIRECTION('',(0.,0.,1.)); +#2694 = PCURVE('',#1184,#2695); +#2695 = DEFINITIONAL_REPRESENTATION('',(#2696),#2700); +#2696 = LINE('',#2697,#2698); +#2697 = CARTESIAN_POINT('',(0.,0.)); +#2698 = VECTOR('',#2699,1.); +#2699 = DIRECTION('',(0.,-1.)); +#2700 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2701 = PCURVE('',#1217,#2702); +#2702 = DEFINITIONAL_REPRESENTATION('',(#2703),#2707); +#2703 = LINE('',#2704,#2705); +#2704 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2705 = VECTOR('',#2706,1.); +#2706 = DIRECTION('',(0.,-1.)); +#2707 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2708 = ORIENTED_EDGE('',*,*,#2028,.T.); +#2709 = ORIENTED_EDGE('',*,*,#2710,.F.); +#2710 = EDGE_CURVE('',#1169,#2031,#2711,.T.); +#2711 = SURFACE_CURVE('',#2712,(#2716,#2723),.PCURVE_S1.); +#2712 = LINE('',#2713,#2714); +#2713 = CARTESIAN_POINT('',(7.75,-15.5,0.)); +#2714 = VECTOR('',#2715,1.); +#2715 = DIRECTION('',(0.,0.,1.)); +#2716 = PCURVE('',#1184,#2717); +#2717 = DEFINITIONAL_REPRESENTATION('',(#2718),#2722); +#2718 = LINE('',#2719,#2720); +#2719 = CARTESIAN_POINT('',(3.,0.)); +#2720 = VECTOR('',#2721,1.); +#2721 = DIRECTION('',(0.,-1.)); +#2722 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2723 = PCURVE('',#1276,#2724); +#2724 = DEFINITIONAL_REPRESENTATION('',(#2725),#2729); +#2725 = LINE('',#2726,#2727); +#2726 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2727 = VECTOR('',#2728,1.); +#2728 = DIRECTION('',(0.,-1.)); +#2729 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2730 = ADVANCED_FACE('',(#2731),#1276,.F.); +#2731 = FACE_BOUND('',#2732,.F.); +#2732 = EDGE_LOOP('',(#2733,#2734,#2755,#2756)); +#2733 = ORIENTED_EDGE('',*,*,#1257,.F.); +#2734 = ORIENTED_EDGE('',*,*,#2735,.T.); +#2735 = EDGE_CURVE('',#1230,#2080,#2736,.T.); +#2736 = SURFACE_CURVE('',#2737,(#2741,#2748),.PCURVE_S1.); +#2737 = LINE('',#2738,#2739); +#2738 = CARTESIAN_POINT('',(12.25,-15.5,0.)); +#2739 = VECTOR('',#2740,1.); +#2740 = DIRECTION('',(0.,0.,1.)); +#2741 = PCURVE('',#1276,#2742); +#2742 = DEFINITIONAL_REPRESENTATION('',(#2743),#2747); +#2743 = LINE('',#2744,#2745); +#2744 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2745 = VECTOR('',#2746,1.); +#2746 = DIRECTION('',(0.,-1.)); +#2747 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2748 = PCURVE('',#1245,#2749); +#2749 = DEFINITIONAL_REPRESENTATION('',(#2750),#2754); +#2750 = LINE('',#2751,#2752); +#2751 = CARTESIAN_POINT('',(0.,0.)); +#2752 = VECTOR('',#2753,1.); +#2753 = DIRECTION('',(0.,-1.)); +#2754 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2755 = ORIENTED_EDGE('',*,*,#2100,.T.); +#2756 = ORIENTED_EDGE('',*,*,#2710,.F.); +#2757 = ADVANCED_FACE('',(#2758),#1245,.T.); +#2758 = FACE_BOUND('',#2759,.T.); +#2759 = EDGE_LOOP('',(#2760,#2761,#2762,#2763)); +#2760 = ORIENTED_EDGE('',*,*,#1229,.F.); +#2761 = ORIENTED_EDGE('',*,*,#2735,.T.); +#2762 = ORIENTED_EDGE('',*,*,#2079,.T.); +#2763 = ORIENTED_EDGE('',*,*,#2764,.F.); +#2764 = EDGE_CURVE('',#1197,#2052,#2765,.T.); +#2765 = SURFACE_CURVE('',#2766,(#2770,#2777),.PCURVE_S1.); +#2766 = LINE('',#2767,#2768); +#2767 = CARTESIAN_POINT('',(12.25,-12.5,0.)); +#2768 = VECTOR('',#2769,1.); +#2769 = DIRECTION('',(0.,0.,1.)); +#2770 = PCURVE('',#1245,#2771); +#2771 = DEFINITIONAL_REPRESENTATION('',(#2772),#2776); +#2772 = LINE('',#2773,#2774); +#2773 = CARTESIAN_POINT('',(3.,0.)); +#2774 = VECTOR('',#2775,1.); +#2775 = DIRECTION('',(0.,-1.)); +#2776 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2777 = PCURVE('',#1217,#2778); +#2778 = DEFINITIONAL_REPRESENTATION('',(#2779),#2783); +#2779 = LINE('',#2780,#2781); +#2780 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2781 = VECTOR('',#2782,1.); +#2782 = DIRECTION('',(0.,-1.)); +#2783 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2784 = ADVANCED_FACE('',(#2785),#1217,.F.); +#2785 = FACE_BOUND('',#2786,.F.); +#2786 = EDGE_LOOP('',(#2787,#2788,#2789,#2790)); +#2787 = ORIENTED_EDGE('',*,*,#1196,.F.); +#2788 = ORIENTED_EDGE('',*,*,#2688,.T.); +#2789 = ORIENTED_EDGE('',*,*,#2051,.T.); +#2790 = ORIENTED_EDGE('',*,*,#2764,.F.); +#2791 = ADVANCED_FACE('',(#2792),#1308,.T.); +#2792 = FACE_BOUND('',#2793,.T.); +#2793 = EDGE_LOOP('',(#2794,#2795,#2816,#2817)); +#2794 = ORIENTED_EDGE('',*,*,#1290,.F.); +#2795 = ORIENTED_EDGE('',*,*,#2796,.T.); +#2796 = EDGE_CURVE('',#1291,#2129,#2797,.T.); +#2797 = SURFACE_CURVE('',#2798,(#2802,#2809),.PCURVE_S1.); +#2798 = LINE('',#2799,#2800); +#2799 = CARTESIAN_POINT('',(21.5,-13.25,0.)); +#2800 = VECTOR('',#2801,1.); +#2801 = DIRECTION('',(0.,0.,1.)); +#2802 = PCURVE('',#1308,#2803); +#2803 = DEFINITIONAL_REPRESENTATION('',(#2804),#2808); +#2804 = LINE('',#2805,#2806); +#2805 = CARTESIAN_POINT('',(0.,-0.)); +#2806 = VECTOR('',#2807,1.); +#2807 = DIRECTION('',(0.,-1.)); +#2808 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2809 = PCURVE('',#1341,#2810); +#2810 = DEFINITIONAL_REPRESENTATION('',(#2811),#2815); +#2811 = LINE('',#2812,#2813); +#2812 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2813 = VECTOR('',#2814,1.); +#2814 = DIRECTION('',(0.,-1.)); +#2815 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2816 = ORIENTED_EDGE('',*,*,#2128,.T.); +#2817 = ORIENTED_EDGE('',*,*,#2818,.F.); +#2818 = EDGE_CURVE('',#1293,#2131,#2819,.T.); +#2819 = SURFACE_CURVE('',#2820,(#2824,#2831),.PCURVE_S1.); +#2820 = LINE('',#2821,#2822); +#2821 = CARTESIAN_POINT('',(18.5,-13.25,0.)); +#2822 = VECTOR('',#2823,1.); +#2823 = DIRECTION('',(0.,0.,1.)); +#2824 = PCURVE('',#1308,#2825); +#2825 = DEFINITIONAL_REPRESENTATION('',(#2826),#2830); +#2826 = LINE('',#2827,#2828); +#2827 = CARTESIAN_POINT('',(3.,0.)); +#2828 = VECTOR('',#2829,1.); +#2829 = DIRECTION('',(0.,-1.)); +#2830 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2831 = PCURVE('',#1400,#2832); +#2832 = DEFINITIONAL_REPRESENTATION('',(#2833),#2837); +#2833 = LINE('',#2834,#2835); +#2834 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2835 = VECTOR('',#2836,1.); +#2836 = DIRECTION('',(0.,-1.)); +#2837 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2838 = ADVANCED_FACE('',(#2839),#1400,.F.); +#2839 = FACE_BOUND('',#2840,.F.); +#2840 = EDGE_LOOP('',(#2841,#2842,#2863,#2864)); +#2841 = ORIENTED_EDGE('',*,*,#1381,.F.); +#2842 = ORIENTED_EDGE('',*,*,#2843,.T.); +#2843 = EDGE_CURVE('',#1354,#2180,#2844,.T.); +#2844 = SURFACE_CURVE('',#2845,(#2849,#2856),.PCURVE_S1.); +#2845 = LINE('',#2846,#2847); +#2846 = CARTESIAN_POINT('',(18.5,-17.75,0.)); +#2847 = VECTOR('',#2848,1.); +#2848 = DIRECTION('',(0.,0.,1.)); +#2849 = PCURVE('',#1400,#2850); +#2850 = DEFINITIONAL_REPRESENTATION('',(#2851),#2855); +#2851 = LINE('',#2852,#2853); +#2852 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2853 = VECTOR('',#2854,1.); +#2854 = DIRECTION('',(0.,-1.)); +#2855 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2856 = PCURVE('',#1369,#2857); +#2857 = DEFINITIONAL_REPRESENTATION('',(#2858),#2862); +#2858 = LINE('',#2859,#2860); +#2859 = CARTESIAN_POINT('',(0.,0.)); +#2860 = VECTOR('',#2861,1.); +#2861 = DIRECTION('',(0.,-1.)); +#2862 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2863 = ORIENTED_EDGE('',*,*,#2200,.T.); +#2864 = ORIENTED_EDGE('',*,*,#2818,.F.); +#2865 = ADVANCED_FACE('',(#2866),#1369,.T.); +#2866 = FACE_BOUND('',#2867,.T.); +#2867 = EDGE_LOOP('',(#2868,#2869,#2870,#2871)); +#2868 = ORIENTED_EDGE('',*,*,#1353,.F.); +#2869 = ORIENTED_EDGE('',*,*,#2843,.T.); +#2870 = ORIENTED_EDGE('',*,*,#2179,.T.); +#2871 = ORIENTED_EDGE('',*,*,#2872,.F.); +#2872 = EDGE_CURVE('',#1321,#2152,#2873,.T.); +#2873 = SURFACE_CURVE('',#2874,(#2878,#2885),.PCURVE_S1.); +#2874 = LINE('',#2875,#2876); +#2875 = CARTESIAN_POINT('',(21.5,-17.75,0.)); +#2876 = VECTOR('',#2877,1.); +#2877 = DIRECTION('',(0.,0.,1.)); +#2878 = PCURVE('',#1369,#2879); +#2879 = DEFINITIONAL_REPRESENTATION('',(#2880),#2884); +#2880 = LINE('',#2881,#2882); +#2881 = CARTESIAN_POINT('',(3.,0.)); +#2882 = VECTOR('',#2883,1.); +#2883 = DIRECTION('',(0.,-1.)); +#2884 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2885 = PCURVE('',#1341,#2886); +#2886 = DEFINITIONAL_REPRESENTATION('',(#2887),#2891); +#2887 = LINE('',#2888,#2889); +#2888 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2889 = VECTOR('',#2890,1.); +#2890 = DIRECTION('',(0.,-1.)); +#2891 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2892 = ADVANCED_FACE('',(#2893),#1341,.F.); +#2893 = FACE_BOUND('',#2894,.F.); +#2894 = EDGE_LOOP('',(#2895,#2896,#2897,#2898)); +#2895 = ORIENTED_EDGE('',*,*,#1320,.F.); +#2896 = ORIENTED_EDGE('',*,*,#2796,.T.); +#2897 = ORIENTED_EDGE('',*,*,#2151,.T.); +#2898 = ORIENTED_EDGE('',*,*,#2872,.F.); +#2899 = ADVANCED_FACE('',(#2900),#1432,.T.); +#2900 = FACE_BOUND('',#2901,.T.); +#2901 = EDGE_LOOP('',(#2902,#2903,#2924,#2925)); +#2902 = ORIENTED_EDGE('',*,*,#1414,.F.); +#2903 = ORIENTED_EDGE('',*,*,#2904,.T.); +#2904 = EDGE_CURVE('',#1415,#2229,#2905,.T.); +#2905 = SURFACE_CURVE('',#2906,(#2910,#2917),.PCURVE_S1.); +#2906 = LINE('',#2907,#2908); +#2907 = CARTESIAN_POINT('',(27.75,-12.5,0.)); +#2908 = VECTOR('',#2909,1.); +#2909 = DIRECTION('',(0.,0.,1.)); +#2910 = PCURVE('',#1432,#2911); +#2911 = DEFINITIONAL_REPRESENTATION('',(#2912),#2916); +#2912 = LINE('',#2913,#2914); +#2913 = CARTESIAN_POINT('',(0.,0.)); +#2914 = VECTOR('',#2915,1.); +#2915 = DIRECTION('',(0.,-1.)); +#2916 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2917 = PCURVE('',#1465,#2918); +#2918 = DEFINITIONAL_REPRESENTATION('',(#2919),#2923); +#2919 = LINE('',#2920,#2921); +#2920 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2921 = VECTOR('',#2922,1.); +#2922 = DIRECTION('',(0.,-1.)); +#2923 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2924 = ORIENTED_EDGE('',*,*,#2228,.T.); +#2925 = ORIENTED_EDGE('',*,*,#2926,.F.); +#2926 = EDGE_CURVE('',#1417,#2231,#2927,.T.); +#2927 = SURFACE_CURVE('',#2928,(#2932,#2939),.PCURVE_S1.); +#2928 = LINE('',#2929,#2930); +#2929 = CARTESIAN_POINT('',(27.75,-15.5,0.)); +#2930 = VECTOR('',#2931,1.); +#2931 = DIRECTION('',(0.,0.,1.)); +#2932 = PCURVE('',#1432,#2933); +#2933 = DEFINITIONAL_REPRESENTATION('',(#2934),#2938); +#2934 = LINE('',#2935,#2936); +#2935 = CARTESIAN_POINT('',(3.,0.)); +#2936 = VECTOR('',#2937,1.); +#2937 = DIRECTION('',(0.,-1.)); +#2938 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2939 = PCURVE('',#1524,#2940); +#2940 = DEFINITIONAL_REPRESENTATION('',(#2941),#2945); +#2941 = LINE('',#2942,#2943); +#2942 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2943 = VECTOR('',#2944,1.); +#2944 = DIRECTION('',(0.,-1.)); +#2945 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2946 = ADVANCED_FACE('',(#2947),#1524,.F.); +#2947 = FACE_BOUND('',#2948,.F.); +#2948 = EDGE_LOOP('',(#2949,#2950,#2971,#2972)); +#2949 = ORIENTED_EDGE('',*,*,#1505,.F.); +#2950 = ORIENTED_EDGE('',*,*,#2951,.T.); +#2951 = EDGE_CURVE('',#1478,#2280,#2952,.T.); +#2952 = SURFACE_CURVE('',#2953,(#2957,#2964),.PCURVE_S1.); +#2953 = LINE('',#2954,#2955); +#2954 = CARTESIAN_POINT('',(32.25,-15.5,0.)); +#2955 = VECTOR('',#2956,1.); +#2956 = DIRECTION('',(0.,0.,1.)); +#2957 = PCURVE('',#1524,#2958); +#2958 = DEFINITIONAL_REPRESENTATION('',(#2959),#2963); +#2959 = LINE('',#2960,#2961); +#2960 = CARTESIAN_POINT('',(1.570796326795,0.)); +#2961 = VECTOR('',#2962,1.); +#2962 = DIRECTION('',(0.,-1.)); +#2963 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2964 = PCURVE('',#1493,#2965); +#2965 = DEFINITIONAL_REPRESENTATION('',(#2966),#2970); +#2966 = LINE('',#2967,#2968); +#2967 = CARTESIAN_POINT('',(0.,0.)); +#2968 = VECTOR('',#2969,1.); +#2969 = DIRECTION('',(0.,-1.)); +#2970 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2971 = ORIENTED_EDGE('',*,*,#2300,.T.); +#2972 = ORIENTED_EDGE('',*,*,#2926,.F.); +#2973 = ADVANCED_FACE('',(#2974),#1493,.T.); +#2974 = FACE_BOUND('',#2975,.T.); +#2975 = EDGE_LOOP('',(#2976,#2977,#2978,#2979)); +#2976 = ORIENTED_EDGE('',*,*,#1477,.F.); +#2977 = ORIENTED_EDGE('',*,*,#2951,.T.); +#2978 = ORIENTED_EDGE('',*,*,#2279,.T.); +#2979 = ORIENTED_EDGE('',*,*,#2980,.F.); +#2980 = EDGE_CURVE('',#1445,#2252,#2981,.T.); +#2981 = SURFACE_CURVE('',#2982,(#2986,#2993),.PCURVE_S1.); +#2982 = LINE('',#2983,#2984); +#2983 = CARTESIAN_POINT('',(32.25,-12.5,0.)); +#2984 = VECTOR('',#2985,1.); +#2985 = DIRECTION('',(0.,0.,1.)); +#2986 = PCURVE('',#1493,#2987); +#2987 = DEFINITIONAL_REPRESENTATION('',(#2988),#2992); +#2988 = LINE('',#2989,#2990); +#2989 = CARTESIAN_POINT('',(3.,0.)); +#2990 = VECTOR('',#2991,1.); +#2991 = DIRECTION('',(0.,-1.)); +#2992 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#2993 = PCURVE('',#1465,#2994); +#2994 = DEFINITIONAL_REPRESENTATION('',(#2995),#2999); +#2995 = LINE('',#2996,#2997); +#2996 = CARTESIAN_POINT('',(4.712388980385,0.)); +#2997 = VECTOR('',#2998,1.); +#2998 = DIRECTION('',(0.,-1.)); +#2999 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3000 = ADVANCED_FACE('',(#3001),#1465,.F.); +#3001 = FACE_BOUND('',#3002,.F.); +#3002 = EDGE_LOOP('',(#3003,#3004,#3005,#3006)); +#3003 = ORIENTED_EDGE('',*,*,#1444,.F.); +#3004 = ORIENTED_EDGE('',*,*,#2904,.T.); +#3005 = ORIENTED_EDGE('',*,*,#2251,.T.); +#3006 = ORIENTED_EDGE('',*,*,#2980,.F.); +#3007 = ADVANCED_FACE('',(#3008),#1556,.T.); +#3008 = FACE_BOUND('',#3009,.T.); +#3009 = EDGE_LOOP('',(#3010,#3011,#3032,#3033)); +#3010 = ORIENTED_EDGE('',*,*,#1538,.F.); +#3011 = ORIENTED_EDGE('',*,*,#3012,.T.); +#3012 = EDGE_CURVE('',#1539,#2329,#3013,.T.); +#3013 = SURFACE_CURVE('',#3014,(#3018,#3025),.PCURVE_S1.); +#3014 = LINE('',#3015,#3016); +#3015 = CARTESIAN_POINT('',(7.75,15.5,0.)); +#3016 = VECTOR('',#3017,1.); +#3017 = DIRECTION('',(0.,0.,1.)); +#3018 = PCURVE('',#1556,#3019); +#3019 = DEFINITIONAL_REPRESENTATION('',(#3020),#3024); +#3020 = LINE('',#3021,#3022); +#3021 = CARTESIAN_POINT('',(0.,0.)); +#3022 = VECTOR('',#3023,1.); +#3023 = DIRECTION('',(0.,-1.)); +#3024 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3025 = PCURVE('',#1589,#3026); +#3026 = DEFINITIONAL_REPRESENTATION('',(#3027),#3031); +#3027 = LINE('',#3028,#3029); +#3028 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3029 = VECTOR('',#3030,1.); +#3030 = DIRECTION('',(0.,-1.)); +#3031 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3032 = ORIENTED_EDGE('',*,*,#2328,.T.); +#3033 = ORIENTED_EDGE('',*,*,#3034,.F.); +#3034 = EDGE_CURVE('',#1541,#2331,#3035,.T.); +#3035 = SURFACE_CURVE('',#3036,(#3040,#3047),.PCURVE_S1.); +#3036 = LINE('',#3037,#3038); +#3037 = CARTESIAN_POINT('',(7.75,12.5,0.)); +#3038 = VECTOR('',#3039,1.); +#3039 = DIRECTION('',(0.,0.,1.)); +#3040 = PCURVE('',#1556,#3041); +#3041 = DEFINITIONAL_REPRESENTATION('',(#3042),#3046); +#3042 = LINE('',#3043,#3044); +#3043 = CARTESIAN_POINT('',(3.,0.)); +#3044 = VECTOR('',#3045,1.); +#3045 = DIRECTION('',(0.,-1.)); +#3046 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3047 = PCURVE('',#1648,#3048); +#3048 = DEFINITIONAL_REPRESENTATION('',(#3049),#3053); +#3049 = LINE('',#3050,#3051); +#3050 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3051 = VECTOR('',#3052,1.); +#3052 = DIRECTION('',(0.,-1.)); +#3053 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3054 = ADVANCED_FACE('',(#3055),#1648,.F.); +#3055 = FACE_BOUND('',#3056,.F.); +#3056 = EDGE_LOOP('',(#3057,#3058,#3079,#3080)); +#3057 = ORIENTED_EDGE('',*,*,#1629,.F.); +#3058 = ORIENTED_EDGE('',*,*,#3059,.T.); +#3059 = EDGE_CURVE('',#1602,#2380,#3060,.T.); +#3060 = SURFACE_CURVE('',#3061,(#3065,#3072),.PCURVE_S1.); +#3061 = LINE('',#3062,#3063); +#3062 = CARTESIAN_POINT('',(12.25,12.5,0.)); +#3063 = VECTOR('',#3064,1.); +#3064 = DIRECTION('',(0.,0.,1.)); +#3065 = PCURVE('',#1648,#3066); +#3066 = DEFINITIONAL_REPRESENTATION('',(#3067),#3071); +#3067 = LINE('',#3068,#3069); +#3068 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3069 = VECTOR('',#3070,1.); +#3070 = DIRECTION('',(0.,-1.)); +#3071 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3072 = PCURVE('',#1617,#3073); +#3073 = DEFINITIONAL_REPRESENTATION('',(#3074),#3078); +#3074 = LINE('',#3075,#3076); +#3075 = CARTESIAN_POINT('',(0.,0.)); +#3076 = VECTOR('',#3077,1.); +#3077 = DIRECTION('',(0.,-1.)); +#3078 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3079 = ORIENTED_EDGE('',*,*,#2400,.T.); +#3080 = ORIENTED_EDGE('',*,*,#3034,.F.); +#3081 = ADVANCED_FACE('',(#3082),#1617,.T.); +#3082 = FACE_BOUND('',#3083,.T.); +#3083 = EDGE_LOOP('',(#3084,#3085,#3086,#3087)); +#3084 = ORIENTED_EDGE('',*,*,#1601,.F.); +#3085 = ORIENTED_EDGE('',*,*,#3059,.T.); +#3086 = ORIENTED_EDGE('',*,*,#2379,.T.); +#3087 = ORIENTED_EDGE('',*,*,#3088,.F.); +#3088 = EDGE_CURVE('',#1569,#2352,#3089,.T.); +#3089 = SURFACE_CURVE('',#3090,(#3094,#3101),.PCURVE_S1.); +#3090 = LINE('',#3091,#3092); +#3091 = CARTESIAN_POINT('',(12.25,15.5,0.)); +#3092 = VECTOR('',#3093,1.); +#3093 = DIRECTION('',(0.,0.,1.)); +#3094 = PCURVE('',#1617,#3095); +#3095 = DEFINITIONAL_REPRESENTATION('',(#3096),#3100); +#3096 = LINE('',#3097,#3098); +#3097 = CARTESIAN_POINT('',(3.,0.)); +#3098 = VECTOR('',#3099,1.); +#3099 = DIRECTION('',(0.,-1.)); +#3100 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3101 = PCURVE('',#1589,#3102); +#3102 = DEFINITIONAL_REPRESENTATION('',(#3103),#3107); +#3103 = LINE('',#3104,#3105); +#3104 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3105 = VECTOR('',#3106,1.); +#3106 = DIRECTION('',(0.,-1.)); +#3107 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3108 = ADVANCED_FACE('',(#3109),#1589,.F.); +#3109 = FACE_BOUND('',#3110,.F.); +#3110 = EDGE_LOOP('',(#3111,#3112,#3113,#3114)); +#3111 = ORIENTED_EDGE('',*,*,#1568,.F.); +#3112 = ORIENTED_EDGE('',*,*,#3012,.T.); +#3113 = ORIENTED_EDGE('',*,*,#2351,.T.); +#3114 = ORIENTED_EDGE('',*,*,#3088,.F.); +#3115 = ADVANCED_FACE('',(#3116),#1680,.T.); +#3116 = FACE_BOUND('',#3117,.T.); +#3117 = EDGE_LOOP('',(#3118,#3119,#3140,#3141)); +#3118 = ORIENTED_EDGE('',*,*,#1662,.F.); +#3119 = ORIENTED_EDGE('',*,*,#3120,.T.); +#3120 = EDGE_CURVE('',#1663,#2429,#3121,.T.); +#3121 = SURFACE_CURVE('',#3122,(#3126,#3133),.PCURVE_S1.); +#3122 = LINE('',#3123,#3124); +#3123 = CARTESIAN_POINT('',(21.5,17.75,0.)); +#3124 = VECTOR('',#3125,1.); +#3125 = DIRECTION('',(0.,0.,1.)); +#3126 = PCURVE('',#1680,#3127); +#3127 = DEFINITIONAL_REPRESENTATION('',(#3128),#3132); +#3128 = LINE('',#3129,#3130); +#3129 = CARTESIAN_POINT('',(0.,-0.)); +#3130 = VECTOR('',#3131,1.); +#3131 = DIRECTION('',(0.,-1.)); +#3132 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3133 = PCURVE('',#1713,#3134); +#3134 = DEFINITIONAL_REPRESENTATION('',(#3135),#3139); +#3135 = LINE('',#3136,#3137); +#3136 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3137 = VECTOR('',#3138,1.); +#3138 = DIRECTION('',(0.,-1.)); +#3139 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3140 = ORIENTED_EDGE('',*,*,#2428,.T.); +#3141 = ORIENTED_EDGE('',*,*,#3142,.F.); +#3142 = EDGE_CURVE('',#1665,#2431,#3143,.T.); +#3143 = SURFACE_CURVE('',#3144,(#3148,#3155),.PCURVE_S1.); +#3144 = LINE('',#3145,#3146); +#3145 = CARTESIAN_POINT('',(18.5,17.75,0.)); +#3146 = VECTOR('',#3147,1.); +#3147 = DIRECTION('',(0.,0.,1.)); +#3148 = PCURVE('',#1680,#3149); +#3149 = DEFINITIONAL_REPRESENTATION('',(#3150),#3154); +#3150 = LINE('',#3151,#3152); +#3151 = CARTESIAN_POINT('',(3.,0.)); +#3152 = VECTOR('',#3153,1.); +#3153 = DIRECTION('',(0.,-1.)); +#3154 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3155 = PCURVE('',#1772,#3156); +#3156 = DEFINITIONAL_REPRESENTATION('',(#3157),#3161); +#3157 = LINE('',#3158,#3159); +#3158 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3159 = VECTOR('',#3160,1.); +#3160 = DIRECTION('',(0.,-1.)); +#3161 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3162 = ADVANCED_FACE('',(#3163),#1772,.F.); +#3163 = FACE_BOUND('',#3164,.F.); +#3164 = EDGE_LOOP('',(#3165,#3166,#3187,#3188)); +#3165 = ORIENTED_EDGE('',*,*,#1753,.F.); +#3166 = ORIENTED_EDGE('',*,*,#3167,.T.); +#3167 = EDGE_CURVE('',#1726,#2480,#3168,.T.); +#3168 = SURFACE_CURVE('',#3169,(#3173,#3180),.PCURVE_S1.); +#3169 = LINE('',#3170,#3171); +#3170 = CARTESIAN_POINT('',(18.5,13.25,0.)); +#3171 = VECTOR('',#3172,1.); +#3172 = DIRECTION('',(0.,0.,1.)); +#3173 = PCURVE('',#1772,#3174); +#3174 = DEFINITIONAL_REPRESENTATION('',(#3175),#3179); +#3175 = LINE('',#3176,#3177); +#3176 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3177 = VECTOR('',#3178,1.); +#3178 = DIRECTION('',(0.,-1.)); +#3179 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3180 = PCURVE('',#1741,#3181); +#3181 = DEFINITIONAL_REPRESENTATION('',(#3182),#3186); +#3182 = LINE('',#3183,#3184); +#3183 = CARTESIAN_POINT('',(0.,0.)); +#3184 = VECTOR('',#3185,1.); +#3185 = DIRECTION('',(0.,-1.)); +#3186 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3187 = ORIENTED_EDGE('',*,*,#2500,.T.); +#3188 = ORIENTED_EDGE('',*,*,#3142,.F.); +#3189 = ADVANCED_FACE('',(#3190),#1741,.T.); +#3190 = FACE_BOUND('',#3191,.T.); +#3191 = EDGE_LOOP('',(#3192,#3193,#3194,#3195)); +#3192 = ORIENTED_EDGE('',*,*,#1725,.F.); +#3193 = ORIENTED_EDGE('',*,*,#3167,.T.); +#3194 = ORIENTED_EDGE('',*,*,#2479,.T.); +#3195 = ORIENTED_EDGE('',*,*,#3196,.F.); +#3196 = EDGE_CURVE('',#1693,#2452,#3197,.T.); +#3197 = SURFACE_CURVE('',#3198,(#3202,#3209),.PCURVE_S1.); +#3198 = LINE('',#3199,#3200); +#3199 = CARTESIAN_POINT('',(21.5,13.25,0.)); +#3200 = VECTOR('',#3201,1.); +#3201 = DIRECTION('',(0.,0.,1.)); +#3202 = PCURVE('',#1741,#3203); +#3203 = DEFINITIONAL_REPRESENTATION('',(#3204),#3208); +#3204 = LINE('',#3205,#3206); +#3205 = CARTESIAN_POINT('',(3.,0.)); +#3206 = VECTOR('',#3207,1.); +#3207 = DIRECTION('',(0.,-1.)); +#3208 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3209 = PCURVE('',#1713,#3210); +#3210 = DEFINITIONAL_REPRESENTATION('',(#3211),#3215); +#3211 = LINE('',#3212,#3213); +#3212 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3213 = VECTOR('',#3214,1.); +#3214 = DIRECTION('',(0.,-1.)); +#3215 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3216 = ADVANCED_FACE('',(#3217),#1713,.F.); +#3217 = FACE_BOUND('',#3218,.F.); +#3218 = EDGE_LOOP('',(#3219,#3220,#3221,#3222)); +#3219 = ORIENTED_EDGE('',*,*,#1692,.F.); +#3220 = ORIENTED_EDGE('',*,*,#3120,.T.); +#3221 = ORIENTED_EDGE('',*,*,#2451,.T.); +#3222 = ORIENTED_EDGE('',*,*,#3196,.F.); +#3223 = ADVANCED_FACE('',(#3224),#1804,.T.); +#3224 = FACE_BOUND('',#3225,.T.); +#3225 = EDGE_LOOP('',(#3226,#3227,#3248,#3249)); +#3226 = ORIENTED_EDGE('',*,*,#1786,.F.); +#3227 = ORIENTED_EDGE('',*,*,#3228,.T.); +#3228 = EDGE_CURVE('',#1787,#2529,#3229,.T.); +#3229 = SURFACE_CURVE('',#3230,(#3234,#3241),.PCURVE_S1.); +#3230 = LINE('',#3231,#3232); +#3231 = CARTESIAN_POINT('',(27.75,15.5,0.)); +#3232 = VECTOR('',#3233,1.); +#3233 = DIRECTION('',(0.,0.,1.)); +#3234 = PCURVE('',#1804,#3235); +#3235 = DEFINITIONAL_REPRESENTATION('',(#3236),#3240); +#3236 = LINE('',#3237,#3238); +#3237 = CARTESIAN_POINT('',(0.,0.)); +#3238 = VECTOR('',#3239,1.); +#3239 = DIRECTION('',(0.,-1.)); +#3240 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3241 = PCURVE('',#1837,#3242); +#3242 = DEFINITIONAL_REPRESENTATION('',(#3243),#3247); +#3243 = LINE('',#3244,#3245); +#3244 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3245 = VECTOR('',#3246,1.); +#3246 = DIRECTION('',(0.,-1.)); +#3247 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3248 = ORIENTED_EDGE('',*,*,#2528,.T.); +#3249 = ORIENTED_EDGE('',*,*,#3250,.F.); +#3250 = EDGE_CURVE('',#1789,#2531,#3251,.T.); +#3251 = SURFACE_CURVE('',#3252,(#3256,#3263),.PCURVE_S1.); +#3252 = LINE('',#3253,#3254); +#3253 = CARTESIAN_POINT('',(27.75,12.5,0.)); +#3254 = VECTOR('',#3255,1.); +#3255 = DIRECTION('',(0.,0.,1.)); +#3256 = PCURVE('',#1804,#3257); +#3257 = DEFINITIONAL_REPRESENTATION('',(#3258),#3262); +#3258 = LINE('',#3259,#3260); +#3259 = CARTESIAN_POINT('',(3.,0.)); +#3260 = VECTOR('',#3261,1.); +#3261 = DIRECTION('',(0.,-1.)); +#3262 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3263 = PCURVE('',#1896,#3264); +#3264 = DEFINITIONAL_REPRESENTATION('',(#3265),#3269); +#3265 = LINE('',#3266,#3267); +#3266 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3267 = VECTOR('',#3268,1.); +#3268 = DIRECTION('',(0.,-1.)); +#3269 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3270 = ADVANCED_FACE('',(#3271),#1896,.F.); +#3271 = FACE_BOUND('',#3272,.F.); +#3272 = EDGE_LOOP('',(#3273,#3274,#3295,#3296)); +#3273 = ORIENTED_EDGE('',*,*,#1877,.F.); +#3274 = ORIENTED_EDGE('',*,*,#3275,.T.); +#3275 = EDGE_CURVE('',#1850,#2580,#3276,.T.); +#3276 = SURFACE_CURVE('',#3277,(#3281,#3288),.PCURVE_S1.); +#3277 = LINE('',#3278,#3279); +#3278 = CARTESIAN_POINT('',(32.25,12.5,0.)); +#3279 = VECTOR('',#3280,1.); +#3280 = DIRECTION('',(0.,0.,1.)); +#3281 = PCURVE('',#1896,#3282); +#3282 = DEFINITIONAL_REPRESENTATION('',(#3283),#3287); +#3283 = LINE('',#3284,#3285); +#3284 = CARTESIAN_POINT('',(1.570796326795,0.)); +#3285 = VECTOR('',#3286,1.); +#3286 = DIRECTION('',(0.,-1.)); +#3287 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3288 = PCURVE('',#1865,#3289); +#3289 = DEFINITIONAL_REPRESENTATION('',(#3290),#3294); +#3290 = LINE('',#3291,#3292); +#3291 = CARTESIAN_POINT('',(0.,0.)); +#3292 = VECTOR('',#3293,1.); +#3293 = DIRECTION('',(0.,-1.)); +#3294 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3295 = ORIENTED_EDGE('',*,*,#2600,.T.); +#3296 = ORIENTED_EDGE('',*,*,#3250,.F.); +#3297 = ADVANCED_FACE('',(#3298),#1865,.T.); +#3298 = FACE_BOUND('',#3299,.T.); +#3299 = EDGE_LOOP('',(#3300,#3301,#3302,#3303)); +#3300 = ORIENTED_EDGE('',*,*,#1849,.F.); +#3301 = ORIENTED_EDGE('',*,*,#3275,.T.); +#3302 = ORIENTED_EDGE('',*,*,#2579,.T.); +#3303 = ORIENTED_EDGE('',*,*,#3304,.F.); +#3304 = EDGE_CURVE('',#1817,#2552,#3305,.T.); +#3305 = SURFACE_CURVE('',#3306,(#3310,#3317),.PCURVE_S1.); +#3306 = LINE('',#3307,#3308); +#3307 = CARTESIAN_POINT('',(32.25,15.5,0.)); +#3308 = VECTOR('',#3309,1.); +#3309 = DIRECTION('',(0.,0.,1.)); +#3310 = PCURVE('',#1865,#3311); +#3311 = DEFINITIONAL_REPRESENTATION('',(#3312),#3316); +#3312 = LINE('',#3313,#3314); +#3313 = CARTESIAN_POINT('',(3.,0.)); +#3314 = VECTOR('',#3315,1.); +#3315 = DIRECTION('',(0.,-1.)); +#3316 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3317 = PCURVE('',#1837,#3318); +#3318 = DEFINITIONAL_REPRESENTATION('',(#3319),#3323); +#3319 = LINE('',#3320,#3321); +#3320 = CARTESIAN_POINT('',(4.712388980385,0.)); +#3321 = VECTOR('',#3322,1.); +#3322 = DIRECTION('',(0.,-1.)); +#3323 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#3324 = ADVANCED_FACE('',(#3325),#1837,.F.); +#3325 = FACE_BOUND('',#3326,.F.); +#3326 = EDGE_LOOP('',(#3327,#3328,#3329,#3330)); +#3327 = ORIENTED_EDGE('',*,*,#1816,.F.); +#3328 = ORIENTED_EDGE('',*,*,#3228,.T.); +#3329 = ORIENTED_EDGE('',*,*,#2551,.T.); +#3330 = ORIENTED_EDGE('',*,*,#3304,.F.); +#3331 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#3335)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#3332,#3333,#3334)) REPRESENTATION_CONTEXT +('Context #1','3D Context with UNIT and UNCERTAINTY') ); +#3332 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#3333 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#3334 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#3335 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#3332, + 'distance_accuracy_value','confusion accuracy'); +#3336 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +#3337 = MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',( + #3338),#3331); +#3338 = STYLED_ITEM('color',(#3339),#15); +#3339 = PRESENTATION_STYLE_ASSIGNMENT((#3340)); +#3340 = SURFACE_STYLE_USAGE(.BOTH.,#3341); +#3341 = SURFACE_SIDE_STYLE('',(#3342)); +#3342 = SURFACE_STYLE_FILL_AREA(#3343); +#3343 = FILL_AREA_STYLE('',(#3344)); +#3344 = FILL_AREA_STYLE_COLOUR('',#3345); +#3345 = DRAUGHTING_PRE_DEFINED_COLOUR('black'); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/topology_selection/examples/selectors_operators.py b/docs/topology_selection/examples/selectors_operators.py new file mode 100644 index 0000000..e19e613 --- /dev/null +++ b/docs/topology_selection/examples/selectors_operators.py @@ -0,0 +1,100 @@ +from copy import copy +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +selectors = [solids, vertices, edges, faces] +line = Line((-9, -9), (9, 9)) +for i, selector in enumerate(selectors): + u = i / (len(selectors) - 1) + with BuildPart() as part: + with Locations(line @ u): + Box(5, 5, 1) + Cylinder(2, 5) + show_object([part, selector()]) + +save_screenshot(os.path.join(filedir, "selectors_select_all.png")) +reset_show() + +for i, selector in enumerate(selectors[1:4]): + u = i / (len(selectors) - 1) + with BuildPart() as part: + with Locations(line @ u): + Box(5, 5, 1) + Cylinder(2, 5) + show_object([part, selector(Select.LAST)]) + +save_screenshot(os.path.join(filedir, "selectors_select_last.png")) +reset_show() + +with BuildPart() as part: + with Locations(line @ 1/3): + Box(5, 5, 1) + Cylinder(2, 5) + edges = part.edges(Select.NEW) + part_copy = copy(part) + + with Locations(line @ 2/3): + b = Box(5, 5, 1) + c = Cylinder(2, 5) + c.color = Color("DarkTurquoise") + + show(part_copy, edges, b, c, alphas=[.5, 1, .5, 1]) + +save_screenshot(os.path.join(filedir, "selectors_select_new.png")) +reset_show() + +with BuildPart() as part: + with Locations(line @ 1/3): + Box(5, 5, 1, align=(Align.CENTER, Align.CENTER, Align.MAX)) + Cylinder(2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN)) + edges = part.edges(Select.NEW) + part_copy = copy(part) + + with Locations(line @ 2/3): + b = Box(5, 5, 1, align=(Align.CENTER, Align.CENTER, Align.MAX), mode=Mode.PRIVATE) + c = Cylinder(2, 2, align=(Align.CENTER, Align.CENTER, Align.MIN), mode=Mode.PRIVATE) + c.color = Color("DarkTurquoise") + show(part_copy, edges, b, c, alphas=[.5, 1, .5, 1]) + +save_screenshot(os.path.join(filedir, "selectors_select_new_none.png")) +reset_show() + +with BuildPart() as part: + with Locations(line @ 1/3): + Box(5, 5, 1) + Cylinder(2, 5) + edges = part.edges().filter_by(lambda a: a.length == 1) + fillet(edges, 1) + show_object([part, part.edges(Select.NEW)]) + +with BuildPart() as part: + with Locations(line @ 2/3): + Box(5, 5, 1) + Cylinder(2, 5) + edges = part.edges().filter_by(lambda a: a.length == 1) + fillet(edges, 1) + show_object([part, part.edges(Select.LAST)]) + +save_screenshot(os.path.join(filedir, "selectors_select_new_fillet.png")) + +show(part, part.vertices().sort_by(Axis.X)[-4:]) +save_screenshot(os.path.join(filedir, "operators_sort_x.png")) + +show(part, part.faces().group_by(SortBy.AREA)[0].edges()) +save_screenshot(os.path.join(filedir, "operators_group_area.png")) + +faces = part.faces().filter_by(lambda f: f.normal_at() == Vector(0, 0, 1)) +show(part, [f.translate(f.normal_at() * 0.01) for f in faces]) +save_screenshot(os.path.join(filedir, "operators_filter_z_normal.png")) + +box = Box(5, 5, 1) +circle = Cylinder(2, 5) +part = box + circle +edges = new_edges(box, circle, combined=part) +show(part, edges) +save_screenshot(os.path.join(filedir, "selectors_new_edges.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/sort_along_wire.py b/docs/topology_selection/examples/sort_along_wire.py new file mode 100644 index 0000000..0870d51 --- /dev/null +++ b/docs/topology_selection/examples/sort_along_wire.py @@ -0,0 +1,31 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildSketch() as along_wire: + Rectangle(48, 16, align=Align.MIN) + Rectangle(16, 48, align=Align.MIN) + Rectangle(32, 32, align=Align.MIN) + + for i, v in enumerate(along_wire.vertices()): + fillet(v, i + 1) + +show(along_wire) +save_screenshot(os.path.join(filedir, "sort_not_along_wire.png")) + + +with BuildSketch() as along_wire: + Rectangle(48, 16, align=Align.MIN) + Rectangle(16, 48, align=Align.MIN) + Rectangle(32, 32, align=Align.MIN) + + sorted_verts = along_wire.vertices().sort_by(along_wire.wire()) + for i, v in enumerate(sorted_verts): + fillet(v, i + 1) + +show(along_wire) +save_screenshot(os.path.join(filedir, "sort_along_wire.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/sort_axis.py b/docs/topology_selection/examples/sort_axis.py new file mode 100644 index 0000000..62074a6 --- /dev/null +++ b/docs/topology_selection/examples/sort_axis.py @@ -0,0 +1,28 @@ +from copy import copy +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + with BuildSketch(Plane.YZ) as profile: + with BuildLine(): + l1 = FilletPolyline((16, 0), (32, 0), (32, 25), radius=12) + l2 = FilletPolyline((16, 4), (28, 4), (28, 15), radius=8) + Line(l1 @ 0, l2 @ 0) + Polyline(l1 @ 1, l1 @ 1 - Vector(2, 0), l2 @ 1 + Vector(2, 0), l2 @ 1) + make_face() + extrude(amount=34) + + before = copy(part).part + + face = part.faces().sort_by(Axis.X)[-1] + edge = face.edges().sort_by(Axis.Y)[0] + revolve(face, -Axis(edge), 90) + +f = face.translate(face.normal_at() * 0.01) +show(before, f, edge, part.part.translate((25, 33))) +save_screenshot(os.path.join(filedir, "sort_axis.png")) diff --git a/docs/topology_selection/examples/sort_distance_from.py b/docs/topology_selection/examples/sort_distance_from.py new file mode 100644 index 0000000..8f82b6f --- /dev/null +++ b/docs/topology_selection/examples/sort_distance_from.py @@ -0,0 +1,21 @@ +import os +from itertools import product + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +boxes = ShapeList( + Box(1, 1, 1).scale(0.75 if (i, j) == (1, 2) else 0.25).translate((i, j, 0)) + for i, j in product(range(-3, 4), repeat=2) +) + +boxes = boxes.sort_by_distance(Vertex()) +show(*boxes, colors=ColorMap.listed(len(boxes))) +save_screenshot(os.path.join(filedir, "sort_distance_from_origin.png")) + +boxes = boxes.sort_by_distance(boxes.sort_by(Solid.volume).last) +show(*boxes, colors=ColorMap.listed(len(boxes))) +save_screenshot(os.path.join(filedir, "sort_distance_from_largest.png")) \ No newline at end of file diff --git a/docs/topology_selection/examples/sort_sortby.py b/docs/topology_selection/examples/sort_sortby.py new file mode 100644 index 0000000..9500e0b --- /dev/null +++ b/docs/topology_selection/examples/sort_sortby.py @@ -0,0 +1,45 @@ +import os + +from build123d import * +from ocp_vscode import * + +working_path = os.path.dirname(os.path.abspath(__file__)) +filedir = os.path.join(working_path, "..", "..", "assets", "topology_selection") + +with BuildPart() as part: + Box(5, 5, 1) + Cylinder(2, 5) + edges = part.edges().filter_by(lambda a: a.length == 1) + fillet(edges, 1) + +box = Box(5, 5, 5).move(Location((-6, -6))) +sphere = Sphere(5 / 2).move(Location((6, 6))) +solids = ShapeList([part.part, box, sphere]) + +part.wires().sort_by(SortBy.LENGTH)[:4] + +part.wires().sort_by(Wire.length)[:4] +part.wires().group_by(SortBy.LENGTH)[0] + +part.vertices().sort_by(SortBy.DISTANCE)[-2:] + +part.vertices().sort_by_distance(Vertex())[-2:] +part.vertices().group_by(Vertex().distance)[-1] + + +show(part, part.wires().sort_by(SortBy.LENGTH)[:4]) +save_screenshot(os.path.join(filedir, "sort_sortby_length.png")) + +# show(part, part.faces().sort_by(SortBy.AREA)[-2:]) +# save_screenshot(os.path.join(filedir, "sort_sortby_area.png")) + +# solid = solids.sort_by(SortBy.VOLUME)[-1] +# solid.color = "violet" +# show([part, box, sphere], solid) +# save_screenshot(os.path.join(filedir, "sort_sortby_volume.png")) + +# show(part, part.edges().filter_by(GeomType.CIRCLE).sort_by(SortBy.RADIUS)[-4:]) +# save_screenshot(os.path.join(filedir, "sort_sortby_radius.png")) + +show(part, part.vertices().sort_by(SortBy.DISTANCE)[-2:]) +save_screenshot(os.path.join(filedir, "sort_sortby_distance.png")) \ No newline at end of file diff --git a/docs/topology_selection/filter_examples.rst b/docs/topology_selection/filter_examples.rst new file mode 100644 index 0000000..0b0f3fc --- /dev/null +++ b/docs/topology_selection/filter_examples.rst @@ -0,0 +1,195 @@ +################## +Filter Examples +################## + +.. _filter_geomtype: + +GeomType +============= + +:class:`~build_enums.GeomType` enums are shape type shorthands for ``Edge`` and ``Face`` +objects. They are most helpful for filtering objects of that specific type for further +operations, and are sometimes necessary e.g. before sorting or filtering by radius. +``Edge`` and ``Face`` each support a subset of ``GeomType``: + +* ``Edge`` can be type ``LINE``, ``CIRCLE``, ``ELLIPSE``, ``HYPERBOLA``, ``PARABOLA``, ``BEZIER``, ``BSPLINE``, ``OFFSET``, ``OTHER`` +* ``Face`` can be type ``PLANE``, ``CYLINDER``, ``CONE``, ``SPHERE``, ``TORUS``, ``BEZIER``, ``BSPLINE``, ``REVOLUTION``, ``EXTRUSION``, ``OFFSET``, ``OTHER`` + +.. dropdown:: Setup + + .. literalinclude:: examples/filter_geomtype.py + :language: build123d + :lines: 3, 8-13 + +.. literalinclude:: examples/filter_geomtype.py + :language: build123d + :lines: 15 + +.. figure:: ../assets/topology_selection/filter_geomtype_line.png + :align: center + +| + +.. literalinclude:: examples/filter_geomtype.py + :language: build123d + :lines: 17 + +.. figure:: ../assets/topology_selection/filter_geomtype_cylinder.png + :align: center + +| + +.. _filter_all_edges_circle: + +All Edges Circle +======================== + +In this complete bearing block, we want to add joints for the bearings. These should be +located in the counterbore recess. One way to locate the joints is by finding faces with +centers located where the joints need to be located. Filtering for faces with only +circular edges selects the counterbore faces that meet the joint criteria. + +.. dropdown:: Setup + + .. literalinclude:: examples/filter_all_edges_circle.py + :language: build123d + :lines: 3, 8-41 + +.. literalinclude:: examples/filter_all_edges_circle.py + :language: build123d + :lines: 43-47 + +.. figure:: ../assets/topology_selection/filter_all_edges_circle.png + :align: center + +| + +.. _filter_axis_plane: + +Axis and Plane +================= + +Filtering by an Axis will select faces perpendicular to the axis. Likewise filtering by +Plane will select faces parallel to the plane. + +.. dropdown:: Setup + + .. code-block:: build123d + + from build123d import * + + with BuildPart() as part: + Box(1, 1, 1) + +.. code-block:: build123d + + part.faces().filter_by(Axis.Z) + part.faces().filter_by(Plane.XY) + +.. figure:: ../assets/topology_selection/filter_axisplane.png + :align: center + +| + +It might be useful to filter by an Axis or Plane in other ways. A lambda can be used to +accomplish this with feature properties or methods. Here, we are looking for faces where +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:: 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) + +.. figure:: ../assets/topology_selection/filter_dot_axisplane.png + :align: center + +| + +.. _filter_inner_wire_count: + +Inner Wire Count +======================== + +This motor bracket imported from a step file needs joints for adding to an assembly. +Joints for the M3 clearance holes were already found by using the cylindrical face's +axis of rotation, but the motor bore and slots need specific placement. The motor bore +can be found by filtering for faces with 5 inner wires, sorting for the desired face, +and then filtering for the specific inner wire by radius. + +- bracket STEP model: :download:`nema-17-bracket.step ` + +.. dropdown:: Setup + + .. literalinclude:: examples/filter_inner_wire_count.py + :language: build123d + :lines: 4, 9-16 + +.. literalinclude:: examples/filter_inner_wire_count.py + :language: build123d + :lines: 18-21 + +.. figure:: ../assets/topology_selection/filter_inner_wire_count.png + :align: center + +| + +Linear joints for the slots are appropriate for mating flexibility, but require more +than a single location. The slot arc centers can be used for creating a linear joint +axis and range. To do that we can filter for faces with 6 inner wires, sort for and +select the top face, and then filter for the circular edges of the inner wires. + +.. literalinclude:: examples/filter_inner_wire_count.py + :language: build123d + :lines: 25-32 + +.. figure:: ../assets/topology_selection/filter_inner_wire_count_linear.png + :align: center + +| + +.. _filter_nested: + +Nested Filters +======================== + +Filters can be nested to specify features by characteristics other than their own, like +child properties. Here we want to chamfer the mating edges of the D bore and square +shaft. A way to do this is first looking for faces with only 2 line edges among the +inner wires. The nested filter captures the straight edges, while the parent filter +selects faces based on the count. Then, from those faces, we filter for the wires with +any line edges. + +.. dropdown:: Setup + + .. literalinclude:: examples/filter_nested.py + :language: build123d + :lines: 4, 9-22 + +.. literalinclude:: examples/filter_nested.py + :language: build123d + :lines: 26-32 + +.. figure:: ../assets/topology_selection/filter_nested.png + :align: center + +| + +.. _filter_shape_properties: + +Shape Properties +======================== + +Selected features can be quickly filtered by feature properties. First, we filter by +interior and exterior edges using the ``Edge`` ``is interior`` property to apply +different fillets accordingly. Then the ``Face`` ``is_circular_*`` properties are used +to highlight the resulting fillets. + +.. literalinclude:: examples/filter_shape_properties.py + :language: build123d + :lines: 3-4, 8-22 + +.. figure:: ../assets/topology_selection/filter_shape_properties.png + :align: center + +| \ No newline at end of file diff --git a/docs/topology_selection/group_examples.rst b/docs/topology_selection/group_examples.rst new file mode 100644 index 0000000..d91de21 --- /dev/null +++ b/docs/topology_selection/group_examples.rst @@ -0,0 +1,116 @@ +################# +Group Examples +################# + +.. _group_axis: + +Axis and Length +================== + +This heatsink component could use fillets on the ends of the fins on the long ends. One +way to accomplish this is to filter by length, sort by axis, and slice the +result knowing how many edges to expect. + +.. dropdown:: Setup + + .. literalinclude:: examples/group_axis.py + :language: build123d + :lines: 4, 9-17 + +.. figure:: ../assets/topology_selection/group_axis_without.png + :align: center + +| + +However, ``group_by`` can be used to first group all the edges by z-axis position and then +group again by length. In both cases, you can select the desired edges from the last group. + +.. literalinclude:: examples/group_axis.py + :language: build123d + :lines: 21-22 + +.. figure:: ../assets/topology_selection/group_axis_with.png + :align: center + +| + +.. _group_hole_area: + +Hole Area +================== + +Callables are available to ``group_by``, like ``sort_by``. Here, the first inner wire +is converted to a face and then that area is the grouping criteria to find the faces +with the largest hole. + +.. dropdown:: Setup + + .. literalinclude:: examples/group_hole_area.py + :language: build123d + :lines: 4, 9-17 + +.. literalinclude:: examples/group_hole_area.py + :language: build123d + :lines: 21-24 + +.. figure:: ../assets/topology_selection/group_hole_area.png + :align: center + +| + +.. _group_properties_with_keys: + +Properties with Keys +==================== + +Groups are usually selected by list slice, often smallest ``[0]`` or largest ``[-1]``, +but they can also be selected by key with the ``group`` method if the keys are known. +Starting with an incomplete bearing block we are looking to add fillets to the ribs +and corners. We know the edge lengths so the edges can be grouped by ``Edge.Length`` and +then the desired groups are selected with the ``group`` method using the lengths as keys. + +.. dropdown:: Setup + + .. literalinclude:: examples/group_properties_with_keys.py + :language: build123d + :lines: 4, 9-26 + +.. literalinclude:: examples/group_properties_with_keys.py + :language: build123d + :lines: 30, 31 + +.. figure:: ../assets/topology_selection/group_length_key.png + :align: center + +| + +Next, we add alignment pin and counterbore holes after the fillets to make sure +screw heads sit flush where they overlap the fillet. Once that is done, it's time to +finalize the tight-tolerance bearing and pin holes with chamfers to make installation +easier. We can filter by ``GeomType.CIRCLE`` and group by ``Edge.radius`` to group the +circular edges. Again, the radii are known, so we can retrieve those groups directly +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: build123d + :lines: 35-43 + +.. literalinclude:: examples/group_properties_with_keys.py + :language: build123d + :lines: 47-50 + +.. figure:: ../assets/topology_selection/group_radius_key.png + :align: center + +| + +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:: build123d + + radius_groups = part.edges().filter_by(GeomType.CIRCLE) + bearing_edges = radius_groups.filter_by(lambda e: e.radius == 8) + pin_edges = radius_groups.filter_by(lambda e: e.radius == 1.5) diff --git a/docs/topology_selection/sort_examples.rst b/docs/topology_selection/sort_examples.rst new file mode 100644 index 0000000..ecdbf96 --- /dev/null +++ b/docs/topology_selection/sort_examples.rst @@ -0,0 +1,144 @@ +################ +Sort Examples +################ + +.. _sort_sortby: + +SortBy +============= + +:class:`~build_enums.SortBy` enums are shape property shorthands which work across +``Shape`` multiple object types. ``SortBy`` is a criteria for both ``sort_by`` and +``group_by``. + +* ``SortBy.LENGTH`` works with ``Edge``, ``Wire`` +* ``SortBy.AREA`` works with ``Face``, ``Solid`` +* ``SortBy.VOLUME`` works with ``Solid`` +* ``SortBy.RADIUS`` works with ``Edge``, ``Face`` with :class:`~build_enums.GeomType` ``CIRCLE``, ``CYLINDER``, ``SPHERE`` +* ``SortBy.DISTANCE`` works ``Vertex``, ``Edge``, ``Wire``, ``Face``, ``Solid`` + +``SortBy`` is often interchangeable with specific shape properties and can alternatively +be used with``group_by``. + +.. dropdown:: Setup + + .. literalinclude:: examples/sort_sortby.py + :language: build123d + :lines: 3, 8-13 + +.. literalinclude:: examples/sort_sortby.py + :language: build123d + :lines: 19-22 + +.. figure:: ../assets/topology_selection/sort_sortby_length.png + :align: center + +| + +.. literalinclude:: examples/sort_sortby.py + :language: build123d + :lines: 24-27 + +.. figure:: ../assets/topology_selection/sort_sortby_distance.png + :align: center + +| + +.. _sort_along_wire: + +Along Wire +============= + +Vertices selected from an edge or wire might have a useful ordering when created from +a single object, but when created from multiple objects, the ordering not useful. For +example, when applying incrementing fillet radii to a list of vertices from the face, +the order is random. + +.. dropdown:: Setup + + .. literalinclude:: examples/sort_along_wire.py + :language: build123d + :lines: 3, 8-12 + +.. literalinclude:: examples/sort_along_wire.py + :language: build123d + :lines: 14-15 + +.. figure:: ../assets/topology_selection/sort_not_along_wire.png + :align: center + +| + +Vertices may be sorted along the wire they fall on to create order. Notice the fillet +radii now increase in order. + +.. literalinclude:: examples/sort_along_wire.py + :language: build123d + :lines: 26-28 + +.. figure:: ../assets/topology_selection/sort_along_wire.png + :align: center + +| + +.. _sort_axis: + +Axis +========================= + +Sorting by axis is often the most straightforward way to optimize selections. In this +part we want to revolve the face at the end around an inside edge of the completed +extrusion. First, the face to extrude can be found by sorting along x-axis and the revolution +edge can be found sorting along y-axis. + +.. dropdown:: Setup + + .. literalinclude:: examples/sort_axis.py + :language: build123d + :lines: 4, 9-18 + +.. literalinclude:: examples/sort_axis.py + :language: build123d + :lines: 22-24 + +.. figure:: ../assets/topology_selection/sort_axis.png + :align: center + +| + +.. _sort_distance_from: + +Distance From +========================= + +A ``sort_by_distance`` can be used to sort objects by their distance from another object. +Here we are sorting the boxes by distance from the origin, using an empty ``Vertex`` +(at the origin) as the reference shape to find distance to. + +.. dropdown:: Setup + + .. literalinclude:: examples/sort_distance_from.py + :language: build123d + :lines: 2-5, 9-13 + +.. literalinclude:: examples/sort_distance_from.py + :language: build123d + :lines: 15-16 + +.. figure:: ../assets/topology_selection/sort_distance_from_origin.png + :align: center + +| + +The example can be extended by first sorting the boxes by volume using the ``Solid`` +property ``volume``, and getting the last (largest) box. Then, the boxes sorted by +their distance from the largest box. + +.. literalinclude:: examples/sort_distance_from.py + :language: build123d + :lines: 19-20 + +.. figure:: ../assets/topology_selection/sort_distance_from_largest.png + :align: center + +| \ No newline at end of file diff --git a/docs/tttt.rst b/docs/tttt.rst index 2376506..1c1f75f 100644 --- a/docs/tttt.rst +++ b/docs/tttt.rst @@ -5,13 +5,13 @@ Too Tall Toby (TTT) Tutorials .. image:: assets/ttt.png :align: center -To enhance users' proficiency with Build123D, this section offers a series of challenges. -In these challenges, users are presented with a CAD drawing and tasked with designing the +To enhance users' proficiency with Build123D, this section offers a series of challenges. +In these challenges, users are presented with a CAD drawing and tasked with designing the part. Their goal is to match the part's mass to a specified target. -These drawings were skillfully crafted and generously provided to Build123D by Too Tall Toby, -a renowned figure in the realm of 3D CAD. Too Tall Toby is the host of the World Championship -of 3D CAD Speedmodeling. For additional 3D CAD challenges and content, be sure to +These drawings were skillfully crafted and generously provided to Build123D by Too Tall Toby, +a renowned figure in the realm of 3D CAD. Too Tall Toby is the host of the World Championship +of 3D CAD Speedmodeling. For additional 3D CAD challenges and content, be sure to visit `Toby's youtube channel `_. Feel free to click on the parts below to embark on these engaging challenges. @@ -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,14 +276,14 @@ 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: 24-SPO-06 Buffer Stand ---------------------- -.. image:: assets/ttt/ttt-24-SPO-06-Buffer_Stand_object.png +.. image:: assets/ttt/ttt-24-SPO-06-Buffer_Stand.png :align: center .. dropdown:: Object Mass @@ -282,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 250de88..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: @@ -106,10 +106,10 @@ For solid or prismatic shapes, extrude the 2D profiles along the necessary axis. also combine multiple extrusions by intersecting or unionizing them to form complex shapes. Use the resulting geometry as sub-parts if needed. -*The next step in implmenting our design in build123d is to convert the above sketch into +*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.py b/docs/tutorial_joints.py index e6d2fd3..ba1b149 100644 --- a/docs/tutorial_joints.py +++ b/docs/tutorial_joints.py @@ -159,7 +159,7 @@ class Hinge(Compound): for hole, hole_location in enumerate(hole_locations): CylindricalJoint( label="hole" + str(hole), - axis=hole_location.to_axis(), + axis=Axis(hole_location), linear_range=(-2 * CM, 2 * CM), angular_range=(0, 360), ) diff --git a/docs/tutorial_joints.rst b/docs/tutorial_joints.rst index f3b90cd..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 @@ -185,10 +195,13 @@ Step 6: Import a Screw and bind a Joint to it :class:`~topology.Joint`'s can be bound to simple objects the a :class:`~topology.Compound` imported - in this case a screw. +- screw STEP model: :download:`M6-1x12-countersunk-screw.step ` + .. image:: assets/tutorial_joint_m6_screw.svg :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] @@ -208,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] @@ -225,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] @@ -241,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] @@ -258,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 168b3d0..fdd02de 100644 --- a/docs/tutorial_lego.rst +++ b/docs/tutorial_lego.rst @@ -21,7 +21,8 @@ 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 - :lines: 29, 32-45 + :language: build123d + :lines: 30,31, 34-47 ******************** Step 2: Part Builder @@ -31,7 +32,8 @@ 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 - :lines: 47 + :language: build123d + :lines: 49 ********************** Step 3: Sketch Builder @@ -43,7 +45,8 @@ 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 - :lines: 47-49 + :language: build123d + :lines: 49-51 :emphasize-lines: 3 @@ -59,7 +62,8 @@ 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 - :lines: 47-51 + :language: build123d + :lines: 49-53 :emphasize-lines: 5 Once the ``Rectangle`` object is created the sketch appears as follows: @@ -76,7 +80,8 @@ hollowed out. This will be done with the ``Offset`` operation which is going to create a new object from ``perimeter``. .. literalinclude:: ../examples/lego.py - :lines: 47-51,55-61 + :language: build123d + :lines: 49-53,58-64 :emphasize-lines: 7-12 The first parameter to ``Offset`` is the reference object. The ``amount`` is a @@ -104,7 +109,8 @@ 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 - :lines: 47-51,55-61,65-69 + :language: build123d + :lines: 49-53,58-64,69-73 :emphasize-lines: 13-17 Here we can see that the first ``GridLocations`` creates two positions which causes @@ -125,8 +131,9 @@ To convert the internal grid to ridges, the center needs to be removed. This wil with another ``Rectangle``. .. literalinclude:: ../examples/lego.py - :lines: 47-51,55-61,65-69,74-78 - :emphasize-lines: 17-22 + :language: build123d + :lines: 49-53,58-64,69-73,78-83 + :emphasize-lines: 18-23 The ``Rectangle`` is subtracted from the sketch to leave the ridges as follows: @@ -142,8 +149,9 @@ 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 - :lines: 47-51,55-61,65-69,74-76,82-87 - :emphasize-lines: 21-26 + :language: build123d + :lines: 49-53,58-64,69-73,78-83,88-93 + :emphasize-lines: 24-29 Here another ``GridLocations`` is used to position the centers of the circles. Note that since both ``Circle`` objects are in the scope of the location context, both @@ -162,8 +170,9 @@ Now that the sketch is complete it needs to be extruded into the three dimension wall object. .. literalinclude:: ../examples/lego.py - :lines: 47-51,55-61,65-69,74-76,82-87,91-92 - :emphasize-lines: 27-28 + :language: build123d + :lines: 49-53,58-64,69-73,78-83,88-93,98-99 + :emphasize-lines: 30-31 Note how the ``Extrude`` operation is no longer in the ``BuildSketch`` scope and has returned back into the ``BuildPart`` scope. This causes ``BuildSketch`` to exit and transfer the @@ -183,8 +192,9 @@ 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 - :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108 - :emphasize-lines: 29-37 + :language: build123d + :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118 + :emphasize-lines: 32-40 To position the top, we'll describe the top center of the lego walls with a ``Locations`` context. To determine the height we'll extract that from the @@ -211,8 +221,9 @@ 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 - :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108,116-124 - :emphasize-lines: 38-46 + :language: build123d + :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118,129-137 + :emphasize-lines: 41-49 In this case, the workplane is created from the top Face of the Lego block by using the ``faces`` method and then sorted vertically and taking the top one ``sort_by(Axis.Z)[-1]``. 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_spitfire_wing_gordon.rst b/docs/tutorial_spitfire_wing_gordon.rst new file mode 100644 index 0000000..18dd1f2 --- /dev/null +++ b/docs/tutorial_spitfire_wing_gordon.rst @@ -0,0 +1,112 @@ +############################################# +Tutorial: Spitfire Wing with Gordon Surface +############################################# + +In this advanced tutorial we construct a Supermarine Spitfire wing as a +:meth:`~topology.Face.make_gordon_surface`—a powerful technique for surfacing +from intersecting *profiles* and *guides*. A Gordon surface blends a grid of +curves into a smooth, coherent surface as long as the profiles and guides +intersect consistently. + +.. note:: + Gordon surfaces work best when *each profile intersects each guide exactly + once*, producing a well‑formed curve network. + +Overview +======== + +We will: + +1. Define overall wing dimensions and elliptic leading/trailing edge guide curves +2. Sample the guides to size the root and tip airfoils (different NACA profiles) +3. Build the Gordon surface from the airfoil *profiles* and wing‑edge *guides* +4. Close the root with a planar face and build the final :class:`~topology.Solid` + +.. raw:: html + + + + +Step 1 — Dimensions and guide curves +==================================== + +We model a single wing (half‑span), with an elliptic leading and trailing edge. +These two edges act as the *guides* for the Gordon surface. + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [Code] + :end-before: [AirfoilSizes] + + +Step 2 — Root and tip airfoil sizing +==================================== + +We intersect the guides with planes normal to the span to size the airfoil sections. +The resulting chord lengths define uniform scales for each airfoil curve. + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [AirfoilSizes] + :end-before: [Airfoils] + +Step 3 — Build airfoil profiles (root and tip) +============================================== + +We place two different NACA airfoils on :data:`Plane.YZ`—with the airfoil origins +shifted so the leading edge fraction is aligned—then scale to the chord lengths +from Step 2. + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [Airfoils] + :end-before: [Profiles] + + +Step 4 — Gordon surface construction +==================================== + +A Gordon surface needs *profiles* and *guides*. Here the airfoil edges are the +profiles; the elliptic edges are the guides. We also add the wing tip section +so the profile grid closes at the tip. + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [Profiles] + :end-before: [Solid] + +.. image:: ./assets/surface_modeling/spitfire_wing_profiles_guides.svg + :align: center + :alt: Elliptic leading/trailing guides + + +Step 5 — Cap the root and create the solid +========================================== + +We extract the closed root edge loop, make a planar cap, and form a solid shell. + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [Solid] + :end-before: [End] + +.. image:: ./assets/surface_modeling/spitfire_wing.png + :align: center + :alt: Final wing solid + +Tips for robust Gordon surfaces +------------------------------- + +- Ensure each profile intersects each guide once and only once +- Keep the curve network coherent (no duplicated or missing intersections) +- When possible, reuse the same :class:`~topology.Edge` objects across adjacent faces + +Complete listing +================ + +For convenience, here is the full script in one block: + +.. literalinclude:: spitfire_wing_gordon.py + :language: build123d + :start-after: [Code] + :end-before: [End] diff --git a/docs/tutorial_surface_heart_token.rst b/docs/tutorial_surface_heart_token.rst new file mode 100644 index 0000000..9931108 --- /dev/null +++ b/docs/tutorial_surface_heart_token.rst @@ -0,0 +1,132 @@ +################################## +Tutorial: Heart Token (Basics) +################################## + +This hands‑on tutorial introduces the fundamentals of surface modeling by building +a heart‑shaped token from a small set of non‑planar faces. We’ll create +non‑planar surfaces, mirror them, add side faces, and assemble a closed shell +into a solid. + +As described in the `topology_` section, a BREP model consists of vertices, edges, faces, +and other elements that define the boundary of an object. When creating objects with +non-planar faces, it is often more convenient to explicitly create the boundary faces of +the object. To illustrate this process, we will create the following game token: + +.. raw:: html + + + + +Useful :class:`~topology.Face` creation methods include +:meth:`~topology.Face.make_surface`, :meth:`~topology.Face.make_bezier_surface`, +and :meth:`~topology.Face.make_surface_from_array_of_points`. See the +:doc:`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. + +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] + +Note that ``l4`` is not in the same plane as the other lines; it defines the center line +of the heart and archs up off ``Plane.XY``. + +.. image:: ./assets/surface_modeling/token_heart_perimeter.png + :align: center + :alt: token perimeter + +In preparation for creating the surface, we'll define a point on the surface: + +.. literalinclude:: heart_token.py + :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] + +.. image:: ./assets/surface_modeling/token_half_surface.png + :align: center + :alt: token perimeter + +Note that the surface was raised up by 0.5 using an Algebra expression with Pos. Also, +note that the ``-`` in front of ``Face`` simply flips the face normal so that the colored +side is up, which isn't necessary but helps with viewing. + +Now that one half of the top of the heart has been created, the remainder of the top +and bottom can be created by mirroring: + +.. literalinclude:: heart_token.py + :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 +as follows: + +.. literalinclude:: heart_token.py + :language: build123d + :start-after: [Surfaces] + :end-before: [Sides] + +.. image:: ./assets/surface_modeling/token_sides.png + :align: center + :alt: token sides + +With the top, bottom, and sides, the complete boundary of the object is defined. We can +now put them together, first into a :class:`~topology.Shell` and then into a +:class:`~topology.Solid`: + +.. literalinclude:: heart_token.py + :language: build123d + :start-after: [Sides] + :end-before: [Solid] + +.. image:: ./assets/surface_modeling/token_heart_solid.png + :align: center + :alt: token heart solid + +.. note:: + When creating a Solid from a Shell, the Shell must be "water-tight," meaning it + should have no holes. For objects with complex Edges, it's best practice to reuse + Edges in adjoining Faces whenever possible to avoid slight mismatches that can + create openings. + +Finally, we'll create the frame around the heart as a simple extrusion of a planar +shape defined by the perimeter of the heart and merge all of the components together: + +.. literalinclude:: heart_token.py + :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 +the frame as a constant distance from the heart itself. + +Summary +------- + +In this tutorial, we've explored surface modeling techniques to create a non-planar +heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face` +class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and +central point of the surface. We then assembled the complete boundary of the object +by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell` +and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart +using the :func:`~operations_generic.offset` function to maintain a constant distance +from the heart. + +Next steps +---------- + +Continue to :doc:`tutorial_spitfire_wing_gordon` for an advanced example using +:meth:`~topology.Face.make_gordon_surface` to create a Supermarine Spitfire wing. diff --git a/docs/tutorial_surface_modeling.rst b/docs/tutorial_surface_modeling.rst index 08ed253..afa7f82 100644 --- a/docs/tutorial_surface_modeling.rst +++ b/docs/tutorial_surface_modeling.rst @@ -1,156 +1,55 @@ -################ +################# Surface Modeling -################ +################# -Surface modeling is employed to create objects with non-planar surfaces that can't be -generated using functions like :func:`~operations_part.extrude`, -:func:`~operations_generic.sweep`, or :func:`~operations_part.revolve`. Since there are no -specific builders designed to assist with the creation of non-planar surfaces or objects, -the following should be considered a more advanced technique. -As described in the `topology_` section, a BREP model consists of vertices, edges, faces, -and other elements that define the boundary of an object. When creating objects with -non-planar faces, it is often more convenient to explicitly create the boundary faces of -the object. To illustrate this process, we will create the following game token: +Surface modeling refers to the direct creation and manipulation of the skin of a 3D +object—its bounding faces—rather than starting from volumetric primitives or solid +operations. -.. raw:: html +Instead of defining a shape by extruding or revolving a 2D profile to fill a volume, +surface modeling focuses on building the individual curved or planar faces that together +define the outer boundary of a part. This approach allows for precise control of complex +freeform geometry such as aerodynamic surfaces, boat hulls, or organic transitions that +cannot easily be expressed with simple parametric solids. - - +In build123d, as in other CAD kernels based on BREP (Boundary Representation) modeling, +all solids are ultimately defined by their boundaries: a hierarchy of faces, edges, and +vertices. Each face represents a finite patch of a geometric surface (plane, cylinder, +Bézier patch, etc.) bounded by one or more edge loops or wires. When adjacent faces share +edges consistently and close into a continuous boundary, they form a manifold +:class:`~topology.Shell`—the watertight surface of a volume. If this shell is properly +oriented and encloses a finite region of space, the model becomes a solid. -There are several methods of the :class:`~topology.Face` class that can be used to create -non-planar surfaces: +Surface modeling therefore operates at the most fundamental level of BREP construction. +Rather than relying on higher-level modeling operations to implicitly generate faces, +it allows you to construct and connect those faces explicitly. This provides a path to +build geometry that blends analytical and freeform shapes seamlessly, with full control +over continuity, tangency, and curvature across boundaries. -* :meth:`~topology.Face.make_bezier_surface`, -* :meth:`~topology.Face.make_surface`, and -* :meth:`~topology.Face.make_surface_from_array_of_points`. +This section provides: +- A concise overview of surface‑building tools in build123d +- Hands‑on tutorials, from fundamentals to advanced techniques like Gordon surfaces -In this case, we'll use the ``make_surface`` method, providing it with the edges that define -the perimeter of the surface and a central point on that surface. +.. rubric:: Available surface methods -To create the perimeter, we'll use a ``BuildLine`` instance as follows. Since the heart is -symmetric, we'll only create half of its surface here: +Methods on :class:`~topology.Face` for creating non‑planar surfaces: -.. code-block:: python - - with BuildLine() as heart_half: - l1 = JernArc((0, 0), (1, 1.4), 40, -17) - l2 = JernArc(l1 @ 1, l1 % 1, 4.5, 175) - l3 = IntersectingLine(l2 @ 1, l2 % 1, other=Edge.make_line((0, 0), (0, 20))) - l4 = ThreePointArc(l3 @ 1, Vector(0, 0, 1.5) + (l3 @ 1 + l1 @ 0) / 2, l1 @ 0) - -Note that ``l4`` is not in the same plane as the other lines; it defines the center line -of the heart and archs up off ``Plane.XY``. - -.. image:: ./assets/token_heart_perimeter.png - :align: center - :alt: token perimeter - -In preparation for creating the surface, we'll define a point on the surface: - -.. code-block:: python - - surface_pnt = l2.edge().arc_center + Vector(0, 0, 1.5) - -We will then use this point to create a non-planar ``Face``: - -.. code-block:: python - - top_right_surface = -Face.make_surface(heart_half.wire(), [surface_pnt]).locate( - Pos(Z=0.5) - ) - -.. image:: ./assets/token_half_surface.png - :align: center - :alt: token perimeter - -Note that the surface was raised up by 0.5 using the locate method. Also, note that -the ``-`` in front of ``Face`` simply flips the face normal so that the colored side -is up, which isn't necessary but helps with viewing. - -Now that one half of the top of the heart has been created, the remainder of the top -and bottom can be created by mirroring: - -.. code-block:: python - - top_left_surface = top_right_surface.mirror(Plane.YZ) - bottom_right_surface = top_right_surface.mirror(Plane.XY) - bottom_left_surface = -top_left_surface.mirror(Plane.XY) - -The sides of the heart are going to be created by extruding the outside of the perimeter -as follows: - -.. code-block:: python - - left_wire = Wire([l3.edge(), l2.edge(), l1.edge()]) - left_side = Face.extrude(left_wire, (0, 0, 1)).locate(Pos(Z=-0.5)) - right_side = left_side.mirror(Plane.YZ) - -.. image:: ./assets/token_sides.png - :align: center - :alt: token sides - -With the top, bottom, and sides, the complete boundary of the object is defined. We can -now put them together, first into a :class:`~topology.Shell` and then into a -:class:`~topology.Solid`: - -.. code-block:: python - - heart = Solid( - Shell( - [ - top_right_surface, - top_left_surface, - bottom_right_surface, - bottom_left_surface, - left_side, - right_side, - ] - ) - ) - -.. image:: ./assets/token_heart_solid.png - :align: center - :alt: token heart solid +* :meth:`~topology.Face.make_bezier_surface` +* :meth:`~topology.Face.make_gordon_surface` +* :meth:`~topology.Face.make_surface` +* :meth:`~topology.Face.make_surface_from_array_of_points` +* :meth:`~topology.Face.make_surface_from_curves` +* :meth:`~topology.Face.make_surface_patch` .. note:: - When creating a Solid from a Shell, the Shell must be "water-tight," meaning it - should have no holes. For objects with complex Edges, it's best practice to reuse - Edges in adjoining Faces whenever possible to avoid slight mismatches that can - create openings. + Surface modeling is an advanced technique. Robust results usually come from + reusing the same :class:`~topology.Edge` objects across adjacent faces and + ensuring the final :class:`~topology.Shell` is *water‑tight* or *manifold* (no gaps). -Finally, we'll create the frame around the heart as a simple extrusion of a planar -shape defined by the perimeter of the heart and merge all of the components together: +.. toctree:: + :maxdepth: 1 - .. code-block:: python + tutorial_surface_heart_token.rst + tutorial_spitfire_wing_gordon.rst - with BuildPart() as heart_token: - with BuildSketch() as outline: - with BuildLine(): - add(l1) - add(l2) - add(l3) - Line(l3 @ 1, l1 @ 0) - make_face() - mirror(about=Plane.YZ) - center = outline.sketch - offset(amount=2, kind=Kind.INTERSECTION) - add(center, mode=Mode.SUBTRACT) - extrude(amount=2, both=True) - add(heart) - -Note that an additional planar line is used to close ``l1`` and ``l3`` so a ``Face`` -can be created. The :func:`~operations_generic.offset` function defines the outside of -the frame as a constant distance from the heart itself. - -Summary -------- - -In this tutorial, we've explored surface modeling techniques to create a non-planar -heart-shaped object using build123d. By utilizing methods from the :class:`~topology.Face` -class, such as :meth:`~topology.Face.make_surface`, we constructed the perimeter and -central point of the surface. We then assembled the complete boundary of the object -by creating the top, bottom, and sides, and combined them into a :class:`~topology.Shell` -and eventually a :class:`~topology.Solid`. Finally, we added a frame around the heart -using the :func:`~operations_generic.offset` function to maintain a constant distance -from the heart. \ No newline at end of file diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 7ea3407..5ae7c96 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -16,3 +16,4 @@ as later tutorials build on the concepts introduced in earlier ones. examples_1.rst tttt.rst tutorial_surface_modeling.rst + tech_drawing_tutorial.rst diff --git a/examples/bicycle_tire.py b/examples/bicycle_tire.py new file mode 100644 index 0000000..dc84a7f --- /dev/null +++ b/examples/bicycle_tire.py @@ -0,0 +1,109 @@ +""" +A bicycle tire with tread. + +name: bicycle_tire.py +by: Gumyr +date: May 20, 2025 + +desc: + + This example demonstrates how to model a realistic bicycle tire with a + patterned tread using build123d. The key concept showcased here is the + use of wrap_faces to project 2D planar geometry onto a curved 3D + surface. + + The tire cross-section is defined using a series of Bezier curves and + revolved to form the main tire body. A 2D tread pattern is created as a + sketch on a plane and then wrapped onto the non-planar revolved surface + using wrap_faces, following a path on the surface. The wrapped faces are + then thickened into 3D solid nubs and copied around the tire using + rotational placement. + + This technique is particularly useful for applying surface detail—such + as grooves, logos, or textures—to curved or freeform geometries in a CAD + model. + + Highlights: + - Complex profile creation using multiple Bezier segments. + - Surface wrapping of planar sketches using wrap_faces. + - Solidification of surface features via thicken. + - Circular duplication of solids using rotational transforms. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +# [Code] +import copy +from build123d import * +from ocp_vscode import show + +wheel_diameter = 740 * MM + +with BuildSketch() as tire_profile: + with BuildLine() as build_profile: + l00 = Bezier((0.0, 0.0), (7.05, 0.0), (12.18, 1.54), (15.13, 4.54)) + l01 = Bezier(l00 @ 1, (15.81, 5.22), (15.98, 5.44), (16.5, 6.23)) + l02 = Bezier(l01 @ 1, (18.45, 9.19), (19.61, 13.84), (19.94, 20.06)) + l03 = Bezier(l02 @ 1, (20.1, 23.24), (19.93, 27.48), (19.56, 29.45)) + l04 = Bezier(l03 @ 1, (19.13, 31.69), (18.23, 33.67), (16.91, 35.32)) + l05 = Bezier(l04 @ 1, (16.26, 36.12), (15.57, 36.77), (14.48, 37.58)) + l06 = Bezier(l05 @ 1, (12.77, 38.85), (11.51, 40.28), (10.76, 41.78)) + l07 = Bezier(l06 @ 1, (10.07, 43.16), (10.15, 43.81), (11.03, 43.98)) + l08 = Bezier(l07 @ 1, (11.82, 44.13), (12.15, 44.55), (12.08, 45.33)) + l09 = Bezier(l08 @ 1, (12.01, 46.07), (11.84, 46.43), (11.43, 46.69)) + l10 = Bezier(l09 @ 1, (10.98, 46.97), (10.07, 46.7), (9.47, 46.1)) + l11 = Bezier(l10 @ 1, (9.03, 45.65), (8.88, 45.31), (8.84, 44.65)) + l12 = Bezier(l11 @ 1, (8.78, 43.6), (9.11, 42.26), (9.72, 41.0)) + l13 = Bezier(l12 @ 1, (10.43, 39.54), (11.52, 38.2), (12.78, 37.22)) + l14 = Bezier(l13 @ 1, (15.36, 35.23), (16.58, 33.76), (17.45, 31.62)) + l15 = Bezier(l14 @ 1, (17.91, 30.49), (18.22, 29.27), (18.4, 27.8)) + l16 = Bezier(l15 @ 1, (18.53, 26.78), (18.52, 23.69), (18.37, 22.61)) + l17 = Bezier(l16 @ 1, (17.8, 18.23), (16.15, 14.7), (13.39, 11.94)) + l18 = Bezier(l17 @ 1, (11.89, 10.45), (10.19, 9.31), (8.09, 8.41)) + l19 = Bezier(l18 @ 1, (3.32, 6.35), (0.0, 6.64)) + mirror(about=Plane.YZ) + make_face() + +tire = revolve(Pos(Y=-wheel_diameter / 2) * tire_profile.face(), Axis.X) + +with BuildSketch() as tread_pattern: + with Locations((1, 1)): + Trapezoid(15, 12, 60, 120, align=Align.MIN) + with Locations((1, 8)): + with GridLocations(0, 5, 1, 2): + Rectangle(50, 2, mode=Mode.SUBTRACT) + +# Define the surface and path that the tread pattern will be wrapped onto +half_road_surface = Face.revolve(Pos(Y=-wheel_diameter / 2) * l00, 360, Axis.X) +tread_path = half_road_surface.edges().sort_by(Axis.X)[0] + +# Wrap the planar tread pattern onto the tire's outside surface +tread_faces = half_road_surface.wrap_faces(tread_pattern.faces(), tread_path) + +# Mirror the faces to the other half of the tire +tread_faces.extend([mirror(t, Plane.YZ) for t in tread_faces]) + +# Thicken the tread to become solid nubs +# tread_prime = [Solid.thicken(f, 3 * MM) for f in tread_faces] +tread_prime = [thicken(f, 3 * MM) for f in tread_faces] + +# Copy the nubs around the whole tire +tread = [Rot(X=r) * copy.copy(t) for t in tread_prime for r in range(0, 360, 2)] + +show(tire, tread) +# [End] diff --git a/examples/build123d_customizable_logo_algebra.py b/examples/build123d_customizable_logo_algebra.py index 4492e48..29bd81c 100644 --- a/examples/build123d_customizable_logo_algebra.py +++ b/examples/build123d_customizable_logo_algebra.py @@ -41,7 +41,7 @@ l2 = Line( (logo_width, -font_height * 0.1), (logo_width, -ext_line_length - font_height * 0.1), ) -extension_lines = l1 + l2 +extension_lines = Curve() + (l1 + l2) extension_lines += Pos(*(l1 @ 0.5)) * arrow_left extension_lines += (Pos(*(l2 @ 0.5)) * Rot(Z=180)) * arrow_left extension_lines += Line(l1 @ 0.5, l1 @ 0.5 + Vector(dim_line_length, 0)) diff --git a/examples/build123d_logo_algebra.py b/examples/build123d_logo_algebra.py index 7c6d65b..f5d732a 100644 --- a/examples/build123d_logo_algebra.py +++ b/examples/build123d_logo_algebra.py @@ -37,7 +37,7 @@ l2 = Line( (logo_width, -font_height * 0.1), (logo_width, -ext_line_length - font_height * 0.1), ) -extension_lines = l1 + l2 +extension_lines = Curve() + (l1 + l2) extension_lines += Pos(*(l1 @ 0.5)) * arrow_left extension_lines += (Pos(*(l2 @ 0.5)) * Rot(Z=180)) * arrow_left extension_lines += Line(l1 @ 0.5, l1 @ 0.5 + Vector(dim_line_length, 0)) diff --git a/examples/cast_bearing_unit.py b/examples/cast_bearing_unit.py new file mode 100644 index 0000000..51f40a8 --- /dev/null +++ b/examples/cast_bearing_unit.py @@ -0,0 +1,73 @@ +""" +An oval flanged bearing unit with tapered sides created with the draft operation. + +name: cast_bearing_unit.py +by: Gumyr +date: May 25, 2025 + +desc: + + This example demonstrates the creation of a castable flanged bearing housing + using the `draft` operation to add appropriate draft angles for mold release. + + ### Highlights: + + - **Component Integration**: The design incorporates a press-fit bore for a + `SingleRowAngularContactBallBearing` and mounting holes for + `SocketHeadCapScrew` fasteners. + - **Draft Angle Application**: Vertical side faces are identified and modified + with a 4-degree draft angle using the `draft()` function. This simulates the + taper needed for cast parts to be removed cleanly from a mold. + - **Filleting**: All edges are filleted to reflect casting-friendly geometry and + improve aesthetics. + - **Parametric Design**: Dimensions such as bolt spacing, bearing size, and + housing depth are parameterized for reuse and adaptation to other sizes. + + The result is a realistic, fabrication-aware model that can be used for + documentation, simulation, or manufacturing workflows. The final assembly + includes the housing, inserted bearing, and positioned screws, rendered with + appropriate coloring for clarity. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +# [Code] +from build123d import * +from ocp_vscode import show + +A, A1, Db2, H, J = 26, 11, 57, 98.5, 76.5 +with BuildPart() as oval_flanged_bearing_unit: + with BuildSketch() as plan: + housing = Circle(Db2 / 2) + with GridLocations(J, 0, 2, 1) as bolt_centers: + Circle((H - J) / 2) + make_hull() + extrude(amount=A1) + extrude(housing, amount=A) + drafted_faces = oval_flanged_bearing_unit.faces().filter_by(Axis.Z, reverse=True) + draft(drafted_faces, Plane.XY, 4) + fillet(oval_flanged_bearing_unit.edges(), 1) + with Locations(oval_flanged_bearing_unit.faces().sort_by(Axis.Z)[-1]): + CounterBoreHole(14 / 2, 47 / 2, 14) + with Locations(*bolt_centers): + Hole(5) + +oval_flanged_bearing_unit.part.color = Color(0x4C6377) + +show(oval_flanged_bearing_unit) +# [End] diff --git a/examples/custom_sketch_objects_algebra.py b/examples/custom_sketch_objects_algebra.py index c67a3f8..a61c4c6 100644 --- a/examples/custom_sketch_objects_algebra.py +++ b/examples/custom_sketch_objects_algebra.py @@ -33,7 +33,7 @@ class Spade(Sketch): b1 = Bezier(b0 @ 1, (242, -72), (114, -168), (11, -105)) b2 = Bezier(b1 @ 1, (31, -174), (42, -179), (53, -198)) l0 = Line(b2 @ 1, (0, -198)) - spade = l0 + b0 + b1 + b2 + spade = b0 + b1 + b2 + l0 spade += mirror(spade, Plane.YZ) spade = make_face(spade) spade = scale(spade, height / spade.bounding_box().size.Y) 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) diff --git a/examples/fast_grid_holes.py b/examples/fast_grid_holes.py new file mode 100644 index 0000000..1d6ca82 --- /dev/null +++ b/examples/fast_grid_holes.py @@ -0,0 +1,65 @@ +""" +A fast way to make many holes. + +name: fast_grid_holes.py +by: Gumyr +date: May 31, 2025 + +desc: + + This example demonstrates an efficient approach to creating a large number of holes + (625 in this case) in a planar part using build123d. + + Instead of modeling and subtracting 3D solids for each hole—which is computationally + expensive—this method constructs a 2D Face from an outer perimeter wire and a list of + hole wires. The entire face is then extruded in a single operation to form the final + 3D object. This approach significantly reduces modeling time and complexity. + + The hexagonal hole pattern is generated using HexLocations, and each location is + populated with a hexagonal wire. These wires are passed directly to the Face constructor + as holes. On a typical Linux laptop, this script completes in approximately 1.02 seconds, + compared to substantially longer runtimes for boolean subtraction of individual holes in 3D. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +# [Code] +import timeit +from build123d import * +from ocp_vscode import show + +start_time = timeit.default_timer() + +# Calculate the locations of 625 holes +major_r = 10 +hole_locs = HexLocations(major_r, 25, 25) + +# Create wires for both the perimeter and all the holes +face_perimeter = Rectangle(500, 600).wire() +hex_hole = RegularPolygon(major_r - 1, 6, major_radius=True).wire() +holes = hole_locs * hex_hole + +# Create a new Face from the perimeter and hole wires +grid_pattern = Face(face_perimeter, holes) + +# Extrude to a 3D part +grid = extrude(grid_pattern, 1) + +print(f"Time: {timeit.default_timer() - start_time:0.3f}s") +show(grid) +# [End] diff --git a/examples/joints.py b/examples/joints.py index f143af5..c51fb50 100644 --- a/examples/joints.py +++ b/examples/joints.py @@ -1,6 +1,7 @@ """ Experimental Joint development file """ + from build123d import * from ocp_vscode import * @@ -72,9 +73,9 @@ swing_arm_hinge_edge: Edge = ( .sort_by(Axis.X)[-2:] .sort_by(Axis.Y)[0] ) -swing_arm_hinge_axis = swing_arm_hinge_edge.to_axis() +swing_arm_hinge_axis = Axis(swing_arm_hinge_edge) base_corner_edge = base.edges().sort_by(Axis((0, 0, 0), (1, 1, 0)))[-1] -base_hinge_axis = base_corner_edge.to_axis() +base_hinge_axis = Axis(base_corner_edge) j3 = RevoluteJoint("hinge", base, axis=base_hinge_axis, angular_range=(0, 180)) j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.location) base.joints["hinge"].connect_to(hinge_arm.joints["corner"], angle=90) @@ -86,7 +87,7 @@ slider_arm = JointBox(4, 1, 2, 0.2) s1 = LinearJoint( "slide", base, - axis=Edge.make_mid_way(*base_top_edges, 0.67).to_axis(), + axis=Axis(Edge.make_mid_way(*base_top_edges, 0.67)), linear_range=(0, base_top_edges[0].length), ) s2 = RigidJoint("slide", slider_arm, Location(Vector(0, 0, 0))) @@ -111,7 +112,7 @@ j5.connect_to(j6, position=-1, angle=90) j7 = LinearJoint( "slot", base, - axis=Edge.make_mid_way(*base_top_edges, 0.33).to_axis(), + axis=Axis(Edge.make_mid_way(*base_top_edges, 0.33)), linear_range=(0, base_top_edges[0].length), ) pin_arm = JointBox(2, 1, 2) diff --git a/examples/joints_algebra.py b/examples/joints_algebra.py index c0da394..1484329 100644 --- a/examples/joints_algebra.py +++ b/examples/joints_algebra.py @@ -62,9 +62,9 @@ swing_arm_hinge_edge = ( .sort_by(Axis.X)[-2:] .sort_by(Axis.Y)[0] ) -swing_arm_hinge_axis = swing_arm_hinge_edge.to_axis() +swing_arm_hinge_axis = Axis(swing_arm_hinge_edge) base_corner_edge = base.edges().sort_by(Axis((0, 0, 0), (1, 1, 0)))[-1] -base_hinge_axis = base_corner_edge.to_axis() +base_hinge_axis = Axis(base_corner_edge) j3 = RevoluteJoint("hinge", base, axis=base_hinge_axis, angular_range=(0, 180)) j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.location) base.joints["hinge"].connect_to(hinge_arm.joints["corner"], angle=90) @@ -77,7 +77,7 @@ slider_arm = JointBox(4, 1, 2, 0.2) s1 = LinearJoint( "slide", base, - axis=Edge.make_mid_way(*base_top_edges, 0.67).to_axis(), + axis=Axis(Edge.make_mid_way(*base_top_edges, 0.67)), linear_range=(0, base_top_edges[0].length), ) s2 = RigidJoint("slide", slider_arm, Location(Vector(0, 0, 0))) @@ -102,7 +102,7 @@ j5.connect_to(j6, position=-1, angle=90) j7 = LinearJoint( "slot", base, - axis=Edge.make_mid_way(*base_top_edges, 0.33).to_axis(), + axis=Axis(Edge.make_mid_way(*base_top_edges, 0.33)), linear_range=(0, base_top_edges[0].length), ) pin_arm = JointBox(2, 1, 2) diff --git a/examples/lego.py b/examples/lego.py index cbd7cad..0cba2b3 100644 --- a/examples/lego.py +++ b/examples/lego.py @@ -26,9 +26,11 @@ license: See the License for the specific language governing permissions and limitations under the License. """ -from build123d import * -from ocp_vscode import * +from build123d import * +from ocp_vscode import show_object + +GEN_DOCS = False pip_count = 6 lego_unit_size = 8 @@ -49,9 +51,10 @@ with BuildPart() as lego: with BuildSketch() as plan: # Start with a Rectangle the size of the block perimeter = Rectangle(width=block_length, height=block_width) - exporter = ExportSVG(scale=6) - exporter.add_shape(plan.sketch) - exporter.write("assets/lego_step4.svg") + if GEN_DOCS: + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step4.svg") # Subtract an offset to create the block walls offset( perimeter, @@ -59,44 +62,51 @@ with BuildPart() as lego: kind=Kind.INTERSECTION, mode=Mode.SUBTRACT, ) - exporter = ExportSVG(scale=6) - exporter.add_shape(plan.sketch) - exporter.write("assets/lego_step5.svg") + if GEN_DOCS: + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step5.svg") # Add a grid of lengthwise and widthwise bars with GridLocations(x_spacing=0, y_spacing=lego_unit_size, x_count=1, y_count=2): Rectangle(width=block_length, height=ridge_width) with GridLocations(lego_unit_size, 0, pip_count, 1): Rectangle(width=ridge_width, height=block_width) - exporter = ExportSVG(scale=6) - exporter.add_shape(plan.sketch) - exporter.write("assets/lego_step6.svg") - # Substract a rectangle leaving ribs on the block walls + if GEN_DOCS: + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step6.svg") + # Subtract a rectangle leaving ribs on the block walls Rectangle( block_length - 2 * (wall_thickness + ridge_depth), block_width - 2 * (wall_thickness + ridge_depth), mode=Mode.SUBTRACT, ) - exporter = ExportSVG(scale=6) - exporter.add_shape(plan.sketch) - exporter.write("assets/lego_step7.svg") + if GEN_DOCS: + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step7.svg") # Add a row of hollow circles to the center with GridLocations( x_spacing=lego_unit_size, y_spacing=0, x_count=pip_count - 1, y_count=1 ): Circle(radius=support_outer_diameter / 2) Circle(radius=support_inner_diameter / 2, mode=Mode.SUBTRACT) - exporter = ExportSVG(scale=6) - exporter.add_shape(plan.sketch) - exporter.write("assets/lego_step8.svg") + if GEN_DOCS: + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step8.svg") # Extrude this base sketch to the height of the walls extrude(amount=base_height - wall_thickness) - visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) - exporter = ExportSVG(scale=6) - exporter.add_layer("Visible") - exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) - exporter.add_shape(visible, layer="Visible") - exporter.add_shape(hidden, layer="Hidden") - exporter.write("assets/lego_step9.svg") + if GEN_DOCS: + visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer( + "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT + ) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego_step9.svg") # Create a box on the top of the walls with Locations((0, 0, lego.vertices().sort_by(Axis.Z)[-1].Z)): # Create the top of the block @@ -106,13 +116,16 @@ with BuildPart() as lego: height=wall_thickness, align=(Align.CENTER, Align.CENTER, Align.MIN), ) - visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) - exporter = ExportSVG(scale=6) - exporter.add_layer("Visible") - exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) - exporter.add_shape(visible, layer="Visible") - exporter.add_shape(hidden, layer="Hidden") - exporter.write("assets/lego_step10.svg") + if GEN_DOCS: + visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer( + "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT + ) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego_step10.svg") # Create a workplane on the top of the block with BuildPart(lego.faces().sort_by(Axis.Z)[-1]): # Create a grid of pips @@ -122,14 +135,17 @@ with BuildPart() as lego: height=pip_height, align=(Align.CENTER, Align.CENTER, Align.MIN), ) - visible, hidden = lego.part.project_to_viewport((-100, -100, 50)) - exporter = ExportSVG(scale=6) - exporter.add_layer("Visible") - exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) - exporter.add_shape(visible, layer="Visible") - exporter.add_shape(hidden, layer="Hidden") - exporter.write("assets/lego.svg") + if GEN_DOCS: + visible, hidden = lego.part.project_to_viewport((-100, -100, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer( + "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT + ) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego.svg") assert abs(lego.part.volume - 3212.187337781355) < 1e-3 -show_object(lego.part.wrapped, name="lego") +show_object(lego.part, name="lego") diff --git a/examples/lego_algebra.py b/examples/lego_algebra.py index 9df8132..54abec2 100644 --- a/examples/lego_algebra.py +++ b/examples/lego_algebra.py @@ -34,7 +34,7 @@ plan += locs * Rectangle(width=block_length, height=ridge_width) locs = GridLocations(lego_unit_size, 0, pip_count, 1) plan += locs * Rectangle(width=ridge_width, height=block_width) -# Substract a rectangle leaving ribs on the block walls +# Subtract a rectangle leaving ribs on the block walls plan -= Rectangle( block_length - 2 * (wall_thickness + ridge_depth), block_width - 2 * (wall_thickness + ridge_depth), diff --git a/examples/loft.py b/examples/loft.py index 1204672..a92ad0b 100644 --- a/examples/loft.py +++ b/examples/loft.py @@ -41,7 +41,11 @@ with BuildPart() as art: top_bottom = art.faces().filter_by(GeomType.PLANE) offset(openings=top_bottom, amount=0.5) -assert abs(art.part.volume - 1306.3405290344635) < 1e-3 +want = 1306.3405290344635 +got = art.part.volume +delta = abs(got - want) +tolerance = want * 1e-5 +assert delta < tolerance, f"{delta=} is greater than {tolerance=}; {got=}, {want=}" show(art, names=["art"]) # [End] diff --git a/examples/packed_boxes.py b/examples/packed_boxes.py index f037d2c..e4eacbd 100644 --- a/examples/packed_boxes.py +++ b/examples/packed_boxes.py @@ -12,6 +12,8 @@ import operator import random import build123d as bd +GEN_DOCS = False + random.seed(123456) test_boxes = [bd.Box(random.randint(1, 20), random.randint(1, 20), random.randint(1, 5)) for _ in range(50)] @@ -28,7 +30,8 @@ def export_svg(parts, name): exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=bd.LineType.ISO_DOT) exporter.add_shape(visible, layer="Visible") exporter.add_shape(hidden, layer="Hidden") - exporter.write(f"../docs/assets/{name}.svg") + if GEN_DOCS: + exporter.write(f"../docs/assets/{name}.svg") export_svg(test_boxes, "packed_boxes_input") export_svg(packed, "packed_boxes_output") diff --git a/examples/platonic_solids.py b/examples/platonic_solids.py index c862690..0d527f4 100644 --- a/examples/platonic_solids.py +++ b/examples/platonic_solids.py @@ -118,7 +118,7 @@ class PlatonicSolid(BasePartObject): platonic_faces.append(Face(Wire.make_polygon(corner_vertices))) # Create the solid from the Faces - platonic_solid = Solid.make_solid(Shell.make_shell(platonic_faces)).clean() + platonic_solid = Solid(Shell(platonic_faces)).clean() # By definition, all vertices are the same distance from the origin so # scale proportionally to this distance diff --git a/examples/projection.py b/examples/projection.py index dedc0ca..b5c3b7b 100644 --- a/examples/projection.py +++ b/examples/projection.py @@ -37,7 +37,7 @@ projection_direction = Vector(0, 1, 0) square = Face.make_rect(20, 20, Plane.ZX.offset(-80)) square_projected = square.project_to_shape(sphere, projection_direction) -square_solids = Compound([f.thicken(2) for f in square_projected]) +square_solids = Compound([Solid.thicken(f, 2) for f in square_projected]) projection_beams = [ Solid.make_loft( [ @@ -75,7 +75,7 @@ text = Compound.make_text( font_size=15, align=(Align.MIN, Align.CENTER), ) -projected_text = sphere.project_faces(text, path=arch_path) +projected_text = Sketch(sphere.project_faces(text, path=arch_path)) # Example 1 show_object(sphere, name="sphere_solid", options={"alpha": 0.8}) diff --git a/examples/projection_algebra.py b/examples/projection_algebra.py index 14ed71d..3e0ece2 100644 --- a/examples/projection_algebra.py +++ b/examples/projection_algebra.py @@ -9,7 +9,7 @@ projection_direction = Vector(0, 1, 0) square = Plane.ZX.offset(-80) * Rectangle(20, 20) square_projected = square.faces()[0].project_to_shape(sphere, projection_direction) -square_solids = Part() + [f.thicken(2) for f in square_projected] +square_solids = Part() + [Solid.thicken(f, 2) for f in square_projected] face = square.faces()[0] projection_beams = loft([face, Pos(0, 160, 0) * face]) @@ -39,7 +39,7 @@ text = Text( font_size=15, align=(Align.MIN, Align.CENTER), ) -projected_text = sphere.project_faces(text.faces(), path=arch_path) +projected_text = Sketch(sphere.project_faces(text.faces(), path=arch_path)) # Example 1 show_object(sphere, name="sphere_solid", options={"alpha": 0.8}) diff --git a/examples/roller_coaster_algebra.py b/examples/roller_coaster_algebra.py index db2fa6b..67c21a3 100644 --- a/examples/roller_coaster_algebra.py +++ b/examples/roller_coaster_algebra.py @@ -1,4 +1,5 @@ from build123d import * +from ocp_vscode import show_object powerup = Spline( (0, 0, 0), @@ -10,11 +11,10 @@ powerup = Spline( corner = RadiusArc(powerup @ 1, (100, 60, 0), -30) screw = Helix(75, 150, 15, center=(75, 40, 15), direction=(-1, 0, 0)) -roller_coaster = powerup + corner + screw +roller_coaster = Curve() + (powerup + corner + screw) roller_coaster += Spline(corner @ 1, screw @ 0, tangents=(corner % 1, screw % 0)) roller_coaster += Spline( screw @ 1, (-100, 30, 10), powerup @ 0, tangents=(screw % 1, powerup % 0) ) -if "show_object" in locals(): - show_object(roller_coaster) +show_object(roller_coaster) diff --git a/examples/tea_cup.py b/examples/tea_cup.py index 866ee1f..8bc8ed6 100644 --- a/examples/tea_cup.py +++ b/examples/tea_cup.py @@ -4,19 +4,19 @@ name: tea_cup.py by: Gumyr date: March 27th 2023 -desc: This example demonstrates the creation a tea cup, which serves as an example of +desc: This example demonstrates the creation a tea cup, which serves as an example of constructing complex, non-flat geometrical shapes programmatically. The tea cup model involves several CAD techniques, such as: - - Revolve Operations: There is 1 occurrence of a revolve operation. This is used - to create the main body of the tea cup by revolving a profile around an axis, + - Revolve Operations: There is 1 occurrence of a revolve operation. This is used + to create the main body of the tea cup by revolving a profile around an axis, a common technique for generating symmetrical objects like cups. - Sweep Operations: There are 2 occurrences of sweep operations. The handle are created by sweeping a profile along a path to generate non-planar surfaces. - Offset/Shell Operations: the bowl of the cup is hollowed out with the offset - operation leaving the top open. - - Fillet Operations: There is 1 occurrence of a fillet operation which is used to - round the edges for aesthetic improvement and to mimic real-world objects more + operation leaving the top open. + - Fillet Operations: There is 1 occurrence of a fillet operation which is used to + round the edges for aesthetic improvement and to mimic real-world objects more closely. license: diff --git a/examples/toy_truck.py b/examples/toy_truck.py new file mode 100644 index 0000000..bbbf8f2 --- /dev/null +++ b/examples/toy_truck.py @@ -0,0 +1,171 @@ +""" + +name: toy_truck.py +by: Gumyr +date: April 4th 2025 + +desc: + + This example demonstrates how to design a toy truck using BuildPart and + BuildSketch in Builder mode. The model includes a detailed body, cab, grill, + and bumper, showcasing techniques like sketch reuse, symmetry, tapered + extrusions, selective filleting, and the use of joints for part assembly. + Ideal for learning complex part construction and hierarchical modeling in + build123d. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +# [Code] +from build123d import * +from ocp_vscode import show + +# Toy Truck Blue +truck_color = Color(0x4683CE) + +# Create the main truck body — from bumper to bed, excluding the cab +with BuildPart() as body: + # The body has two axes of symmetry, so we start with a centered sketch. + # The default workplane is Plane.XY. + with BuildSketch() as body_skt: + Rectangle(20, 35) + # Fillet all the corners of the sketch. + # Alternatively, you could use RectangleRounded. + fillet(body_skt.vertices(), 1) + + # Extrude the body shape upward + extrude(amount=10, taper=4) + # Reuse the sketch by accessing it explicitly + extrude(body_skt.sketch, amount=8, taper=2) + + # Create symmetric fenders on Plane.YZ + with BuildSketch(Plane.YZ) as fender: + # The trapezoid has asymmetric angles (80°, 88°) + Trapezoid(18, 6, 80, 88, align=Align.MIN) + # Fillet top edge vertices (Y-direction highest group) + fillet(fender.vertices().group_by(Axis.Y)[-1], 1.5) + + # Extrude the fender in both directions + extrude(amount=10.5, both=True) + + # Create wheel wells with a shifted sketch on Plane.YZ + with BuildSketch(Plane.YZ.shift_origin((0, 3.5, 0))) as wheel_well: + Trapezoid(12, 4, 70, 85, align=Align.MIN) + fillet(wheel_well.vertices().group_by(Axis.Y)[-1], 2) + + # Subtract the wheel well geometry + extrude(amount=10.5, both=True, mode=Mode.SUBTRACT) + + # Fillet the top edges of the body + fillet(body.edges().group_by(Axis.Z)[-1], 1) + + # Isolate a set of body edges and preview before filleting + body_edges = body.edges().group_by(Axis.Z)[-6] + fillet(body_edges, 0.1) + + # Combine edge groups from both sides of the fender and fillet them + fender_edges = body.edges().group_by(Axis.X)[0] + body.edges().group_by(Axis.X)[-1] + fender_edges = fender_edges.group_by(Axis.Z)[1:] + fillet(fender_edges, 0.4) + + # Create a sketch on the front of the truck for the grill + with BuildSketch( + Plane.XZ.offset(-body.vertices().sort_by(Axis.Y)[-1].Y - 0.5) + ) as grill: + Rectangle(16, 8.5, align=(Align.CENTER, Align.MIN)) + fillet(grill.vertices().group_by(Axis.Y)[-1], 1) + + # Add headlights (subtractive circles) + with Locations((0, 6.5)): + with GridLocations(12, 0, 2, 1): + Circle(1, mode=Mode.SUBTRACT) + + # Add air vents (subtractive slots) + with Locations((0, 3)): + with GridLocations(0, 0.8, 1, 4): + SlotOverall(10, 0.5, mode=Mode.SUBTRACT) + + # Extrude the grill forward + extrude(amount=2) + + # Fillet only the outer grill edges (exclude headlight/vent cuts) + grill_perimeter = body.faces().sort_by(Axis.Y)[-1].outer_wire() + fillet(grill_perimeter.edges(), 0.2) + + # Create the bumper as a separate part inside the body + with BuildPart() as bumper: + # Find the midpoint of a front edge and shift slightly to position the bumper + front_cnt = body.edges().group_by(Axis.Z)[0].sort_by(Axis.Y)[-1] @ 0.5 - (0, 3) + + with BuildSketch() as bumper_plan: + # Use BuildLine to draw an elliptical arc and offset + with BuildLine(): + EllipticalCenterArc(front_cnt, 20, 4, start_angle=60, end_angle=120) + offset(amount=1) + make_face() + + # Extrude the bumper symmetrically + extrude(amount=1, both=True) + fillet(bumper.edges(), 0.25) + + # Define a joint on top of the body to connect the cab later + RigidJoint("body_top", joint_location=Location((0, -7.5, 10))) + body.part.color = truck_color + +# Create the cab as an independent part to mount on the body +with BuildPart() as cab: + with BuildSketch() as cab_plan: + RectangleRounded(16, 16, 1) + # Split the sketch to work on one symmetric half + split(bisect_by=Plane.YZ) + + # Extrude the cab forward and upward at an angle + extrude(amount=7, dir=(0, 0.15, 1)) + fillet(cab.edges().group_by(Axis.Z)[-1].group_by(Axis.X)[1:], 1) + + # Rear window + with BuildSketch(Plane.XZ.shift_origin((0, 0, 3))) as rear_window: + RectangleRounded(8, 4, 0.75) + extrude(amount=10, mode=Mode.SUBTRACT) + + # Front window + with BuildSketch(Plane.XZ) as front_window: + RectangleRounded(15.2, 11, 0.75) + extrude(amount=-10, mode=Mode.SUBTRACT) + + # Side windows + with BuildSketch(Plane.YZ) as side_window: + with Locations((3.5, 0)): + with GridLocations(10, 0, 2, 1): + Trapezoid(9, 5.5, 80, 100, align=(Align.CENTER, Align.MIN)) + fillet(side_window.vertices().group_by(Axis.Y)[-1], 0.5) + extrude(amount=12, both=True, mode=Mode.SUBTRACT) + + # Mirror to complete the cab + mirror(about=Plane.YZ) + + # Define joint on cab base + RigidJoint("cab_base", joint_location=Location((0, 0, 0))) + cab.part.color = truck_color + +# Attach the cab to the truck body using joints +body.joints["body_top"].connect_to(cab.joints["cab_base"]) + +# Show the result +show(body.part, cab.part) +# [End] diff --git a/mypy.ini b/mypy.ini index b014dd5..e6a7c3d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -41,5 +41,5 @@ ignore_missing_imports = True [mypy-setuptools_scm.*] ignore_missing_imports = True -[mypy-py_lib3mf.*] +[mypy-lib3mf.*] ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 4a71828..22f6440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = [ "brep", "cad", "cadquery", - "opencscade", + "opencascade", "python", ] license = {text = "Apache-2.0"} @@ -41,16 +41,21 @@ dependencies = [ "svgpathtools >= 1.5.1, < 2", "anytree >= 2.8.0, < 3", "ezdxf >= 1.1.0, < 2", - "ipython >= 8.0.0, < 9", - "py-lib3mf >= 2.3.1", - "ocpsvg >= 0.4", + "ipython >= 8.0.0, < 10", + "lib3mf >= 2.4.1", + "ocpsvg >= 0.5, < 0.6", + "ocp_gordon >= 0.1.17", "trianglesolver", + "sympy", + "scipy", + "webcolors ~= 24.8.0", ] [project.urls] "Homepage" = "https://github.com/gumyr/build123d" "Documentation" = "https://build123d.readthedocs.io/en/latest/index.html" "Bug Tracker" = "https://github.com/gumyr/build123d/issues" +"Citation" = "https://doi.org/10.5281/zenodo.14872323" [project.optional-dependencies] # enable the optional ocp_vscode visualization package @@ -60,12 +65,14 @@ ocp_vscode = [ # development dependencies development = [ - "wheel", - "pytest", - "pytest-cov", - "pylint", - "mypy", "black", + "mypy", + "pylint", + "pytest==8.4.2", # TODO: unpin on resolution of pytest-dev/pytest-xdist/issues/1273 + "pytest-benchmark", + "pytest-cov", + "pytest-xdist", + "wheel", ] # typing stubs for the OCP CAD kernel @@ -73,11 +80,6 @@ stubs = [ "cadquery-ocp-stubs >= 7.8, < 7.9", ] -# dependency to run the pytest benchmarks -benchmark = [ - "pytest-benchmark", -] - # dependencies to build the docs docs = [ "sphinx==8.1.3", # pin for stability of docs builds @@ -92,7 +94,6 @@ docs = [ all = [ "build123d[ocp_vscode]", "build123d[development]", - "build123d[benchmark]", "build123d[docs]", # "build123d[stubs]", # excluded for now as mypy fails ] diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index a1f59ca..2dcf0b0 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -21,6 +21,7 @@ from build123d.topology import * from build123d.drafting import * from build123d.persistence import modify_copyreg from build123d.exporters3d import * +from build123d.utils import available_fonts from .version import version as __version__ @@ -45,6 +46,7 @@ __all__ = [ "ApproxOption", "AngularDirection", "CenterOf", + "ContinuityLevel", "Extrinsic", "FontStyle", "FrameMethod", @@ -53,16 +55,19 @@ __all__ = [ "Intrinsic", "Keep", "Kind", + "Sagitta", "LengthMode", "MeshType", "Mode", "NumberDisplay", "PageSize", + "Tangency", "PositionMode", "PrecisionMode", "Select", "Side", "SortBy", + "TextAlign", "Transition", "Unit", "Until", @@ -76,7 +81,9 @@ __all__ = [ "BuildSketch", # 1D Curve Objects "BaseLineObject", + "Airfoil", "Bezier", + "BlendCurve", "CenterArc", "DoubleTangentArc", "EllipticalCenterArc", @@ -93,6 +100,10 @@ __all__ = [ "TangentArc", "JernArc", "ThreePointArc", + "PointArcTangentLine", + "ArcArcTangentLine", + "PointArcTangentArc", + "ArcArcTangentArc", # 2D Sketch Objects "ArrowHead", "Arrow", @@ -127,6 +138,7 @@ __all__ = [ "Wedge", # Direct API Classes "BoundBox", + "OrientedBoundBox", "Rotation", "Rot", "Pos", @@ -149,6 +161,7 @@ __all__ = [ "Compound", "Location", "LocationEncoder", + "GeomEncoder", "Joint", "RigidJoint", "RevoluteJoint", @@ -156,6 +169,7 @@ __all__ = [ "LinearJoint", "CylindricalJoint", "BallJoint", + "DraftAngleError", # Exporter classes "Export2D", "ExportDXF", @@ -175,6 +189,7 @@ __all__ = [ "new_edges", "pack", "polar", + "available_fonts", # Context aware selectors "solids", "faces", @@ -190,6 +205,7 @@ __all__ = [ "add", "bounding_box", "chamfer", + "draft", "extrude", "fillet", "full_round", diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index fc6e350..d14b556 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -50,7 +50,7 @@ import functools from abc import ABC, abstractmethod from itertools import product from math import sqrt, cos, pi -from typing import Any, cast, overload, Type, TypeVar +from typing import Any, cast, overload, Protocol, Type, TypeVar, Generic from collections.abc import Callable, Iterable from typing_extensions import Self @@ -155,6 +155,7 @@ operations_apply_to = { "add": ["BuildPart", "BuildSketch", "BuildLine"], "bounding_box": ["BuildPart", "BuildSketch", "BuildLine"], "chamfer": ["BuildPart", "BuildSketch", "BuildLine"], + "draft": ["BuildPart"], "extrude": ["BuildPart"], "fillet": ["BuildPart", "BuildSketch", "BuildLine"], "full_round": ["BuildSketch"], @@ -177,8 +178,11 @@ operations_apply_to = { B = TypeVar("B", bound="Builder") """Builder type hint""" +ShapeT = TypeVar("ShapeT", bound=Shape) +"""Builder's are generic shape creators""" -class Builder(ABC): + +class Builder(ABC, Generic[ShapeT]): """Builder Base class for the build123d Builders. @@ -230,7 +234,7 @@ class Builder(ABC): @property @abstractmethod - def _obj(self) -> Shape: + def _obj(self) -> Shape | None: """Object to pass to parent""" raise NotImplementedError # pragma: no cover @@ -247,6 +251,8 @@ class Builder(ABC): @property def new_edges(self) -> ShapeList[Edge]: """Edges that changed during last operation""" + if self._obj is None: + return ShapeList() before_list = [] if self.obj_before is None else [self.obj_before] return new_edges(*(before_list + self.to_combine), combined=self._obj) @@ -460,7 +466,7 @@ class Builder(ABC): elif mode == Mode.INTERSECT: if self._obj is None: raise RuntimeError("Nothing to intersect with") - combined = self._obj.intersect(*typed[self._shape]) + combined = self._obj.intersect(Compound(typed[self._shape])) elif mode == Mode.REPLACE: combined = self._sub_class(list(typed[self._shape])) @@ -534,7 +540,8 @@ class Builder(ABC): """ vertex_list: list[Vertex] = [] if select == Select.ALL: - for obj_edge in self._obj.edges(): + obj_edges = [] if self._obj is None else self._obj.edges() + for obj_edge in obj_edges: vertex_list.extend(obj_edge.vertices()) elif select == Select.LAST: vertex_list = self.lasts[Vertex] @@ -578,7 +585,7 @@ class Builder(ABC): ShapeList[Edge]: Edges extracted """ if select == Select.ALL: - edge_list = self._obj.edges() + edge_list = ShapeList() if self._obj is None else self._obj.edges() elif select == Select.LAST: edge_list = self.lasts[Edge] elif select == Select.NEW: @@ -621,7 +628,7 @@ class Builder(ABC): ShapeList[Wire]: Wires extracted """ if select == Select.ALL: - wire_list = self._obj.wires() + wire_list = ShapeList() if self._obj is None else self._obj.wires() elif select == Select.LAST: wire_list = Wire.combine(self.lasts[Edge]) elif select == Select.NEW: @@ -664,7 +671,7 @@ class Builder(ABC): ShapeList[Face]: Faces extracted """ if select == Select.ALL: - face_list = self._obj.faces() + face_list = ShapeList() if self._obj is None else self._obj.faces() elif select == Select.LAST: face_list = self.lasts[Face] elif select == Select.NEW: @@ -707,7 +714,7 @@ class Builder(ABC): ShapeList[Solid]: Solids extracted """ if select == Select.ALL: - solid_list = self._obj.solids() + solid_list = ShapeList() if self._obj is None else self._obj.solids() elif select == Select.LAST: solid_list = self.lasts[Solid] elif select == Select.NEW: @@ -744,17 +751,18 @@ class Builder(ABC): ) -> ShapeList: """Extract Shapes""" obj_type = self._shape if obj_type is None else obj_type + if self._obj is None: + return ShapeList() + if obj_type == Vertex: - result = self._obj.vertices() - elif obj_type == Edge: - result = self._obj.edges() - elif obj_type == Face: - result = self._obj.faces() - elif obj_type == Solid: - result = self._obj.solids() - else: - result = None - return result + return self._obj.vertices() + if obj_type == Edge: + return self._obj.edges() + if obj_type == Face: + return self._obj.faces() + if obj_type == Solid: + return self._obj.solids() + return ShapeList() def validate_inputs( self, validating_class, objects: Shape | Iterable[Shape] | None = None @@ -1110,7 +1118,7 @@ class Locations(LocationList): elif isinstance(point, Vector): local_locations.append(Location(point)) elif isinstance(point, Vertex): - local_locations.append(Location(Vector(point.to_tuple()))) + local_locations.append(Location(Vector(point))) elif isinstance(point, tuple): local_locations.append(Location(Vector(point))) elif isinstance(point, Plane): @@ -1341,10 +1349,16 @@ class WorkplaneList: # Type variable representing the return type of the wrapped function T2 = TypeVar("T2") +T2_covar = TypeVar("T2_covar", covariant=True) + + +class ContextComponentGetter(Protocol[T2_covar]): + def __call__(self, select: Select = Select.ALL) -> T2_covar: ... def __gen_context_component_getter( - func: Callable[[Builder, Select], T2] + func: Callable[[Builder, Select], T2], + # ) -> ContextComponentGetter[T2]: ) -> Callable[[Select], T2]: """ Wraps a Builder method to automatically provide the Builder context. @@ -1360,7 +1374,7 @@ def __gen_context_component_getter( a `Select` instance as its second argument. Returns: - Callable[[Select], T2]: A callable that takes only a `Select` argument and + ContextComponentGetter[T2]: A callable that takes only a `Select` argument and internally retrieves the Builder context to call the original method. Raises: @@ -1371,8 +1385,8 @@ def __gen_context_component_getter( @functools.wraps(func) def getter(select: Select = Select.ALL) -> T2: # Retrieve the current Builder context based on the method name - context = Builder._get_context(func.__name__) - if not context: + context: Builder | None = Builder._get_context(func.__name__) + if context is None: raise RuntimeError( f"{func.__name__}() requires a Builder context to be in scope" ) diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index 8f8059a..44d7c8b 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -28,10 +28,16 @@ license: from __future__ import annotations -from enum import Enum, auto -from typing import Union +from enum import Enum, auto, IntEnum, unique +from typing import TypeAlias, Union -from typing import TypeAlias +from OCP.GccEnt import ( + GccEnt_unqualified, + GccEnt_enclosing, + GccEnt_enclosed, + GccEnt_outside, + GccEnt_noqualifier, +) class Align(Enum): @@ -89,6 +95,28 @@ class CenterOf(Enum): return f"<{self.__class__.__name__}.{self.name}>" +@unique +class ContinuityLevel(IntEnum): + """ + Continuity level for evaluating geometric connections. + + Used to determine how smoothly adjacent geometry joins together, + such as at shared vertices between edges or shared edges between faces. + + Levels: + + - C0 (G0): Positional continuity—elements meet at a point but may have sharp angles. + - C1 (G1): Tangent continuity—elements have the same tangent direction at the junction. + - C2 (G2): Curvature continuity—elements have matching curvature at the junction. + + These levels correspond to common CAD definitions and are compatible with OCCT's GeomAbs_Shape. + """ + + C0 = 0 + C1 = 1 + C2 = 2 + + class Extrinsic(Enum): """Order to apply extrinsic rotations by axis""" @@ -220,6 +248,18 @@ class FontStyle(Enum): REGULAR = auto() BOLD = auto() ITALIC = auto() + BOLDITALIC = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + +class Sagitta(Enum): + """Sagitta selection""" + + SHORT = 0 + LONG = -1 + BOTH = 1 def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" @@ -280,6 +320,18 @@ class PageSize(Enum): return f"<{self.__class__.__name__}.{self.name}>" +class Tangency(Enum): + """Tangency constraint for solvers edge selection""" + + UNQUALIFIED = GccEnt_unqualified + ENCLOSING = GccEnt_enclosing + ENCLOSED = GccEnt_enclosed + OUTSIDE = GccEnt_outside + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class PositionMode(Enum): """Position along curve mode""" @@ -344,6 +396,20 @@ class SortBy(Enum): return f"<{self.__class__.__name__}.{self.name}>" +class TextAlign(Enum): + """Text Alignment""" + + BOTTOM = auto() + CENTER = auto() + LEFT = auto() + RIGHT = auto() + TOP = auto() + TOPFIRSTLINE = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class Transition(Enum): """Sweep discontinuity handling option""" diff --git a/src/build123d/build_line.py b/src/build123d/build_line.py index 79c8252..52e9e74 100644 --- a/src/build123d/build_line.py +++ b/src/build123d/build_line.py @@ -36,7 +36,7 @@ from build123d.geometry import Location, Plane from build123d.topology import Curve, Edge, Face -class BuildLine(Builder): +class BuildLine(Builder[Curve]): """BuildLine The BuildLine class is a subclass of Builder for building lines (objects @@ -89,7 +89,15 @@ class BuildLine(Builder): """Set the current line""" self._line = value - _obj = line # Alias _obj to line + @property + def _obj(self) -> Curve | None: + """Alias _obj to line""" + return self._line + + @_obj.setter + def _obj(self, value: Curve) -> None: + """Set the current line""" + self._line = value def __exit__(self, exception_type, exception_value, traceback): """Upon exiting restore context and send object to parent""" diff --git a/src/build123d/build_part.py b/src/build123d/build_part.py index 3120f44..d37bdae 100644 --- a/src/build123d/build_part.py +++ b/src/build123d/build_part.py @@ -37,7 +37,7 @@ from build123d.geometry import Location, Plane from build123d.topology import Edge, Face, Joint, Part, Solid, Wire -class BuildPart(Builder): +class BuildPart(Builder[Part]): """BuildPart The BuildPart class is another subclass of Builder for building parts @@ -80,7 +80,15 @@ class BuildPart(Builder): """Set the current part""" self._part = value - _obj = part # Alias _obj to part + @property + def _obj(self) -> Part | None: + """Alias _obj to part""" + return self._part + + @_obj.setter + def _obj(self, value: Part) -> None: + """Set the current part""" + self._part = value @property def pending_edges_as_wire(self) -> Wire: diff --git a/src/build123d/build_sketch.py b/src/build123d/build_sketch.py index b42721e..496ef4e 100644 --- a/src/build123d/build_sketch.py +++ b/src/build123d/build_sketch.py @@ -36,7 +36,7 @@ from build123d.geometry import Location, Plane from build123d.topology import Compound, Edge, Face, ShapeList, Sketch, Wire -class BuildSketch(Builder): +class BuildSketch(Builder[Sketch]): """BuildSketch The BuildSketch class is a subclass of Builder for building planar 2D @@ -50,7 +50,7 @@ class BuildSketch(Builder): Note that all sketch construction is done within sketch_local on Plane.XY. When objects are added to the sketch they must be coplanar to Plane.XY, usually handled automatically but may need user input for Edges and Wires - since their construction plane isn't alway able to be determined. + since their construction plane isn't always able to be determined. Args: workplanes (Union[Face, Plane, Location], optional): objects converted to @@ -83,7 +83,15 @@ class BuildSketch(Builder): """Set the builder's object""" self._sketch_local = value - _obj = sketch_local # Alias _obj to sketch_local + @property + def _obj(self) -> Sketch | None: + """Alias _obj to sketch""" + return self._sketch_local + + @_obj.setter + def _obj(self, value: Sketch) -> None: + """Set the current sketch""" + self._sketch_local = value @property def sketch(self): diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index 2c995ab..415d33e 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -52,7 +52,7 @@ from build123d.objects_curve import Line, TangentArc from build123d.objects_sketch import BaseSketchObject, Polygon, Text from build123d.operations_generic import fillet, mirror, sweep from build123d.operations_sketch import make_face, trace -from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire +from build123d.topology import Compound, Curve, Edge, ShapeList, Sketch, Vertex, Wire class ArrowHead(BaseSketchObject): @@ -227,11 +227,11 @@ class Draft: """Convert a raw number to a unit of measurement string based on the class settings""" def simplify_fraction(numerator: int, denominator: int) -> tuple[int, int]: - """Mathematically simplify a fraction given a numerator and demoninator""" - greatest_common_demoninator = gcd(numerator, denominator) + """Mathematically simplify a fraction given a numerator and denominator""" + greatest_common_denominator = gcd(numerator, denominator) return ( - int(numerator / greatest_common_demoninator), - int(denominator / greatest_common_demoninator), + int(numerator / greatest_common_denominator), + int(denominator / greatest_common_denominator), ) if display_units is None: @@ -258,15 +258,15 @@ class Draft: return_value = f"{measurement}{unit_str}{tolerance_str}" else: whole_part = floor(number / IN) - (numerator, demoninator) = simplify_fraction( + (numerator, denominator) = simplify_fraction( round((number / IN - whole_part) * self.fractional_precision), self.fractional_precision, ) if whole_part == 0: - return_value = f"{numerator}/{demoninator}{unit_str}{tolerance_str}" + return_value = f"{numerator}/{denominator}{unit_str}{tolerance_str}" else: return_value = ( - f"{whole_part} {numerator}/{demoninator}{unit_str}{tolerance_str}" + f"{whole_part} {numerator}/{denominator}{unit_str}{tolerance_str}" ) return return_value @@ -277,10 +277,7 @@ class Draft: if isinstance(path, (Edge, Wire)): processed_path = path elif isinstance(path, Iterable): - pnts = [ - Vector(p.to_tuple()) if isinstance(p, Vertex) else Vector(p) - for p in path - ] + pnts = [Vector(p) for p in path] if len(pnts) == 2: processed_path = Edge.make_line(*pnts) else: @@ -456,9 +453,9 @@ 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().size + bbox_size = d_line.bounding_box().diagonal # Minimize size while avoiding intersections if sketch is None: @@ -470,9 +467,9 @@ 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.X + score = (d_line.area - 10 * common_area) / bbox_size d_lines[d_line] = score # Sort by score to find the best option @@ -712,7 +709,7 @@ class TechnicalDrawing(BaseSketchObject): # Text Box Frame bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5 bf_pnt2 = frame_wire.edges().sort_by(Axis.X)[-1] @ 0.75 - box_frame_curve = Wire.make_polygon( + box_frame_curve: Edge | Wire | ShapeList[Edge] = Wire.make_polygon( [bf_pnt1, (bf_pnt1.X, bf_pnt2.Y), bf_pnt2], close=False ) bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 49339ee..f5828bd 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -34,8 +34,10 @@ 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 typing import cast as tcast from warnings import warn from collections.abc import Callable, Iterable @@ -47,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 @@ -636,13 +638,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 +652,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") # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -751,14 +758,14 @@ class ExportDXF(Export2D): # Extract the relevant segment of the curve. spline = GeomConvert.SplitBSplineCurve_s( - curve, + tcast(Geom_BSplineCurve, curve), u1, u2, Export2D.PARAMETRIC_TOLERANCE, ) # 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) @@ -1130,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] @@ -1257,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)) @@ -1310,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)) @@ -1345,7 +1352,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) @@ -1355,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: @@ -1411,7 +1418,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 @@ -1497,13 +1504,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 +1556,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 7f2b53b..52fe4e9 100644 --- a/src/build123d/exporters3d.py +++ b/src/build123d/exporters3d.py @@ -29,10 +29,10 @@ license: # pylint has trouble with the OCP imports # pylint: disable=no-name-in-module, import-error -from io import BytesIO +from datetime import datetime import warnings +from io import BytesIO from os import PathLike, fsdecode, fspath -from typing import Union import OCP.TopAbs as ta from anytree import PreOrderIter @@ -47,7 +47,11 @@ from OCP.RWGltf import RWGltf_CafWriter from OCP.STEPCAFControl import STEPCAFControl_Controller, STEPCAFControl_Writer from OCP.STEPControl import STEPControl_Controller, STEPControl_StepModelType from OCP.StlAPI import StlAPI_Writer -from OCP.TCollection import TCollection_AsciiString, TCollection_ExtendedString, TCollection_HAsciiString +from OCP.TCollection import ( + TCollection_AsciiString, + TCollection_ExtendedString, + TCollection_HAsciiString, +) from OCP.TColStd import TColStd_IndexedDataMapOfStringString from OCP.TDataStd import TDataStd_Name from OCP.TDF import TDF_Label @@ -240,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) @@ -258,10 +262,12 @@ 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, + *, # Too many positional arguments + timestamp: str | datetime | None = None, ) -> bool: """export_step @@ -271,7 +277,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. @@ -291,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) @@ -302,6 +308,11 @@ def export_step( header = APIHeaderSection_MakeHeader(writer.Writer().Model()) if to_export.label: header.SetName(TCollection_HAsciiString(to_export.label)) + if timestamp is not None: + if isinstance(timestamp, datetime): + header.SetTimeStamp(TCollection_HAsciiString(timestamp.isoformat())) + else: + header.SetTimeStamp(TCollection_HAsciiString(timestamp)) # consider using e.g. the non *Value versions instead # header.SetAuthorValue(1, TCollection_HAsciiString("Volker")); # header.SetOrganizationValue(1, TCollection_HAsciiString("myCompanyName")); @@ -315,7 +326,13 @@ 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): + status = ( + writer.Write(fsdecode(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone + ) + else: + status = writer.WriteStream(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone + if not status: raise RuntimeError("Failed to write STEP file") @@ -335,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]): 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 @@ -357,7 +374,4 @@ def export_stl( writer = StlAPI_Writer() writer.ASCIIMode = ascii_format - - file_path = str(file_path) - - return writer.Write(to_export.wrapped, file_path) + return writer.Write(to_export.wrapped, fsdecode(file_path)) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 418d98c..3e3807f 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -34,18 +34,18 @@ 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 import logging +import warnings +from collections.abc import Callable, Iterable, Sequence +from math import degrees, isclose, log10, pi, radians, prod +from typing import TYPE_CHECKING, Any, TypeAlias, overload + import numpy as np - -from math import degrees, pi, radians -from typing import Any, overload, TypeAlias, TYPE_CHECKING - -from collections.abc import Iterable, Sequence - -import OCP.TopAbs as TopAbs_ShapeEnum - +import webcolors # type: ignore from OCP.Bnd import Bnd_Box, Bnd_OBB from OCP.BRep import BRep_Tool from OCP.BRepBndLib import BRepBndLib @@ -53,7 +53,7 @@ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform from OCP.BRepGProp import BRepGProp, BRepGProp_Face # used for mass calculation from OCP.BRepTools import BRepTools from OCP.Geom import Geom_BoundedSurface, Geom_Line, Geom_Plane -from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf, GeomAPI_IntCS, GeomAPI_IntSS +from OCP.GeomAPI import GeomAPI_IntCS, GeomAPI_IntSS, GeomAPI_ProjectPointOnSurf from OCP.gp import ( gp_Ax1, gp_Ax2, @@ -73,10 +73,11 @@ 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.TopAbs import TopAbs_ShapeEnum from OCP.TopLoc import TopLoc_Location from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Vertex -from build123d.build_enums import Align, Align2DType, Align3DType, Intrinsic, Extrinsic +from build123d.build_enums import Align, Align2DType, Align3DType, Extrinsic, Intrinsic if TYPE_CHECKING: # pragma: no cover from .topology import Edge, Face, Shape, Vertex @@ -87,6 +88,7 @@ logging.getLogger("build123d").addHandler(logging.NullHandler()) logger = logging.getLogger("build123d") TOLERANCE = 1e-6 +TOL_DIGITS = abs(int(log10(TOLERANCE))) TOL = 1e-2 DEG2RAD = pi / 180.0 RAD2DEG = 180 / pi @@ -142,6 +144,10 @@ class Vector: """ + # Note: Vector can't be made into a Sequence as NumPy attempts to be "helpful" by + # auto-converting array-like objects (objects with __len__() and indexing) into NumPy + # arrays during certain arithmetic operations. + # pylint: disable=too-many-public-methods _wrapped: gp_Vec _dim = 0 @@ -270,6 +276,12 @@ class Vector: def to_tuple(self) -> tuple[float, float, float]: """Return tuple equivalent""" + warnings.warn( + "to_tuple is deprecated and will be removed in a future version. " + "Use 'tuple(Vector)' instead.", + DeprecationWarning, + stacklevel=2, + ) return (self.X, self.Y, self.Z) @property @@ -444,11 +456,17 @@ class Vector: """Vectors equal operator ==""" if not isinstance(other, Vector): return NotImplemented - return self.wrapped.IsEqual(other.wrapped, 0.00001, 0.00001) + return self.wrapped.IsEqual(other.wrapped, TOLERANCE, TOLERANCE) def __hash__(self) -> int: """Hash of Vector""" - return hash((round(self.X, 6), round(self.Y, 6), round(self.Z, 6))) + return hash( + ( + round(self.X, TOL_DIGITS - 1), + round(self.Y, TOL_DIGITS - 1), + round(self.Z, TOL_DIGITS - 1), + ) + ) def __copy__(self) -> Vector: """Return copy of self""" @@ -485,8 +503,8 @@ class Vector: return_value = Vector(gp_Vec(pnt_t.XYZ())) else: # to gp_Dir for transformation of "direction vectors" (no translation or scaling) - dir = self.to_dir() - dir_t = dir.Transformed(affine_transform.wrapped.Trsf()) + gp_dir = self.to_dir() + dir_t = gp_dir.Transformed(affine_transform.wrapped.Trsf()) return_value = Vector(gp_Vec(dir_t.XYZ())) return return_value @@ -510,17 +528,22 @@ class Vector: @overload def intersect(self, location: Location) -> Vector | None: - """Find intersection of location and vector""" + """Find intersection of vector and location""" @overload def intersect(self, axis: Axis) -> Vector | None: - """Find intersection of axis and vector""" + """Find intersection of vector and axis""" @overload def intersect(self, plane: Plane) -> Vector | None: - """Find intersection of plane and vector""" + """Find intersection of vector and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of vector and shape""" def intersect(self, *args, **kwargs): + """Find intersection of vector and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -538,6 +561,8 @@ class Vector: if shape is not None: return shape.intersect(self) + return None + VectorLike: TypeAlias = ( Vector | tuple[float, float] | tuple[float, float, float] | Sequence[float] @@ -580,6 +605,7 @@ class Axis(metaclass=AxisMeta): origin (VectorLike): start point direction (VectorLike): direction edge (Edge): origin & direction defined by start of edge + location (Location): location to convert to axis Attributes: position (Vector): the global position of the axis origin @@ -590,86 +616,104 @@ class Axis(metaclass=AxisMeta): _dim = 1 @overload - def __init__(self, gp_ax1: gp_Ax1): # pragma: no cover + def __init__(self, gp_ax1: gp_Ax1): """Axis: point and direction""" @overload - def __init__(self, origin: VectorLike, direction: VectorLike): # pragma: no cover + def __init__(self, location: Location): + """Axis from location""" + + @overload + def __init__(self, origin: VectorLike, direction: VectorLike): """Axis: point and direction""" @overload - def __init__(self, edge: Edge): # pragma: no cover + def __init__(self, edge: Edge): """Axis: start of Edge""" - def __init__(self, *args, **kwargs): + def __init__( + self, *args, **kwargs + ): # pylint: disable=too-many-branches, too-many-locals gp_ax1 = kwargs.pop("gp_ax1", None) origin = kwargs.pop("origin", None) direction = kwargs.pop("direction", None) edge = kwargs.pop("edge", None) + location = kwargs.pop("location", None) # Handle unexpected kwargs if kwargs: raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + # Handle positional arguments if len(args) == 1: - if isinstance(args[0], gp_Ax1): - gp_ax1 = args[0] - elif ( - hasattr(args[0], "wrapped") - and args[0].wrapped is not None - and isinstance(args[0].wrapped, TopoDS_Edge) - ): - edge = args[0] + arg = args[0] + if isinstance(arg, gp_Ax1): + gp_ax1 = arg + elif isinstance(arg, Location): + location = arg + elif hasattr(arg, "wrapped") and isinstance(arg.wrapped, TopoDS_Edge): + edge = arg + elif isinstance(arg, (Vector, tuple)): + origin = arg else: - origin = args[0] + raise ValueError(f"Unrecognized single argument: {arg}") elif len(args) == 2: origin, direction = args + # Handle edge-based construction if edge is not None: - if ( - hasattr(edge, "wrapped") - and edge.wrapped is not None - and isinstance(edge.wrapped, TopoDS_Edge) - ): - # Extract the start point and tangent - topods_edge: TopoDS_Edge = edge.wrapped # type: ignore[annotation-unchecked] - curve = BRep_Tool.Curve_s(topods_edge, float(), float()) - param_min, param_max = BRep_Tool.Range_s(topods_edge) - origin_pnt = gp_Pnt() - tangent_vec = gp_Vec() - curve.D1(param_min, origin_pnt, tangent_vec) - origin = Vector(origin_pnt) - direction = Vector(gp_Dir(tangent_vec)) - else: - raise ValueError(f"Invalid argument {edge}") + if not (hasattr(edge, "wrapped") and isinstance(edge.wrapped, TopoDS_Edge)): + raise ValueError(f"Invalid edge argument: {edge}") - if gp_ax1 is not None: - if not isinstance(gp_ax1, gp_Ax1): - raise ValueError(f"Invalid Axis parameter {gp_ax1}") - self.wrapped: gp_Ax1 = gp_ax1 # type: ignore[annotation-unchecked] - else: + topods_edge: TopoDS_Edge = edge.wrapped # type: ignore[annotation-unchecked] + curve = BRep_Tool.Curve_s(topods_edge, float(), float()) + param_min, _ = BRep_Tool.Range_s(topods_edge) + origin_pnt = gp_Pnt() + tangent_vec = gp_Vec() + curve.D1(param_min, origin_pnt, tangent_vec) + origin = Vector(origin_pnt) + direction = Vector(gp_Dir(tangent_vec)) + + # Convert location to axis + if location is not None: + gp_ax1 = Axis.Z.located(location).wrapped + + # Construct self.wrapped from gp_ax1 or origin/direction + if gp_ax1 is None: try: origin_vector = Vector(origin) direction_vector = Vector(direction) - except TypeError as exc: + gp_ax1 = gp_Ax1( + origin_vector.to_pnt(), + gp_Dir(*tuple(direction_vector.normalized())), + ) + except Exception as exc: raise ValueError("Invalid Axis parameters") from exc + elif not isinstance(gp_ax1, gp_Ax1): + raise ValueError(f"Invalid Axis parameter: {gp_ax1}") - self.wrapped = gp_Ax1( - origin_vector.to_pnt(), - gp_Dir(*tuple(direction_vector.normalized())), - ) + self.wrapped: gp_Ax1 = gp_ax1 # type: ignore[annotation-unchecked] - self.position = Vector( - self.wrapped.Location().X(), - self.wrapped.Location().Y(), - self.wrapped.Location().Z(), - ) #: Axis origin - self.direction = Vector( - self.wrapped.Direction().X(), - self.wrapped.Direction().Y(), - self.wrapped.Direction().Z(), - ) #: Axis direction + @property + def position(self) -> Vector: + """The position or origin of the Axis""" + return Vector(self.wrapped.Location()) + + @position.setter + def position(self, position: VectorLike): + """Set the position or origin of the Axis""" + self.wrapped.SetLocation(Vector(position).to_pnt()) + + @property + def direction(self) -> Vector: + """The normalized direction of the Axis""" + return Vector(self.wrapped.Direction()) + + @direction.setter + def direction(self, direction: VectorLike): + """Set the direction of the Axis""" + self.wrapped.SetDirection(Vector(direction).to_dir()) @property def location(self) -> Location: @@ -684,13 +728,25 @@ class Axis(metaclass=AxisMeta): """Return deepcopy of self""" return Axis(self.position, self.direction) + def __hash__(self) -> int: + """Hash of Axis""" + return hash( + ( + round(v, TOL_DIGITS - 1) + for vector in [self.position, self.direction] + for v in vector + ) + ) + def __repr__(self) -> str: """Display self""" - return f"({self.position.to_tuple()},{self.direction.to_tuple()})" + return f"({tuple(self.position)},{tuple(self.direction)})" def __str__(self) -> str: """Display self""" - return f"{type(self).__name__}: ({self.position.to_tuple()},{self.direction.to_tuple()})" + return ( + f"{type(self).__name__}: ({tuple(self.position)},{tuple(self.direction)})" + ) def __eq__(self, other: object) -> bool: if not isinstance(other, Axis): @@ -708,6 +764,12 @@ class Axis(metaclass=AxisMeta): def to_plane(self) -> Plane: """Return self as Plane""" + warnings.warn( + "to_tuple is deprecated and will be removed in a future version. " + "Use 'Plane(Axis)' instead.", + DeprecationWarning, + stacklevel=2, + ) return Plane(origin=self.position, z_dir=self.direction) def is_coaxial( @@ -788,8 +850,10 @@ class Axis(metaclass=AxisMeta): and never intersect. Mathematically, this means: + - The axes are **not parallel** (the cross product of their direction vectors is nonzero). + - The axes are **not coplanar** (the vector between their positions is not aligned with the plane spanned by their directions). @@ -847,21 +911,26 @@ class Axis(metaclass=AxisMeta): @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and axis""" + """Find intersection of axis and vector""" @overload - def intersect(self, location: Location) -> Location | None: - """Find intersection of location and axis""" + def intersect(self, location: Location) -> Vector | Location | None: + """Find intersection of axis and location""" @overload - def intersect(self, axis: Axis) -> Axis | None: + def intersect(self, axis: Axis) -> Vector | Axis | None: """Find intersection of axis and axis""" @overload - def intersect(self, plane: Plane) -> Axis | None: - """Find intersection of plane and axis""" + def intersect(self, plane: Plane) -> Vector | Axis | None: + """Find intersection of axis and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of axis and shape""" def intersect(self, *args, **kwargs): + """Find intersection of axis and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -880,7 +949,7 @@ class Axis(metaclass=AxisMeta): # Solve the system of equations to find the intersection system_of_equations = np.array([d1, -d2, np.cross(d1, d2)]).T origin_diff = p2 - p1 - t1, t2, _ = np.linalg.lstsq(system_of_equations, origin_diff, rcond=None)[0] + t1, _, _ = np.linalg.lstsq(system_of_equations, origin_diff, rcond=None)[0] # Calculate the intersection point intersection_point = p1 + t1 * d1 @@ -905,16 +974,18 @@ class Axis(metaclass=AxisMeta): # Find the "direction" of the location location_dir = Plane(location).z_dir - # Is the location on the axis with the same direction? - if ( - self.intersect(location.position) is not None - and location_dir == self.direction - ): - return location + if self.intersect(location.position) is not None: + # Is the location on the axis with the same direction? + if location_dir == self.direction: + return location + else: + return location.position if shape is not None: return shape.intersect(self) + return None + class BoundBox: """A BoundingBox for a Shape""" @@ -922,7 +993,7 @@ class BoundBox: def __init__(self, bounding_box: Bnd_Box) -> None: if bounding_box.IsVoid(): - x_min, y_min, z_min, x_max, y_max, z_max = (0,) * 6 + x_min, y_min, z_min, x_max, y_max, z_max = (0.0,) * 6 else: x_min, y_min, z_min, x_max, y_max, z_max = bounding_box.Get() self.wrapped = None if bounding_box.IsVoid() else bounding_box @@ -930,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)""" @@ -981,7 +1062,7 @@ class BoundBox: if isinstance(obj, tuple): tmp.Update(*obj) elif isinstance(obj, Vector): - tmp.Update(*obj.to_tuple()) + tmp.Update(*obj) elif isinstance(obj, BoundBox) and obj.wrapped is not None: tmp.Add(obj.wrapped) @@ -1030,7 +1111,6 @@ class BoundBox: shape: TopoDS_Shape, tolerance: float | None = None, optimal: bool = True, - oriented: bool = False, ) -> BoundBox: """Constructs a bounding box from a TopoDS_Shape @@ -1046,22 +1126,13 @@ class BoundBox: tolerance = TOL if tolerance is None else tolerance # tol = TOL (by default) bbox = Bnd_Box() - bbox_obb = Bnd_OBB() if optimal: - # this is 'exact' but expensive - if oriented: - BRepBndLib.AddOBB_s(shape, bbox_obb, False, True, False) - else: - BRepBndLib.AddOptimal_s(shape, bbox) + BRepBndLib.AddOptimal_s(shape, bbox) else: - # this is adds +margin but is faster - if oriented: - BRepBndLib.AddOBB_s(shape, bbox_obb) - else: - BRepBndLib.Add_s(shape, bbox, True) + BRepBndLib.Add_s(shape, bbox, True) - return cls(bbox_obb) if oriented else cls(bbox) + return cls(bbox) def is_inside(self, second_box: BoundBox) -> bool: """Is the provided bounding box inside this one? @@ -1083,7 +1154,7 @@ class BoundBox: def to_align_offset(self, align: Align2DType | Align3DType) -> Vector: """Amount to move object to achieve the desired alignment""" - return to_align_offset(self.min.to_tuple(), self.max.to_tuple(), align) + return to_align_offset(self.min, self.max, align) class Color: @@ -1095,22 +1166,36 @@ class Color: """ @overload - def __init__(self, q_color: Quantity_ColorRGBA): - """Color from OCCT color object + def __init__(self, color_like: ColorLike): + """Color from ColorLike Args: - name (Quantity_ColorRGBA): q_color + color_like (ColorLike): + 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, + hex + alpha, ex: (0xff0000, 0x80), + Color, + Quantity_ColorRGBA """ @overload def __init__(self, name: str, alpha: float = 1.0): - """Color from name + """Color from name or hexadecimal string + + `CSS3 Color Names + ` `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 """ @overload @@ -1121,89 +1206,113 @@ class Color: red (float): 0.0 <= red <= 1.0 green (float): 0.0 <= green <= 1.0 blue (float): 0.0 <= blue <= 1.0 - alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 0.0. - """ - - @overload - def __init__(self, color_tuple: tuple[float]): - """Color from a 3 or 4 tuple of float values - - Args: - color_tuple (tuple[float]): _description_ + alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0 """ @overload def __init__(self, color_code: int, alpha: int = 0xFF): - """Color from a hexidecimal color code with an optional alpha value + """Color from a hexadecimal color code with an optional alpha value Args: - color_code (hexidecimal int): 0xRRGGBB - alpha (hexidecimal int): 0x00 <= alpha as hex <= 0xFF + color_code (hexadecimal int): 0xRRGGBB + alpha (hexadecimal int): 0x00 <= alpha as hex <= 0xFF """ def __init__(self, *args, **kwargs): - # pylint: disable=too-many-branches - red, green, blue, alpha, color_tuple, name, color_code, q_color = ( - 1.0, - 1.0, - 1.0, - 1.0, - None, - None, - None, - None, - ) - if len(args) == 1 and isinstance(args[0], tuple): - red, green, blue, alpha = args[0] + (1.0,) * (4 - len(args[0])) - elif len(args) == 1 or len(args) == 2: - if isinstance(args[0], Quantity_ColorRGBA): - q_color = args[0] - elif isinstance(args[0], int): - color_code = args[0] - alpha = args[1] if len(args) == 2 else 0xFF - elif isinstance(args[0], str): - name = args[0] - if len(args) == 2: - alpha = args[1] - elif len(args) >= 3: - red, green, blue = args[0:3] - if len(args) == 4: - alpha = args[3] - - color_code = kwargs.get("color_code", color_code) - red = kwargs.get("red", red) - green = kwargs.get("green", green) - blue = kwargs.get("blue", blue) - color_tuple = kwargs.get("color_tuple", color_tuple) - - if color_code is None: - alpha = kwargs.get("alpha", alpha) - else: - alpha = kwargs.get("alpha", alpha) - alpha = alpha / 255 - - if color_code is not None and isinstance(color_code, int): - red, remainder = divmod(color_code, 256**2) - green, blue = divmod(remainder, 256) - red = red / 255 - green = green / 255 - blue = blue / 255 - - if color_tuple is not None: - red, green, blue, alpha = color_tuple + (1.0,) * (4 - len(color_tuple)) - - if q_color is not None: - self.wrapped = q_color - elif name: - self.wrapped = Quantity_ColorRGBA() - exists = Quantity_ColorRGBA.ColorFromName_s(args[0], self.wrapped) - if not exists: - raise ValueError(f"Unknown color name: {name}") - self.wrapped.SetAlpha(alpha) - else: - self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha) - + self.wrapped = None self.iter_index = 0 + red, green, blue, alpha, name, color_code = (1.0, 1.0, 1.0, 1.0, None, None) + default_rgb = (red, green, blue, alpha) + + # Conform inputs to complete color_like tuples + # color_like does not use other kwargs or args, but benefits from conformity + color_like = kwargs.get("color_like", None) + if color_like is not None: + args = (color_like,) + + if args: + args = args[0] if isinstance(args[0], tuple) else args + + # Fills missing defaults from b if a is short + def fill_defaults(a, b): + return tuple(a[i] if i < len(a) else b[i] for i in range(len(b))) + + if args: + if len(args) >= 3: + red, green, blue, alpha = fill_defaults(args, default_rgb) + else: + match args[0]: + case Color(): + self.wrapped = args[0].wrapped + return + case Quantity_ColorRGBA(): + self.wrapped = args[0] + 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(): + red, green, blue, alpha = fill_defaults(args, default_rgb) + case _: + raise TypeError(f"Unsupported color definition: {args}") + + # Replace positional values with kwargs unless from color_like + if color_like is None: + name = kwargs.get("name", name) + color_code = kwargs.get("color_code", color_code) + red = kwargs.get("red", red) + green = kwargs.get("green", green) + blue = kwargs.get("blue", blue) + alpha = kwargs.get("alpha", alpha) + + if name: + color_format = (name, alpha) + elif color_code: + color_format = (color_code, alpha) + else: + color_format = (red, green, blue, alpha) + + # Convert color_format to rgb + match color_format: + case (name, a) if isinstance(name, str) and isinstance(a, (float, int)): + red, green, blue = Color._rgb_from_str(name) + alpha = a + case (hexa, a) if isinstance(hexa, int) and isinstance(a, (float, int)): + red, green, blue = Color._rgb_from_int(hexa) + if a != 1: + # alpha == 1 is special case as default, don't divide + alpha = a / 0xFF + case (red, green, blue, alpha) if all( + isinstance(c, (int, float)) for c in (red, green, blue, alpha) + ): + pass + case _: + raise TypeError(f"Unsupported color definition: {color_format}") + + if not self.wrapped: + self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha) def __iter__(self): """Initialize to beginning""" @@ -1217,16 +1326,10 @@ class Color: if self.iter_index > 3: raise StopIteration - else: - value = rgb_tuple[self.iter_index] - self.iter_index += 1 + value = rgb_tuple[self.iter_index] + self.iter_index += 1 return value - # @deprecated - def to_tuple(self): - """Value as tuple""" - return tuple(self) - def __copy__(self) -> Color: """Return copy of self""" return Color(*tuple(self)) @@ -1237,14 +1340,191 @@ class Color: def __str__(self) -> str: """Generate string""" - quantity_color_enum = self.wrapped.GetRGB().Name() - quantity_color_str = Quantity_Color.StringName_s(quantity_color_enum) - return f"Color: {str(tuple(self))} ~ {quantity_color_str}" + rgb = self.wrapped.GetRGB() + rgb = (rgb.Red(), rgb.Green(), rgb.Blue()) + try: + name = webcolors.rgb_to_name([int(c * 255) for c in rgb]) + qualifier = "is" + except ValueError: + # This still uses OCCT X11 colors instead of css3 + 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}" def __repr__(self) -> str: """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) + green, blue = divmod(remainder, 256) + return red / 255, green / 255, blue / 255 + + @staticmethod + def _rgb_from_str(name: str) -> tuple: + if "#" not in name: + try: + # Use css3 color names by default + triplet = webcolors.name_to_rgb(name) + except ValueError as exc: + # Fall back to OCCT/X11 color names + color = Quantity_Color() + exists = Quantity_Color.ColorFromName_s(name, color) + if not exists: + raise ValueError( + f"{name!r} is not defined as a named color in CSS3 or OCCT/X11" + ) from exc + return (color.Red(), color.Green(), color.Blue()) + else: + triplet = webcolors.hex_to_rgb(name) + return tuple(i / 255 for i in tuple(triplet)) + + +ColorLike: TypeAlias = ( + str # name, ex: "red" + | tuple[str, float | int] # name + alpha, ex: ("red", 0.5) + | tuple[float | int, float | int, float | int] # rgb, ex: (1, 0, 0) + | tuple[ + float | int, float | int, float | int, float | int + ] # rgb + alpha, ex: (1, 0, 0, 0.5) + | int # hex, ex: 0xff0000 + | tuple[int, int] # hex + alpha, ex: (0xff0000, 0x80) + | Color + | Quantity_ColorRGBA # OCP color +) + + +class GeomEncoder(json.JSONEncoder): + """ + A JSON encoder for build123d geometry objects. + + This class extends ``json.JSONEncoder`` to provide custom serialization for + geometry objects such as Axis, Color, Location, Plane, and Vector. It converts + each geometry object into a dictionary containing exactly one key that identifies + the geometry type (e.g. ``"Axis"``, ``"Vector"``, etc.), paired with a tuple or + list that represents the underlying data. Any other object types are handled by + the standard encoder. + + The inverse decoding is performed by the ``geometry_hook`` static method, which + expects the dictionary to have precisely one key from the known geometry types. + It then uses a class registry (``CLASS_REGISTRY``) to look up and instantiate + the appropriate class with the provided values. + + **Usage Example**:: + + import json + + # Suppose we have some geometry objects: + axis = Axis(position=(0, 0, 0), direction=(1, 0, 0)) + vector = Vector(0.0, 1.0, 2.0) + + data = { + "my_axis": axis, + "my_vector": vector + } + + # Encode them to JSON: + encoded_data = json.dumps(data, cls=GeomEncoder, indent=4) + + # Decode them back: + decoded_data = json.loads(encoded_data, object_hook=GeomEncoder.geometry_hook) + + """ + + def default(self, o): + """Return a JSON-serializable representation of a known geometry object.""" + if isinstance(o, Axis): + return {"Axis": (tuple(o.position), tuple(o.direction))} + if isinstance(o, Color): + return {"Color": tuple(o)} + if isinstance(o, Location): + tup = tuple(o) + return {"Location": (tuple(tup[0]), tuple(tup[1]))} + if isinstance(o, Plane): + return {"Plane": (tuple(o.origin), tuple(o.x_dir), tuple(o.z_dir))} + if isinstance(o, Vector): + return {"Vector": tuple(o)} + # Let the base class default method raise the TypeError + return super().default(o) + + @staticmethod + def geometry_hook(json_dict): + """Convert dictionaries back into geometry objects for decoding.""" + if len(json_dict.items()) != 1: + raise ValueError(f"Invalid geometry json object {json_dict}") + for key, value in json_dict.items(): + return CLASS_REGISTRY[key](*value) + class Location: """Location in 3D space. Depending on usage can be absolute or relative. @@ -1286,22 +1566,20 @@ class Location: } @overload - def __init__(self): # pragma: no cover + def __init__(self): """Empty location with not rotation or translation with respect to the original location.""" @overload - def __init__(self, location: Location): # pragma: no cover + def __init__(self, location: Location): """Location with another given location.""" @overload - def __init__(self, translation: VectorLike, angle: float = 0): # pragma: no cover + 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""" @overload - def __init__( - self, translation: VectorLike, rotation: RotationLike | None = None - ): # pragma: no cover + 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) """ @@ -1312,122 +1590,123 @@ class Location: translation: VectorLike, rotation: RotationLike, ordering: Extrinsic | Intrinsic, - ): # pragma: no cover + ): """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 """ @overload - def __init__(self, plane: Plane): # pragma: no cover + def __init__(self, plane: Plane): """Location corresponding to the location of the Plane.""" @overload - def __init__(self, plane: Plane, plane_offset: VectorLike): # pragma: no cover + def __init__(self, plane: Plane, plane_offset: VectorLike): """Location corresponding to the angular location of the Plane with translation plane_offset.""" @overload - def __init__(self, top_loc: TopLoc_Location): # pragma: no cover + def __init__(self, top_loc: TopLoc_Location): """Location wrapping the low-level TopLoc_Location object t""" @overload - def __init__(self, gp_trsf: gp_Trsf): # pragma: no cover + def __init__(self, gp_trsf: gp_Trsf): """Location wrapping the low-level gp_Trsf object t""" @overload - def __init__( - self, translation: VectorLike, direction: VectorLike, angle: float - ): # pragma: no cover + 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, *args): - # pylint: disable=too-many-branches - transform = gp_Trsf() + def __init__( + self, *args, **kwargs + ): # pylint: disable=too-many-branches, too-many-locals, too-many-statements - if len(args) == 0: - pass + self.location_index = 0 - elif len(args) == 1: - translation = args[0] + position = kwargs.pop("position", None) + orientation = kwargs.pop("orientation", None) + ordering = kwargs.pop("ordering", None) + angle = kwargs.pop("angle", None) + plane = kwargs.pop("plane", None) + location = kwargs.pop("location", None) + top_loc = kwargs.pop("top_loc", None) + gp_trsf = kwargs.pop("gp_trsf", None) - if isinstance(translation, (Vector, Iterable)): - transform.SetTranslationPart(Vector(translation).wrapped) - elif isinstance(translation, Plane): - coordinate_system = gp_Ax3( - translation._origin.to_pnt(), - translation.z_dir.to_dir(), - translation.x_dir.to_dir(), - ) - transform.SetTransformation(coordinate_system) - transform.Invert() - elif isinstance(args[0], Location): - self.wrapped = translation.wrapped - return - elif isinstance(translation, TopLoc_Location): - self.wrapped = translation - return - elif isinstance(translation, gp_Trsf): - transform = translation + # If any unexpected kwargs remain + if kwargs: + raise TypeError(f"Unexpected keyword arguments: {', '.join(kwargs)}") + + # Fill from positional args if not given via kwargs + if args: + if plane is None and isinstance(args[0], Plane): + plane = args[0] + elif location is None and isinstance(args[0], (Location, Rotation)): + location = args[0] + elif top_loc is None and isinstance(args[0], TopLoc_Location): + top_loc = args[0] + elif gp_trsf is None and isinstance(args[0], gp_Trsf): + gp_trsf = args[0] + elif isinstance(args[0], (Vector, Iterable)): + position = Vector(args[0]) + if len(args) > 1: + if isinstance(args[1], (Vector, Iterable)): + orientation = Vector(args[1]) + elif isinstance(args[1], (int, float)): + angle = args[1] + if len(args) > 2: + if isinstance(args[2], (int, float)) and orientation is not None: + angle = args[2] + elif isinstance(args[2], (Intrinsic, Extrinsic)): + ordering = args[2] + else: + raise TypeError( + f"Third parameter must be a float or order not {args[2]}" + ) else: - raise TypeError("Unexpected parameters") + raise TypeError(f"Invalid positional arguments: {args}") - elif len(args) == 2: - ordering = Intrinsic.XYZ - if isinstance(args[0], (Vector, Iterable)): - if isinstance(args[1], (Vector, Iterable)): - rotation = [radians(a) for a in args[1]] - quaternion = gp_Quaternion() - quaternion.SetEulerAngles(self._rot_order_dict[ordering], *rotation) - transform.SetRotation(quaternion) - elif isinstance(args[0], (Vector, tuple)) and isinstance( - args[1], (int, float) - ): - angle = radians(args[1]) - quaternion = gp_Quaternion() - quaternion.SetEulerAngles( - self._rot_order_dict[ordering], 0, 0, angle - ) - transform.SetRotation(quaternion) + # Construct transformation + trsf = gp_Trsf() - # set translation part after setting rotation (if exists) - transform.SetTranslationPart(Vector(args[0]).wrapped) - else: - translation, origin = args - coordinate_system = gp_Ax3( - Vector(origin).to_pnt(), - translation.z_dir.to_dir(), - translation.x_dir.to_dir(), - ) - transform.SetTransformation(coordinate_system) - transform.Invert() - elif len(args) == 3: - if ( - isinstance(args[0], (Vector, Iterable)) - and isinstance(args[1], (Vector, Iterable)) - and isinstance(args[2], (int, float)) - ): - translation, axis, angle = args - transform.SetRotation( - gp_Ax1(Vector().to_pnt(), Vector(axis).to_dir()), angle * pi / 180.0 - ) - elif ( - isinstance(args[0], (Vector, Iterable)) - and isinstance(args[1], (Vector, Iterable)) - and isinstance(args[2], (Extrinsic, Intrinsic)) - ): - translation = args[0] - rotation = [radians(a) for a in args[1]] - ordering = args[2] - quaternion = gp_Quaternion() - quaternion.SetEulerAngles(self._rot_order_dict[ordering], *rotation) - transform.SetRotation(quaternion) - else: - raise TypeError("Unsupported argument types for Location") + if plane: + cs = gp_Ax3( + plane.origin.to_pnt(), + plane.z_dir.to_dir(), + plane.x_dir.to_dir(), + ) + trsf.SetTransformation(cs) + trsf.Invert() - transform.SetTranslationPart(Vector(translation).wrapped) - self.wrapped = TopLoc_Location(transform) + elif gp_trsf: + trsf = gp_trsf + + 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), + ) + trsf.SetRotation(axis, radians(angle)) + + elif orientation is not None: + angles = [radians(a) for a in orientation] + rot_order = self._rot_order_dict.get( + ordering, gp_EulerSequence.gp_Intrinsic_XYZ + ) + quat = gp_Quaternion() + quat.SetEulerAngles(rot_order, *angles) + trsf.SetRotation(quat) + + if position: + trsf.SetTranslationPart(Vector(position).wrapped) + + # Final assignment based on input + if location is not None: + self.wrapped = location.wrapped + elif top_loc is not None: + self.wrapped = top_loc + else: + self.wrapped = TopLoc_Location(trsf) @property def position(self) -> Vector: @@ -1437,7 +1716,7 @@ class Location: Vector: Position part of Location """ - return Vector(self.to_tuple()[0]) + return Vector(tuple(self)[0]) @position.setter def position(self, value: VectorLike): @@ -1462,7 +1741,7 @@ class Location: Vector: orientation part of Location """ - return Vector(self.to_tuple()[1]) + return Vector(tuple(self)[1]) @orientation.setter def orientation(self, rotation: VectorLike): @@ -1535,7 +1814,9 @@ class Location: # other is a Shape if hasattr(other, "wrapped") and isinstance(other.wrapped, TopoDS_Shape): # result = other.moved(self) - downcast_LUT = { + downcast_lut: dict[ + TopAbs_ShapeEnum, Callable[[TopoDS_Shape], TopoDS_Shape] + ] = { TopAbs_ShapeEnum.TopAbs_VERTEX: TopoDS.Vertex_s, TopAbs_ShapeEnum.TopAbs_EDGE: TopoDS.Edge_s, TopAbs_ShapeEnum.TopAbs_WIRE: TopoDS.Wire_s, @@ -1546,7 +1827,7 @@ class Location: } assert other.wrapped is not None try: - f_downcast = downcast_LUT[other.wrapped.ShapeType()] + f_downcast = downcast_lut[other.wrapped.ShapeType()] except KeyError as exc: raise ValueError(f"Unknown object type {other}") from exc @@ -1592,7 +1873,49 @@ class Location: radians(other.orientation.Y), radians(other.orientation.Z), ) - return self.position == other.position and quaternion1.IsEqual(quaternion2) + # Test quaternions with tolerance + q_values = [ + [get_value() for get_value in (q.X, q.Y, q.Z, q.W)] + for q in (quaternion1, quaternion2) + ] + quaternion_eq = all( + isclose(v1, v2, abs_tol=TOLERANCE) for v1, v2 in zip(*q_values) + ) + return self.position == other.position and quaternion_eq + + def __hash__(self) -> int: + """Hash of Location""" + return hash( + ( + round(v, TOL_DIGITS - 1) + for vector in [self.position, self.orientation] + for v in vector + ) + ) + + def __iter__(self): + """Initialize to beginning""" + self.location_index = 0 + return self + + def __next__(self) -> Vector: + """return the next value""" + transformation = self.wrapped.Transformation() + trans = transformation.TranslationPart() + rot = transformation.GetRotation() + rv_trans: Vector = Vector(trans.X(), trans.Y(), trans.Z()) + rv_rot: Vector = Vector( + *[degrees(a) for a in rot.GetEulerAngles(gp_EulerSequence.gp_Intrinsic_XYZ)] + ) # type: ignore[assignment] + if self.location_index == 0: + self.location_index += 1 + value = rv_trans + elif self.location_index == 1: + self.location_index += 1 + value = rv_rot + else: + raise StopIteration + return value def __neg__(self) -> Location: """Flip the orientation without changing the position operator -""" @@ -1604,13 +1927,70 @@ class Location: """intersect axis with other &""" return self.intersect(other) + def center(self) -> Vector: + """Return center of the location - useful for sorting""" + return self.position + + def mirror(self, mirror_plane: Plane) -> Location: + """ + Return a new Location mirrored across the given plane. + + This method reflects both the position and orientation of the current Location + across the specified mirror_plane using affine vector mathematics. + + Due to the mathematical properties of reflection: + - The true mirror of a right-handed coordinate system is a *left-handed* one. + + However, `build123d` requires all coordinate systems to be right-handed. + Therefore, this implementation: + - Reflects the X and Z directions across the mirror plane + - Recomputes the Y direction as: `Y = X × Z` + + This ensures the resulting Location maintains a valid right-handed frame, + while remaining as close as possible to the geometric mirror. + + Args: + mirror_plane (Plane): The plane to mirror across. + + Returns: + Location: A new mirrored Location that preserves right-handedness. + """ + + def mirror_dir(v: Vector, pln: Plane) -> Vector: + return v - 2 * (v.dot(pln.z_dir)) * pln.z_dir + + # Mirror the location position + to_plane = self.position - mirror_plane.origin + distance = to_plane.dot(mirror_plane.z_dir) + pos = self.position - 2 * distance * mirror_plane.z_dir + + # Mirror the orientation + loc_plane = Plane(self) + mx_dir = mirror_dir(loc_plane.x_dir, mirror_plane) + mz_dir = mirror_dir(loc_plane.z_dir, mirror_plane) + + return Location(Plane(origin=pos, x_dir=mx_dir, z_dir=mz_dir)) + def to_axis(self) -> Axis: """Convert the location into an Axis""" + warnings.warn( + "to_axis is deprecated and will be removed in a future version. " + "Use 'Axis(Location)' instead.", + DeprecationWarning, + stacklevel=2, + ) return Axis.Z.located(self) def to_tuple(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Convert the location to a translation, rotation tuple.""" + warnings.warn( + "to_tuple is deprecated and will be removed in a future version. " + "Use 'tuple(Location)' instead.", + DeprecationWarning, + stacklevel=2, + ) + transformation = self.wrapped.Transformation() trans = transformation.TranslationPart() rot = transformation.GetRotation() @@ -1630,8 +2010,8 @@ class Location: Returns: Location as String """ - position_str = ", ".join(f"{v:.2f}" for v in self.to_tuple()[0]) - orientation_str = ", ".join(f"{v:.2f}" for v in self.to_tuple()[1]) + 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): @@ -1642,27 +2022,32 @@ class Location: Returns: Location as String """ - position_str = ", ".join(f"{v:.2f}" for v in self.to_tuple()[0]) - orientation_str = ", ".join(f"{v:.2f}" for v in self.to_tuple()[1]) + 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}))" @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and location""" + """Find intersection of location and vector""" @overload - def intersect(self, location: Location) -> Location | None: + def intersect(self, location: Location) -> Vector | Location | None: """Find intersection of location and location""" @overload - def intersect(self, axis: Axis) -> Location | None: - """Find intersection of axis and location""" + def intersect(self, axis: Axis) -> Vector | Location | None: + """Find intersection of location and axis""" @overload - def intersect(self, plane: Plane) -> Location | None: - """Find intersection of plane and location""" + def intersect(self, plane: Plane) -> Vector | Location | None: + """Find intersection of location and plane""" + + @overload + def intersect(self, shape: Shape) -> Shape | None: + """Find intersection of location and shape""" def intersect(self, *args, **kwargs): + """Find intersection of location and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) if axis is not None: @@ -1674,12 +2059,17 @@ class Location: if vector is not None and self.position == vector: return vector - if location is not None and self == location: - return self + if location is not None: + if self == location: + return self + elif self.position == location.position: + return self.position if shape is not None: return shape.intersect(self) + return None + class LocationEncoder(json.JSONEncoder): """Custom JSON Encoder for Location values @@ -1708,6 +2098,7 @@ class LocationEncoder(json.JSONEncoder): def default(self, o: Location) -> dict: """Return a serializable object""" + warnings.warn("Use GeomEncoder instead", DeprecationWarning, stacklevel=2) if not isinstance(o, Location): raise TypeError("Only applies to Location objects") return {"Location": o.to_tuple()} @@ -1719,11 +2110,221 @@ class LocationEncoder(json.JSONEncoder): Example: read_json = json.load(infile, object_hook=LocationEncoder.location_hook) """ + warnings.warn("Use GeomEncoder instead", DeprecationWarning, stacklevel=2) if "Location" in obj: obj = Location(*[[float(f) for f in v] for v in obj["Location"]]) return obj +class OrientedBoundBox: + """ + An Oriented Bounding Box + + This class computes the oriented bounding box for a given build123d shape. + It exposes properties such as the center, principal axis directions, the + extents along these axes, and the full diagonal length of the box. + + Note: The axes of the oriented bounding box are arbitrary and may not be + consistent across platforms or time. + """ + + def __init__(self, shape: Bnd_OBB | Shape): + """ + Create an oriented bounding box from either a precomputed Bnd_OBB or + a build123d Shape (which wraps a TopoDS_Shape). + + Args: + shape (Bnd_OBB | Shape): Either a precomputed Bnd_OBB or a build123d shape + from which to compute the oriented bounding box. + """ + if isinstance(shape, Bnd_OBB): + obb = shape + elif hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Shape): + obb = Bnd_OBB() + # Compute the oriented bounding box for the shape. + BRepBndLib.AddOBB_s(shape.wrapped, obb, True) + else: + raise TypeError(f"Expected Bnd_OBB or Shape, got {type(shape).__name__}") + self.wrapped = obb + + @property + def corners(self) -> list[Vector]: + """ + Compute and return the unique corner points of the oriented bounding box + in the coordinate system defined by the OBB's plane. + + For degenerate shapes (e.g. a line or a planar face), only the unique + points are returned. For 2D shapes the corners are returned in an order + that allows a polygon to be directly created from them. + + Returns: + list[Vector]: The unique corner points. + """ + + # Build a dictionary keyed by a tuple indicating if each axis is degenerate. + orders = { + # Straight line cases + (True, True, False): [(1, 1, 1), (1, 1, -1)], + (True, False, True): [(1, 1, 1), (1, -1, 1)], + (False, True, True): [(1, 1, 1), (-1, 1, 1)], + # Planar face cases + (True, False, False): [(1, 1, 1), (1, 1, -1), (1, -1, -1), (1, -1, 1)], + (False, True, False): [(1, 1, 1), (1, 1, -1), (-1, 1, -1), (-1, 1, 1)], + (False, False, True): [(1, 1, 1), (1, -1, 1), (-1, -1, 1), (-1, 1, 1)], + # 3D object case + (False, False, False): list(itertools.product((-1, 1), (-1, 1), (-1, 1))), + } + hs = self.size * 0.5 + order = orders[(hs.X < TOLERANCE, hs.Y < TOLERANCE, hs.Z < TOLERANCE)] + local_corners = [ + Vector(sx * hs.X, sy * hs.Y, sz * hs.Z) for sx, sy, sz in order + ] + corners = [self.plane.from_local_coords(c) for c in local_corners] + + return corners + + @property + def diagonal(self) -> float: + """ + The full length of the body diagonal of the oriented bounding box, + which represents the maximum size of the object. + + Returns: + float: The diagonal length. + """ + if self.wrapped is None: + return 0.0 + return self.wrapped.SquareExtent() ** 0.5 + + @property + def location(self) -> Location: + """ + The Location of the center of the oriented bounding box. + + Returns: + Location: center location + """ + return Location(self.plane) + + @property + def plane(self) -> Plane: + """ + The oriented coordinate system of the bounding box. + + Returns: + Plane: The coordinate system defined by the center and primary + (X) and tertiary (Z) directions of the bounding box. + """ + return Plane( + origin=self.center(), x_dir=self.x_direction, z_dir=self.z_direction + ) + + @property + def size(self) -> Vector: + """ + The full extents of the bounding box along its primary axes. + + Returns: + Vector: The oriented size (full dimensions) of the box. + """ + return ( + Vector(self.wrapped.XHSize(), self.wrapped.YHSize(), self.wrapped.ZHSize()) + * 2.0 + ) + + @property + def x_direction(self) -> Vector: + """ + The primary (X) direction of the oriented bounding box. + + Returns: + Vector: The X direction as a unit vector. + """ + x_direction_xyz = self.wrapped.XDirection() + coords = [getattr(x_direction_xyz, attr)() for attr in ("X", "Y", "Z")] + return Vector(*coords) + + @property + def y_direction(self) -> Vector: + """ + The secondary (Y) direction of the oriented bounding box. + + Returns: + Vector: The Y direction as a unit vector. + """ + y_direction_xyz = self.wrapped.YDirection() + coords = [getattr(y_direction_xyz, attr)() for attr in ("X", "Y", "Z")] + return Vector(*coords) + + @property + def z_direction(self) -> Vector: + """ + The tertiary (Z) direction of the oriented bounding box. + + Returns: + Vector: The Z direction as a unit vector. + """ + z_direction_xyz = self.wrapped.ZDirection() + coords = [getattr(z_direction_xyz, attr)() for attr in ("X", "Y", "Z")] + return Vector(*coords) + + def center(self) -> Vector: + """ + Compute and return the center point of the oriented bounding box. + + Returns: + Vector: The center point of the box. + """ + center_xyz = self.wrapped.Center() + coords = [getattr(center_xyz, attr)() for attr in ("X", "Y", "Z")] + return Vector(*coords) + + def is_completely_inside(self, other: OrientedBoundBox) -> bool: + """ + Determine whether the given oriented bounding box is entirely contained + within this bounding box. + + This method checks that every point of 'other' lies strictly within the + boundaries of this box, according to the tolerance criteria inherent to the + underlying OCCT implementation. + + Args: + other (OrientedBoundBox): The bounding box to test for containment. + + Raises: + ValueError: If the 'other' bounding box has an uninitialized (null) underlying geometry. + + Returns: + bool: True if 'other' is completely inside this bounding box; otherwise, False. + """ + if other.wrapped is None: + raise ValueError("Can't compare to a null obb") + return self.wrapped.IsCompletelyInside(other.wrapped) + + def is_outside(self, point: Vector) -> bool: + """ + Determine whether a given point lies entirely outside this oriented bounding box. + + A point is considered outside if it is neither inside the box nor on its surface, + based on the criteria defined by the OCCT implementation. + + Args: + point (Vector): The point to test. + + Raises: + ValueError: If the point's underlying geometry is not set (null). + + Returns: + bool: True if the point is completely outside the bounding box; otherwise, False. + """ + if point.wrapped is None: + raise ValueError("Can't compare to a null point") + return self.wrapped.IsOut(point.to_pnt()) + + def __repr__(self) -> str: + return f"OrientedBoundBox(center={self.center()}, size={self.size}, plane={self.plane})" + + class Rotation(Location): """Subclass of Location used only for object rotation @@ -1767,7 +2368,7 @@ class Rotation(Location): if tuples: angles = list(*tuples) if vectors: - angles = vectors[0].to_tuple() + angles = tuple(vectors[0]) if len(angles) < 3: angles.extend([0.0] * (3 - len(angles))) rotations = list(filter(lambda item: isinstance(item, Rotation), args)) @@ -2133,85 +2734,89 @@ class Plane(metaclass=PlaneMeta): return Vector(normal) @overload - def __init__(self, gp_pln: gp_Pln): # pragma: no cover + def __init__(self, gp_pln: gp_Pln): """Return a plane from a OCCT gp_pln""" - @overload - def __init__(self, face: Face, x_dir: VectorLike | None = None): # pragma: no cover - """Return a plane extending the face. - Note: for non planar face this will return the underlying work plane""" - - @overload - def __init__(self, location: Location): # pragma: no cover - """Return a plane aligned with a given location""" - @overload def __init__( self, origin: VectorLike, x_dir: VectorLike | None = None, z_dir: VectorLike = (0, 0, 1), - ): # pragma: no cover + ): """Return a new plane at origin with x_dir and z_dir""" + @overload + def __init__(self, face: Face, x_dir: VectorLike | None = None): + """Return a plane extending the face. + Note: for non planar face this will return the underlying work plane""" + + @overload + def __init__(self, location: Location): + """Return a plane aligned with a given location""" + + @overload + def __init__(self, axis: Axis, x_dir: VectorLike | None = None): + """Return a plane with the z_dir aligned with the axis and optional x_dir direction""" + def __init__(self, *args, **kwargs): # pylint: disable=too-many-locals,too-many-branches,too-many-statements - """Create a plane from either an OCCT gp_pln or coordinates""" + """Create a plane from either an OCCT gp_pln, Face, Location, or coordinates""" - def optarg(kwargs, name, args, index, default): - if name in kwargs: - return kwargs[name] - if len(args) > index: - return args[index] - return default - - arg_plane = None - arg_face = None - arg_location = None - arg_origin = None - arg_x_dir = None - arg_z_dir = (0, 0, 1) - - arg0 = args[0] if args else None type_error_message = "Expected gp_Pln, Face, Location, or VectorLike" - if "gp_pln" in kwargs: - arg_plane = kwargs["gp_pln"] - elif isinstance(arg0, gp_Pln): - arg_plane = arg0 - elif "face" in kwargs: - arg_face = kwargs["face"] - arg_x_dir = kwargs.get("x_dir", None) - # Check for Face by using the OCCT class to avoid circular imports of the Face class - elif hasattr(arg0, "wrapped") and isinstance(arg0.wrapped, TopoDS_Face): - arg_face = arg0 - arg_x_dir = optarg(kwargs, "x_dir", args, 1, arg_x_dir) - elif "location" in kwargs: - arg_location = kwargs["location"] - elif isinstance(arg0, Location): - arg_location = arg0 - elif "origin" in kwargs: - arg_origin = kwargs["origin"] - arg_x_dir = kwargs.get("x_dir", arg_x_dir) - arg_z_dir = kwargs.get("z_dir", arg_z_dir) - else: - try: - arg_origin = Vector(arg0) - except TypeError as exc: - raise TypeError(type_error_message) from exc - arg_x_dir = optarg(kwargs, "x_dir", args, 1, arg_x_dir) - arg_z_dir = optarg(kwargs, "z_dir", args, 2, arg_z_dir) + arg_plane = kwargs.pop("gp_pln", None) + arg_face = kwargs.pop("face", None) + arg_location = kwargs.pop("location", None) + arg_axis = kwargs.pop("axis", None) + arg_origin = kwargs.pop("origin", None) + arg_x_dir = kwargs.pop("x_dir", None) + arg_z_dir = kwargs.pop("z_dir", (0, 0, 1)) + + if kwargs: + raise TypeError(f"Unexpected keyword arguments: {', '.join(kwargs)}") + + if args: + arg0 = args[0] + if arg_plane is None and isinstance(arg0, gp_Pln): + arg_plane = arg0 + elif ( + arg_face is None + and hasattr(arg0, "wrapped") + and isinstance(arg0.wrapped, TopoDS_Face) + ): + arg_face = arg0 + if arg_x_dir is None and len(args) > 1: + arg_x_dir = args[1] + elif arg_location is None and isinstance(arg0, Location): + arg_location = arg0 + elif arg_axis is None and isinstance(arg0, Axis): + arg_axis = arg0 + if len(args) > 1: + try: + arg_x_dir = Vector(args[1]) + except Exception as exc: + raise TypeError(type_error_message) from exc + elif arg_origin is None: + try: + arg_origin = Vector(arg0) + if arg_x_dir is None and len(args) > 1: + arg_x_dir = Vector(args[1]).normalized() + if len(args) > 2: + arg_z_dir = Vector(args[2]).normalized() + except Exception as exc: + raise TypeError(type_error_message) from exc if arg_plane: self.wrapped = arg_plane elif arg_face: - # Determine if face is planar surface = BRep_Tool.Surface_s(arg_face.wrapped) if not arg_face.is_planar: raise ValueError("Planes can only be created from planar faces") properties = GProp_GProps() BRepGProp.SurfaceProperties_s(arg_face.wrapped, properties) self._origin = Vector(properties.CentreOfMass()) + if isinstance(surface, Geom_BoundedSurface): point = gp_Pnt() face_x_dir = gp_Vec() @@ -2219,6 +2824,7 @@ class Plane(metaclass=PlaneMeta): surface.D1(0.5, 0.5, point, face_x_dir, tangent_v) else: face_x_dir = surface.Position().XDirection() + self.x_dir = Vector(arg_x_dir) if arg_x_dir else Vector(face_x_dir) self.x_dir = Vector(round(i, 14) for i in self.x_dir) self.z_dir = Plane.get_topods_face_normal(arg_face.wrapped) @@ -2233,10 +2839,16 @@ class Plane(metaclass=PlaneMeta): self.x_dir = Vector(round(i, 14) for i in self.x_dir) self.z_dir = Plane.get_topods_face_normal(topo_face) self.z_dir = Vector(round(i, 14) for i in self.z_dir) - elif arg_origin: + elif arg_axis: + self._origin = arg_axis.position + self.x_dir = Vector(arg_x_dir) if arg_x_dir is not None else None + self.z_dir = arg_axis.direction + elif arg_origin is not None: self._origin = Vector(arg_origin) self.x_dir = Vector(arg_x_dir) if arg_x_dir else None self.z_dir = Vector(arg_z_dir) + else: + raise TypeError(type_error_message) if hasattr(self, "wrapped"): self._origin = Vector(self.wrapped.Location()) @@ -2248,17 +2860,19 @@ class Plane(metaclass=PlaneMeta): raise ValueError("z_dir must be non null") self.z_dir = self.z_dir.normalized() - if not self.x_dir: + if self.x_dir is None: ax3 = gp_Ax3(self._origin.to_pnt(), self.z_dir.to_dir()) self.x_dir = Vector(ax3.XDirection()).normalized() else: if Vector(self.x_dir).length == 0.0: raise ValueError("x_dir must be non null") self.x_dir = Vector(self.x_dir).normalized() + self.y_dir = self.z_dir.cross(self.x_dir).normalized() self.wrapped = gp_Pln( gp_Ax3(self._origin.to_pnt(), self.z_dir.to_dir(), self.x_dir.to_dir()) ) + self.local_coord_system = None #: gp_Ax3 | None self.reverse_transform = None #: Matrix | None self.forward_transform = None #: Matrix | None @@ -2284,8 +2898,8 @@ class Plane(metaclass=PlaneMeta): return NotImplemented # equality tolerances - eq_tolerance_origin = 1e-6 - eq_tolerance_dot = 1e-6 + eq_tolerance_origin = TOLERANCE + eq_tolerance_dot = TOLERANCE return ( # origins are the same @@ -2296,6 +2910,16 @@ class Plane(metaclass=PlaneMeta): and abs(self.x_dir.dot(other.x_dir) - 1) < eq_tolerance_dot ) + def __hash__(self) -> int: + """Hash of Plane""" + return hash( + ( + round(v, TOL_DIGITS - 1) + for vector in [self.origin, self.x_dir, self.z_dir] + for v in vector + ) + ) + def __neg__(self) -> Plane: """Reverse z direction of plane operator -""" return Plane(self.origin, self.x_dir, -self.z_dir) @@ -2329,9 +2953,9 @@ class Plane(metaclass=PlaneMeta): Returns: Plane as String """ - origin_str = ", ".join(f"{v:.2f}" for v in self._origin.to_tuple()) - x_dir_str = ", ".join(f"{v:.2f}" for v in self.x_dir.to_tuple()) - z_dir_str = ", ".join(f"{v:.2f}" for v in self.z_dir.to_tuple()) + 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 reverse(self) -> Plane: @@ -2398,19 +3022,17 @@ class Plane(metaclass=PlaneMeta): ) -> Plane: """Returns a copy of this plane, rotated about the specified axes - Since the z axis is always normal the plane, rotating around Z will - always produce a plane that is parallel to this one. - The origin of the workplane is unaffected by the rotation. Rotations are done in order x, y, z. If you need a different order, - manually chain together multiple rotate() commands. + specify ordering. e.g. Intrinsic.ZYX changes rotation to + (z angle, y angle, x angle) and rotates in that order. Args: - rotation (VectorLike, optional): (xDegrees, yDegrees, zDegrees). - Defaults to (0, 0, 0). + rotation (VectorLike, optional): (x angle, y angle, z angle). + Defaults to (0, 0, 0) ordering (Intrinsic | Extrinsic, optional): order of rotations in - Intrinsic or Extrinsic rotation mode, defaults to Intrinsic.XYZ + Intrinsic or Extrinsic rotation mode. Defaults to Intrinsic.XYZ Returns: Plane: a copy of this plane rotated as requested. @@ -2460,9 +3082,9 @@ class Plane(metaclass=PlaneMeta): global_coord_system = gp_Ax3() local_coord_system = gp_Ax3( - gp_Pnt(*self._origin.to_tuple()), - gp_Dir(*self.z_dir.to_tuple()), - gp_Dir(*self.x_dir.to_tuple()), + gp_Pnt(*self._origin), + gp_Dir(*self.z_dir), + gp_Dir(*self.x_dir), ) forward_t.SetTransformation(global_coord_system, local_coord_system) @@ -2516,15 +3138,17 @@ class Plane(metaclass=PlaneMeta): local_bottom_left = global_bottom_left.transform(transform_matrix) local_top_right = global_top_right.transform(transform_matrix) local_bbox = Bnd_Box( - gp_Pnt(*local_bottom_left.to_tuple()), - gp_Pnt(*local_top_right.to_tuple()), + gp_Pnt(*local_bottom_left), + gp_Pnt(*local_top_right), ) return BoundBox(local_bbox) if hasattr(obj, "wrapped") and obj.wrapped is None: # Empty shape raise ValueError("Cant's reposition empty object") if hasattr(obj, "wrapped") and isinstance(obj.wrapped, TopoDS_Shape): # Shapes # return_value = obj.transform_shape(transform_matrix) - downcast_LUT = { + downcast_lut: dict[ + TopAbs_ShapeEnum, Callable[[TopoDS_Shape], TopoDS_Shape] + ] = { TopAbs_ShapeEnum.TopAbs_VERTEX: TopoDS.Vertex_s, TopAbs_ShapeEnum.TopAbs_EDGE: TopoDS.Edge_s, TopAbs_ShapeEnum.TopAbs_WIRE: TopoDS.Wire_s, @@ -2535,7 +3159,7 @@ class Plane(metaclass=PlaneMeta): } assert obj.wrapped is not None try: - f_downcast = downcast_LUT[obj.wrapped.ShapeType()] + f_downcast = downcast_lut[obj.wrapped.ShapeType()] except KeyError as exc: raise ValueError(f"Unknown object type {obj}") from exc @@ -2610,18 +3234,18 @@ class Plane(metaclass=PlaneMeta): @overload def intersect(self, vector: VectorLike) -> Vector | None: - """Find intersection of vector and plane""" + """Find intersection of plane and vector""" @overload - def intersect(self, location: Location) -> Location | None: - """Find intersection of location and plane""" + def intersect(self, location: Location) -> Vector | Location | None: + """Find intersection of plane and location""" @overload - def intersect(self, axis: Axis) -> Axis | Vector | None: - """Find intersection of axis and plane""" + def intersect(self, axis: Axis) -> Vector | Axis | None: + """Find intersection of plane and axis""" @overload - def intersect(self, plane: Plane) -> Axis | None: + def intersect(self, plane: Plane) -> Axis | Plane | None: """Find intersection of plane and plane""" @overload @@ -2629,6 +3253,7 @@ class Plane(metaclass=PlaneMeta): """Find intersection of plane and shape""" def intersect(self, *args, **kwargs): + """Find intersection of plane and geometric object or shape""" axis, plane, vector, location, shape = _parse_intersect_args(*args, **kwargs) @@ -2653,6 +3278,9 @@ class Plane(metaclass=PlaneMeta): return intersection_point if plane is not None: + if self.contains(plane.origin) and self.z_dir == plane.z_dir: + return self + surface1 = Geom_Plane(self.wrapped) surface2 = Geom_Plane(plane.wrapped) intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE) @@ -2668,12 +3296,26 @@ class Plane(metaclass=PlaneMeta): if location is not None: pln = Plane(location) - if pln.origin == self.origin and pln.z_dir == self.z_dir: - return location + if self.contains(pln.origin): + if self.z_dir == pln.z_dir: + return location + else: + return pln.origin if shape is not None: return shape.intersect(self) + return None + + +CLASS_REGISTRY = { + "Axis": Axis, + "Color": Color, + "Location": Location, + "Plane": Plane, + "Vector": Vector, +} + def to_align_offset( min_point: VectorLike, diff --git a/src/build123d/importers.py b/src/build123d/importers.py index 0fa4bb8..d53628a 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -38,8 +38,10 @@ from pathlib import Path from typing import Literal, Optional, TextIO, Union import warnings +from OCP.Bnd import Bnd_Box from OCP.BRep import BRep_Builder -from OCP.BRepGProp import BRepGProp +from OCP.BRepBndLib import BRepBndLib +from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepTools import BRepTools from OCP.GProp import GProp_GProps from OCP.Quantity import Quantity_ColorRGBA @@ -70,7 +72,8 @@ from OCP.XCAFDoc import ( from ocpsvg import ColorAndLabel, import_svg_document from svgpathtools import svg2paths -from build123d.geometry import Color, Location +from build123d.build_enums import Align +from build123d.geometry import Color, Location, Vector, to_align_offset from build123d.topology import ( Compound, Edge, @@ -144,37 +147,42 @@ def import_step(filename: PathLike | str | bytes) -> Compound: clean_name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C") return clean_name.translate(str.maketrans(" .()", "____")) - def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA: + def get_shape_color_from_cache(obj: TopoDS_Shape) -> Quantity_ColorRGBA | None: + """Get the color of a shape from a cache""" + key = obj.TShape().__hash__() + if key in _color_cache: + return _color_cache[key] + + col = Quantity_ColorRGBA() + has_color = ( + color_tool.GetColor(obj, XCAFDoc_ColorCurv, col) + or color_tool.GetColor(obj, XCAFDoc_ColorGen, col) + or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col) + ) + _color_cache[key] = col if has_color else None + return _color_cache[key] + + def get_color(shape: TopoDS_Shape) -> Quantity_ColorRGBA | None: """Get the color - take that of the largest Face if multiple""" + shape_color = get_shape_color_from_cache(shape) + if shape_color is not None: + return shape_color - def get_col(obj: TopoDS_Shape) -> Quantity_ColorRGBA: - col = Quantity_ColorRGBA() - if ( - color_tool.GetColor(obj, XCAFDoc_ColorCurv, col) - or color_tool.GetColor(obj, XCAFDoc_ColorGen, col) - or color_tool.GetColor(obj, XCAFDoc_ColorSurf, col) - ): - return col - - shape_color = get_col(shape) - - colors = {} - face_explorer = TopExp_Explorer(shape, TopAbs_FACE) - while face_explorer.More(): - current_face = face_explorer.Current() - properties = GProp_GProps() - BRepGProp.SurfaceProperties_s(current_face, properties) - area = properties.Mass() - color = get_col(current_face) - if color is not None: - colors[area] = color - face_explorer.Next() - - # If there are multiple colors, return the one from the largest face - if colors: - shape_color = sorted(colors.items())[-1][1] - - return shape_color + max_extent = -1.0 + winner = None + exp = TopExp_Explorer(shape, TopAbs_FACE) + while exp.More(): + face = exp.Current() + col = get_shape_color_from_cache(face) + if col is not None: + box = Bnd_Box() + BRepBndLib.Add_s(face, box) + extent = box.SquareExtent() + if extent > max_extent: + max_extent = extent + winner = col + exp.Next() + return winner def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]: """Recursively extract object into an assembly""" @@ -200,7 +208,7 @@ def import_step(filename: PathLike | str | bytes) -> Compound: else: sub_shape = topods_lut[type(sub_topo_shape)](sub_topo_shape) - sub_shape.color = Color(get_color(sub_topo_shape)) + sub_shape.color = get_color(sub_topo_shape) sub_shape.label = get_name(ref_tdf_label) sub_shape.move(Location(shape_tool.GetLocation_s(sub_tdf_label))) @@ -210,6 +218,9 @@ def import_step(filename: PathLike | str | bytes) -> Compound: if not os.path.exists(filename): raise FileNotFoundError(filename) + # Retrieving color info is expensive so cache the lookups + _color_cache: dict[int, Quantity_ColorRGBA | None] = {} + fmt = TCollection_ExtendedString("XCAF") doc = TDocStd_Document(fmt) shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) @@ -337,6 +348,7 @@ def import_svg( svg_file: str | Path | TextIO, *, flip_y: bool = True, + align: Align | tuple[Align, Align] | None = Align.MIN, ignore_visibility: bool = False, label_by: Literal["id", "class", "inkscape:label"] | str = "id", is_inkscape_label: bool | None = None, # TODO remove for `1.0` release @@ -346,6 +358,8 @@ def import_svg( Args: svg_file (Union[str, Path, TextIO]): svg file flip_y (bool, optional): flip objects to compensate for svg orientation. Defaults to True. + align (Align | tuple[Align, Align] | None, optional): alignment of the SVG's viewbox, + if None, the viewbox's origin will be at `(0,0,0)`. Defaults to Align.MIN. ignore_visibility (bool, optional): Defaults to False. label_by (str, optional): XML attribute to use for imported shapes' `label` property. Defaults to "id". @@ -368,12 +382,18 @@ def import_svg( label_by = re.sub( r"^inkscape:(.+)", r"{http://www.inkscape.org/namespaces/inkscape}\1", label_by ) - for face_or_wire, color_and_label in import_svg_document( + imported = import_svg_document( svg_file, flip_y=flip_y, ignore_visibility=ignore_visibility, metadata=ColorAndLabel.Label_by(label_by), - ): + ) + + doc_xy = Vector(imported.viewbox.x, imported.viewbox.y) + doc_wh = Vector(imported.viewbox.width, imported.viewbox.height) + offset = to_align_offset(doc_xy, doc_xy + doc_wh, align) + + for face_or_wire, color_and_label in imported: if isinstance(face_or_wire, TopoDS_Wire): shape = Wire(face_or_wire) elif isinstance(face_or_wire, TopoDS_Face): @@ -381,6 +401,9 @@ def import_svg( else: # should not happen raise ValueError(f"unexpected shape type: {type(face_or_wire).__name__}") + if offset.X != 0 or offset.Y != 0: # avoid copying if we don't need to + shape = shape.translate(offset) + if shape.wrapped: shape.color = Color(*color_and_label.color_for(shape.wrapped)) shape.label = color_and_label.label diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py index ce05a73..9e3600b 100644 --- a/src/build123d/jupyter_tools.py +++ b/src/build123d/jupyter_tools.py @@ -66,8 +66,10 @@ def shape_to_html(shape: Any) -> HTML: ) # A new div with a unique id, plus the JS code templated with the id - div_id = 'shape-' + uuid.uuid4().hex[:8] - code = Template(TEMPLATE_JS).substitute(data=dumps(payload), div_id=div_id, ratio=0.5) + div_id = "shape-" + uuid.uuid4().hex[:8] + code = Template(TEMPLATE_JS).substitute( + data=dumps(payload), div_id=div_id, ratio=0.5 + ) html = HTML(f"
") return html diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index f4ba23c..c268a2f 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 @@ -105,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 py_lib3mf import Lib3MF +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: @@ -242,7 +251,7 @@ class Mesher: return meta_data_contents def get_meta_data_by_key(self, name_space: str, name: str) -> dict: - """Retrive the metadata value and type for the provided name space and name""" + """Retrieve the metadata value and type for the provided name space and name""" meta_data_group = self.model.GetMetaDataGroup() meta_data_contents = {} meta_data = meta_data_group.GetMetaDataByKey(name_space, name) @@ -295,7 +304,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] @@ -312,12 +321,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 +334,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 +351,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): @@ -464,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() @@ -477,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 @@ -551,3 +571,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/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index e293ac6..ad2a17a 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -29,25 +29,37 @@ license: from __future__ import annotations import copy as copy_module +import warnings +import numpy as np +import sympy # type: ignore +from collections.abc import Iterable +from itertools import product from math import copysign, cos, radians, sin, sqrt from scipy.optimize import minimize -from typing import Union - -from collections.abc import Iterable +from typing import overload, Literal from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs -from build123d.build_enums import AngularDirection, GeomType, Keep, LengthMode, Mode +from build123d.build_enums import ( + AngularDirection, + ContinuityLevel, + GeomType, + LengthMode, + Keep, + Mode, + Side, +) 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 def _add_curve_to_context(curve, mode: Mode): """Helper function to add a curve to the context. Args: - curve (Union[Wire, Edge]): curve to add to the context (either a Wire or an Edge). - mode (Mode): combination mode. + curve (Wire | Edge): curve to add to the context (either a Wire or an Edge) + mode (Mode): combination mode """ context: BuildLine | None = BuildLine._get_context(log=False) @@ -62,8 +74,8 @@ class BaseLineObject(Wire): """BaseLineObject specialized for Wire. Args: - curve (Wire): wire to create. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + curve (Wire): wire to create + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -71,15 +83,16 @@ class BaseLineObject(Wire): def __init__(self, curve: Wire, mode: Mode = Mode.ADD): # Use the helper function to handle adding the curve to the context _add_curve_to_context(curve, mode) - super().__init__(curve.wrapped) + if curve.wrapped is not None: + super().__init__(curve.wrapped) class BaseEdgeObject(Edge): """BaseEdgeObject specialized for Edge. Args: - curve (Edge): edge to create. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + curve (Edge): edge to create + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -90,17 +103,140 @@ class BaseEdgeObject(Edge): super().__init__(curve.wrapped) +class Airfoil(BaseLineObject): + """ + Create an airfoil described by a 4-digit (or fractional) NACA airfoil + (e.g. '2412' or '2213.323'). + + The NACA four-digit wing sections define the airfoil_code by: + - First digit describing maximum camber as percentage of the chord. + - Second digit describing the distance of maximum camber from the airfoil leading edge + in tenths of the chord. + - Last two digits describing maximum thickness of the airfoil as percent of the chord. + + Args: + airfoil_code : str + The NACA 4-digit (or fractional) airfoil code (e.g. '2213.323'). + n_points : int + Number of points per upper/lower surface. + finite_te : bool + If True, enforces a finite trailing edge (default False). + mode (Mode, optional): combination mode. Defaults to Mode.ADD + + """ + + _applies_to = [BuildLine._tag] + + @staticmethod + def parse_naca4(value: str | float) -> tuple[float, float, float]: + """ + Parse NACA 4-digit (or fractional) airfoil code into parameters. + """ + s = str(value).replace("NACA", "").strip() + if "." in s: + int_part, frac_part = s.split(".", 1) + m = int(int_part[0]) / 100 + p = int(int_part[1]) / 10 + t = float(f"{int(int_part[2:]):02}.{frac_part}") / 100 + else: + m = int(s[0]) / 100 + p = int(s[1]) / 10 + t = int(s[2:]) / 100 + return m, p, t + + def __init__( + self, + airfoil_code: str, + n_points: int = 50, + finite_te: bool = False, + mode: Mode = Mode.ADD, + ): + + # Airfoil thickness distribution equation: + # + # yₜ=5t[0.2969√x-0.1260x-0.3516x²+0.2843x³-0.1015x⁴] + # + # where: + # - x is the distance along the chord (0 at the leading edge, 1 at the trailing edge), + # - t is the maximum thickness as a fraction of the chord (e.g. 0.12 for a NACA 2412), + # - yₜ gives the half-thickness at each chordwise location. + + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + m, p, t = Airfoil.parse_naca4(airfoil_code) + + # Cosine-spaced x values for better nose resolution + beta = np.linspace(0.0, np.pi, n_points) + x = (1 - np.cos(beta)) / 2 + + # Thickness distribution + a0, a1, a2, a3 = 0.2969, -0.1260, -0.3516, 0.2843 + a4 = -0.1015 if finite_te else -0.1036 + yt = 5 * t * (a0 * np.sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4) + + # Camber line and slope + if m == 0 or p == 0 or p == 1: + yc = np.zeros_like(x) + dyc_dx = np.zeros_like(x) + else: + yc = np.empty_like(x) + dyc_dx = np.empty_like(x) + mask = x < p + yc[mask] = m / p**2 * (2 * p * x[mask] - x[mask] ** 2) + yc[~mask] = ( + m / (1 - p) ** 2 * ((1 - 2 * p) + 2 * p * x[~mask] - x[~mask] ** 2) + ) + dyc_dx[mask] = 2 * m / p**2 * (p - x[mask]) + dyc_dx[~mask] = 2 * m / (1 - p) ** 2 * (p - x[~mask]) + + theta = np.arctan(dyc_dx) + self._camber_points = [Vector(xi, yi) for xi, yi in zip(x, yc)] + + # Upper and lower surfaces + xu = x - yt * np.sin(theta) + yu = yc + yt * np.cos(theta) + xl = x + yt * np.sin(theta) + yl = yc - yt * np.cos(theta) + + upper_pnts = [Vector(x, y) for x, y in zip(xu, yu)] + lower_pnts = [Vector(x, y) for x, y in zip(xl, yl)] + unique_points: list[ + Vector | tuple[float, float] | tuple[float, float, float] + ] = list(dict.fromkeys(upper_pnts[::-1] + lower_pnts)) + surface = Edge.make_spline(unique_points, periodic=not finite_te) # type: ignore[arg-type] + if finite_te: + trailing_edge = Edge.make_line(surface @ 0, surface @ 1) + airfoil_profile = Wire([surface, trailing_edge]) + else: + airfoil_profile = Wire([surface]) + + super().__init__(airfoil_profile, mode=mode) + + # Store metadata + self.code: str = airfoil_code #: NACA code string (e.g. "2412") + self.max_camber: float = m #: Maximum camber as fraction of chord + self.camber_pos: float = p #: Chordwise position of max camber (0–1) + self.thickness: float = t #: Maximum thickness as fraction of chord + self.finite_te: bool = finite_te #: If True, trailing edge is finite + + @property + def camber_line(self) -> Edge: + """Camber line of the airfoil as an Edge.""" + return Edge.make_spline(self._camber_points) # type: ignore[arg-type] + + class Bezier(BaseEdgeObject): """Line Object: Bezier Curve - Create a rational (with weights) or non-rational bezier curve. The first and last - control points represent the start and end of the curve respectively. If weights - are provided, there must be one provided for each control point. + Create a non-rational bezier curve defined by a sequence of points and include optional + weights to create a rational bezier curve. The number of weights must match the number + of control points. Args: cntl_pnts (sequence[VectorLike]): points defining the curve - weights (list[float], optional): control point weights list. Defaults to None. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + weights (list[float], optional): control point weights. Defaults to None + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -121,17 +257,180 @@ class Bezier(BaseEdgeObject): super().__init__(curve, mode=mode) +class BlendCurve(BaseEdgeObject): + """Line Object: BlendCurve + + Create a smooth Bézier-based transition curve between two existing edges. + + The blend is constructed as a cubic (C1) or quintic (C2) Bézier curve + whose control points are determined from the position, first derivative, + and (for C2) second derivative of the input curves at the chosen endpoints. + Optional scalar multipliers can be applied to the endpoint tangents to + control the "tension" of the blend. + + Args: + curve0 (Edge): First curve to blend from. + curve1 (Edge): Second curve to blend to. + continuity (ContinuityLevel, optional): + Desired geometric continuity at the join: + - ContinuityLevel.C0: position match only (straight line) + - ContinuityLevel.C1: match position and tangent direction (cubic Bézier) + - ContinuityLevel.C2: match position, tangent, and curvature (quintic Bézier) + Defaults to ContinuityLevel.C2. + end_points (tuple[VectorLike, VectorLike] | None, optional): + Pair of points specifying the connection points on `curve0` and `curve1`. + Each must coincide (within TOLERANCE) with the start or end of the + respective curve. If None, the closest pair of endpoints is chosen. + Defaults to None. + tangent_scalars (tuple[float, float] | None, optional): + Scalar multipliers applied to the first derivatives at the start + of `curve0` and the end of `curve1` before computing control points. + Useful for adjusting the pull/tension of the blend without altering + the base curves. Defaults to (1.0, 1.0). + mode (Mode, optional): Boolean operation mode when used in a + BuildLine context. Defaults to Mode.ADD. + + Raises: + ValueError: `tangent_scalars` must be a pair of float values. + ValueError: If specified `end_points` are not coincident with the start + or end of their respective curves. + + Example: + >>> blend = BlendCurve(curve_a, curve_b, ContinuityLevel.C1, tangent_scalars=(1.2, 0.8)) + >>> show(blend) + """ + + def __init__( + self, + curve0: Edge, + curve1: Edge, + continuity: ContinuityLevel = ContinuityLevel.C2, + end_points: tuple[VectorLike, VectorLike] | None = None, + tangent_scalars: tuple[float, float] | None = None, + mode: Mode = Mode.ADD, + ): + # + # Process the inputs + + tan_scalars = (1.0, 1.0) if tangent_scalars is None else tangent_scalars + if len(tan_scalars) != 2: + raise ValueError("tangent_scalars must be a (start, end) pair") + + # Find the vertices that will be connected using closest if None + end_pnts = ( + min( + product(curve0.vertices(), curve1.vertices()), + key=lambda pair: pair[0].distance_to(pair[1]), + ) + if end_points is None + else end_points + ) + + # Find the Edge parameter that matches the end points + curves: tuple[Edge, Edge] = (curve0, curve1) + end_params = [0, 0] + for i, end_pnt in enumerate(end_pnts): + curve_start_pnt = curves[i].position_at(0) + curve_end_pnt = curves[i].position_at(1) + given_end_pnt = Vector(end_pnt) + if (given_end_pnt - curve_start_pnt).length < TOLERANCE: + end_params[i] = 0 + elif (given_end_pnt - curve_end_pnt).length < TOLERANCE: + end_params[i] = 1 + else: + raise ValueError( + "end_points must be at either the start or end of a curve" + ) + + # + # Bézier endpoint derivative constraints (degree n=5 case) + # + # For a degree-n Bézier curve: + # B(t) = Σ_{i=0}^n binom(n,i) (1-t)^(n-i) t^i P_i + # B'(t) = n(P_1 - P_0) at t=0 + # n(P_n - P_{n-1}) at t=1 + # B''(t) = n(n-1)(P_2 - 2P_1 + P_0) at t=0 + # n(n-1)(P_{n-2} - 2P_{n-1} + P_n) at t=1 + # + # Matching a desired start derivative D0 and curvature vector K0: + # P1 = P0 + (1/n) * D0 + # P2 = P0 + (2/n) * D0 + (1/(n*(n-1))) * K0 + # + # Matching a desired end derivative D1 and curvature vector K1: + # P_{n-1} = P_n - (1/n) * D1 + # P_{n-2} = P_n - (2/n) * D1 + (1/(n*(n-1))) * K1 + # + # For n=5 specifically: + # P1 = P0 + D0 / 5 + # P2 = P0 + (2*D0)/5 + K0/20 + # P4 = P5 - D1 / 5 + # P3 = P5 - (2*D1)/5 + K1/20 + # + # D0, D1 are first derivatives at endpoints (can be scaled for tension). + # K0, K1 are second derivatives at endpoints (for C² continuity). + # Works in any dimension; P_i are vectors in ℝ² or ℝ³. + + # + # | Math symbol | Meaning in code | Python name | + # | ----------- | -------------------------- | ------------ | + # | P_0 | start position | start_pos | + # | P_1 | 1st control pt after start | ctrl_pnt1 | + # | P_2 | 2nd control pt after start | ctrl_pnt2 | + # | P_{n-2} | 2nd control pt before end | ctrl_pnt3 | + # | P_{n-1} | 1st control pt before end | ctrl_pnt4 | + # | P_n | end position | end_pos | + # | D_0 | derivative at start | start_deriv | + # | D_1 | derivative at end | end_deriv | + # | K_0 | curvature vec at start | start_curv | + # | K_1 | curvature vec at end | end_curv | + + start_pos = curve0.position_at(end_params[0]) + end_pos = curve1.position_at(end_params[1]) + + # Note: derivative_at(..,1) is being used instead of tangent_at as + # derivate_at isn't normalized which allows for a natural "speed" to be used + # if no scalar is provided. + start_deriv = curve0.derivative_at(end_params[0], 1) * tan_scalars[0] + end_deriv = curve1.derivative_at(end_params[1], 1) * tan_scalars[1] + + if continuity == ContinuityLevel.C0: + joining_curve = Line(start_pos, end_pos) + elif continuity == ContinuityLevel.C1: + cntl_pnt1 = start_pos + start_deriv / 3 + cntl_pnt4 = end_pos - end_deriv / 3 + cntl_pnts = [start_pos, cntl_pnt1, cntl_pnt4, end_pos] # degree-3 Bézier + joining_curve = Bezier(*cntl_pnts) + else: # C2 + start_curv = curve0.derivative_at(end_params[0], 2) + end_curv = curve1.derivative_at(end_params[1], 2) + cntl_pnt1 = start_pos + start_deriv / 5 + cntl_pnt2 = start_pos + (2 * start_deriv) / 5 + start_curv / 20 + cntl_pnt4 = end_pos - end_deriv / 5 + cntl_pnt3 = end_pos - (2 * end_deriv) / 5 + end_curv / 20 + cntl_pnts = [ + start_pos, + cntl_pnt1, + cntl_pnt2, + cntl_pnt3, + cntl_pnt4, + end_pos, + ] # degree-5 Bézier + joining_curve = Bezier(*cntl_pnts) + + super().__init__(joining_curve, mode=mode) + + class CenterArc(BaseEdgeObject): """Line Object: Center Arc - Add center arc to the line. + Create a circular arc defined by a center point and radius. Args: center (VectorLike): center point of arc radius (float): arc radius - start_angle (float): arc staring angle - arc_size (float): arc size - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + start_angle (float): arc starting angle from x-axis + arc_size (float): angular size of arc + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -177,19 +476,19 @@ class CenterArc(BaseEdgeObject): class DoubleTangentArc(BaseEdgeObject): """Line Object: Double Tangent Arc - Create an arc defined by a point/tangent pair and another line which the other end - is tangent to. + Create a circular arc defined by a point/tangent pair and another line find a tangent to. + + The arc specified with TOP or BOTTOM depends on the geometry and isn't predictable. Contains a solver. Args: - pnt (VectorLike): starting point of tangent arc - tangent (VectorLike): tangent at starting point of tangent arc - other (Union[Curve, Edge, Wire]): reference line - keep (Keep, optional): selector for which arc to keep when two arcs are - possible. The arc generated with TOP or BOTTOM depends on the geometry - and isn't necessarily easy to predict. Defaults to Keep.TOP. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + pnt (VectorLike): start point + tangent (VectorLike): tangent at start point + other (Curve | Edge | Wire): line object to tangent + keep (Keep, optional): specify which arc if more than one, TOP or BOTTOM. + Defaults to Keep.TOP + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: RunTimeError: no double tangent arcs found @@ -208,6 +507,9 @@ class DoubleTangentArc(BaseEdgeObject): context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) + if keep not in [Keep.TOP, Keep.BOTTOM]: + raise ValueError(f"Only the TOP or BOTTOM options are supported not {keep}") + arc_pt = WorkplaneList.localize(pnt) arc_tangent = WorkplaneList.localize(tangent).normalized() if WorkplaneList._get_context() is not None: @@ -270,27 +572,29 @@ class DoubleTangentArc(BaseEdgeObject): _, p1, _ = other.distance_to_with_closest_points(center) TangentArc(arc_pt, p1, tangent=arc_tangent) - super().__init__(double.wire(), mode=mode) + double_edge = double.edge() + assert isinstance(double_edge, Edge) + super().__init__(double_edge, mode=mode) class EllipticalStartArc(BaseEdgeObject): """Line Object: Elliptical Start Arc - Makes an arc of an ellipse from the start point. + Create an elliptical arc defined by a start point, end point, x- and y- radii. Args: - start (VectorLike): initial point of arc - end (VectorLike): final point of arc - x_radius (float): semi-major radius - y_radius (float): semi-minor radius + start (VectorLike): start point + end (VectorLike): end point + 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) rotation (float, optional): the angle from the x-axis of the plane to the x-axis - of the ellipse. Defaults to 0.0. + of the ellipse. Defaults to 0.0 large_arc (bool, optional): True if the arc spans greater than 180 degrees. - Defaults to True. + Defaults to True sweep_flag (bool, optional): False if the line joining center to arc sweeps through - decreasing angles, or True if it sweeps through increasing angles. Defaults to True. - plane (Plane, optional): base plane. Defaults to Plane.XY. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + decreasing angles, or True if it sweeps through increasing angles. Defaults to True + plane (Plane, optional): base plane. Defaults to Plane.XY + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -382,19 +686,21 @@ class EllipticalStartArc(BaseEdgeObject): class EllipticalCenterArc(BaseEdgeObject): """Line Object: Elliptical Center Arc - Makes an arc of an ellipse from a center point. + Create an elliptical arc defined by a center point, x- and y- radii. Args: center (VectorLike): ellipse 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): Defaults to 0.0. - end_angle (float, optional): Defaults to 90.0. - rotation (float, optional): amount to rotate arc. Defaults to 0.0. + 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. + 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] @@ -438,17 +744,22 @@ class EllipticalCenterArc(BaseEdgeObject): class Helix(BaseEdgeObject): """Line Object: Helix - Add a helix to the line. + Create a helix defined by pitch, height, and radius. The helix may have a taper + defined by cone_angle. + + If cone_angle is not 0, radius is the initial helix radius at center. cone_angle > 0 + increases the final radius. cone_angle < 0 decreases the final radius. Args: - pitch (float): distance between successive loops - height (float): helix size + pitch (float): distance between loops + height (float): helix height radius (float): helix radius - center (VectorLike, optional): center point. Defaults to (0, 0, 0). - direction (VectorLike, optional): direction of central axis. Defaults to (0, 0, 1). - cone_angle (float, optional): conical angle. Defaults to 0. - lefthand (bool, optional): left handed helix. Defaults to False. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + center (VectorLike, optional): center point. Defaults to (0, 0, 0) + direction (VectorLike, optional): direction of central axis. Defaults to (0, 0, 1) + cone_angle (float, optional): conical angle from direction. + Defaults to 0 + lefthand (bool, optional): left handed helix. Defaults to False + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -475,20 +786,23 @@ class Helix(BaseEdgeObject): class FilletPolyline(BaseLineObject): - """Line Object: FilletPolyline - - Add a sequence of straight lines defined by successive points that - are filleted to a given radius. + """Line Object: Fillet Polyline + Create a sequence of straight lines defined by successive points that are filleted + to a given radius. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two or more points - radius (float): radius of filleted corners - close (bool, optional): close by generating an extra Edge. Defaults to False. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + 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. + 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] @@ -496,34 +810,49 @@ class FilletPolyline(BaseLineObject): def __init__( self, *pts: VectorLike | Iterable[VectorLike], - radius: float, + radius: float | Iterable[float], 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: raise ValueError("FilletPolyline requires two or more pts") - if radius <= 0: - raise ValueError("radius must be positive") + + 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 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 = [] + ordered_vertices: list[Vertex] = [] + for pnts in lines_pts: distance = { v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() @@ -531,55 +860,108 @@ 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 - fillets = [] - for vertex, edges in vertex_to_edges.items(): + # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) + fillets: list[None | Edge] = [] + + 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.to_tuple() for v in other_vertices]) - fillet_face = Face(Wire(edges + [third_edge])).fillet_2d(radius, [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_fillet = fillets[i - 1] + curr_fillet = fillets[i] + prev_idx = i - 1 + curr_idx = i + # Determine start and end points + if prev_fillet is None: + start_pt: Vertex | Vector = ordered_vertices[prev_idx] + else: + start_pt = prev_fillet @ 1 + + if curr_fillet is None: + end_pt: Vertex | Vector = ordered_vertices[curr_idx] + else: + end_pt = curr_fillet @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + end_edges = [] + + 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 curr_fillet is None: + start_pt = ordered_vertices[ + curr_idx + 1 + ] # +1 because first vertex has no fillet + else: + start_pt = curr_fillet @ 1 + + if next_fillet is None: + end_pt = ordered_vertices[next_idx + 1] + else: + end_pt = next_fillet @ 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): - """JernArc + """Line Object: Jern Arc - Circular tangent arc with given radius and arc_size + Create a circular arc defined by a start point/tangent pair, radius and arc size. Args: start (VectorLike): start point tangent (VectorLike): tangent at start point radius (float): arc radius - arc_size (float): arc size in degrees (negative to change direction) - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + arc_size (float): angular size of arc (negative to change direction) + mode (Mode, optional): combination mode. Defaults to Mode.ADD Attributes: start (Vector): start point @@ -634,11 +1016,11 @@ class JernArc(BaseEdgeObject): class Line(BaseEdgeObject): """Line Object: Line - Add a straight line defined by two end points. + Create a straight line defined by two points. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two points - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + pts (VectorLike | Iterable[VectorLike]): sequence of two points + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Two point not provided @@ -665,13 +1047,13 @@ class Line(BaseEdgeObject): class IntersectingLine(BaseEdgeObject): """Intersecting Line Object: Line - Add a straight line that intersects another line at a given parameter and angle. + Create a straight line defined by a point/direction pair and another line to intersect. Args: start (VectorLike): start point direction (VectorLike): direction to make line - other (Edge): stop at the intersection of other - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + other (Edge): line object to intersect + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ @@ -706,15 +1088,18 @@ class IntersectingLine(BaseEdgeObject): class PolarLine(BaseEdgeObject): """Line Object: Polar Line - Add line defined by a start point, length and angle. + Create a straight line defined by a start point, length, and angle. + The length can specify the DIAGONAL, HORIZONTAL, or VERTICAL component of the triangle + defined by the angle. Args: start (VectorLike): start point length (float): line length - angle (float): angle from the local "X" axis. - length_mode (LengthMode, optional): length value specifies a diagonal, horizontal - or vertical value. Defaults to LengthMode.DIAGONAL - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + angle (float, optional): angle from the local x-axis + direction (VectorLike, optional): vector direction to determine angle + length_mode (LengthMode, optional): how length defines the line. + Defaults to LengthMode.DIAGONAL + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Either angle or direction must be provided @@ -743,7 +1128,7 @@ class PolarLine(BaseEdgeObject): ) if direction is not None: - direction_localized = WorkplaneList.localize(direction) + direction_localized = WorkplaneList.localize(direction).normalized() angle = Vector(1, 0, 0).get_angle(direction_localized) elif angle is not None: direction_localized = polar_workplane.x_dir.rotate( @@ -756,9 +1141,9 @@ class PolarLine(BaseEdgeObject): if length_mode == LengthMode.DIAGONAL: length_vector = direction_localized * length elif length_mode == LengthMode.HORIZONTAL: - length_vector = direction_localized * (length / cos(radians(angle))) + length_vector = direction_localized * abs(length / cos(radians(angle))) elif length_mode == LengthMode.VERTICAL: - length_vector = direction_localized * (length / sin(radians(angle))) + length_vector = direction_localized * abs(length / sin(radians(angle))) new_edge = Edge.make_line(start, start + length_vector) @@ -768,12 +1153,12 @@ class PolarLine(BaseEdgeObject): class Polyline(BaseLineObject): """Line Object: Polyline - Add a sequence of straight lines defined by successive point pairs. + Create a sequence of straight lines defined by successive points. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two or more points - close (bool, optional): close by generating an extra Edge. Defaults to False. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + pts (VectorLike | Iterable[VectorLike]): sequence of two or more points + close (bool, optional): close by generating an extra Edge. Defaults to False + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Two or more points not provided @@ -809,15 +1194,15 @@ class Polyline(BaseLineObject): class RadiusArc(BaseEdgeObject): """Line Object: Radius Arc - Add an arc defined by two end points and a radius + Create a circular arc defined by two points and a radius. Args: - start_point (VectorLike): start - end_point (VectorLike): end - radius (float): radius - short_sagitta (bool): If True selects the short sagitta, else the - long sagitta crossing the center. Defaults to True. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + start_point (VectorLike): start point + end_point (VectorLike): end point + radius (float): arc radius + short_sagitta (bool): If True selects the short sagitta (height of arc from + chord), else the long sagitta crossing the center. Defaults to True + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Insufficient radius to connect end points @@ -855,19 +1240,21 @@ class RadiusArc(BaseEdgeObject): else: arc = SagittaArc(start, end, -sagitta, mode=Mode.PRIVATE) - super().__init__(arc, mode=mode) + arc_edge = arc.edge() + assert isinstance(arc_edge, Edge) + super().__init__(arc_edge, mode=mode) class SagittaArc(BaseEdgeObject): """Line Object: Sagitta Arc - Add an arc defined by two points and the height of the arc (sagitta). + Create a circular arc defined by two points and the sagitta (height of the arc from chord). Args: - start_point (VectorLike): start - end_point (VectorLike): end - sagitta (float): arc height - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + start_point (VectorLike): start point + end_point (VectorLike): end point + sagitta (float): arc height from chord between points + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -899,21 +1286,24 @@ class SagittaArc(BaseEdgeObject): sag_point = mid_point + sagitta_vector arc = ThreePointArc(start, sag_point, end, mode=Mode.PRIVATE) - super().__init__(arc, mode=mode) + arc_edge = arc.edge() + assert isinstance(arc_edge, Edge) + super().__init__(arc_edge, mode=mode) class Spline(BaseEdgeObject): """Line Object: Spline - Add a spline through the provided points optionally constrained by tangents. + Create a spline defined by a sequence of points, optionally constrained by tangents. + Tangents and tangent scalars must have length of 2 for only the end points or a length + of the number of points. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two or more points - tangents (Iterable[VectorLike], optional): tangents at end points. Defaults to None. - tangent_scalars (Iterable[float], optional): change shape by amplifying tangent. - Defaults to None. - periodic (bool, optional): make the spline periodic. Defaults to False. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + pts (VectorLike | Iterable[VectorLike]): sequence of two or more points + tangents (Iterable[VectorLike], optional): tangent directions. Defaults to None + tangent_scalars (Iterable[float], optional): tangent scales. Defaults to None + periodic (bool, optional): make the spline periodic (closed). Defaults to False + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildLine._tag] @@ -963,14 +1353,14 @@ class Spline(BaseEdgeObject): class TangentArc(BaseEdgeObject): """Line Object: Tangent Arc - Add an arc defined by two points and a tangent. + Create a circular arc defined by two points and a tangent. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two points + pts (VectorLike | Iterable[VectorLike]): sequence of two points tangent (VectorLike): tangent to constrain arc - tangent_from_first (bool, optional): apply tangent to first point. Note, applying - tangent to end point will flip the orientation of the arc. Defaults to True. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + tangent_from_first (bool, optional): apply tangent to first point. Applying + tangent to end point will flip the orientation of the arc. Defaults to True + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Two points are required @@ -1005,11 +1395,11 @@ class TangentArc(BaseEdgeObject): class ThreePointArc(BaseEdgeObject): """Line Object: Three Point Arc - Add an arc generated by three points. + Create a circular arc defined by three points. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of three points - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + pts (VectorLike | Iterable[VectorLike]): sequence of three points + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Three points must be provided @@ -1028,3 +1418,569 @@ class ThreePointArc(BaseEdgeObject): arc = Edge.make_three_point_arc(*points_localized) super().__init__(arc, mode=mode) + + +class PointArcTangentLine(BaseEdgeObject): + """Line Object: Point Arc Tangent Line + + Create a straight, tangent line from a point to a circular arc. + + Args: + point (VectorLike): intersection point for tangent + arc (Curve | Edge | Wire): circular arc to tangent, must be GeomType.CIRCLE + side (Side, optional): side of arcs to place tangent arc center, LEFT or RIGHT. + Defaults to Side.LEFT + mode (Mode, optional): combination mode. Defaults to Mode.ADD + """ + + warnings.warn( + "The 'PointArcTangentLine' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + _applies_to = [BuildLine._tag] + + def __init__( + self, + point: VectorLike, + arc: Curve | Edge | Wire, + side: Side = Side.LEFT, + mode: Mode = Mode.ADD, + ): + + side_sign = { + Side.LEFT: -1, + Side.RIGHT: 1, + } + + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + if arc.geom_type != GeomType.CIRCLE: + raise ValueError("Arc must have GeomType.CIRCLE.") + + tangent_point = WorkplaneList.localize(point) + if context is None: + # Making the plane validates points and arc are coplanar + coplane = Edge.make_line(tangent_point, arc.arc_center).common_plane(arc) + if coplane is None: + raise ValueError("PointArcTangentLine only works on a single plane.") + + workplane = Plane(coplane.origin, z_dir=arc.normal()) + else: + workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0]) + + arc_center = arc.arc_center + radius = arc.radius + midline = tangent_point - arc_center + + if midline.length <= radius: + raise ValueError("Cannot find tangent for point on or inside arc.") + + # Find angle phi between midline and x + # and angle theta between midplane length and radius + # add the resulting angles with a sign on theta to pick a direction + # This angle is the tangent location around the circle from x + phi = midline.get_signed_angle(workplane.x_dir) + other_leg = sqrt(midline.length**2 - radius**2) + theta = WorkplaneList.localize((radius, other_leg)).get_signed_angle( + workplane.x_dir + ) + angle = side_sign[side] * theta + phi + intersect = ( + WorkplaneList.localize( + (radius * cos(radians(angle)), radius * sin(radians(angle))) + ) + + arc_center + ) + + tangent = Edge.make_line(tangent_point, intersect) + super().__init__(tangent, mode) + + +class PointArcTangentArc(BaseEdgeObject): + """Line Object: Point Arc Tangent Arc + + Create an arc defined by a point/tangent pair and another line which the other end + is tangent to. + + Args: + point (VectorLike): starting point of tangent arc + direction (VectorLike): direction at starting point of tangent arc + arc (Union[Curve, Edge, Wire]): ending arc, must be GeomType.CIRCLE + side (Side, optional): select which arc to keep Defaults to Side.LEFT + mode (Mode, optional): combination mode. Defaults to Mode.ADD + + Raises: + ValueError: Arc must have GeomType.CIRCLE + ValueError: Point is already tangent to arc + RuntimeError: No tangent arc found + """ + + warnings.warn( + "The 'PointArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + _applies_to = [BuildLine._tag] + + def __init__( + self, + point: VectorLike, + direction: VectorLike, + arc: Curve | Edge | Wire, + side: Side = Side.LEFT, + mode: Mode = Mode.ADD, + ): + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + if arc.geom_type != GeomType.CIRCLE: + raise ValueError("Arc must have GeomType.CIRCLE") + + arc_point = WorkplaneList.localize(point) + wp_tangent = WorkplaneList.localize(direction).normalized() + + if context is None: + # Making the plane validates point, tangent, and arc are coplanar + coplane = Edge.make_line(arc_point, arc_point + wp_tangent).common_plane( + arc + ) + if coplane is None: + raise ValueError("PointArcTangentArc only works on a single plane.") + + workplane = Plane(coplane.origin, z_dir=arc.normal()) + else: + workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0]) + + arc_tangent = ( + Vector(direction) + .transform(workplane.reverse_transform, is_direction=True) + .normalized() + ) + + midline = arc_point - arc.arc_center + if midline.length == arc.radius: + raise ValueError("Cannot find tangent for point on arc.") + + if midline.length <= arc.radius: + raise NotImplementedError("Point inside arc not yet implemented.") + + # Determine where arc_point is located relative to arc + # ref forms a bisecting line parallel to arc tangent with same distance from arc + # center as arc point in direction of arc tangent + tangent_perp = arc_tangent.cross(workplane.z_dir) + ref_scale = (arc.arc_center - arc_point).dot(-arc_tangent) + ref = ref_scale * arc_tangent + arc.arc_center + ref_to_point = (arc_point - ref).dot(tangent_perp) + + keep_sign = -1 if side == Side.LEFT else 1 + # Tangent radius to infinity (and beyond) + if keep_sign * ref_to_point == arc.radius: + raise ValueError("Point is already tangent to arc, use tangent line.") + + # Use magnitude and sign of ref to arc point along with keep to determine + # which "side" angle the arc center will be on + # - the arc center is the same side if the point is further from ref than arc radius + # - minimize type determines near or far side arc to minimize to + side_sign = 1 if ref_to_point < 0 else -1 + if abs(ref_to_point) < arc.radius: + # point/tangent pointing inside arc, both arcs near + arc_type = 1 + angle = keep_sign * -90 + if ref_scale > 1: + angle = -angle + else: + # point/tangent pointing outside arc, one near arc one far + angle = side_sign * -90 + if side == side.LEFT: + arc_type = -side_sign + else: + arc_type = side_sign + + # Protect against massive circles that are effectively straight lines + max_size = 1000 * arc.bounding_box().add(arc_point).diagonal + + # Function to be minimized - note radius is a numpy array + def func(radius, perpendicular_bisector, minimize_type): + center = arc_point + perpendicular_bisector * radius[0] + separation = (arc.arc_center - center).length - arc.radius + + if minimize_type == 1: + # near side arc + target = abs(separation - radius) + elif minimize_type == -1: + # far side arc + target = abs(separation - radius + arc.radius * 2) + return target + + # Find arc center by minimizing func result + rotation_axis = Axis(workplane.origin, workplane.z_dir) + perpendicular_bisector = arc_tangent.rotate(rotation_axis, angle) + result = minimize( + func, + x0=0, + args=(perpendicular_bisector, arc_type), + method="Nelder-Mead", + bounds=[(0.0, max_size)], + tol=TOLERANCE, + ) + tangent_radius = result.x[0] + tangent_center = arc_point + perpendicular_bisector * tangent_radius + + # Check if minimizer hit max size + if tangent_radius == max_size: + raise RuntimeError("Arc radius very large. Can tangent line be used?") + + # dir needs to be flipped for far arc + tangent_normal = (arc.arc_center - tangent_center).normalized() + tangent_dir = arc_type * tangent_normal.cross(workplane.z_dir) + tangent_point = tangent_radius * tangent_normal + tangent_center + + # Sanity Checks + # Confirm tangent point is on arc + if abs(arc.radius - (tangent_point - arc.arc_center).length) > TOLERANCE: + raise RuntimeError("No tangent arc found, no tangent point found.") + + # Confirm new tangent point is colinear with point tangent on arc + arc_dir = arc.tangent_at(tangent_point) + if tangent_dir.cross(arc_dir).length > TOLERANCE: + raise RuntimeError("No tangent arc found, found tangent out of tolerance.") + + arc = TangentArc(arc_point, tangent_point, tangent=arc_tangent) + super().__init__(arc, mode=mode) + + +class ArcArcTangentLine(BaseEdgeObject): + """Line Object: Arc Arc Tangent Line + + Create a straight line tangent to two arcs. + + Args: + start_arc (Curve | Edge | Wire): starting arc, must be GeomType.CIRCLE + end_arc (Curve | Edge | Wire): ending arc, must be GeomType.CIRCLE + side (Side): side of arcs to place tangent arc center, LEFT or RIGHT. + Defaults to Side.LEFT + keep (Keep): which tangent arc to keep, INSIDE or OUTSIDE. + Defaults to Keep.INSIDE + mode (Mode, optional): combination mode. Defaults to Mode.ADD + """ + + warnings.warn( + "The 'ArcArcTangentLine' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + _applies_to = [BuildLine._tag] + + def __init__( + self, + start_arc: Curve | Edge | Wire, + end_arc: Curve | Edge | Wire, + side: Side = Side.LEFT, + keep: Keep = Keep.INSIDE, + mode: Mode = Mode.ADD, + ): + + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + if start_arc.geom_type != GeomType.CIRCLE: + raise ValueError("Start arc must have GeomType.CIRCLE.") + + if end_arc.geom_type != GeomType.CIRCLE: + raise ValueError("End arc must have GeomType.CIRCLE.") + + if context is None: + # Making the plane validates start arc and end arc are coplanar + coplane = start_arc.common_plane(end_arc) + if coplane is None: + raise ValueError("ArcArcTangentLine only works on a single plane.") + + workplane = Plane(coplane.origin, z_dir=start_arc.normal()) + else: + workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0]) + + side_sign = 1 if side == Side.LEFT else -1 + arcs = [start_arc, end_arc] + points = [arc.arc_center for arc in arcs] + radii = [arc.radius for arc in arcs] + midline = points[1] - points[0] + + if midline.length <= abs(radii[1] - radii[0]): + raise ValueError("Cannot find tangent when one arc contains the other.") + + if keep == Keep.INSIDE: + if midline.length < sum(radii): + raise ValueError("Cannot find INSIDE tangent for overlapping arcs.") + + if midline.length == sum(radii): + raise ValueError("Cannot find INSIDE tangent for tangent arcs.") + + # Method: + # https://en.wikipedia.org/wiki/Tangent_lines_to_circles#Tangent_lines_to_two_circles + # - angle to point on circle of tangent incidence is theta + phi + # - phi is angle between x axis and midline + # - OUTSIDE theta is angle formed by triangle legs (midline.length) and (r0 - r1) + # - INSIDE theta is angle formed by triangle legs (midline.length) and (r0 + r1) + # - INSIDE theta for arc1 is 180 from theta for arc0 + + phi = midline.get_signed_angle(workplane.x_dir) + radius = radii[0] + radii[1] if keep == Keep.INSIDE else radii[0] - radii[1] + other_leg = sqrt(midline.length**2 - radius**2) + theta = WorkplaneList.localize((radius, other_leg)).get_signed_angle( + workplane.x_dir + ) + angle = side_sign * theta + phi + + intersect = [] + for i in range(len(arcs)): + angle = i * 180 + angle if keep == Keep.INSIDE else angle + intersect.append( + WorkplaneList.localize( + (radii[i] * cos(radians(angle)), radii[i] * sin(radians(angle))) + ) + + points[i] + ) + + tangent = Edge.make_line(intersect[0], intersect[1]) + super().__init__(tangent, mode) + + +class ArcArcTangentArc(BaseEdgeObject): + """Line Object: Arc Arc Tangent Arc + + Create an arc tangent to two arcs and a radius. + + keep specifies tangent arc position with a Keep pair: (placement, type) + + - placement: start_arc is tangent INSIDE or OUTSIDE the tangent arc. BOTH is a + special case for overlapping arcs with type INSIDE + - type: tangent arc is INSIDE or OUTSIDE start_arc and end_arc + + Args: + start_arc (Curve | Edge | Wire): starting arc, must be GeomType.CIRCLE + end_arc (Curve | Edge | Wire): ending arc, must be GeomType.CIRCLE + radius (float): radius of tangent arc + side (Side): side of arcs to place tangent arc center, LEFT or RIGHT. + Defaults to Side.LEFT + keep (Keep | tuple[Keep, Keep]): which tangent arc to keep, INSIDE or OUTSIDE. + Defaults to (Keep.INSIDE, Keep.INSIDE) + short_sagitta (bool): If True selects the short sagitta (height of arc from + chord), else the long sagitta crossing the center. Defaults to True + mode (Mode, optional): combination mode. Defaults to Mode.ADD + """ + + warnings.warn( + "The 'ArcArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + _applies_to = [BuildLine._tag] + + def __init__( + self, + start_arc: Curve | Edge | Wire, + end_arc: Curve | Edge | Wire, + radius: float, + side: Side = Side.LEFT, + keep: Keep | tuple[Keep, Keep] = (Keep.INSIDE, Keep.INSIDE), + short_sagitta: bool = True, + mode: Mode = Mode.ADD, + ): + keep_placement, keep_type = (keep, keep) if isinstance(keep, Keep) else keep + + context: BuildLine | None = BuildLine._get_context(self) + validate_inputs(context, self) + + if keep_placement == Keep.BOTH and keep_type != Keep.INSIDE: + raise ValueError( + "Keep.BOTH can only be used in configuration: (Keep.BOTH, Keep.INSIDE)" + ) + + if start_arc.geom_type != GeomType.CIRCLE: + raise ValueError("Start arc must have GeomType.CIRCLE.") + + if end_arc.geom_type != GeomType.CIRCLE: + raise ValueError("End arc must have GeomType.CIRCLE.") + + if context is None: + # Making the plane validates start arc and end arc are coplanar + coplane = start_arc.common_plane(end_arc) + if coplane is None: + raise ValueError("ArcArcTangentArc only works on a single plane.") + + workplane = Plane(coplane.origin, z_dir=start_arc.normal()) + else: + workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0]) + + arcs = [start_arc, end_arc] + points = [arc.arc_center for arc in arcs] + radii = [arc.radius for arc in arcs] + side_sign = 1 if side == Side.LEFT else -1 + keep_sign = 1 if keep_placement == Keep.OUTSIDE else -1 + r_sign = 1 if radii[0] < radii[1] else -1 + + # Make a normal vector for sorting intersections + midline = points[1] - points[0] + normal = side_sign * midline.cross(workplane.z_dir) + + if midline.length < TOLERANCE: + raise ValueError("Cannot find tangent for concentric arcs.") + + if abs(midline.length - sum(radii)) < TOLERANCE and keep_type == Keep.INSIDE: + raise ValueError( + "Cannot find tangent type Keep.INSIDE for non-overlapping arcs " + "already tangent." + ) + + if ( + abs(midline.length - abs(radii[0] - radii[1])) < TOLERANCE + and keep_placement == Keep.INSIDE + ): + raise ValueError( + "Cannot find tangent placement Keep.INSIDE for completely " + "overlapping arcs already tangent." + ) + + # Set following parameters based on overlap condition and keep configuration + min_radius = 0.0 + max_radius = None + x_sign = [1, 1] + pick_index = 0 + if midline.length > abs(radii[0] - radii[1]) and keep_type == Keep.OUTSIDE: + # No full overlap, placed externally + ref_radii = [keep_sign * radii[0] + radius, keep_sign * radii[1] + radius] + x_sign = [keep_sign, keep_sign] + min_radius = (midline.length - keep_sign * (radii[0] + radii[1])) / 2 + min_radius = 0 if min_radius < 0 else min_radius + + elif midline.length > radii[0] + radii[1] and keep_type == Keep.INSIDE: + # No overlap, placed inside + ref_radii = [ + abs(radii[0] + keep_sign * radius), + abs(radii[1] - keep_sign * radius), + ] + x_sign = [1, -1] if keep_placement == Keep.OUTSIDE else [-1, 1] + min_radius = (midline.length - keep_sign * (radii[0] - radii[1])) / 2 + + elif midline.length <= abs(radii[0] - radii[1]): + # Full Overlap + pick_index = -1 + if keep_placement == Keep.OUTSIDE: + # External tangent to start + ref_radii = [radii[0] + r_sign * radius, radii[1] - r_sign * radius] + min_radius = ( + -midline.length - r_sign * radii[0] + r_sign * radii[1] + ) / 2 + max_radius = ( + midline.length - r_sign * radii[0] + r_sign * radii[1] + ) / 2 + + elif keep_placement == Keep.INSIDE: + # Internal tangent to start + ref_radii = [abs(radii[0] - radius), abs(radii[1] - radius)] + min_radius = (-midline.length + radii[0] + radii[1]) / 2 + max_radius = (midline.length + radii[0] + radii[1]) / 2 + if radii[0] < radii[1]: + x_sign = [-1, 1] + else: + x_sign = [1, -1] + else: + # Partial Overlap + pick_index = -1 + if keep_placement == Keep.BOTH: + # Internal tangent to both + ref_radii = [abs(radii[0] - radius), abs(radii[1] - radius)] + max_radius = (-midline.length + radii[0] + radii[1]) / 2 + + elif keep_placement == Keep.OUTSIDE: + # External tangent to start + ref_radii = [radii[0] + r_sign * radius, radii[1] - r_sign * radius] + max_radius = ( + midline.length - r_sign * radii[0] + r_sign * radii[1] + ) / 2 + + elif keep_placement == Keep.INSIDE: + # Internal tangent to start + ref_radii = [radii[0] - r_sign * radius, radii[1] + r_sign * radius] + max_radius = ( + midline.length + r_sign * radii[0] - r_sign * radii[1] + ) / 2 + + if min_radius >= radius: + raise ValueError( + f"The arc radius is too small. Should be greater than {min_radius}." + ) + + if max_radius is not None and max_radius <= radius: + raise ValueError( + f"The arc radius is too large. Should be less than {max_radius}." + ) + + # Method: + # https://www.youtube.com/watch?v=-STj2SSv6TU + # For (*, OUTSIDE) Not completely overlapping + # - the centerpoint of the inner arc is found by the intersection of the + # arcs made by adding the inner radius to the point radii + # - the centerpoint of the outer arc is found by the intersection of the + # arcs made by subtracting the outer radius from the point radii + # - then it's a matter of finding the points where the connecting lines + # intersect the point circles + # Other placements and types vary construction radii + local = [workplane.to_local_coords(p) for p in points] + ref_circles = [ + sympy.Circle(sympy.Point(local[i].X, local[i].Y), ref_radii[i]) + for i in range(len(arcs)) + ] + + ref_intersections = ShapeList( + [ + workplane.from_local_coords( + Vector(float(sympy.N(p.x)), float(sympy.N(p.y))) + ) + for p in sympy.intersection(*ref_circles) + ] + ) + arc_center = ref_intersections.sort_by(Axis(points[0], normal))[pick_index] + + # x_sign determines if tangent is near side or far side of circle + intersect = [ + points[i] + + x_sign[i] * radii[i] * (Vector(arc_center) - points[i]).normalized() + for i in range(len(arcs)) + ] + + if side == Side.LEFT: + intersect.reverse() + + arc = RadiusArc( + intersect[0], + intersect[1], + radius=radius, + short_sagitta=short_sagitta, + mode=Mode.PRIVATE, + ) + + # Check and flip arc if not tangent + start_circle = CenterArc( + start_arc.arc_center, start_arc.radius, 0, 360, mode=Mode.PRIVATE + ) + _, _, point = start_circle.distance_to_with_closest_points(arc) + if ( + start_circle.tangent_at(point).cross(arc.tangent_at(point)).length + > TOLERANCE + ): + arc = RadiusArc( + intersect[0], + intersect[1], + radius=-radius, + short_sagitta=short_sagitta, + mode=Mode.PRIVATE, + ) + + super().__init__(arc, mode) diff --git a/src/build123d/objects_part.py b/src/build123d/objects_part.py index 9a3da0e..7054d7a 100644 --- a/src/build123d/objects_part.py +++ b/src/build123d/objects_part.py @@ -44,10 +44,10 @@ class BasePartObject(Part): Args: solid (Solid): object to create - 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 None. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + 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 None + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -103,16 +103,16 @@ class BasePartObject(Part): class Box(BasePartObject): """Part Object: Box - Create a box(es) and combine with part. + Create a box defined by length, width, and height. Args: - length (float): box size - width (float): box size - height (float): box size - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + length (float): box length + width (float): box width + height (float): box height + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -147,17 +147,17 @@ class Box(BasePartObject): class Cone(BasePartObject): """Part Object: Cone - Create a cone(s) and combine with part. + Create a cone defined by bottom radius, top radius, and height. Args: - bottom_radius (float): cone size - top_radius (float): top size, could be zero - height (float): cone size - arc_size (float, optional): angular size of cone. Defaults to 360. - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + bottom_radius (float): bottom radius + top_radius (float): top radius, may be zero + height (float): cone height + arc_size (float, optional): angular size of cone. Defaults to 360 + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -200,14 +200,14 @@ class Cone(BasePartObject): class CounterBoreHole(BasePartObject): """Part Operation: Counter Bore Hole - Create a counter bore hole in part. + Create a counter bore hole defined by radius, counter bore radius, counter bore and depth. Args: - radius (float): hole size - counter_bore_radius (float): counter bore size + radius (float): hole radius + counter_bore_radius (float): counter bore radius counter_bore_depth (float): counter bore depth - depth (float, optional): hole depth - None implies through part. Defaults to None. - mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT. + depth (float, optional): hole depth, through part if None. Defaults to None + mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT """ _applies_to = [BuildPart._tag] @@ -253,14 +253,15 @@ class CounterBoreHole(BasePartObject): class CounterSinkHole(BasePartObject): """Part Operation: Counter Sink Hole - Create a counter sink hole in part. + Create a countersink hole defined by radius, countersink radius, countersink + angle, and depth. Args: - radius (float): hole size - counter_sink_radius (float): counter sink size - depth (float, optional): hole depth - None implies through part. Defaults to None. - counter_sink_angle (float, optional): cone angle. Defaults to 82. - mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT. + radius (float): hole radius + counter_sink_radius (float): countersink radius + depth (float, optional): hole depth, through part if None. Defaults to None + counter_sink_angle (float, optional): cone angle. Defaults to 82 + mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT """ _applies_to = [BuildPart._tag] @@ -310,16 +311,16 @@ class CounterSinkHole(BasePartObject): class Cylinder(BasePartObject): """Part Object: Cylinder - Create a cylinder(s) and combine with part. + Create a cylinder defined by radius and height. Args: - radius (float): cylinder size - height (float): cylinder size + radius (float): cylinder radius + height (float): cylinder height arc_size (float, optional): angular size of cone. Defaults to 360. - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -358,12 +359,12 @@ class Cylinder(BasePartObject): class Hole(BasePartObject): """Part Operation: Hole - Create a hole in part. + Create a hole defined by radius and depth. Args: - radius (float): hole size - depth (float, optional): hole depth - None implies through part. Defaults to None. - mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT. + radius (float): hole radius + depth (float, optional): hole depth, through part if None. Defaults to None + mode (Mode, optional): combination mode. Defaults to Mode.SUBTRACT """ _applies_to = [BuildPart._tag] @@ -405,17 +406,17 @@ class Hole(BasePartObject): class Sphere(BasePartObject): """Part Object: Sphere - Create a sphere(s) and combine with part. + Create a sphere defined by a radius. Args: - radius (float): sphere size - arc_size1 (float, optional): angular size of sphere. Defaults to -90. - arc_size2 (float, optional): angular size of sphere. Defaults to 90. - arc_size3 (float, optional): angular size of sphere. Defaults to 360. - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + radius (float): sphere radius + arc_size1 (float, optional): angular size of bottom hemisphere. Defaults to -90. + arc_size2 (float, optional): angular size of top hemisphere. Defaults to 90. + arc_size3 (float, optional): angular revolution about pole. Defaults to 360. + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -457,18 +458,18 @@ class Sphere(BasePartObject): class Torus(BasePartObject): """Part Object: Torus - Create a torus(es) and combine with part. - + Create a torus defined by major and minor radii. Args: - major_radius (float): torus size - minor_radius (float): torus size - major_arc_size (float, optional): angular size of torus. Defaults to 0. - minor_arc_size (float, optional): angular size or torus. Defaults to 360. - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + major_radius (float): major torus radius + minor_radius (float): minor torus radius + minor_start_angle (float, optional): angle to start minor arc. Defaults to 0 + minor_end_angle (float, optional): angle to end minor arc. Defaults to 360 + major_angle (float, optional): angle to revolve minor arc. Defaults to 360 + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] @@ -513,20 +514,21 @@ class Torus(BasePartObject): class Wedge(BasePartObject): """Part Object: Wedge - Create a wedge(s) and combine with part. + Create a wedge with a near face defined by xsize and z size, a far face defined by + xmin to xmax and zmin to zmax, and a depth of ysize. Args: - xsize (float): distance along the X axis - ysize (float): distance along the Y axis - zsize (float): distance along the Z axis - xmin (float): minimum X location - zmin (float): minimum Z location - xmax (float): maximum X location - zmax (float): maximum Z location - 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.CENTER, Align.CENTER, Align.CENTER). - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + xsize (float): length of near face along x-axis + ysize (float): length of part along y-axis + zsize (float): length of near face z-axis + xmin (float): minimum position far face along x-axis + zmin (float): minimum position far face along z-axis + xmax (float): maximum position far face along x-axis + zmax (float): maximum position far face along z-axis + 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.CENTER, Align.CENTER, Align.CENTER) + mode (Mode, optional): combine mode. Defaults to Mode.ADD """ _applies_to = [BuildPart._tag] diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py index 65771d2..f301c0e 100644 --- a/src/build123d/objects_sketch.py +++ b/src/build123d/objects_sketch.py @@ -36,7 +36,7 @@ from typing import cast from collections.abc import Iterable from build123d.build_common import LocationList, flatten_sequence, validate_inputs -from build123d.build_enums import Align, FontStyle, Mode +from build123d.build_enums import Align, FontStyle, Mode, TextAlign from build123d.build_sketch import BuildSketch from build123d.geometry import ( Axis, @@ -53,6 +53,7 @@ from build123d.topology import ( Face, ShapeList, Sketch, + Vertex, Wire, tuplify, topo_explore_common_vertex, @@ -66,10 +67,10 @@ class BaseSketchObject(Sketch): Args: face (Face): face to create - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to None. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to None + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -109,13 +110,13 @@ class BaseSketchObject(Sketch): class Circle(BaseSketchObject): """Sketch Object: Circle - Add circle(s) to the sketch. + Create a circle defined by radius. Args: - radius (float): circle size - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + radius (float): circle radius + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -139,15 +140,15 @@ class Circle(BaseSketchObject): class Ellipse(BaseSketchObject): """Sketch Object: Ellipse - Add ellipse(s) to sketch. + Create an ellipse defined by x- and y- radii. Args: - x_radius (float): horizontal radius - y_radius (float): vertical radius - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + 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) + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -174,20 +175,19 @@ class Ellipse(BaseSketchObject): class Polygon(BaseSketchObject): """Sketch Object: Polygon - Add polygon(s) defined by given sequence of points to sketch. + Create a polygon defined by given sequence of points. - Note that the order of the points define the normal of the Face that is created in - Algebra mode, where counter clockwise order creates Faces with their normal being up - while a clockwise order will have a normal that is down. In Builder mode, all Faces - added to the sketch are up. + Note: the order of the points defines the resulting normal of the Face in Algebra + mode, where counter-clockwise order creates an upward normal while clockwise order + a downward normal. In Builder mode, the Face is added with an upward normal. Args: - pts (Union[VectorLike, Iterable[VectorLike]]): sequence of points defining the + pts (VectorLike | Iterable[VectorLike]): sequence of points defining the vertices of the polygon - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -206,7 +206,7 @@ class Polygon(BaseSketchObject): self.pts = flattened_pts self.align = tuplify(align, 2) - poly_pts = [Vector(p) for p in pts] + poly_pts = [Vector(p) for p in self.pts] face = Face(Wire.make_polygon(poly_pts)) super().__init__(face, rotation, self.align, mode) @@ -214,15 +214,15 @@ class Polygon(BaseSketchObject): class Rectangle(BaseSketchObject): """Sketch Object: Rectangle - Add rectangle(s) to sketch. + Create a rectangle defined by width and height. Args: - width (float): horizontal size - height (float): vertical size - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + width (float): rectangle width + height (float): rectangle height + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -247,18 +247,18 @@ class Rectangle(BaseSketchObject): class RectangleRounded(BaseSketchObject): - """Sketch Object: RectangleRounded + """Sketch Object: Rectangle Rounded - Add rectangle(s) with filleted corners to sketch. + Create a rectangle defined by width and height with filleted corners. Args: - width (float): horizontal size - height (float): vertical size + width (float): rectangle width + height (float): rectangle height radius (float): fillet radius - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -290,19 +290,18 @@ class RectangleRounded(BaseSketchObject): class RegularPolygon(BaseSketchObject): """Sketch Object: Regular Polygon - Add regular polygon(s) to sketch. + Create a regular polygon defined by radius and side count. Use major_radius to define whether + the polygon circumscribes (along the vertices) or inscribes (along the sides) the radius circle. Args: - radius (float): distance from origin to vertices (major), or - optionally from the origin to side (minor) with major_radius = False - side_count (int): number of polygon sides - major_radius (bool): If True the radius is the major radius, else the - radius is the minor radius (also known as inscribed radius). - Defaults to True. - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + radius (float): construction radius + side_count (int): number of sides + major_radius (bool): If True the radius is the major radius (circumscribed circle), + else the radius is the minor radius (inscribed circle). Defaults to True + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -361,15 +360,15 @@ class RegularPolygon(BaseSketchObject): class SlotArc(BaseSketchObject): - """Sketch Object: Arc Slot + """Sketch Object: Slot Arc - Add slot(s) following an arc to sketch. + Create a slot defined by a line and height. May be an arc, stright line, spline, etc. Args: - arc (Union[Edge, Wire]): center line of slot - height (float): diameter of end circles - rotation (float, optional): angles to rotate objects. Defaults to 0. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + arc (Edge | Wire): center line of slot + height (float): diameter of end arcs + rotation (float, optional): angle to rotate object. Defaults to 0 + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -388,23 +387,22 @@ class SlotArc(BaseSketchObject): self.slot_height = height arc = arc if isinstance(arc, Wire) else Wire([arc]) - face = Face(arc.offset_2d(height / 2)).rotate(Axis.Z, rotation) + face = Face(arc.offset_2d(height / 2)) super().__init__(face, rotation, None, mode) class SlotCenterPoint(BaseSketchObject): - """Sketch Object: Center Point Slot + """Sketch Object: Slot Center Point - Add a slot(s) defined by the center of the slot and the center of one of the - circular arcs at the end. The other end will be generated to create a symmetric - slot. + Create a slot defined by the center of the slot and the center of one end arc. + The slot will be symmetric about the center point. Args: - center (VectorLike): slot center point - point (VectorLike): slot center of arc point - height (float): diameter of end circles - rotation (float, optional): angles to rotate objects. Defaults to 0. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + center (VectorLike): center point + point (VectorLike): center of arc point + height (float): diameter of end arcs + rotation (float, optional): angle to rotate object. Defaults to 0 + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -428,10 +426,10 @@ class SlotCenterPoint(BaseSketchObject): half_line = point_v - center_v - if half_line.length * 2 <= height: + if half_line.length <= 0: raise ValueError( - f"Slots must have width > height. " - "Got: {height=} width={half_line.length * 2} (computed)" + "Distance between center and point must be greater than 0 " + f"Got: distance = {half_line.length} (computed)" ) face = Face( @@ -446,16 +444,15 @@ class SlotCenterPoint(BaseSketchObject): class SlotCenterToCenter(BaseSketchObject): - """Sketch Object: Center to Center points Slot + """Sketch Object: Slot Center To Center - Add slot(s) defined by the distance between the center of the two - end arcs. + Create a slot defined by the distance between the centers of the two end arcs. Args: - center_separation (float): distance between two arc centers - height (float): diameter of end circles - rotation (float, optional): angles to rotate objects. Defaults to 0. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + center_separation (float): distance between arc centers + height (float): diameter of end arcs + rotation (float, optional): angle to rotate object. Defaults to 0 + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -467,7 +464,7 @@ class SlotCenterToCenter(BaseSketchObject): rotation: float = 0, mode: Mode = Mode.ADD, ): - if center_separation <= 0: + if center_separation < 0: raise ValueError( f"Requires center_separation > 0. Got: {center_separation=}" ) @@ -478,29 +475,33 @@ class SlotCenterToCenter(BaseSketchObject): self.center_separation = center_separation self.slot_height = height - face = Face( - Wire( - [ - Edge.make_line(Vector(-center_separation / 2, 0, 0), Vector()), - Edge.make_line(Vector(), Vector(+center_separation / 2, 0, 0)), - ] - ).offset_2d(height / 2) - ) + if center_separation > 0: + face = Face( + Wire( + [ + Edge.make_line(Vector(-center_separation / 2, 0, 0), Vector()), + Edge.make_line(Vector(), Vector(+center_separation / 2, 0, 0)), + ] + ).offset_2d(height / 2) + ) + else: + face = cast(Face, Circle(height / 2, mode=mode).face()) + super().__init__(face, rotation, None, mode) class SlotOverall(BaseSketchObject): - """Sketch Object: Center to Center points Slot + """Sketch Object: Slot Overall - Add slot(s) defined by the overall with of the slot. + Create a slot defined by the overall width and height. Args: - width (float): overall width of the slot - height (float): diameter of end circles - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + width (float): overall width of slot + height (float): diameter of end arcs + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ _applies_to = [BuildSketch._tag] @@ -513,7 +514,7 @@ class SlotOverall(BaseSketchObject): align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - if width <= height: + if width < height: raise ValueError( f"Slot requires that width > height. Got: {width=}, {height=}" ) @@ -524,7 +525,7 @@ class SlotOverall(BaseSketchObject): self.width = width self.slot_height = height - if width != height: + if width > height: face = Face( Wire( [ @@ -535,27 +536,49 @@ class SlotOverall(BaseSketchObject): ) else: face = cast(Face, Circle(width / 2, mode=mode).face()) + super().__init__(face, rotation, align, mode) class Text(BaseSketchObject): """Sketch Object: Text - Add text(s) to the sketch. + Create text defined by text string and font size. + + Fonts installed to the system can be specified by name and FontStyle. Fonts with + subfamilies not in FontStyle should be specified with the subfamily name, e.g. + "Arial Black". Alternatively, a specific font file can be specified with font_path. + + Use `available_fonts()` to list available font names for `font` and FontStyles. + Note: on Windows, fonts must be installed with "Install for all users" to be found + by name. + + Not all fonts have every FontStyle available, however ITALIC and BOLDITALIC will + still italicize the font if the respective font file is not available. + + text_align specifies alignment of text inside the bounding box, while align the + aligns the bounding box itself. + + Optionally, the Text can be positioned on a non-linear edge or wire with a path and + position_on_path. Args: - txt (str): text to be rendered + txt (str): text to render font_size (float): size of the font in model units - font (str, optional): font name. Defaults to "Arial". - font_path (str, optional): system path to font library. Defaults to None. - font_style (Font_Style, optional): style. Defaults to Font_Style.REGULAR. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - path (Union[Edge, Wire], optional): path for text to follow. Defaults to None. - position_on_path (float, optional): the relative location on path to position the - text, values must be between 0.0 and 1.0. Defaults to 0.0. - rotation (float, optional): angles to rotate objects. Defaults to 0. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + font (str, optional): font name. Defaults to "Arial" + font_path (str, optional): system path to font file. Defaults to None + font_style (Font_Style, optional): font style, REGULAR, BOLD, BOLDITALIC, or + ITALIC. Defaults to Font_Style.REGULAR + text_align (tuple[TextAlign, TextAlign], optional): horizontal text align + LEFT, CENTER, or RIGHT. Vertical text align BOTTOM, CENTER, TOP, or + TOPFIRSTLINE. Defaults to (TextAlign.CENTER, TextAlign.CENTER) + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of + object. Defaults to None + path (Edge | Wire, optional): path for text to follow. Defaults to None + position_on_path (float, optional): the relative location on path to position + the text, values must be between 0.0 and 1.0. Defaults to 0.0 + rotation (float, optional): angle to rotate object. Defaults to 0 + mode (Mode, optional): combination mode. Defaults to Mode.ADD """ # pylint: disable=too-many-instance-attributes @@ -568,7 +591,8 @@ class Text(BaseSketchObject): font: str = "Arial", font_path: str | None = None, font_style: FontStyle = FontStyle.REGULAR, - align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), + text_align: tuple[TextAlign, TextAlign] = (TextAlign.CENTER, TextAlign.CENTER), + align: Align | tuple[Align, Align] | None = None, path: Edge | Wire | None = None, position_on_path: float = 0.0, rotation: float = 0.0, @@ -582,6 +606,7 @@ class Text(BaseSketchObject): self.font = font self.font_path = font_path self.font_style = font_style + self.text_align = text_align self.align = align self.text_path = path self.position_on_path = position_on_path @@ -594,6 +619,7 @@ class Text(BaseSketchObject): font=font, font_path=font_path, font_style=font_style, + text_align=text_align, align=align, position_on_path=position_on_path, text_path=path, @@ -604,18 +630,18 @@ class Text(BaseSketchObject): class Trapezoid(BaseSketchObject): """Sketch Object: Trapezoid - Add trapezoid(s) to the sketch. + Create a trapezoid defined by major width, height, and interior angle(s). Args: - width (float): horizontal width - height (float): vertical height + width (float): trapezoid major width + height (float): trapezoid height left_side_angle (float): bottom left interior angle right_side_angle (float, optional): bottom right interior angle. If not provided, - the trapezoid will be symmetric. Defaults to None. - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to (Align.CENTER, Align.CENTER). - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + the trapezoid will be symmetric. Defaults to None + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to (Align.CENTER, Align.CENTER) + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Give angles result in an invalid trapezoid @@ -685,21 +711,22 @@ class Trapezoid(BaseSketchObject): class Triangle(BaseSketchObject): """Sketch Object: Triangle - Add any triangle to the sketch by specifying the length of any side and any - two other side lengths or interior angles. Note that the interior angles are - opposite the side with the same designation (i.e. side 'a' is opposite angle 'A'). + Create a triangle defined by one side length and any of two other side lengths or interior + angles. The interior angles are opposite the side with the same designation + (i.e. side 'a' is opposite angle 'A'). Side 'a' is the bottom side, followed by 'b' + on the right, going counter-clockwise. Args: - a (float, optional): side 'a' length. Defaults to None. - b (float, optional): side 'b' length. Defaults to None. - c (float, optional): side 'c' length. Defaults to None. - A (float, optional): interior angle 'A' in degrees. Defaults to None. - B (float, optional): interior angle 'B' in degrees. Defaults to None. - C (float, optional): interior angle 'C' in degrees. Defaults to None. - rotation (float, optional): angles to rotate objects. Defaults to 0. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. - Defaults to None. - mode (Mode, optional): combination mode. Defaults to Mode.ADD. + a (float, optional): side 'a' length. Defaults to None + b (float, optional): side 'b' length. Defaults to None + c (float, optional): side 'c' length. Defaults to None + A (float, optional): interior angle 'A'. Defaults to None + B (float, optional): interior angle 'B'. Defaults to None + C (float, optional): interior angle 'C'. Defaults to None + rotation (float, optional): angle to rotate object. Defaults to 0 + align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of object. + Defaults to None + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: One length and two other values were not provided @@ -763,9 +790,15 @@ class Triangle(BaseSketchObject): self.vertex_A = topo_explore_common_vertex( self.edge_b, self.edge_c ) #: vertex 'A' + assert isinstance(self.vertex_A, Vertex) + self.vertex_A.topo_parent = self self.vertex_B = topo_explore_common_vertex( self.edge_a, self.edge_c ) #: vertex 'B' + assert isinstance(self.vertex_B, Vertex) + self.vertex_B.topo_parent = self self.vertex_C = topo_explore_common_vertex( self.edge_a, self.edge_b ) #: vertex 'C' + assert isinstance(self.vertex_C, Vertex) + self.vertex_C.topo_parent = self diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 80b0f00..2a2f007 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -119,11 +119,11 @@ def add( ( obj.unwrap(fully=False) if isinstance(obj, Compound) - else obj._obj if isinstance(obj, Builder) else obj + else obj._obj if isinstance(obj, Builder) and obj._obj is not None else obj ) for obj in object_list + if not (isinstance(obj, Builder) and obj._obj is None) ] - validate_inputs(context, "add", object_iter) if isinstance(context, BuildPart): @@ -364,11 +364,14 @@ def chamfer( return new_sketch if target._dim == 1: - target = ( - Wire(target.wrapped) - if isinstance(target, BaseLineObject) - else target.wires()[0] - ) + if isinstance(target, BaseLineObject): + if not target: + target = Wire([]) # empty wire + else: + target = Wire(target.wrapped) + else: + target = target.wires()[0] + if not all([isinstance(obj, Vertex) for obj in object_list]): raise ValueError("1D fillet operation takes only Vertices") # Remove any end vertices as these can't be filleted @@ -376,14 +379,8 @@ def chamfer( object_list = ShapeList( filter( lambda v: not ( - isclose_b( - (Vector(*v.to_tuple()) - target.position_at(0)).length, - 0.0, - ) - or isclose_b( - (Vector(*v.to_tuple()) - target.position_at(1)).length, - 0.0, - ) + isclose_b((Vector(v) - target.position_at(0)).length, 0.0) + or isclose_b((Vector(v) - target.position_at(1)).length, 0.0) ), object_list, ) @@ -467,11 +464,14 @@ def fillet( return new_sketch if target._dim == 1: - target = ( - Wire(target.wrapped) - if isinstance(target, BaseLineObject) - else target.wires()[0] - ) + if isinstance(target, BaseLineObject): + if not target: + target = Wire([]) # empty wire + else: + target = Wire(target.wrapped) + else: + target = target.wires()[0] + if not all([isinstance(obj, Vertex) for obj in object_list]): raise ValueError("1D fillet operation takes only Vertices") # Remove any end vertices as these can't be filleted @@ -479,14 +479,8 @@ def fillet( object_list = ShapeList( filter( lambda v: not ( - isclose_b( - (Vector(*v.to_tuple()) - target.position_at(0)).length, - 0.0, - ) - or isclose_b( - (Vector(*v.to_tuple()) - target.position_at(1)).length, - 0.0, - ) + isclose_b((Vector(v) - target.position_at(0)).length, 0.0) + or isclose_b((Vector(v) - target.position_at(1)).length, 0.0) ), object_list, ) @@ -758,9 +752,7 @@ def project( # The size of the object determines the size of the target projection screen # as the screen is normal to the direction of parallel projection - shape_list = [ - Vertex(*o.to_tuple()) if isinstance(o, Vector) else o for o in object_list - ] + shape_list = [Vertex(o) if isinstance(o, Vector) else o for o in object_list] object_size = Compound(children=shape_list).bounding_box(optimal=False).diagonal vct_vrt_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] @@ -821,7 +813,7 @@ def project( elif isinstance(context, BuildLine): projected_shapes.extend(projection) else: # BuildPart - projected_shapes.append(projection[0]) + projected_shapes.extend(projection.faces()) projected_points: ShapeList[Vector] = ShapeList() for pnt in point_list: diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index b843c1e..e3fe8dd 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -30,12 +30,13 @@ from __future__ import annotations from typing import cast from collections.abc import Iterable -from build123d.build_enums import Mode, Until, Kind, Side +from build123d.build_enums import GeomType, Mode, Until, Kind, Side from build123d.build_part import BuildPart from build123d.geometry import Axis, Plane, Vector, VectorLike from build123d.topology import ( Compound, Curve, + DraftAngleError, Edge, Face, Shell, @@ -55,6 +56,59 @@ from build123d.build_common import ( ) +def draft( + faces: Face | Iterable[Face], + neutral_plane: Plane, + angle: float, +) -> Part: + """Part Operation: draft + + Apply a draft angle to the given faces of the part + + Args: + faces: Faces to which the draft should be applied. + neutral_plane: Plane defining the neutral direction and position. + angle: Draft angle in degrees. + """ + context: BuildPart | None = BuildPart._get_context("draft") + + face_list: ShapeList[Face] = flatten_sequence(faces) + assert all(isinstance(f, Face) for f in face_list), "all faces must be of type Face" + validate_inputs(context, "draft", face_list) + + valid_geom_types = {GeomType.PLANE, GeomType.CYLINDER, GeomType.CONE} + unsupported = [f for f in face_list if f.geom_type not in valid_geom_types] + if unsupported: + raise ValueError( + f"Draft not supported on face(s) with geometry: " + f"{', '.join(set(f.geom_type.name for f in unsupported))}" + ) + + # Check that all the faces are associated with the same Solid + topo_parents = set(f.topo_parent for f in face_list if f.topo_parent is not None) + if len(topo_parents) != 1: + raise ValueError("All faces must share the same topological parent (a Solid)") + parent_solids = next(iter(topo_parents)).solids() + if len(parent_solids) != 1: + raise ValueError("Topological parent must be a single Solid") + + # Create the drafted solid + try: + new_solid = parent_solids[0].draft(face_list, neutral_plane, angle) + except DraftAngleError as err: + raise DraftAngleError( + f"Draft operation failed. " + f"Use `err.face` and `err.problematic_shape` for more information.", + face=err.face, + problematic_shape=err.problematic_shape, + ) from err + + if context is not None: + context._add_to_context(new_solid, clean=False, mode=Mode.REPLACE) + + return Part(Compound([new_solid]).wrapped) + + def extrude( to_extrude: Face | Sketch | None = None, amount: float | None = None, @@ -169,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, ) @@ -208,54 +262,82 @@ def loft( clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ + + def normalize_list_of_lists(lst): + lengths = {len(sub) for sub in lst} + if lengths <= {0}: + return [] + if lengths == {1}: + return [sub[0] for sub in lst] + if len(lengths) > 1: + raise ValueError("The number of holes in the sections must be the same") + if max(lengths) > 1: + raise ValueError( + f"loft supports a maximum of 1 hole per section but one or more section " + f"has {max(lengths)} hole - loft the perimeter and holes separately and " + f"subtract the holes" + ) + context: BuildPart | None = BuildPart._get_context("loft") section_list = flatten_sequence(sections) validate_inputs(context, "loft", section_list) - if all([s is None for s in section_list]): - if context is None or (context is not None and not context.pending_faces): + # If no explicit sections provided, use pending_faces from context + if all(s is None for s in section_list): + if context is None or not context.pending_faces: raise ValueError("No sections provided") - loft_wires = [face.outer_wire() for face in context.pending_faces] + input_sections = context.pending_faces context.pending_faces = [] context.pending_face_planes = [] else: - if not any(isinstance(s, Vertex) for s in section_list): - loft_wires = [ - face.outer_wire() - for section in section_list - for face in section.faces() - ] - elif any(isinstance(s, Vertex) for s in section_list) and any( - isinstance(s, (Face, Sketch)) for s in section_list + input_sections = section_list + + # Validate Vertex placement + if any(isinstance(s, Vertex) for s in input_sections): + if not isinstance(input_sections[0], Vertex) and not isinstance( + input_sections[-1], Vertex ): - if any(isinstance(s, Vertex) for s in section_list[1:-1]): - raise ValueError( - "Vertices must be the first, last, or first and last elements" - ) - loft_wires = [] - for s in section_list: - if isinstance(s, Vertex): - loft_wires.append(s) - elif isinstance(s, Face): - loft_wires.append(s.outer_wire()) - elif isinstance(s, Sketch): - loft_wires.extend([f.outer_wire() for f in s.faces()]) - elif all(isinstance(s, Vertex) for s in section_list): raise ValueError( - "At least one face/sketch is required if vertices are the first, last, " - "or first and last elements" + "Vertices must be the first, last, or first and last elements" + ) + if any(isinstance(s, Vertex) for s in input_sections[1:-1]): + raise ValueError( + "Vertices must be the first, last, or first and last elements" ) - new_solid = Solid.make_loft(loft_wires, ruled) + # Normalize all input into loft_sections: each is either a Vertex or a Wire + loft_sections = [] + hole_candidates = [] + for s in input_sections: + if isinstance(s, Vertex): + loft_sections.append(s) + else: + for face in s.faces(): + loft_sections.append(face.outer_wire()) + hole_candidates.append(face.inner_wires()) - # Try to recover an invalid loft - if not new_solid.is_valid(): - new_solid = Solid.make_solid(Shell.make_shell(new_solid.faces() + section_list)) - if clean: - new_solid = new_solid.clean() - if not new_solid.is_valid(): - raise RuntimeError("Failed to create valid loft") + holes = normalize_list_of_lists(hole_candidates) + + # Perform lofts + new_solid = Solid.make_loft(loft_sections, ruled) + if holes: + # Since the holes are interior a Solid will be generated here + new_solid = cast(Solid, new_solid.cut(Solid.make_loft(holes, ruled))) + + # Try to recover an invalid loft - untestable code + if not new_solid.is_valid: + try: + recovery_faces = new_solid.faces() + [ + s for s in loft_sections if isinstance(s, Face) + ] + new_solid = Solid(Shell(recovery_faces)) + if clean: + new_solid = new_solid.clean() + if not new_solid.is_valid: + raise ValueError("Recovery failed") + except Exception as e: + raise RuntimeError("Failed to create valid loft") from e if context is not None: context._add_to_context(new_solid, clean=clean, mode=mode) @@ -338,12 +420,10 @@ def make_brake_formed( raise TypeError("station_widths must be either a single number or an iterable") for vertex in line_vertices: - others = offset_vertices.sort_by_distance(Vector(vertex.X, vertex.Y, vertex.Z)) + others = offset_vertices.sort_by_distance(Vector(vertex)) for other in others[1:]: - if abs(Vector(*(vertex - other).to_tuple()).length - thickness) < 1e-2: - station_edges.append( - Edge.make_line(vertex.to_tuple(), other.to_tuple()) - ) + if abs(Vector((vertex - other)).length - thickness) < 1e-2: + station_edges.append(Edge.make_line(vertex, other)) break station_edges = station_edges.sort_by(line) @@ -371,6 +451,7 @@ def make_brake_formed( if context is not None: context._add_to_context(new_solid, clean=clean, mode=mode) + context.pending_edges = ShapeList() elif clean: new_solid = new_solid.clean() @@ -464,8 +545,9 @@ def revolve( # Make sure we account for users specifying angles larger than 360 degrees, and # for OCCT not assuming that a 0 degree revolve means a 360 degree revolve - angle = revolution_arc % 360.0 - angle = 360.0 if angle == 0 else angle + sign = 1 if revolution_arc >= 0 else -1 + angle = revolution_arc % (sign * 360.0) + angle = sign * 360.0 if angle == 0 else angle if all([s is None for s in profile_list]): if context is None or (context is not None and not context.pending_faces): @@ -516,7 +598,8 @@ def section( else: raise ValueError("No object to section") - max_size = to_section.bounding_box(optimal=False).diagonal + bbox = to_section.bounding_box(optimal=False) + max_size = max(abs(v) for v in list(bbox.min) + list(bbox.max)) + bbox.diagonal if section_by is not None: section_planes = ( diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index 4883be5..be5380c 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -31,19 +31,20 @@ from __future__ import annotations from collections.abc import Iterable from scipy.spatial import Voronoi -from build123d.build_enums import Mode, SortBy +from typing import cast +from build123d.build_enums import Mode, SortBy, Transition from build123d.topology import ( Compound, Curve, Edge, Face, ShapeList, + Shell, Wire, Sketch, topo_explore_connected_edges, - topo_explore_common_vertex, ) -from build123d.geometry import Vector, TOLERANCE +from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs from build123d.build_sketch import BuildSketch @@ -72,8 +73,7 @@ def full_round( ValueError: Invalid geometry Returns: - (Sketch, Vector, float): A tuple where the first value is the modified shape, the second the - geometric center of the arc, and the third the radius of the arc + Sketch: the modified shape """ context: BuildSketch | None = BuildSketch._get_context("full_round") @@ -82,6 +82,9 @@ def full_round( raise ValueError("A single Edge must be provided") validate_inputs(context, "full_round", edge) + if edge.topo_parent is None: + raise ValueError("edge must be extracted from shape") + # # Generate a set of evenly spaced points along the given edge and the # edges connected to it and use them to generate the Voronoi vertices @@ -112,24 +115,19 @@ def full_round( (float("inf"), int()), (float("inf"), int()), ] - for i, v in enumerate(voronoi_vertices): - distances = [edge_group[i].distance_to(v) for i in range(3)] + distances = [edge.distance_to(v) for edge in edge_group] avg_distance = sum(distances) / 3 - differences = max(abs(dist - avg_distance) for dist in distances) + difference = max(abs(d - avg_distance) for d in distances) - # Check if this delta is among the three smallest and update best_three if so - # Compare with the largest delta in the best three - if differences < best_three[-1][0]: - # Replace the last element with the new one - best_three[-1] = (differences, i) - # Sort the list to keep the smallest deltas first + # Prefer vertices with minimal difference + if difference < best_three[-1][0]: + best_three[-1] = (difference, i) best_three.sort(key=lambda x: x[0]) - # Extract the indices of the best three and average them - best_indices = [x[1] for x in best_three] - voronoi_circle_center: Vector = ( - sum((voronoi_vertices[i] for i in best_indices), Vector(0, 0, 0)) / 3.0 + # Refine by averaging the best three + voronoi_circle_center = ( + sum((voronoi_vertices[i] for _, i in best_three), Vector(0, 0, 0)) / 3 ) # Determine where the connected edges intersect with the largest empty circle @@ -137,73 +135,63 @@ def full_round( e.distance_to_with_closest_points(voronoi_circle_center)[1] for e in connected_edges ] + + # Determine where the target edge intersects with the largest empty circle middle_edge_arc_point = edge.distance_to_with_closest_points(voronoi_circle_center)[ 1 ] + + # Trim the connected edges to allow room for the circular feature + origin = sum(connected_edges_end_points, Vector(0, 0, 0)) / 2 + x_dir = (connected_edges_end_points[1] - connected_edges_end_points[0]).normalized() + to_arc_vec = origin - middle_edge_arc_point + # Project `to_arc_vec` onto the plane perpendicular to `x_dir` + z_dir = (to_arc_vec - x_dir * to_arc_vec.dot(x_dir)).normalized() + + split_pln = Plane(origin=origin, x_dir=x_dir, z_dir=z_dir) + trimmed_connected_edges = [e.split(split_pln) for e in connected_edges] + typed_trimmed_connected_edges = [] + for trimmed_edge in trimmed_connected_edges: + if trimmed_edge is None: + raise ValueError("Invalid geometry to create the end arc") + assert isinstance(trimmed_edge, Edge) + typed_trimmed_connected_edges.append(trimmed_edge) # Make mypy happy + + # Flip the middle point if the user wants the concave solution if invert: middle_edge_arc_point = voronoi_circle_center * 2 - middle_edge_arc_point - connected_edges_end_params = [ - e.param_at_point(connected_edges_end_points[i]) - for i, e in enumerate(connected_edges) - ] - for param in connected_edges_end_params: - if not 0.0 < param < 1.0: - raise ValueError("Invalid geometry to create the end arc") - - common_vertex_points = [ - Vector(topo_explore_common_vertex(edge, e)) for e in connected_edges - ] - common_vertex_params = [ - e.param_at_point(common_vertex_points[i]) for i, e in enumerate(connected_edges) - ] - - # Trim the connected edges to end at the closest points to the circle center - trimmed_connected_edges = [ - e.trim(*sorted([1.0 - common_vertex_params[i], connected_edges_end_params[i]])) - for i, e in enumerate(connected_edges) - ] - # Record the position of the newly trimmed connected edges to build the arc - # accurately - trimmed_end_points = [] - for i in range(2): - if ( - trimmed_connected_edges[i].position_at(0) - - connected_edges[i].position_at(0) - ).length < TOLERANCE: - trimmed_end_points.append(trimmed_connected_edges[i].position_at(1)) - else: - trimmed_end_points.append(trimmed_connected_edges[i].position_at(0)) # Generate the new circular edge new_arc = Edge.make_three_point_arc( - trimmed_end_points[0], + connected_edges_end_points[0], middle_edge_arc_point, - trimmed_end_points[1], + connected_edges_end_points[1], ) # Recover other edges - if edge.topo_parent is None: - other_edges: ShapeList[Edge] = ShapeList() - else: - other_edges = ( - edge.topo_parent.edges() - - topo_explore_connected_edges(edge) - - ShapeList([edge]) - ) + other_edges = ( + edge.topo_parent.edges() + - topo_explore_connected_edges(edge) + - ShapeList([edge]) + ) # Rebuild the face # Note that the longest wire must be the perimeter and others holes face_wires = Wire.combine( - trimmed_connected_edges + [new_arc] + other_edges + typed_trimmed_connected_edges + [new_arc] + other_edges ).sort_by(SortBy.LENGTH, reverse=True) pending_face = Face(face_wires[0], face_wires[1:]) + # Flip the face to match the original parent + if edge.topo_parent.faces()[0].normal_at() != pending_face.normal_at(): + pending_face = -pending_face + if context is not None: context._add_to_context(pending_face, mode=mode) context.pending_edges = ShapeList() # return Sketch(Compound([pending_face]).wrapped) - return Sketch([pending_face]), new_arc.arc_center, new_arc.radius + return Sketch([pending_face]) def make_face( @@ -310,10 +298,15 @@ def trace( else: raise ValueError("No objects to trace") + # Group the edges into wires to allow for nice transitions + trace_wires = Wire.combine(trace_edges) + new_faces: list[Face] = [] - for edge in trace_edges: - trace_pen = edge.perpendicular_line(line_width, 0) - new_faces.extend(Face.sweep(trace_pen, edge).faces()) + for to_trace in trace_wires: + trace_pen = to_trace.perpendicular_line(line_width, 0) + new_faces.extend( + Shell.sweep(trace_pen, to_trace, transition=Transition.RIGHT).faces() + ) if context is not None: context._add_to_context(*new_faces, mode=mode) context.pending_edges = ShapeList() diff --git a/src/build123d/topology/__init__.py b/src/build123d/topology/__init__.py index 2bf01d3..6471c29 100644 --- a/src/build123d/topology/__init__.py +++ b/src/build123d/topology/__init__.py @@ -5,7 +5,7 @@ name: __init__.py by: Gumyr date: January 07, 2025 -desc: +desc: This package contains modules for representing and manipulating 3D geometric shapes, including operations on vertices, edges, faces, solids, and composites. The package provides foundational classes to work with 3D objects, and methods to @@ -61,12 +61,13 @@ from .one_d import ( topo_explore_connected_faces, ) from .two_d import Face, Shell, Mixin2D, sort_wires_by_build_order -from .three_d import Solid, Mixin3D +from .three_d import Solid, Mixin3D, DraftAngleError from .composite import Compound, Curve, Sketch, Part __all__ = [ "Shape", "Comparable", + "DraftAngleError", "ShapePredicate", "GroupBy", "ShapeList", diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 5be6a8f..0919312 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -58,20 +58,31 @@ 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, Font_FA_Italic, Font_FA_Regular, Font_FontMgr, Font_SystemFont, ) +from OCP.gp import gp_Ax3 +from OCP.Graphic3d import ( + Graphic3d_HTA_LEFT, + Graphic3d_HTA_CENTER, + Graphic3d_HTA_RIGHT, + Graphic3d_VTA_BOTTOM, + Graphic3d_VTA_CENTER, + Graphic3d_VTA_TOP, + Graphic3d_VTA_TOPFIRSTLINE, +) from OCP.GProp import GProp_GProps from OCP.NCollection import NCollection_Utf8String from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder, StdPrs_BRepFont @@ -85,7 +96,7 @@ from OCP.TopoDS import ( TopoDS_Shape, ) from anytree import PreOrderIter -from build123d.build_enums import Align, CenterOf, FontStyle +from build123d.build_enums import Align, CenterOf, FontStyle, TextAlign from build123d.geometry import ( TOLERANCE, Axis, @@ -96,7 +107,6 @@ from build123d.geometry import ( VectorLike, logger, ) -from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D from .shape_core import ( @@ -119,7 +129,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 @@ -131,7 +141,6 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): order = 4.0 - project_to_viewport = Mixin1D.project_to_viewport # ---- Constructor ---- def __init__( @@ -155,7 +164,7 @@ class Compound(Mixin3D, Shape[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] @@ -203,6 +212,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): ta.TopAbs_SHELL: Shell, ta.TopAbs_SOLID: Solid, ta.TopAbs_COMPOUND: Compound, + ta.TopAbs_COMPSOLID: Compound, } shape_type = shapetype(obj) @@ -237,7 +247,8 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): font: str = "Arial", font_path: str | None = None, font_style: FontStyle = FontStyle.REGULAR, - align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), + text_align: tuple[TextAlign, TextAlign] = (TextAlign.CENTER, TextAlign.CENTER), + align: Align | tuple[Align, Align] | None = None, position_on_path: float = 0.0, text_path: Edge | Wire | None = None, ) -> Compound: @@ -253,12 +264,15 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): font_size: size of the font in model units font: font name font_path: path to font file - font_style: text style. Defaults to FontStyle.REGULAR. + font_style: text style. Defaults to FontStyle.REGULAR + text_align (tuple[TextAlign, TextAlign], optional): horizontal text align + LEFT, CENTER, or RIGHT. Vertical text align BOTTOM, CENTER, TOP, or + TOPFIRSTLINE. Defaults to (TextAlign.CENTER, TextAlign.CENTER) align (Union[Align, tuple[Align, Align]], optional): align min, center, or max - of object. Defaults to (Align.CENTER, Align.CENTER). + of object. Defaults to None position_on_path: the relative location on path to position the text, - between 0.0 and 1.0. Defaults to 0.0. - text_path: a path for the text to follows. Defaults to None - linear text. + between 0.0 and 1.0. Defaults to 0.0 + text_path: a path for the text to follows. Defaults to None (linear text) Returns: a Compound object containing multiple Faces representing the text @@ -305,8 +319,39 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): FontStyle.REGULAR: Font_FA_Regular, FontStyle.BOLD: Font_FA_Bold, FontStyle.ITALIC: Font_FA_Italic, + FontStyle.BOLDITALIC: Font_FA_BoldItalic, }[font_style] + if text_align[0] not in [TextAlign.LEFT, TextAlign.CENTER, TextAlign.RIGHT]: + raise ValueError( + "Horizontal TextAlign must be LEFT, CENTER, or RIGHT. " + f"Got {text_align[0]}" + ) + + if text_align[1] not in [ + TextAlign.BOTTOM, + TextAlign.CENTER, + TextAlign.TOP, + TextAlign.TOPFIRSTLINE, + ]: + raise ValueError( + "Vertical TextAlign must be BOTTOM, CENTER, TOP, or TOPFIRSTLINE. " + f"Got {text_align[1]}" + ) + + horiz_align = { + TextAlign.LEFT: Graphic3d_HTA_LEFT, + TextAlign.CENTER: Graphic3d_HTA_CENTER, + TextAlign.RIGHT: Graphic3d_HTA_RIGHT, + }[text_align[0]] + + vert_align = { + TextAlign.BOTTOM: Graphic3d_VTA_BOTTOM, + TextAlign.CENTER: Graphic3d_VTA_CENTER, + TextAlign.TOP: Graphic3d_VTA_TOP, + TextAlign.TOPFIRSTLINE: Graphic3d_VTA_TOPFIRSTLINE, + }[text_align[1]] + mgr = Font_FontMgr.GetInstance_s() if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()): @@ -329,7 +374,18 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): font_kind, float(font_size), ) - text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt))) + + text_flat = Compound( + TopoDS.Compound_s( + builder.Perform( + font_i, + NCollection_Utf8String(txt), + gp_Ax3(), + horiz_align, + vert_align, + ) + ) + ) # Align the text from the bounding box align_text = tuplify(align, 2) @@ -396,16 +452,23 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): # ---- Instance Methods ---- - def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound: + def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound | Wire: """Combine other to self `+` operator Note that if all of the objects are connected Edges/Wires the result will be a Wire, otherwise a Shape. """ if self._dim == 1: - curve = Curve() if self.wrapped is None else Curve(self.wrapped) - self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"]) - return curve + other + 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) + elif isinstance(sum1d, Edge): + result1d = Curve([sum1d]) + else: # Wire + result1d = sum1d + self.copy_attributes_to(result1d, ["wrapped", "_NodeMixin__children"]) + return result1d summands: ShapeList[Shape] if other is None: @@ -447,6 +510,8 @@ class Compound(Mixin3D, Shape[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] ) @@ -458,7 +523,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]: """ @@ -475,7 +540,7 @@ class Compound(Mixin3D, Shape[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 @@ -534,7 +599,7 @@ class Compound(Mixin3D, Shape[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, @@ -543,7 +608,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 @@ -592,11 +657,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, @@ -647,11 +708,182 @@ class Compound(Mixin3D, Shape[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 + 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: + # Wrap Shape._bool_op for corrected output + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return ShapeList() + + def expand_compound(compound: Compound) -> ShapeList: + shapes = ShapeList(compound.children) + for shape_type in [Vertex, Edge, Wire, Face, Shell, Solid]: + shapes.extend(compound.get_type(shape_type)) + 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[Shape] = expand_compound(self) + target: ShapeList | Shape + for other in to_intersect: + # Conform target type + match other: + case Axis(): + # 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(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[Vertex | Edge | Wire | Face | Shell | Solid] = [] + result: ShapeList + for obj in common_set: + if isinstance(target, Shape): + target = ShapeList([target]) + result = ShapeList() + for t in target: + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( + 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(result) + + expanded: ShapeList = ShapeList() + if 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] + ) + else: + return None + + 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 @@ -705,8 +937,8 @@ class Compound(Mixin3D, Shape[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`.""" diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py new file mode 100644 index 0000000..4e53ddb --- /dev/null +++ b/src/build123d/topology/constrained_lines.py @@ -0,0 +1,822 @@ +""" +build123d topology + +name: constrained_lines.py +by: Gumyr +date: September 07, 2025 + +desc: + +This module generates lines and arcs that are constrained against other objects. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +from __future__ import annotations + +from math import atan2, cos, isnan, sin +from typing import overload, TYPE_CHECKING, Callable, TypeVar +from typing import cast as tcast + +from OCP.BRep import BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeVertex +from OCP.GCPnts import GCPnts_AbscissaPoint +from OCP.Geom import Geom_Curve, Geom_Plane +from OCP.Geom2d import ( + Geom2d_CartesianPoint, + Geom2d_Circle, + Geom2d_Curve, + Geom2d_Line, + Geom2d_Point, + Geom2d_TrimmedCurve, +) +from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve +from OCP.Geom2dAPI import Geom2dAPI_ProjectPointOnCurve, Geom2dAPI_InterCurveCurve +from OCP.Geom2dGcc import ( + Geom2dGcc_Circ2d2TanOn, + Geom2dGcc_Circ2d2TanRad, + Geom2dGcc_Circ2d3Tan, + Geom2dGcc_Circ2dTanCen, + Geom2dGcc_Circ2dTanOnRad, + Geom2dGcc_Lin2dTanObl, + Geom2dGcc_Lin2d2Tan, + Geom2dGcc_QualifiedCurve, +) +from OCP.GeomAPI import GeomAPI +from OCP.gp import ( + gp_Ax2d, + gp_Ax3, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Lin2d, + gp_Pln, + gp_Pnt, + gp_Pnt2d, +) +from OCP.IntAna2d import IntAna2d_AnaIntersection +from OCP.Standard import Standard_ConstructionError, Standard_Failure +from OCP.TopoDS import TopoDS_Edge, TopoDS_Vertex + +from build123d.build_enums import Sagitta, Tangency +from build123d.geometry import Axis, TOLERANCE, Vector, VectorLike +from .zero_d import Vertex +from .shape_core import ShapeList + +if TYPE_CHECKING: + from build123d.topology.one_d import Edge # pragma: no cover + +TWrap = TypeVar("TWrap") # whatever the factory returns (Edge or a subclass) + +# Reuse a single XY plane for 3D->2D projection and for 2D-edge building +_pln_xy = gp_Pln(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0))) +_surf_xy = Geom_Plane(_pln_xy) + + +# --------------------------- +# Normalization utilities +# --------------------------- +def _norm_on_period(u: float, first: float, period: float) -> float: + """Map parameter u into [first, first+per).""" + return (u - first) % period + first + + +def _forward_delta(u1: float, u2: float, first: float, period: float) -> float: + """ + Forward (positive) delta from u1 to u2 on a periodic domain anchored at + 'first'. + """ + u1n = _norm_on_period(u1, first, period) + u2n = _norm_on_period(u2, first, period) + delta = u2n - u1n + if delta < 0.0: + delta += period + return delta + + +# --------------------------- +# Core helpers +# --------------------------- +def _edge_to_qualified_2d( + edge: TopoDS_Edge, position_constaint: Tangency +) -> tuple[Geom2dGcc_QualifiedCurve, Geom2d_Curve, float, float, Geom2dAdaptor_Curve]: + """Convert a TopoDS_Edge into 2d curve & extract properties""" + + # 1) Underlying curve + range (also retrieve location to be safe) + hcurve3d = BRep_Tool.Curve_s(edge, float(), float()) + first, last = BRep_Tool.Range_s(edge) + + # 2) Convert to 2D on Plane.XY (Z-up frame at origin) + hcurve2d = GeomAPI.To2d_s(hcurve3d, _pln_xy) # -> Handle_Geom2d_Curve + + # 3) Wrap in an adaptor using the same parametric range + adapt2d = Geom2dAdaptor_Curve(hcurve2d, first, last) + + # 4) Create the qualified curve (unqualified is fine here) + qcurve = Geom2dGcc_QualifiedCurve(adapt2d, position_constaint.value) + return qcurve, hcurve2d, first, last, adapt2d + + +def _edge_from_circle(h2d_circle: Geom2d_Circle, u1: float, u2: float) -> TopoDS_Edge: + """Build a 3D edge on XY from a trimmed 2D circle segment [u1, u2].""" + arc2d = Geom2d_TrimmedCurve(h2d_circle, u1, u2, True) # sense=True + return BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge() + + +def _param_in_trim( + u: float | None, first: float | None, last: float | None, h2d: Geom2d_Curve | None +) -> bool: + """Normalize (if periodic) then test [first, last] with tolerance.""" + if u is None or first is None or last is None or h2d is None: # for typing + raise TypeError("Invalid parameters to _param_in_trim") + u = _norm_on_period(u, first, h2d.Period()) if h2d.IsPeriodic() else u + return (u >= first - TOLERANCE) and (u <= last + TOLERANCE) + + +@overload +def _as_gcc_arg( + obj: Edge, constaint: Tangency +) -> tuple[ + Geom2dGcc_QualifiedCurve, Geom2d_Curve | None, float | None, float | None, bool +]: ... +@overload +def _as_gcc_arg( + obj: Vector, constaint: Tangency +) -> tuple[Geom2d_CartesianPoint, None, None, None, bool]: ... + + +def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ + Geom2dGcc_QualifiedCurve | Geom2d_CartesianPoint, + Geom2d_Curve | None, + float | None, + float | None, + bool, +]: + """ + Normalize input to a GCC argument. + Returns: (q_obj, h2d, first, last, is_edge) + - Edge -> (QualifiedCurve, h2d, first, last, True) + - Vector -> (CartesianPoint, None, None, None, False) + """ + if not obj: + raise TypeError("Can't create a qualified curve from empty edge") + + if isinstance(obj.wrapped, TopoDS_Edge): + return _edge_to_qualified_2d(obj.wrapped, constaint)[0:4] + (True,) + + gp_pnt = gp_Pnt2d(obj.X, obj.Y) + return Geom2d_CartesianPoint(gp_pnt), None, None, None, False + + +def _two_arc_edges_from_params( + circ: gp_Circ2d, u1: float, u2: float +) -> list[TopoDS_Edge]: + """ + Given two parameters on a circle, return both the forward (minor) + and complementary (major) arcs as TopoDS_Edge(s). + Uses centralized normalization utilities. + """ + h2d_circle = Geom2d_Circle(circ) + period = h2d_circle.Period() # usually 2*pi + + # Minor (forward) span + d = _forward_delta(u1, u2, 0.0, period) # anchor at 0 for circle convenience + u1n = _norm_on_period(u1, 0.0, period) + u2n = _norm_on_period(u2, 0.0, period) + + # Guard degeneracy + if d <= TOLERANCE or abs(period - d) <= TOLERANCE: + return ShapeList() + + minor = _edge_from_circle(h2d_circle, u1n, u1n + d) + major = _edge_from_circle(h2d_circle, u2n, u2n + (period - d)) + return [minor, major] + + +def _edge_from_line( + p1: gp_Pnt2d, + p2: gp_Pnt2d, +) -> TopoDS_Edge: + """ + Build a finite Edge from two 2D contact points. + + Parameters + ---------- + p1, p2 : gp_Pnt2d + Endpoints of the line segment (in 2D). + edge_factory : type[Edge], optional + Factory for building the Edge subtype (defaults to Edge). + + Returns + ------- + TopoDS_Edge + Finite line segment between the two points. + """ + v1 = BRepBuilderAPI_MakeVertex(gp_Pnt(p1.X(), p1.Y(), 0)).Vertex() + v2 = BRepBuilderAPI_MakeVertex(gp_Pnt(p2.X(), p2.Y(), 0)).Vertex() + + mk_edge = BRepBuilderAPI_MakeEdge(v1, v2) + if not mk_edge.IsDone(): + raise RuntimeError("Failed to build edge from line contacts") + return mk_edge.Edge() + + +def _gp_lin2d_from_axis(ax: Axis) -> gp_Lin2d: + """Build a 2D reference line from an Axis (XY plane).""" + p = gp_Pnt2d(ax.position.X, ax.position.Y) + d = gp_Dir2d(ax.direction.X, ax.direction.Y) + return gp_Lin2d(gp_Ax2d(p, d)) + + +def _qstr(q) -> str: # pragma: no cover + """Debugging facility that works with OCP's GccEnt enum values""" + try: + from OCP.GccEnt import GccEnt_enclosed, GccEnt_enclosing, GccEnt_outside + + try: + from OCP.GccEnt import GccEnt_unqualified + except ImportError: + # Some OCCT versions name this 'noqualifier' + from OCP.GccEnt import GccEnt_noqualifier as GccEnt_unqualified + mapping = { + GccEnt_enclosed: "enclosed", + GccEnt_enclosing: "enclosing", + GccEnt_outside: "outside", + GccEnt_unqualified: "unqualified", + } + return mapping.get(q, f"unknown({int(q)})") + except Exception: + # Fallback if enums aren't importable for any reason + return str(int(q)) + + +def _make_2tan_rad_arcs( + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 + radius: float, + sagitta: Sagitta = Sagitta.SHORT, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. + + Inputs must be coplanar with ``Plane.XY``. Non-coplanar edges are not supported. + + Args: + tangencies (tuple[Edge, PositionConstraint] | Edge | Vertex | VectorLike: + Geometric entity to be contacted/touched by the circle(s) + radius (float): Circle radius for all candidate solutions. + + Raises: + ValueError: Invalid input + ValueError: Invalid curve + RuntimeError: no valid circle solutions found + + Returns: + ShapeList[Edge]: A list of planar circular edges (on XY) representing both + the minor and major arcs between the two tangency points for every valid + circle solution. + + """ + + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + tangent_tuples = [ + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies + ] + + # Build inputs for GCC + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) + + gcc = Geom2dGcc_Circ2d2TanRad(*q_o, radius, TOLERANCE) + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc") + + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) + + # --------------------------- + # Solutions + # --------------------------- + solutions: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ: gp_Circ2d = gcc.ThisSolution(i) + + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _ok(0, u_arg1): + continue + + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _ok(1, u_arg2): + continue + + # qual1 = GccEnt_Position(int()) + # qual2 = GccEnt_Position(int()) + # gcc.WhichQualifier(i, qual1, qual2) # returns two GccEnt_Position values + # print( + # f"Solution {i}: " + # f"arg1={_qstr(qual1)}, arg2={_qstr(qual2)} | " + # f"u_circ=({u_circ1:.6g}, {u_circ2:.6g}) " + # f"u_arg=({u_arg1:.6g}, {u_arg2:.6g})" + # ) + + # Build BOTH sagitta arcs and select by LengthConstraint + if sagitta == Sagitta.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + arcs = sorted( + arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + ) + solutions.append(arcs[sagitta.value]) + return ShapeList([edge_factory(e) for e in solutions]) + + +def _make_2tan_on_arcs( + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 2 + center_on: Edge, + sagitta: Sagitta = Sagitta.SHORT, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Create all planar circular arcs whose circle is tangent to two objects and whose + CENTER lies on a given locus (line/circle/curve) on the XY plane. + + Notes + ----- + - `center_on` is treated as a **center locus** (not a tangency target). + """ + + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + tangent_tuples = [ + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) + for t in list(tangencies) + [center_on] + ] + + # Build inputs for GCC + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[ + Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve + ] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) + adapt_on = Geom2dAdaptor_Curve(h_e[2], e_first[2], e_last[2]) + + # Provide initial middle guess parameters for all of the edges + guesses: list[float] = [ + (e_last[i] - e_first[i]) / 2 + e_first[i] + for i in range(len(tangent_tuples)) + if is_edge[i] + ] + + if sum(is_edge) > 1: + gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE, *guesses) + else: + assert isinstance(q_o[0], Geom2d_Point) + assert isinstance(q_o[1], Geom2d_Point) + gcc = Geom2dGcc_Circ2d2TanOn(q_o[0], q_o[1], adapt_on, TOLERANCE) + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find a tangent arc with center_on constraint") + + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) + + # --------------------------- + # Solutions + # --------------------------- + solutions: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ: gp_Circ2d = gcc.ThisSolution(i) + + # Tangency on curve 1 + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _ok(0, u_arg1): + continue + + # Tangency on curve 2 + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _ok(1, u_arg2): + continue + + # Build sagitta arc(s) and select by LengthConstraint + if sagitta == Sagitta.BOTH: + solutions.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + arcs = sorted( + arcs, key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)) + ) + solutions.append(arcs[sagitta.value]) + + return ShapeList([edge_factory(e) for e in solutions]) + + +def _make_3tan_arcs( + *tangencies: tuple[Edge, Tangency] | Edge | Vector, # 3 + sagitta: Sagitta = Sagitta.SHORT, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Create planar circular arc(s) on XY tangent to three provided objects. + + The circle is determined by the three tangency constraints; the returned arc(s) + are trimmed between the two tangency points corresponding to `tangencies[0]` and + `tangencies[1]`. Use `sagitta` to select the shorter/longer (or both) arc. + Inputs must be representable on Plane.XY. + """ + + # Unpack optional per-edge qualifiers (default UNQUALIFIED) + tangent_tuples = [ + t if isinstance(t, tuple) else (t, Tangency.UNQUALIFIED) for t in tangencies + ] + + # Build inputs for GCC + results = [_as_gcc_arg(*t) for t in tangent_tuples] + q_o: tuple[ + Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve, Geom2dGcc_QualifiedCurve + ] + q_o, h_e, e_first, e_last, is_edge = map(tuple, zip(*results)) + + # Provide initial middle guess parameters for all of the edges + guesses: tuple[float, float, float] = tuple( + [(e_last[i] - e_first[i]) / 2 + e_first[i] for i in range(3)] + ) + + # Generate all valid circles tangent to the 3 inputs + msg = "Unable to find a circle tangent to all three objects" + try: + gcc = Geom2dGcc_Circ2d3Tan(*q_o, TOLERANCE, *guesses) + except (Standard_ConstructionError, Standard_Failure) as con_err: + raise RuntimeError(msg) from con_err + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError(msg) + + def _ok(i: int, u: float) -> bool: + """Does the given parameter value lie within the edge range?""" + return ( + True if not is_edge[i] else _param_in_trim(u, e_first[i], e_last[i], h_e[i]) + ) + + # --------------------------- + # Enumerate solutions + # --------------------------- + out_topos: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ: gp_Circ2d = gcc.ThisSolution(i) + + # Look at all of the solutions + # h2d_circle = Geom2d_Circle(circ) + # arc2d = Geom2d_TrimmedCurve(h2d_circle, 0, 2 * pi, True) + # out_topos.append(BRepBuilderAPI_MakeEdge(arc2d, _surf_xy).Edge()) + # continue + + # Tangency on curve 1 (arc endpoint A) + p1 = gp_Pnt2d() + u_circ1, u_arg1 = gcc.Tangency1(i, p1) + if not _ok(0, u_arg1): + continue + + # Tangency on curve 2 (arc endpoint B) + p2 = gp_Pnt2d() + u_circ2, u_arg2 = gcc.Tangency2(i, p2) + if not _ok(1, u_arg2): + continue + + # Tangency on curve 3 (validates circle; does not define arc endpoints) + p3 = gp_Pnt2d() + _u_circ3, u_arg3 = gcc.Tangency3(i, p3) + if not _ok(2, u_arg3): + continue + + # Build arc(s) between u_circ1 and u_circ2 per LengthConstraint + if sagitta == Sagitta.BOTH: + out_topos.extend(_two_arc_edges_from_params(circ, u_circ1, u_circ2)) + else: + arcs = _two_arc_edges_from_params(circ, u_circ1, u_circ2) + arcs = sorted( + arcs, + key=lambda e: GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e)), + ) + out_topos.append(arcs[sagitta.value]) + + return ShapeList([edge_factory(e) for e in out_topos]) + + +def _make_tan_cen_arcs( + tangency: tuple[Edge, Tangency] | Edge | Vector, + *, + center: VectorLike | Vertex, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Create planar circle(s) on XY whose center is fixed and that are tangent/contacting + a single object. + + Notes + ----- + - With a **fixed center** and a single tangency constraint, the natural geometric + result is a full circle; there are no second endpoints to define an arc span. + This routine therefore returns closed circular edges (full 2π trims). + - If the tangency target is a point (Vertex/VectorLike), the circle is the one + centered at `center` and passing through that point (built directly). + """ + + # Unpack optional qualifier on the tangency arg (edges only) + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency + else: + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED + + # --------------------------- + # Build fixed center (gp_Pnt2d) + # --------------------------- + if isinstance(center, Vertex): + loc_xyz = center.position if center.position is not None else Vector(0, 0) + base = Vector(center) + c2d = gp_Pnt2d(base.X + loc_xyz.X, base.Y + loc_xyz.Y) + else: + v = Vector(center) + c2d = gp_Pnt2d(v.X, v.Y) + + # --------------------------- + # Tangency input + # --------------------------- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) + + solutions_topo: list[TopoDS_Edge] = [] + + # Case A: tangency target is a point -> circle passes through that point + if not is_edge1 and isinstance(q_o1, Geom2d_CartesianPoint): + p = q_o1.Pnt2d() + # radius = distance(center, point) + dx, dy = p.X() - c2d.X(), p.Y() - c2d.Y() + r = (dx * dx + dy * dy) ** 0.5 + if r <= TOLERANCE: + # Center coincides with point: no valid circle + return ShapeList([]) + # Build full circle + circ = gp_Circ2d(gp_Ax2d(c2d, gp_Dir2d(1.0, 0.0)), r) + h2d = Geom2d_Circle(circ) + per = h2d.Period() + solutions_topo.append(_edge_from_circle(h2d, 0.0, per)) + + else: + assert isinstance(q_o1, Geom2dGcc_QualifiedCurve) + # Case B: tangency target is a curve/edge (qualified curve) + gcc = Geom2dGcc_Circ2dTanCen(q_o1, Geom2d_CartesianPoint(c2d), TOLERANCE) + assert ( + gcc.IsDone() and gcc.NbSolutions() > 0 + ), "Unexpected: GCC failed to return a tangent circle" + + for i in range(1, gcc.NbSolutions() + 1): + circ = gcc.ThisSolution(i) # gp_Circ2d + + # Validate tangency lies on trimmed span if the target is an Edge + p1 = gp_Pnt2d() + _u_on_circ, u_on_arg = gcc.Tangency1(i, p1) + if is_edge1 and not _param_in_trim(u_on_arg, e1_first, e1_last, h_e1): + continue + + # Emit full circle (2π trim) + h2d = Geom2d_Circle(circ) + per = h2d.Period() + solutions_topo.append(_edge_from_circle(h2d, 0.0, per)) + + return ShapeList([edge_factory(e) for e in solutions_topo]) + + +def _make_tan_on_rad_arcs( + tangency: tuple[Edge, Tangency] | Edge | Vector, + *, + center_on: Edge, + radius: float, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Create planar circle(s) on XY that: + - are tangent/contacting a single object, and + - have a fixed radius, and + - have their CENTER constrained to lie on a given locus curve. + + Notes + ----- + - The center locus must be a 2D curve (line/circle/any Geom2d curve) — i.e. an Edge + after projection to XY. + - With only one tangency, the natural geometric result is a full circle; arc cropping + would require an additional endpoint constraint. This routine therefore returns + closed circular edges (2π trims) for each valid solution. + """ + + # --- unpack optional qualifier on the tangency arg (edges only) --- + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency + else: + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED + + # --- build tangency input (point/edge) --- + q_o1, h_e1, e1_first, e1_last, is_edge1 = _as_gcc_arg(object_one, obj1_qual) + + # --- center locus ('center_on') must be a curve; ignore any qualifier there --- + on_obj = center_on[0] if isinstance(center_on, tuple) else center_on + if not isinstance(on_obj.wrapped, TopoDS_Edge): + raise TypeError("center_on must be an Edge (line/circle/curve) for TanOnRad.") + + # Project the center locus Edge to 2D (XY) + _, h_on2d, on_first, on_last, adapt_on = _edge_to_qualified_2d( + on_obj.wrapped, Tangency.UNQUALIFIED + ) + gcc = Geom2dGcc_Circ2dTanOnRad(q_o1, adapt_on, radius, TOLERANCE) + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find circle(s) for TanOnRad constraints") + + def _ok1(u: float) -> bool: + return True if not is_edge1 else _param_in_trim(u, e1_first, e1_last, h_e1) + + # --- enumerate solutions; emit full circles (2π trims) --- + out_topos: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + circ: gp_Circ2d = gcc.ThisSolution(i) + + # Validate tangency lies on trimmed span when the target is an Edge + p = gp_Pnt2d() + _u_on_circ, u_on_arg = gcc.Tangency1(i, p) + if not _ok1(u_on_arg): + continue + + # Center must lie on the trimmed center_on curve segment + center2d = circ.Location() # gp_Pnt2d + + # Project center onto the (trimmed) 2D locus + proj = Geom2dAPI_ProjectPointOnCurve(center2d, h_on2d) + u_on = proj.Parameter(1) + + # Respect the trimmed interval (handles periodic curves too) + if not _param_in_trim(u_on, on_first, on_last, h_on2d): + continue + + h2d = Geom2d_Circle(circ) + per = h2d.Period() + out_topos.append(_edge_from_circle(h2d, 0.0, per)) + + return ShapeList([edge_factory(e) for e in out_topos]) + + +# ----------------------------------------------------------------------------- +# Line solvers (siblings of constrained arcs) +# ----------------------------------------------------------------------------- + + +def _make_2tan_lines( + tangency1: tuple[Edge, Tangency] | Edge, + tangency2: tuple[Edge, Tangency] | Edge | Vector, + *, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Construct line(s) tangent to two curves. + + Parameters + ---------- + curve1, curve2 : Edge + Target curves. + + Returns + ------- + ShapeList[Edge] + Finite tangent line(s). + """ + if isinstance(tangency1, tuple): + object_one, obj1_qual = tangency1 + else: + object_one, obj1_qual = tangency1, Tangency.UNQUALIFIED + q1, c1, _, _, _ = _as_gcc_arg(object_one, obj1_qual) + + if isinstance(tangency2, Vector): + pnt_2d = gp_Pnt2d(tangency2.X, tangency2.Y) + gcc = Geom2dGcc_Lin2d2Tan(q1, pnt_2d, TOLERANCE) + else: + if isinstance(tangency2, tuple): + object_two, obj2_qual = tangency2 + else: + object_two, obj2_qual = tangency2, Tangency.UNQUALIFIED + q2, c2, _, _, _ = _as_gcc_arg(object_two, obj2_qual) + gcc = Geom2dGcc_Lin2d2Tan(q1, q2, TOLERANCE) + + if not gcc.IsDone() or gcc.NbSolutions() == 0: + raise RuntimeError("Unable to find common tangent line(s)") + + out_edges: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + lin2d = Geom2d_Line(gcc.ThisSolution(i)) + + # Two tangency points - Note Tangency1/Tangency2 can use different + # indices for the same line + inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c1) + pt1 = inter_cc.Point(1) # There will always be one tangent intersection + + if isinstance(tangency2, Vector): + pt2 = gp_Pnt2d(tangency2.X, tangency2.Y) + else: + inter_cc = Geom2dAPI_InterCurveCurve(lin2d, c2) + pt2 = inter_cc.Point(1) + + # Skip degenerate lines + separation = pt1.Distance(pt2) + if isnan(separation) or separation < TOLERANCE: + continue + + out_edges.append(_edge_from_line(pt1, pt2)) + return ShapeList([edge_factory(e) for e in out_edges]) + + +def _make_tan_oriented_lines( + tangency: tuple[Edge, Tangency] | Edge, + reference: Axis, + angle: float, # radians; absolute angle offset from `reference` + *, + edge_factory: Callable[[TopoDS_Edge], Edge], +) -> ShapeList[Edge]: + """ + Construct line(s) tangent to a curve and forming a given angle with a + reference line (Axis) per Geom2dGcc_Lin2dTanObl. Trimmed between: + - the tangency point on the curve, and + - the intersection with the reference line. + """ + if isinstance(tangency, tuple): + object_one, obj1_qual = tangency + else: + object_one, obj1_qual = tangency, Tangency.UNQUALIFIED + + if abs(abs(reference.direction.Z) - 1) < TOLERANCE: + raise ValueError("reference Axis can't be perpendicular to Plane.XY") + + q_curve, _, _, _, _ = _as_gcc_arg(object_one, obj1_qual) + + # reference axis direction (2D angle in radians) + ref_dir = reference.direction + theta_ref = atan2(ref_dir.Y, ref_dir.X) + + # total absolute angle + theta_abs = theta_ref + angle + + dir2d = gp_Dir2d(cos(theta_abs), sin(theta_abs)) + + # Reference axis as gp_Lin2d + ref_lin = _gp_lin2d_from_axis(reference) + + # Note that is seems impossible for Geom2dGcc_Lin2dTanObl to provide no solutions + gcc = Geom2dGcc_Lin2dTanObl(q_curve, ref_lin, TOLERANCE, angle) + + out: list[TopoDS_Edge] = [] + for i in range(1, gcc.NbSolutions() + 1): + # Tangency on the curve + p_tan = gp_Pnt2d() + gcc.Tangency1(i, p_tan) + + tan_line = gp_Lin2d(p_tan, dir2d) + + # Intersect with reference axis + # Note: Intersection2 doesn't seem reliable + inter = IntAna2d_AnaIntersection(tan_line, ref_lin) + if not inter.IsDone() or inter.NbPoints() == 0: + continue + p_isect = inter.Point(1).Value() + + # Skip degenerate lines + separation = p_tan.Distance(p_isect) + if isnan(separation) or separation < TOLERANCE: + continue + + out.append(_edge_from_line(p_tan, p_isect)) + + return ShapeList([edge_factory(e) for e in out]) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 366cf51..d2a4913 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -52,17 +52,14 @@ license: from __future__ import annotations import copy -import itertools import warnings -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from itertools import combinations -from math import radians, inf, pi, cos, copysign, ceil, floor -from typing import Literal, overload, TYPE_CHECKING -from typing_extensions import Self -from numpy import ndarray -from scipy.optimize import minimize -from scipy.spatial import ConvexHull +from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians +from typing import TYPE_CHECKING, Literal, overload +from typing import cast as tcast +import numpy as np import OCP.TopAbs as ta from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve @@ -75,66 +72,110 @@ from OCP.BRepBuilderAPI import ( BRepBuilderAPI_DisconnectedWire, BRepBuilderAPI_EmptyWire, BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeEdge2d, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeWire, BRepBuilderAPI_NonManifoldWire, ) -from OCP.BRepExtrema import BRepExtrema_DistShapeShape +from OCP.BRepExtrema import BRepExtrema_DistShapeShape, BRepExtrema_SupportType from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepLib import BRepLib, BRepLib_FindSurface +from OCP.BRepLProp import BRepLProp from OCP.BRepOffset import BRepOffset_MakeOffset from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepProj import BRepProj_Projection -from OCP.BRepTools import BRepTools +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, + Geom_BSplineCurve, Geom_ConicalSurface, Geom_CylindricalSurface, + Geom_Line, Geom_Plane, Geom_Surface, Geom_TrimmedCurve, - Geom_Line, ) -from OCP.Geom2d import Geom2d_Curve, Geom2d_Line, Geom2d_TrimmedCurve +from OCP.Geom2d import ( + Geom2d_CartesianPoint, + Geom2d_Circle, + Geom2d_Curve, + Geom2d_Line, + Geom2d_Point, + Geom2d_TrimmedCurve, +) +from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve +from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve +from OCP.GeomAbs import ( + GeomAbs_C0, + GeomAbs_C1, + GeomAbs_C2, + GeomAbs_C3, + GeomAbs_CN, + GeomAbs_C1, + GeomAbs_G1, + GeomAbs_G2, + GeomAbs_JoinType, +) +from OCP.GeomAdaptor import GeomAdaptor_Curve from OCP.GeomAPI import ( + GeomAPI, GeomAPI_IntCS, GeomAPI_Interpolate, GeomAPI_PointsToBSpline, GeomAPI_ProjectPointOnCurve, ) -from OCP.GeomAbs import GeomAbs_JoinType -from OCP.GeomAdaptor import GeomAdaptor_Curve +from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve from OCP.GeomFill import ( GeomFill_CorrectedFrenet, GeomFill_Frenet, GeomFill_TrihedronLaw, ) +from OCP.GeomProjLib import GeomProjLib +from OCP.gp import ( + gp_Ax1, + gp_Ax2, + gp_Ax3, + gp_Circ, + gp_Circ2d, + gp_Dir, + gp_Dir2d, + gp_Elips, + gp_Pln, + gp_Pnt, + gp_Pnt2d, + gp_Trsf, + gp_Vec, +) +from OCP.GProp import GProp_GProps from OCP.HLRAlgo import HLRAlgo_Projector from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe -from OCP.Standard import Standard_Failure, Standard_NoSuchObject +from OCP.Standard import ( + Standard_ConstructionError, + Standard_Failure, + Standard_NoSuchObject, +) +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt from OCP.TColStd import ( TColStd_Array1OfReal, TColStd_HArray1OfBoolean, TColStd_HArray1OfReal, ) -from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum from OCP.TopExp import TopExp, TopExp_Explorer from OCP.TopLoc import TopLoc_Location -from OCP.TopTools import ( - TopTools_HSequenceOfShape, - TopTools_IndexedDataMapOfShapeListOfShape, - TopTools_ListOfShape, -) from OCP.TopoDS import ( TopoDS, TopoDS_Compound, @@ -142,33 +183,35 @@ from OCP.TopoDS import ( TopoDS_Face, TopoDS_Shape, TopoDS_Shell, + TopoDS_Vertex, TopoDS_Wire, ) -from OCP.gp import ( - gp_Ax1, - gp_Ax2, - gp_Ax3, - gp_Circ, - gp_Dir, - gp_Dir2d, - gp_Elips, - gp_Pnt, - gp_Pnt2d, - gp_Trsf, - gp_Vec, +from OCP.TopTools import ( + TopTools_HSequenceOfShape, + TopTools_IndexedDataMapOfShapeListOfShape, + TopTools_IndexedMapOfShape, + TopTools_ListOfShape, ) +from scipy.optimize import minimize_scalar +from scipy.spatial import ConvexHull +from typing_extensions import Self + from build123d.build_enums import ( AngularDirection, CenterOf, + ContinuityLevel, FrameMethod, GeomType, Keep, Kind, + Sagitta, + Tangency, PositionMode, Side, ) from build123d.geometry import ( DEG2RAD, + TOL_DIGITS, TOLERANCE, Axis, Color, @@ -180,6 +223,7 @@ from build123d.geometry import ( ) from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, @@ -189,23 +233,31 @@ from .shape_core import ( shapetype, topods_dim, unwrap_topods_compound, + _topods_bool_op, ) from .utils import ( _extrude_topods_shape, - isclose_b, _make_topods_face_from_wires, - _topods_bool_op, + isclose_b, +) +from .zero_d import Vertex, topo_explore_common_vertex +from .constrained_lines import ( + _make_2tan_rad_arcs, + _make_2tan_on_arcs, + _make_3tan_arcs, + _make_tan_cen_arcs, + _make_tan_on_rad_arcs, + _make_tan_oriented_lines, + _make_2tan_lines, ) -from .zero_d import topo_explore_common_vertex, Vertex - if TYPE_CHECKING: # pragma: no cover - from .two_d import Face, Shell # pylint: disable=R0801 + from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801 from .three_d import Solid # pylint: disable=R0801 - from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 + from .two_d import Face, Shell # pylint: disable=R0801 -class Mixin1D(Shape): +class Mixin1D(Shape[TOPODS]): """Methods to add to the Edge and Wire classes""" # ---- Properties ---- @@ -218,14 +270,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 @@ -260,7 +312,9 @@ class Mixin1D(Shape): @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: @@ -313,6 +367,21 @@ class Mixin1D(Shape): """Unused - only here because Mixin1D is a subclass of Shape""" return NotImplemented + # ---- Static Methods ---- + + @staticmethod + def _to_param(edge_wire: Mixin1D, value: float | VectorLike, name: str) -> float: + """Convert a float or VectorLike into a curve parameter.""" + if isinstance(value, (int, float)): + return float(value) + try: + point = Vector(value) + except TypeError as exc: + raise TypeError( + f"{name} must be a float or VectorLike, not {value!r}" + ) from exc + return edge_wire.param_at_point(point) + # ---- Instance Methods ---- def __add__( @@ -322,36 +391,38 @@ class Mixin1D(Shape): # Convert `other` to list of base topods objects and filter out None values if other is None: - summands = [] + topods_summands = [] else: - summands = [ + topods_summands = [ 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 summands: + if not topods_summands: return self - if not all(topods_dim(summand) == 1 for summand in summands): + if not all(topods_dim(summand) == 1 for summand in topods_summands): raise ValueError("Only shapes with the same dimension can be added") # Convert back to Edge/Wire objects now that it's safe to do so - summands = [Mixin1D.cast(s) for s in summands] + summands = ShapeList( + [tcast(Edge | Wire, Mixin1D.cast(s)) for s in topods_summands] + ) 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 = summands[0] + sum_shape: Edge | Wire | ShapeList[Edge] = summands[0] else: try: sum_shape = Wire(summand_edges) except Exception: + # pylint: disable=[no-member] sum_shape = summands[0].fuse(*summands[1:]) if type(self).order == 4: - sum_shape = type(self)(sum_shape) + sum_shape = type(self)(sum_shape) # type: ignore else: try: sum_shape = Wire(self.edges() + ShapeList(summand_edges)) @@ -362,7 +433,7 @@ class Mixin1D(Shape): sum_shape = sum_shape.clean() # If there is only one Edge, return that - sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape + sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape # type: ignore return sum_shape @@ -389,13 +460,16 @@ class Mixin1D(Shape): Returns: Vector: center """ + if self._wrapped is None: + raise ValueError("Can't find center of empty edge/wire") + if center_of == CenterOf.GEOMETRY: middle = self.position_at(0.5) elif center_of == CenterOf.MASS: properties = GProp_GProps() BRepGProp.LinearProperties_s(self.wrapped, properties) middle = Vector(properties.CentreOfMass()) - elif center_of == CenterOf.BOUNDING_BOX: + else: # center_of == CenterOf.BOUNDING_BOX: middle = self.bounding_box().center() return middle @@ -430,7 +504,7 @@ class Mixin1D(Shape): if all(a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)): origin = as_axis[0].position x_dir = as_axis[0].direction - z_dir = as_axis[0].to_plane().x_dir + z_dir = Plane(as_axis[0]).x_dir c_plane = Plane(origin, z_dir=z_dir) result = c_plane.shift_origin((0, 0)) @@ -474,16 +548,166 @@ class Mixin1D(Shape): return result - def edge(self) -> Edge | None: - """Return the Edge""" - return Shape.get_single_shape(self, "Edge") + def curvature_comb( + self, count: int = 100, max_tooth_size: float | None = None + ) -> ShapeList[Edge]: + """ + Build a *curvature comb* for a planar (XY) 1D curve. - def edges(self) -> ShapeList[Edge]: - """edges - all the edges in this Shape""" - edge_list = Shape.get_shape_list(self, "Edge") - return edge_list.filter_by( - lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True + 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 + (left normal for κ>0, right normal for κ<0). This is useful for inspecting + fairness and continuity (C0/C1/C2) of edges and wires. + + Args: + count (int, optional): Number of uniformly spaced samples over the normalized + parameter. Increase for a denser comb. Defaults to 100. + max_tooth_size (float | None, optional): Maximum tooth height in model units. + If None, set to 10% maximum curve dimension. Defaults to None. + + Raises: + ValueError: Empty curve. + ValueError: If the curve is not planar on `Plane.XY`. + + Returns: + ShapeList[Edge]: A list of short `Edge` objects (lines) anchored on the curve + and oriented along the left normal `n̂ = normalize(t) × +Z`. + + Notes: + - On circles, κ = 1/R so tooth length is constant. + - 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. + + Example: + >>> comb = my_wire.curvature_comb(count=200, max_tooth_size=2.0) + >>> show(my_wire, Curve(comb)) + + """ + 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): + raise ValueError("curvature_comb only works for curves on Plane.XY") + + # If periodic the first and last tooth would be the same so skip them + u_values = np.linspace(0, 1, count, endpoint=not self.is_closed) + + # first pass: gather kappas for scaling + kappas = [] + tangents, curvatures = [], [] + for u in u_values: + tangent = self.derivative_at(u, 1) + curvature = self.derivative_at(u, 2) + tangents.append(tangent) + curvatures.append(curvature) + cross = tangent.cross(curvature) + kappa = cross.length / (tangent.length**3 + TOLERANCE) + # signed for XY: + sign = 1.0 if cross.Z >= 0 else -1.0 + kappas.append(sign * kappa) + + # choose a scale so the tallest tooth is max_tooth_size + max_kappa_size = max(TOLERANCE, max(abs(k) for k in kappas)) + curve_size = max(self.bounding_box().size) + max_tooth_size = ( + max_tooth_size if max_tooth_size is not None else curve_size / 10 ) + scale = max_tooth_size / max_kappa_size + + comb_edges = ShapeList[Edge]() + for u, kappa, tangent in zip(u_values, kappas, tangents): + # Avoid tiny teeth + if abs(length := scale * kappa) < TOLERANCE: + continue + pnt_on_curve = self @ u + # left normal in XY (principal normal direction for a planar curve) + kappa_dir = tangent.normalized().cross(Vector(0, 0, 1)) + comb_edges.append( + Edge.make_line(pnt_on_curve, pnt_on_curve + length * kappa_dir) + ) + + return comb_edges + + def derivative_at( + self, + position: float | VectorLike, + order: int = 2, + position_mode: PositionMode = PositionMode.PARAMETER, + ) -> Vector: + """Derivative At + + Generate a derivative along the underlying curve. + + Args: + position (float | VectorLike): distance, parameter value or point + order (int): derivative order. Defaults to 2 + position_mode (PositionMode, optional): position calculation mode. Defaults to + PositionMode.PARAMETER. + + Raises: + ValueError: position must be a float or a point + + Returns: + Vector: position on the underlying curve + """ + if isinstance(position, (float, int)): + comp_curve, occt_param, closest_forward = self._occt_param_at( + position, position_mode + ) + else: + try: + point_on_curve = Vector(position) + except Exception as exc: + raise ValueError("position must be a float or a point") from exc + if isinstance(self, Wire): + closest = min(self.edges(), key=lambda e: e.distance_to(point_on_curve)) + else: + closest = self + u_value = closest.param_at_point(point_on_curve) + comp_curve, occt_param, closest_forward = closest._occt_param_at(u_value) + + derivative_gp_vec = comp_curve.DN(occt_param, order) + if derivative_gp_vec.Magnitude() == 0: + return Vector(0, 0, 0) + + derivative = Vector(derivative_gp_vec) + # Potentially flip the direction of the derivative + if order % 2 == 1: + if isinstance(self, Wire): + edge_same_as_wire = closest_forward == self.is_forward + derivative = derivative if edge_same_as_wire else -derivative + else: + derivative = derivative if self.is_forward else -derivative + + 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. @@ -491,16 +715,138 @@ class Mixin1D(Shape): Note that circles may have identical start and end points. """ curve = self.geom_adaptor() - umax = curve.LastParameter() + umax = curve.LastParameter() if self.is_forward else curve.FirstParameter() return Vector(curve.Value(umax)) + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge]: + """Intersect Edge with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges + """ + + def to_vector(objs: Iterable) -> ShapeList: + return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + + def to_vertex(objs: Iterable) -> ShapeList: + return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + + 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 + match other: + case Axis(): + # 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 _ if issubclass(type(other), Shape): + target = other + case _: + raise ValueError(f"Unsupported type to_intersect: {type(other)}") + + # Find common matches + common: list[Vertex | Edge | Wire] = [] + result: ShapeList | None + for obj in common_set: + match (obj, target): + case (_, Plane()): + assert isinstance(other.wrapped, gp_Pln) + target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) + operation1 = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation1) + operation2 = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation2)) + + case (_, Vertex() | Edge() | Wire()): + operation1 = BRepAlgoAPI_Section() + section = bool_op((obj,), (target,), operation1) + result = section + if not section: + operation2 = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation2)) + + case _ if issubclass(type(target), Shape): + result = target.intersect(obj) + + if result: + common.extend(result) + + if common: + 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 + + return ShapeList(common_set) + def location_at( self, distance: float, position_mode: PositionMode = PositionMode.PARAMETER, frame_method: FrameMethod = FrameMethod.FRENET, - planar: bool = False, + planar: bool | None = None, + x_dir: VectorLike | None = None, ) -> Location: """Locations along curve @@ -511,8 +857,18 @@ class Mixin1D(Shape): position_mode (PositionMode, optional): position calculation mode. Defaults to PositionMode.PARAMETER. frame_method (FrameMethod, optional): moving frame calculation method. + The FRENET frame can “twist” or flip unexpectedly, especially near flat + spots. The CORRECTED frame behaves more like a “camera dolly” or + sweep profile would — it's smoother and more stable. Defaults to FrameMethod.FRENET. - planar (bool, optional): planar mode. Defaults to False. + planar (bool, optional): planar mode. Defaults to None. + x_dir (VectorLike, optional): override the x_dir to help with plane + creation along a 1D shape. Must be perpendicalar to shapes tangent. + Defaults to None. + + .. deprecated:: + The `planar` parameter is deprecated and will be removed in a future release. + Use `x_dir` to specify orientation instead. Returns: Location: A Location object representing local coordinate system @@ -520,6 +876,12 @@ class Mixin1D(Shape): """ curve = self.geom_adaptor() + if not self.is_forward: + if position_mode == PositionMode.PARAMETER: + distance = 1 - distance + else: + distance = self.length - distance + if position_mode == PositionMode.PARAMETER: param = self.param_at(distance) else: @@ -539,23 +901,47 @@ class Mixin1D(Shape): pnt = curve.Value(param) transformation = gp_Trsf() - if planar: + if planar is not None: + warnings.warn( + "The 'planar' parameter is deprecated and will be removed in a future version. " + "Use 'x_dir' to control orientation instead.", + DeprecationWarning, + stacklevel=2, + ) + if planar is not None and planar: transformation.SetTransformation( gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3() ) + elif x_dir is not None: + try: + + transformation.SetTransformation( + gp_Ax3(pnt, gp_Dir(tangent.XYZ()), Vector(x_dir).to_dir()), gp_Ax3() + ) + except Standard_ConstructionError as exc: + raise ValueError( + f"Unable to create location with given x_dir {x_dir}. " + f"x_dir must be perpendicular to shape's tangent " + f"{tuple(Vector(tangent))}." + ) from exc + else: transformation.SetTransformation( gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3() ) + loc = Location(TopLoc_Location(transformation)) - return Location(TopLoc_Location(transformation)) + if self.is_forward: + return loc + return -loc def locations( self, distances: Iterable[float], position_mode: PositionMode = PositionMode.PARAMETER, frame_method: FrameMethod = FrameMethod.FRENET, - planar: bool = False, + planar: bool | None = None, + x_dir: VectorLike | None = None, ) -> list[Location]: """Locations along curve @@ -568,13 +954,21 @@ class Mixin1D(Shape): frame_method (FrameMethod, optional): moving frame calculation method. Defaults to FrameMethod.FRENET. planar (bool, optional): planar mode. Defaults to False. + x_dir (VectorLike, optional): override the x_dir to help with plane + creation along a 1D shape. Must be perpendicalar to shapes tangent. + Defaults to None. + + .. deprecated:: + The `planar` parameter is deprecated and will be removed in a future release. + Use `x_dir` to specify orientation instead. Returns: list[Location]: A list of Location objects representing local coordinate systems at the specified distances. """ return [ - self.location_at(d, position_mode, frame_method, planar) for d in distances + self.location_at(d, position_mode, frame_method, planar, x_dir) + for d in distances ] def normal(self) -> Vector: @@ -587,6 +981,9 @@ class Mixin1D(Shape): Returns: """ + if self._wrapped is None: + raise ValueError("Can't find normal of empty edge/wire") + curve = self.geom_adaptor() gtype = self.geom_type @@ -643,10 +1040,12 @@ class Mixin1D(Shape): # Avoiding a bug when the wire contains a single Edge if len(line.edges()) == 1: edge = line.edges()[0] + # pylint: disable=[no-member] edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)] topods_wire = Wire(edges).wrapped else: topods_wire = line.wrapped + assert topods_wire is not None offset_builder = BRepOffsetAPI_MakeOffset() offset_builder.Init(kind_dict[kind]) @@ -664,19 +1063,15 @@ class Mixin1D(Shape): if side != Side.BOTH: # Find and remove the end arcs - offset_edges = offset_wire.edges() - edges_to_keep: list[list[int]] = [[], [], []] - i = 0 - for edge in offset_edges: - if edge.geom_type == GeomType.CIRCLE and ( - edge.arc_center == line.position_at(0) - or edge.arc_center == line.position_at(1) - ): - i += 1 - else: - edges_to_keep[i].append(edge) - edges_to_keep[0] += edges_to_keep[2] - wires = [Wire(edges) for edges in edges_to_keep[0:2]] + endpoints = (line.position_at(0), line.position_at(1)) + offset_edges = offset_wire.edges().filter_by( + lambda e: ( + e.geom_type == GeomType.CIRCLE + and any((e.arc_center - pt).length < TOLERANCE for pt in endpoints) + ), + reverse=True, + ) + wires = edges_to_wires(offset_edges) centers = [w.position_at(0.5) for w in wires] angles = [ line.tangent_at(0).get_signed_angle(c - line.position_at(0)) @@ -705,22 +1100,40 @@ class Mixin1D(Shape): offset_edges = offset_wire.edges() return offset_edges[0] if len(offset_edges) == 1 else offset_wire - def param_at(self, distance: float) -> float: - """Parameter along a curve + def param_at(self, position: float) -> float: + """ + Map a normalized arc-length position to the underlying OCCT parameter. - Compute parameter value at the specified normalized distance. + 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. + + - **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. Args: - d (float): normalized distance (0.0 >= d >= 1.0) + position (float): Normalized arc-length position along the shape, + where `0.0` is the start and `1.0` is the end. Values outside + `[0.0, 1.0]` are not validated and yield OCCT-dependent results. Returns: - float: parameter value + float: OCCT parameter (for edges) **or** composite “edgeIndex + fraction” + parameter (for wires), as described above. + """ + curve = self.geom_adaptor() length = GCPnts_AbscissaPoint.Length_s(curve) return GCPnts_AbscissaPoint( - curve, length * distance, curve.FirstParameter() + curve, length * position, curve.FirstParameter() ).Parameter() def perpendicular_line( @@ -749,47 +1162,75 @@ class Mixin1D(Shape): return line def position_at( - self, distance: float, position_mode: PositionMode = PositionMode.PARAMETER + self, position: float, position_mode: PositionMode = PositionMode.PARAMETER ) -> Vector: """Position At - Generate a position along the underlying curve. + Generate a position along the underlying Wire. Args: - distance (float): distance or parameter value + position (float): distance or parameter value position_mode (PositionMode, optional): position calculation mode. Defaults to PositionMode.PARAMETER. Returns: Vector: position on the underlying curve """ - curve = self.geom_adaptor() + # Find the TopoDS_Edge and parameter on that edge at given position + edge_curve_adaptor, occt_edge_param, _ = self._occt_param_at( + position, position_mode + ) - if position_mode == PositionMode.PARAMETER: - param = self.param_at(distance) - else: - param = self.param_at(distance / self.length) - - return Vector(curve.Value(param)) + return Vector(edge_curve_adaptor.Value(occt_edge_param)) 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 | GCPnts_QuasiUniformDeflection + ) = 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 @@ -804,8 +1245,8 @@ class Mixin1D(Shape): Returns: """ - if self.wrapped is None: - raise ValueError("Can't project an empty Edge or Wire") + if self._wrapped is None or not face: + raise ValueError("Can't project an empty Edge or Wire onto empty Face") bldr = BRepProj_Projection( self.wrapped, face.wrapped, Vector(direction).to_dir() @@ -843,6 +1284,7 @@ class Mixin1D(Shape): 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 @@ -854,6 +1296,8 @@ class Mixin1D(Shape): 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 @@ -873,6 +1317,9 @@ class Mixin1D(Shape): return edges + if self._wrapped is None: + raise ValueError("Can't project empty edge/wire") + # Setup the projector hidden_line_removal = HLRBRep_Algo() hidden_line_removal.Add(self.wrapped) @@ -886,7 +1333,11 @@ class Mixin1D(Shape): gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir()) ) camera_coordinate_system.SetYDirection(viewport_up.to_dir()) - projector = HLRAlgo_Projector(camera_coordinate_system) + projector = ( + HLRAlgo_Projector(camera_coordinate_system, focus) + if focus + else HLRAlgo_Projector(camera_coordinate_system) + ) hidden_line_removal.Projector(projector) hidden_line_removal.Update() @@ -926,145 +1377,13 @@ class Mixin1D(Shape): 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. - """ - shape_list = TopTools_ListOfShape() - shape_list.Append(self.wrapped) - - # Define the splitting tool - trim_tool = ( - BRepBuilderAPI_MakeFace(tool.wrapped).Face() # Plane 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 algoritm 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): - faceExplorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE) - tool_face = TopoDS.Face_s(faceExplorer.Current()) - else: - tool_face = trim_tool - - # Create a reference point off the +ve side of the tool - surface_point = 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_point, surface_normal - ) - normalized_surface_normal = Vector( - surface_normal.X(), surface_normal.Y(), surface_normal.Z() - ).normalized() - surface_point = Vector(surface_point) - ref_point = surface_point + normalized_surface_normal - - # Create a HalfSpace - Solidish object to determine top/bottom - halfSpaceMaker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt()) - tool_solid = halfSpaceMaker.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 Note that circles may have identical start and end points. """ curve = self.geom_adaptor() - umin = curve.FirstParameter() + umin = curve.FirstParameter() if self.is_forward else curve.LastParameter() return Vector(curve.Value(umin)) @@ -1107,61 +1426,29 @@ class Mixin1D(Shape): position_mode (PositionMode, optional): position calculation mode. Defaults to PositionMode.PARAMETER. - Raises: - ValueError: invalid position - Returns: Vector: tangent value """ - if isinstance(position, (float, int)): - curve = self.geom_adaptor() - if position_mode == PositionMode.PARAMETER: - parameter = self.param_at(position) - else: - parameter = self.param_at(position / self.length) - else: - try: - pnt = Vector(position) - except Exception as exc: - raise ValueError("position must be a float or a point") from exc - # GeomAPI_ProjectPointOnCurve only works with Edges so find - # the closest Edge if the shape has multiple Edges. - my_edges: list[Edge] = self.edges() - distances = [(e.distance_to(pnt), i) for i, e in enumerate(my_edges)] - sorted_distances = sorted(distances, key=lambda x: x[0]) - closest_edge = my_edges[sorted_distances[0][1]] - # Get the extreme of the parameter values for this Edge - first: float = closest_edge.param_at(0) - last: float = closest_edge.param_at(1) - # Extract the Geom_Curve from the Shape - curve = BRep_Tool.Curve_s(closest_edge.wrapped, first, last) - projector = GeomAPI_ProjectPointOnCurve(pnt.to_pnt(), curve) - parameter = projector.LowerDistanceParameter() + return self.derivative_at(position, 1, position_mode).normalized() - tmp = gp_Pnt() - res = gp_Vec() - curve.D1(parameter, tmp, res) + # def vertex(self) -> Vertex | None: + # """Return the Vertex""" + # return Shape.get_single_shape(self, "Vertex") - return Vector(gp_Dir(res)) + # def vertices(self) -> ShapeList[Vertex]: + # """vertices - all the vertices in this Shape""" + # return Shape.get_shape_list(self, "Vertex") - def vertex(self) -> Vertex | None: - """Return the Vertex""" - return Shape.get_single_shape(self, "Vertex") + # def wire(self) -> Wire | None: + # """Return the Wire""" + # return Shape.get_single_shape(self, "Wire") - 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 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, 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 @@ -1242,6 +1529,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: extruded shape """ + if not obj: + raise ValueError("Can't extrude empty vertex") return Edge(TopoDS.Edge_s(_extrude_topods_shape(obj.wrapped, direction))) @classmethod @@ -1333,6 +1622,397 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) return return_value + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + *, + radius: float, + sagitta: Sagitta = Sagitta.SHORT, + ) -> ShapeList[Edge]: + """ + Create all planar circular arcs of a given radius that are tangent/contacting + the two provided objects on the XY plane. + Args: + tangency_one, tangency_two + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + radius (float): arc radius + sagitta (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + *, + center_on: Axis | Edge, + sagitta: Sagitta = Sagitta.SHORT, + ) -> ShapeList[Edge]: + """ + Create all planar circular arcs whose circle is tangent to two objects and whose + CENTER lies on a given locus (line/circle/curve) on the XY plane. + + Args: + tangency_one, tangency_two + (tuple[Axus | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + center_on (Axis | Edge): center must lie on this object + sagitta (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + tangency_three: ( + tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike + ), + *, + sagitta: Sagitta = Sagitta.SHORT, + ) -> ShapeList[Edge]: + """ + Create planar circular arc(s) on XY tangent to three provided objects. + + Args: + tangency_one, tangency_two, tangency_three + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): + Geometric entities to be contacted/touched by the circle(s) + sagitta (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + *, + center: VectorLike, + ) -> ShapeList[Edge]: + """make_constrained_arcs + + Create planar circle(s) on XY whose center is fixed and that are tangent/contacting + a single object. + + Args: + tangency_one + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): + Geometric entity to be contacted/touched by the circle(s) + center (VectorLike): center position + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @overload + @classmethod + def make_constrained_arcs( + cls, + tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike, + *, + radius: float, + center_on: Edge, + ) -> ShapeList[Edge]: + """make_constrained_arcs + + Create planar circle(s) on XY that: + - are tangent/contacting a single object, and + - have a fixed radius, and + - have their CENTER constrained to lie on a given locus curve. + + Args: + tangency_one + (tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike): + Geometric entity to be contacted/touched by the circle(s) + radius (float): arc radius + center_on (Axis | Edge): center must lie on this object + sagitta (LengthConstraint, optional): returned arc selector + (i.e. either the short, long or both arcs). Defaults to + LengthConstraint.SHORT. + + Returns: + ShapeList[Edge]: tangent arcs + """ + + @classmethod + def make_constrained_arcs( + cls, + *args, + sagitta: Sagitta = Sagitta.SHORT, + **kwargs, + ) -> ShapeList[Edge]: + + tangency_one = args[0] if len(args) > 0 else None + tangency_two = args[1] if len(args) > 1 else None + tangency_three = args[2] if len(args) > 2 else None + + tangency_one = kwargs.pop("tangency_one", tangency_one) + tangency_two = kwargs.pop("tangency_two", tangency_two) + tangency_three = kwargs.pop("tangency_three", tangency_three) + + radius = kwargs.pop("radius", None) + center = kwargs.pop("center", None) + center_on = kwargs.pop("center_on", None) + + # Handle unexpected kwargs + if kwargs: + raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + tangency_args = [ + t for t in (tangency_one, tangency_two, tangency_three) if t is not None + ] + tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = [] + for tangency_arg in tangency_args: + if isinstance(tangency_arg, Axis): + tangencies.append(Edge(tangency_arg)) + continue + elif isinstance(tangency_arg, Edge): + tangencies.append(tangency_arg) + continue + if isinstance(tangency_arg, tuple): + if isinstance(tangency_arg[0], Axis): + tangencies.append(tuple(Edge(tangency_arg[0], tangency_arg[1]))) + continue + elif isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue + if isinstance(tangency_arg, Vertex): + tangencies.append(Vector(tangency_arg) + tangency_arg.position) + continue + + # if not Axes, Edges, constrained Edges or Vertex convert to Vectors + try: + tangencies.append(Vector(tangency_arg)) + except Exception as exc: + raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc + + # # Sort the tangency inputs so points are always last + tangencies = sorted(tangencies, key=lambda x: isinstance(x, Vector)) + + tan_count = len(tangencies) + if not (1 <= tan_count <= 3): + raise TypeError("Provide 1 to 3 tangency targets.") + + # Radius sanity + if radius is not None and radius <= 0: + raise ValueError("radius must be > 0.0") + + if center_on is not None and isinstance(center_on, Axis): + center_on = Edge(center_on) + + # --- decide problem kind --- + if ( + tan_count == 2 + and radius is not None + and center is None + and center_on is None + ): + return _make_2tan_rad_arcs( + *tangencies, + radius=radius, + sagitta=sagitta, + edge_factory=cls, + ) + if ( + tan_count == 2 + and center_on is not None + and radius is None + and center is None + ): + return _make_2tan_on_arcs( + *tangencies, + center_on=center_on, + sagitta=sagitta, + edge_factory=cls, + ) + if tan_count == 3 and radius is None and center is None and center_on is None: + return _make_3tan_arcs(*tangencies, sagitta=sagitta, edge_factory=cls) + if ( + tan_count == 1 + and center is not None + and radius is None + and center_on is None + ): + return _make_tan_cen_arcs(*tangencies, center=center, edge_factory=cls) + if tan_count == 1 and center_on is not None and radius is not None: + return _make_tan_on_rad_arcs( + *tangencies, center_on=center_on, radius=radius, edge_factory=cls + ) + + raise ValueError("Unsupported or ambiguous combination of constraints.") + + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: tuple[Edge, Tangency] | Axis | Edge, + tangency_two: tuple[Edge, Tangency] | Axis | Edge, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to two provided curves. + + Args: + tangency_one, tangency_two + (tuple[Edge, Tangency] | Axis | Edge): + Geometric entities to be contacted/touched by the line(s). + + Returns: + ShapeList[Edge]: tangent lines + """ + + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: tuple[Edge, Tangency] | Edge, + tangency_two: Vector, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to one curve and passing + through a fixed point. + + Args: + tangency_one + (tuple[Edge, Tangency] | Edge): + Geometric entity to be contacted/touched by the line(s). + tangency_two (Vector): + Fixed point through which the line(s) must pass. + + Returns: + ShapeList[Edge]: tangent lines + """ + + @overload + @classmethod + def make_constrained_lines( + cls, + tangency_one: tuple[Edge, Tangency] | Edge, + tangency_two: Axis, + *, + angle: float | None = None, + direction: VectorLike | None = None, + ) -> ShapeList[Edge]: + """ + Create all planar line(s) on the XY plane tangent to one curve and passing + through a fixed point. + + Args: + tangency_one (Edge): edge that line will be tangent to + tangency_two (Axis): axis that angle will be measured against + angle : float, optional + Line orientation in degrees (measured CCW from the X-axis). + direction : VectorLike, optional + Direction vector for the line (only X and Y components are used). + Note: one of angle or direction must be provided + + Returns: + ShapeList[Edge]: tangent lines + """ + + @classmethod + def make_constrained_lines(cls, *args, **kwargs) -> ShapeList[Edge]: + """ + Create planar line(s) on XY subject to tangency/contact constraints. + + Supported cases + --------------- + 1. Tangent to two curves + 2. Tangent to one curve and passing through a given point + """ + tangency_one = args[0] if len(args) > 0 else None + tangency_two = args[1] if len(args) > 1 else None + + tangency_one = kwargs.pop("tangency_one", tangency_one) + tangency_two = kwargs.pop("tangency_two", tangency_two) + + angle = kwargs.pop("angle", None) + direction = kwargs.pop("direction", None) + direction = Vector(direction) if direction is not None else None + + is_ref = angle is not None or direction is not None + # Handle unexpected kwargs + if kwargs: + raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + tangency_args = [t for t in (tangency_one, tangency_two) if t is not None] + if len(tangency_args) != 2: + raise TypeError("Provide exactly 2 tangency targets.") + + tangencies: list[tuple[Edge, Tangency] | Axis | Edge | Vector] = [] + for i, tangency_arg in enumerate(tangency_args): + if isinstance(tangency_arg, Axis): + if i == 1 and is_ref: + tangencies.append(tangency_arg) + else: + tangencies.append(Edge(tangency_arg)) + continue + elif isinstance(tangency_arg, Edge): + tangencies.append(tangency_arg) + continue + if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge): + tangencies.append(tangency_arg) + continue + # Fallback: treat as a point + try: + tangencies.append(Vector(tangency_arg)) + except Exception as exc: + raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc + + # Sort so Vector (point) | Axis is always last + tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector))) + + # --- decide problem kind --- + if angle is not None or direction is not None: + if isinstance(tangencies[0], tuple): + assert isinstance( + tangencies[0][0], Edge + ), "Internal error - 1st tangency must be Edge" + else: + assert isinstance( + tangencies[0], Edge + ), "Internal error - 1st tangency must be Edge" + if angle is not None: + ang_rad = radians(angle) + else: + assert direction is not None + ang_rad = atan2(direction.Y, direction.X) + assert isinstance( + tangencies[1], Axis + ), "Internal error - 2nd tangency must be an Axis" + return _make_tan_oriented_lines( + tangencies[0], tangencies[1], ang_rad, edge_factory=cls + ) + else: + assert not isinstance( + tangencies[0], (Axis, Vector) + ), "Internal error - 1st tangency can't be an Axis | Vector" + assert not isinstance( + tangencies[1], Axis + ), "Internal error - 2nd tangency can't be an Axis" + + return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls) + @classmethod def make_ellipse( cls, @@ -1485,7 +2165,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: linear Edge between two Edges """ - flip = first.to_axis().is_opposite(second.to_axis()) + flip = Axis(first).is_opposite(Axis(second)) pnts = [ Edge.make_line( first.position_at(i), second.position_at(1 - i if flip else i) @@ -1733,6 +2413,52 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return locations + def _extend_spline( + self, + at_start: bool, + geom_surface: Geom_Surface, + extension_factor: float = 0.1, + ): + """Helper method to slightly extend an edge that is bound to a surface""" + 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") + + u_start: float = self.param_at(0) + u_end: float = self.param_at(1) + + curve_original = tcast( + Geom_BSplineCurve, BRep_Tool.Curve_s(self.wrapped, u_start, u_end) + ) + n_poles = curve_original.NbPoles() + poles = [curve_original.Pole(i + 1) for i in range(n_poles)] + # Find position and tangent past end of spline to extend it + ends = (-extension_factor, 1) if at_start else (0, 1 + extension_factor) + if at_start: + new_pole = self.position_at(-extension_factor).to_pnt() + poles = [new_pole] + poles + else: + new_pole = self.position_at(1 + extension_factor).to_pnt() + poles = poles + [new_pole] + tangents: list[VectorLike] = [self.tangent_at(p) for p in ends] + + pnts: list[VectorLike] = [Vector(p) for p in poles] + extended_edge = Edge.make_spline(pnts, tangents=tangents) + assert extended_edge.wrapped is not None + + geom_curve = BRep_Tool.Curve_s( + extended_edge.wrapped, extended_edge.param_at(0), extended_edge.param_at(1) + ) + snapped_geom_curve = GeomProjLib.Project_s(geom_curve, geom_surface) + if snapped_geom_curve is None: + raise RuntimeError("Failed to snap extended edge to surface") + + # Build a new projected edge + snapped_edge = Edge(BRepBuilderAPI_MakeEdge(snapped_geom_curve).Edge()) + + return snapped_edge, snapped_geom_curve + def find_intersection_points( self, other: Axis | Edge | None = None, tolerance: float = TOLERANCE ) -> ShapeList[Vector]: @@ -1745,17 +2471,22 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): tolerance (float, optional): the precision of computing the intersection points. Defaults to TOLERANCE. + Raises: + ValueError: empty edge + Returns: ShapeList[Vector]: list of intersection points """ + 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 if isinstance(other, Axis): - self_bbox_w_edge = self.bounding_box().add( - Vertex(other.position).bounding_box() - ) + pos = tcast(Vector, other.position) + self_bbox_w_edge = self.bounding_box().add(Vertex(pos).bounding_box()) other = Edge.make_line( - other.position + other.direction * (-1 * self_bbox_w_edge.diagonal), - other.position + other.direction * self_bbox_w_edge.diagonal, + pos + other.direction * (-1 * self_bbox_w_edge.diagonal), + pos + other.direction * self_bbox_w_edge.diagonal, ) # To determine the 2D plane to work on plane = self.common_plane(other) @@ -1772,7 +2503,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): self.param_at(0), self.param_at(1), ) - if other is not None: + if other is not None and other.wrapped is not None: edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( other.wrapped, edge_surface, @@ -1874,128 +2605,179 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): def geom_adaptor(self) -> BRepAdaptor_Curve: """Return the Geom Curve from this Edge""" + if self._wrapped is None: + raise ValueError("Can't find adaptor for empty edge") return BRepAdaptor_Curve(self.wrapped) - def intersect( - self, *to_intersect: Edge | Axis | Plane - ) -> None | Vertex | Edge | ShapeList[Vertex | Edge]: - """intersect Edge with Edge or Axis + def _occt_param_at( + self, position: float, position_mode: PositionMode = PositionMode.PARAMETER + ) -> tuple[BRepAdaptor_Curve, float, bool]: + """ + Map a position on this edge to its underlying OCCT parameter. + + This returns the OCCT `BRepAdaptor_CompCurve` for the edge together with + the corresponding (non-normalized) curve parameter at the given position. + The interpretation of `position` depends on `position_mode`: + + - ``PositionMode.PARAMETER``: `position` is a normalized curve parameter in [0, 1]. + - ``PositionMode.DISTANCE``: `position` is an arc length distance along the edge. + + Edge orientation (`is_forward`) is taken into account so that positions are + measured consistently along the geometric curve. Args: - other (Edge | Axis): other object + position (float): Position along the edge, either a normalized parameter + (0-1) or a distance, depending on `position_mode`. + position_mode (PositionMode, optional): How to interpret `position`. + Defaults to ``PositionMode.PARAMETER``. Returns: - Shape | None: Compound of vertices and/or edges + tuple[BRepAdaptor_CompCurve, float, bool]: The curve adaptor for this edge, + the corresponding OCCT curve parameter and is_forward. """ - edges: list[Edge] = [] - planes: list[Plane] = [] - edges_common_to_planes: list[Edge] = [] + comp_curve = self.geom_adaptor() + length = GCPnts_AbscissaPoint.Length_s(comp_curve) - for obj in to_intersect: - match obj: - case Axis(): - edges.append(Edge(obj)) - case Edge(): - edges.append(obj) - case Plane(): - planes.append(obj) - case _: - raise ValueError(f"Unknown object type: {type(obj)}") + if position_mode == PositionMode.PARAMETER: + if not self.is_forward: + position = 1 - position + value = position + else: + if not self.is_forward: + position = self.length - position + value = position / self.length - # Find any edge / edge intersection points - points_sets: list[set[Vector]] = [] - # Find crossing points - for edge_pair in combinations([self] + edges, 2): - intersection_points = edge_pair[0].find_intersection_points(edge_pair[1]) - points_sets.append(set(intersection_points)) - - # Find common end points - self_end_points = set(Vector(v) for v in self.vertices()) - edge_end_points = set(Vector(v) for edge in edges for v in edge.vertices()) - common_end_points = set.intersection(self_end_points, edge_end_points) - - # Find any edge / plane intersection points & edges - for edge, plane in itertools.product([self] + edges, planes): - # Find point intersections - geom_line = BRep_Tool.Curve_s( - edge.wrapped, edge.param_at(0), edge.param_at(1) - ) - geom_plane = Geom_Plane(plane.local_coord_system) - intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) - plane_intersection_points: list[Vector] = [] - if intersection_calculator.IsDone(): - plane_intersection_points = [ - Vector(intersection_calculator.Point(i + 1)) - for i in range(intersection_calculator.NbPoints()) - ] - points_sets.append(set(plane_intersection_points)) - - # Find edge intersections - if all( - plane.contains(v) for v in edge.positions(i / 7 for i in range(8)) - ): # is a 2D edge - edges_common_to_planes.append(edge) - - edges.extend(edges_common_to_planes) - - # Find the intersection of all sets - common_points = set.intersection(*points_sets) - common_vertices = [ - Vertex(pnt) for pnt in common_points.union(common_end_points) - ] - - # Find Edge/Edge overlaps - common_edges: list[Edge] = [] - if edges: - common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges() - - if common_vertices or common_edges: - # If there is just one vertex or edge return it - if len(common_vertices) == 1 and len(common_edges) == 0: - return common_vertices[0] - if len(common_vertices) == 0 and len(common_edges) == 1: - return common_edges[0] - return ShapeList(common_vertices + common_edges) - return None + occt_param = GCPnts_AbscissaPoint( + comp_curve, length * value, comp_curve.FirstParameter() + ).Parameter() + return comp_curve, occt_param, self.is_forward def param_at_point(self, point: VectorLike) -> float: - """Normalized parameter at point along Edge""" + """ + Return the normalized parameter (∈ [0.0, 1.0]) of the location on this edge + closest to `point`. - # Note that this search algorithm would ideally be replaced with - # an OCP based solution, something like that which is shown below. - # However, there are known issues with the OCP methods for some + This method always returns a **normalized** parameter across the edge's full + OCCT parameter range, even though the underlying OCP/OCCT queries work in + native (non-normalized) parameters. It is robust to several OCCT quirks: + + 1) Vertex snap (fast path) + If `point` coincides (within tolerance) with one of the edge's vertices, + that vertex's OCCT parameter is used and normalized to [0, 1]. + Note: for a closed edge, a vertex may represent both start and end; the + mapping is therefore ambiguous and either end may be chosen. + + 2) Projection via GeomAPI_ProjectPointOnCurve + The OCCT projector's `LowerDistanceParameter()` can legitimately return a + value **outside** the edge's [param_min, param_max] (e.g., periodic curves + or implementation behavior). The result is wrapped back into range using a + modulo by the parameter span and then normalized to [0, 1]. The projected + answer is accepted only if re-evaluating the 3D point at that normalized + parameter is within tolerance of the input `point`. + + 3) Fallback numeric search (robust path) + If the projector fails the validation, a bounded 1D search is performed + over [0, 1] using progressive subdivision and local minimization of the + 3D distance ‖edge(u) - point‖. The first minimum found under geometric + resolution is returned. + + Args: + point (VectorLike): A point expected to lie on this edge (within tolerance). + + Raises: + ValueError: If `point` is not on the edge within tolerance. + ValueError: Can't find param on empty edge + RuntimeError: If no parameter can be found (e.g., extremely pathological + curves or numerical failure). + Returns: + float: Normalized parameter in [0.0, 1.0] corresponding to the point's + closest location on the edge. + """ + if self._wrapped is None: + raise ValueError("Can't find param on empty edge") + + pnt = Vector(point) + # Extract the edge's end parameters + param_min, param_max = BRep_Tool.Range_s(self.wrapped) + param_range = param_max - param_min + + # Method 1: the point is a Vertex + + # Check to see if the point is a Vertex of the Edge + # Note: on a closed edge a single point is ambiguous so the result + # is undefined with respect to matching the "start" or "end". + nearest_vertex = min(self.vertices(), key=lambda v: (Vector(v) - pnt).length) + if ( + Vector(nearest_vertex) - pnt + ).length <= TOLERANCE and nearest_vertex.wrapped is not None: + param = BRep_Tool.Parameter_s(nearest_vertex.wrapped, self.wrapped) + return (param - param_min) / param_range + + separation = self.distance_to(pnt) + if not isclose_b(separation, 0, abs_tol=TOLERANCE): + raise ValueError(f"point ({pnt}) is {separation} from edge") + + # Method 2: project the point onto the edge + # There are known issues with the OCP methods for some # curves which may return negative values or incorrect values at - # end points. Also note that this search takes about 1.5ms while - # the OCP methods take about 0.4ms. + # end points. + + # Extract the normalized parameter using OCCT GeomAPI_ProjectPointOnCurve + curve = BRep_Tool.Curve_s(self.wrapped, float(), float()) + projector = GeomAPI_ProjectPointOnCurve(pnt.to_pnt(), curve) + param = projector.LowerDistanceParameter() + # Note that for some periodic curves the LowerDistanceParameter might + # be outside the given range + curve_adaptor = BRepAdaptor_Curve(self.wrapped) + if curve_adaptor.IsPeriodic(): + u_value = ((param - param_min) % curve_adaptor.Period()) / param_range + else: + u_value = (param - param_min) / param_range + # Validate that GeomAPI_ProjectPointOnCurve worked correctly + if (self.position_at(u_value) - pnt).length < TOLERANCE: + return u_value + + # Method 3: search the edge for the point + # Note that this search takes about 1.3ms on a complex curve while the + # OCP methods take about 0.4ms. + + # This algorithm finds the normalized [0, 1] parameter of a point on an edge + # by minimizing the 3D distance between the edge and the given point. # - # curve = BRep_Tool.Curve_s(self.wrapped, float(), float()) - # param_min, param_max = BRep_Tool.Range_s(self.wrapped) - # projector = GeomAPI_ProjectPointOnCurve(point.to_pnt(), curve) - # param_value = projector.LowerDistanceParameter() - # u_value = (param_value - param_min) / (param_max - param_min) + # Because some edges (e.g., BSplines) can have multiple local minima in the + # distance function, we subdivide the [0, 1] domain into 2^n intervals + # (logarithmic refinement) and perform a bounded minimization in each subinterval. + # + # The first solution found with an error smaller than the geometric resolution + # is returned. If no such minimum is found after all subdivisions, a runtime error + # is raised. - point = Vector(point) + max_divisions = 10 # Logarithmic refinement depth - if not isclose_b(self.distance_to(point), 0, abs_tol=TOLERANCE): - raise ValueError(f"point ({point}) is not on edge") + for division in range(max_divisions): + intervals = 2**division + step = 1.0 / intervals - # Function to be minimized - def func(param: ndarray) -> float: - return (self.position_at(param[0]) - point).length + for i in range(intervals): + lo, hi = i * step, (i + 1) * step - # Find the u value that results in a point within tolerance of the target - initial_guess = max( - 0.0, min(1.0, (point - self.position_at(0)).length / self.length) - ) - result = minimize( - func, - x0=initial_guess, - method="Nelder-Mead", - bounds=[(0.0, 1.0)], - tol=TOLERANCE, - ) - u_value = float(result.x[0]) - return u_value + result = minimize_scalar( + lambda u: (self.position_at(u) - pnt).length, + bounds=(lo, hi), + method="bounded", + options={"xatol": TOLERANCE / 2}, + ) + + # Early exit if we're below resolution limit + if ( + result.fun + < ( + self @ (result.x + TOLERANCE) - self @ (result.x - TOLERANCE) + ).length + ): + return round(float(result.x), TOL_DIGITS) + + raise RuntimeError("Unable to find parameter, Edge is too complex") def project_to_shape( self, @@ -2033,20 +2815,44 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): projected_edges = [w.edges()[0] for w in projected_wires] return projected_edges - def reversed(self) -> Edge: - """Return a copy of self with the opposite orientation""" + def reversed(self, reconstruct: bool = False) -> Edge: + """reversed + + Return a copy of self with the opposite orientation. + + Args: + reconstruct (bool, optional): rebuild edge instead of setting OCCT flag. + Defaults to False. + + Returns: + Edge: reversed + """ + if self._wrapped is None: + raise ValueError("An empty edge can't be reversed") + + assert isinstance(self.wrapped, TopoDS_Edge) + reversed_edge = copy.deepcopy(self) - first: float = self.param_at(0) - last: float = self.param_at(1) - curve = BRep_Tool.Curve_s(self.wrapped, first, last) - first = curve.ReversedParameter(first) - last = curve.ReversedParameter(last) - topods_edge = BRepBuilderAPI_MakeEdge(curve.Reversed(), last, first).Edge() - reversed_edge.wrapped = topods_edge + if reconstruct: + first: float = self.param_at(0) + last: float = self.param_at(1) + curve = BRep_Tool.Curve_s(self.wrapped, first, last) + first = curve.ReversedParameter(first) + last = curve.ReversedParameter(last) + topods_edge = BRepBuilderAPI_MakeEdge(curve.Reversed(), last, first).Edge() + reversed_edge.wrapped = topods_edge + else: + reversed_edge.wrapped = TopoDS.Edge_s(self.wrapped.Reversed()) return reversed_edge def to_axis(self) -> Axis: """Translate a linear Edge to an Axis""" + warnings.warn( + "to_axis is deprecated and will be removed in a future version. " + "Use 'Axis(Edge)' instead.", + DeprecationWarning, + stacklevel=2, + ) if self.geom_type != GeomType.LINE: raise ValueError( f"to_axis is only valid for linear Edges not {self.geom_type}" @@ -2055,31 +2861,63 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): def to_wire(self) -> Wire: """Edge as Wire""" + warnings.warn( + "to_wire is deprecated and will be removed in a future version. " + "Use 'Wire(Edge)' instead.", + DeprecationWarning, + stacklevel=2, + ) return Wire([self]) - def trim(self, start: float, end: float) -> Edge: + def trim(self, start: float | VectorLike, end: float | VectorLike) -> Edge: + """_summary_ + + Args: + start (float | VectorLike): _description_ + end (float | VectorLike): _description_ + + Raises: + TypeError: _description_ + ValueError: _description_ + + Returns: + Edge: _description_ + """ """trim Create a new edge by keeping only the section between start and end. Args: - start (float): 0.0 <= start < 1.0 - end (float): 0.0 < end <= 1.0 + start (float | VectorLike): 0.0 <= start < 1.0 or point on edge + end (float | VectorLike): 0.0 < end <= 1.0 or point on edge Raises: - ValueError: start >= end + TypeError: invalid input, must be float or VectorLike + ValueError: can't trim empty edge Returns: Edge: trimmed edge """ - if start >= end: - raise ValueError(f"start ({start}) must be less than end ({end})") + + start_u = Mixin1D._to_param(self, start, "start") + end_u = Mixin1D._to_param(self, end, "end") + + start_u, end_u = sorted([start_u, end_u]) + + # if start_u >= end_u: + # raise ValueError(f"start ({start_u}) must be less than end ({end_u})") + + if self._wrapped is None: + raise ValueError("Can't trim empty edge") + + self_copy = copy.deepcopy(self) + assert self_copy.wrapped is not None new_curve = BRep_Tool.Curve_s( - copy.deepcopy(self).wrapped, self.param_at(0), self.param_at(1) + self_copy.wrapped, self.param_at(0), self.param_at(1) ) - parm_start = self.param_at(start) - parm_end = self.param_at(end) + parm_start = self.param_at(start_u) + parm_end = self.param_at(end_u) trimmed_curve = Geom_TrimmedCurve( new_curve, parm_start, @@ -2088,28 +2926,39 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() return Edge(new_edge) - def trim_to_length(self, start: float, length: float) -> Edge: + def trim_to_length(self, start: float | VectorLike, length: float) -> Edge: """trim_to_length Create a new edge starting at the given normalized parameter of a given length. Args: - start (float): 0.0 <= start < 1.0 + start (float | VectorLike): 0.0 <= start < 1.0 or point on edge length (float): target length + Raise: + ValueError: can't trim empty edge + Returns: Edge: trimmed edge """ + if self._wrapped is None: + raise ValueError("Can't trim empty edge") + + start_u = Mixin1D._to_param(self, start, "start") + + self_copy = copy.deepcopy(self) + assert self_copy.wrapped is not None + new_curve = BRep_Tool.Curve_s( - copy.deepcopy(self).wrapped, self.param_at(0), self.param_at(1) + self_copy.wrapped, self.param_at(0), self.param_at(1) ) # Create an adaptor for the curve adaptor_curve = GeomAdaptor_Curve(new_curve) # Find the parameter corresponding to the desired length - parm_start = self.param_at(start) + parm_start = self.param_at(start_u) abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start) # Get the parameter at the desired length @@ -2122,7 +2971,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 @@ -2235,7 +3084,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): edge, label, color, parent = args[:4] + (None,) * (4 - l_a) elif isinstance(args[0], Wire): wire, label, color, parent = args[:4] + (None,) * (4 - l_a) - # elif isinstance(args[0], Curve): elif ( hasattr(args[0], "wrapped") and isinstance(args[0].wrapped, TopoDS_Compound) @@ -2340,7 +3188,8 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): wire_builder = BRepBuilderAPI_MakeWire() combined_edges = TopTools_ListOfShape() for edge in edges: - combined_edges.Append(edge.wrapped) + if edge.wrapped is not None: + combined_edges.Append(edge.wrapped) wire_builder.Add(combined_edges) wire_builder.Build() @@ -2377,13 +3226,14 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): wires_out = TopTools_HSequenceOfShape() for edge in [e for w in wires for e in w.edges()]: - edges_in.Append(edge.wrapped) + if edge.wrapped is not None: + edges_in.Append(edge.wrapped) ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out) wires = ShapeList() for i in range(wires_out.Length()): - wires.append(Wire(downcast(wires_out.Value(i + 1)))) + wires.append(Wire(tcast(TopoDS_Wire, downcast(wires_out.Value(i + 1))))) return wires @@ -2459,7 +3309,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): for edge_index, edge in enumerate(edges): for i in range(fragments_per_edge): param = i / (fragments_per_edge - 1) - points.append(edge.position_at(param).to_tuple()[:2]) + points.append(tuple(edge.position_at(param))[:2]) points_lookup[edge_index * fragments_per_edge + i] = (edge_index, param) convex_hull = ConvexHull(points) @@ -2618,7 +3468,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): return Wire.make_polygon(corners_world, close=True) # ---- Static Methods ---- - @staticmethod def order_chamfer_edges( reference_edge: Edge | None, edges: tuple[Edge, Edge] @@ -2656,6 +3505,9 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: chamfered wire """ + if self._wrapped is None: + raise ValueError("Can't chamfer empty wire") + reference_edge = edge # Create a face to chamfer @@ -2668,20 +3520,25 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): ) for v in vertices: + if not v: + continue edge_list = vertex_edge_map.FindFromKey(v.wrapped) # 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(tcast(TopoDS_Edge, downcast(edge_list.First()))), + Edge(tcast(TopoDS_Edge, downcast(edge_list.Last()))), + ) edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges) - - chamfer_builder.AddChamfer( - TopoDS.Edge_s(edge1.wrapped), - TopoDS.Edge_s(edge2.wrapped), - distance, - distance2, - ) + if edge1.wrapped is not None and edge2.wrapped is not None: + chamfer_builder.AddChamfer( + TopoDS.Edge_s(edge1.wrapped), + TopoDS.Edge_s(edge2.wrapped), + distance, + distance2, + ) chamfer_builder.Build() chamfered_face = chamfer_builder.Shape() @@ -2689,6 +3546,8 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): shape_fix = ShapeFix_Shape(chamfered_face) shape_fix.Perform() chamfered_face = downcast(shape_fix.Shape()) + if not isinstance(chamfered_face, TopoDS_Face): + raise RuntimeError("An internal error occured creating the chamfer") # Return the outer wire return Wire(BRepTools.OuterWire_s(chamfered_face)) @@ -2702,6 +3561,21 @@ class Wire(Mixin1D, Shape[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 @@ -2711,17 +3585,27 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): radius (float): vertices (Iterable[Vertex]): vertices to fillet + Raises: + RuntimeError: Internal error + ValueError: empty wire + Returns: Wire: filleted wire """ + if self._wrapped is None: + raise ValueError("Can't fillet an empty wire") + # Create a face to fillet unfilleted_face = _make_topods_face_from_wires(self.wrapped) # Fillet the face fillet_builder = BRepFilletAPI_MakeFillet2d(unfilleted_face) for vertex in vertices: - fillet_builder.AddFillet(vertex.wrapped, radius) + if vertex.wrapped is not None: + fillet_builder.AddFillet(vertex.wrapped, radius) fillet_builder.Build() filleted_face = downcast(fillet_builder.Shape()) + if not isinstance(filleted_face, TopoDS_Face): + raise RuntimeError("An internal error occured creating the fillet") # Return the outer wire return Wire(BRepTools.OuterWire_s(filleted_face)) @@ -2736,65 +3620,182 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: fixed wire """ + if self._wrapped is None: + raise ValueError("Can't fix an empty edge") + sf_w = ShapeFix_Wireframe(self.wrapped) sf_w.SetPrecision(precision) sf_w.SetMaxTolerance(1e-6) sf_w.FixSmallEdges() sf_w.FixWireGaps() - return Wire(downcast(sf_w.Shape())) + return Wire(tcast(TopoDS_Wire, downcast(sf_w.Shape()))) def geom_adaptor(self) -> BRepAdaptor_CompCurve: """Return the Geom Comp Curve for this Wire""" + if self._wrapped is None: + raise ValueError("Can't get geom adaptor of empty wire") + return BRepAdaptor_CompCurve(self.wrapped) def order_edges(self) -> ShapeList[Edge]: """Return the edges in self ordered by wire direction and orientation""" - ordered_edges = [ - e if e.is_forward else e.reversed() for e in self.edges().sort_by(self) - ] - return ShapeList(ordered_edges) + + sorted_edges = self.edges().sort_by(self) + ordered_edges = ShapeList([sorted_edges[0]]) + + for edge in sorted_edges[1:]: + last_edge = ordered_edges[-1] + if abs(last_edge @ 1 - edge @ 0) < TOLERANCE: + ordered_edges.append(edge) + else: + ordered_edges.append(edge.reversed()) + + return ordered_edges def param_at_point(self, point: VectorLike) -> float: - """Parameter at point on Wire""" + """ + Return the normalized wire parameter for the point closest to this wire. - # OCP doesn't support this so this algorithm finds the edge that contains the - # point, finds the u value/fractional distance of the point on that edge and - # sums up the length of the edges from the start to the edge with the point. + This method projects the given point onto the wire, finds the nearest edge, + and accumulates arc lengths to determine the fractional position along the + entire wire. The result is normalized to the interval [0.0, 1.0], where: - wire_length = self.length - edge_list = self.edges() - target = self.position_at(0) # To start, find the edge at the beginning - distance = 0.0 # distance along wire - found = False + - 0.0 corresponds to the start of the wire + - 1.0 corresponds to the end of the wire - while edge_list: - # Find the edge closest to the target - edge = sorted(edge_list, key=lambda e: e.distance_to(target))[0] - edge_list.pop(edge_list.index(edge)) + Unlike the edge version of this method, the returned value is **not** + an OCCT curve parameter, but a normalized parameter across the wire as a whole. - # The edge might be flipped requiring the u value to be reversed - edge_p0 = edge.position_at(0) - edge_p1 = edge.position_at(1) - flipped = (target - edge_p0).length > (target - edge_p1).length + Args: + point (VectorLike): The point to project onto the wire. - # Set the next start to "end" of the current edge - target = edge_p0 if flipped else edge_p1 + Raises: + ValueError: Can't find point on empty wire - # If this edge contain the point, get a fractional distance - otherwise the whole - if edge.distance_to(point) <= TOLERANCE: - found = True - u_value = edge.param_at_point(point) - if flipped: - distance += (1 - u_value) * edge.length - else: - distance += u_value * edge.length + Returns: + float: Normalized parameter in [0.0, 1.0] representing the relative + position of the projected point along the wire. + """ + if self._wrapped is None: + raise ValueError("Can't find point on empty wire") + + point_on_curve = Vector(point) + vertex_on_curve = Vertex(point_on_curve) + assert vertex_on_curve.wrapped is not None + + separation = self.distance_to(point) + if not isclose_b(separation, 0, abs_tol=TOLERANCE): + raise ValueError(f"point ({point}) is {separation} from wire") + + extrema = BRepExtrema_DistShapeShape(vertex_on_curve.wrapped, self.wrapped) + extrema.Perform() + if not extrema.IsDone() or extrema.NbSolution() == 0: + raise ValueError("point is not on Wire") + + supp_type = extrema.SupportTypeShape2(1) + + if supp_type == BRepExtrema_SupportType.BRepExtrema_IsOnEdge: + closest_topods_edge = tcast( + TopoDS_Edge, downcast(extrema.SupportOnShape2(1)) + ) + closest_topods_edge_param = extrema.ParOnEdgeS2(1)[0] + elif supp_type == BRepExtrema_SupportType.BRepExtrema_IsVertex: + v_hit = tcast(TopoDS_Vertex, downcast(extrema.SupportOnShape2(1))) + vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + self.wrapped, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map + ) + closest_topods_edge = tcast( + TopoDS_Edge, downcast(vertex_edge_map.FindFromKey(v_hit).First()) + ) + closest_topods_edge_param = BRep_Tool.Parameter_s( + v_hit, closest_topods_edge + ) + + curve_adaptor = BRepAdaptor_Curve(closest_topods_edge) + param_min, param_max = BRep_Tool.Range_s(closest_topods_edge) + if curve_adaptor.IsPeriodic(): + closest_topods_edge_param = ( + (closest_topods_edge_param - param_min) % curve_adaptor.Period() + ) + param_min + param_pair = ( + (param_min, closest_topods_edge_param) + if closest_topods_edge.Orientation() == TopAbs_Orientation.TopAbs_FORWARD + else (closest_topods_edge_param, param_max) + ) + distance_along_wire = GCPnts_AbscissaPoint.Length_s(curve_adaptor, *param_pair) + + # Find all of the edges prior to the closest edge + wire_explorer = BRepTools_WireExplorer(self.wrapped) + while wire_explorer.More(): + topods_edge = wire_explorer.Current() + # Skip degenerate edges + if BRep_Tool.Degenerated_s(topods_edge): + wire_explorer.Next() + continue + # Stop when we find the closest edge + if topods_edge.IsEqual(closest_topods_edge): break - distance += edge.length + # Add the length of the current edge to the running total + distance_along_wire += GCPnts_AbscissaPoint.Length_s( + BRepAdaptor_Curve(topods_edge) + ) + wire_explorer.Next() - if not found: - raise ValueError(f"{point} not on wire") + return distance_along_wire / self.length - return distance / wire_length + def _occt_param_at( + self, position: float, position_mode: PositionMode = PositionMode.PARAMETER + ) -> tuple[BRepAdaptor_Curve, float, bool]: + """ + Map a position along this wire to the underlying OCCT edge and curve parameter. + + Unlike the edge version, this method determines which constituent edge of the + wire contains the requested position, then returns a curve adaptor for that + edge together with the corresponding OCCT parameter. + + The interpretation of `position` depends on `position_mode`: + + - ``PositionMode.PARAMETER``: `position` is a normalized parameter in [0, 1] + across the entire wire. + - ``PositionMode.DISTANCE``: `position` is an arc length distance along the wire. + + Edge and wire orientation (`is_forward`) is respected so that positions are + measured consistently along the wire. + + Args: + position (float): Position along the wire, either a normalized parameter + (0-1) or a distance, depending on `position_mode`. + position_mode (PositionMode, optional): How to interpret `position`. + Defaults to ``PositionMode.PARAMETER``. + + Returns: + tuple[BRepAdaptor_Curve, float]: The curve adaptor for the specific edge + at the given position, the corresponding OCCT parameter on that edge and + if edge is_forward. + """ + wire_curve_adaptor = self.geom_adaptor() + + if position_mode == PositionMode.PARAMETER: + if not self.is_forward: + position = 1 - position + occt_wire_param = self.param_at(position) + else: + if not self.is_forward: + position = self.length - position + occt_wire_param = self.param_at(position / self.length) + + topods_edge_at_position = TopoDS_Edge() + occt_edge_params = wire_curve_adaptor.Edge( + occt_wire_param, topods_edge_at_position + ) + edge_curve_adaptor = BRepAdaptor_Curve(topods_edge_at_position) + + return ( + edge_curve_adaptor, + occt_edge_params[0], + topods_edge_at_position.Orientation() == TopAbs_Orientation.TopAbs_FORWARD, + ) def project_to_shape( self, @@ -2828,30 +3829,30 @@ 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 not target_object: raise ValueError("Can't project empty Wires or to empty Shapes") - if not (direction is None) ^ (center is None): - raise ValueError("One of either direction or center must be provided") - if direction is not None: + if direction is not None and center is None: direction_vector = Vector(direction).normalized() center_point = Vector() # for typing, never used - else: + elif center is not None and direction is None: direction_vector = None center_point = Vector(center) + else: + raise ValueError("Provide exactly one of direction or center") # Project the wire on the target object if direction_vector is not None: projection_object = BRepProj_Projection( self.wrapped, target_object.wrapped, - gp_Dir(*direction_vector.to_tuple()), + gp_Dir(*direction_vector), ) else: projection_object = BRepProj_Projection( self.wrapped, target_object.wrapped, - gp_Pnt(*center_point.to_tuple()), + gp_Pnt(*center_point), ) # Generate a list of the projected wires with aligned orientation @@ -2862,7 +3863,9 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): if target_orientation == projected_wire.Orientation(): output_wires.append(Wire(projected_wire)) else: - output_wires.append(Wire(projected_wire.Reversed())) + output_wires.append( + Wire(tcast(TopoDS_Wire, downcast(projected_wire.Reversed()))) + ) projection_object.Next() logger.debug("wire generated %d projected wires", len(output_wires)) @@ -2904,14 +3907,19 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): return output_wires def stitch(self, other: Wire) -> Wire: - """Attempt to stich wires + """Attempt to stitch wires Args: - other: Wire: + other (Wire): wire to combine + + Raises: + ValueError: Can't stitch empty wires Returns: - + Wire: stitched wires """ + if self._wrapped is None or not other: + raise ValueError("Can't stitch empty wires") wire_builder = BRepBuilderAPI_MakeWire() wire_builder.Add(TopoDS.Wire_s(self.wrapped)) @@ -2920,93 +3928,126 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): return self.__class__.cast(wire_builder.Wire()) - def to_wire(self) -> Wire: - """Return Wire - used as a pair with Edge.to_wire when self is Wire | Edge""" - return self + def _to_bspline(self) -> Edge: + """ + Collapse this wire into a single BSpline edge (internal use). - def trim(self: Wire, start: float, end: float) -> Wire: - """trim + Concatenates the wire's constituent edges—**in topological order**—into one + `Geom_BSplineCurve` using OCP/OCCT's `GeomConvert_CompCurveToBSplineCurve`. + Degenerate edges are skipped. The resulting topology is a **single Edge**; + former junctions between original edges become **internal spline knots** + (C0 corners) but **not vertices**. - Create a new wire by keeping only the section between start and end. + ⚠️ Not intended for general user workflows. The loss of vertex boundaries + can make downstream operations (e.g., splitting at vertices, continuity checks, + feature recognition) surprising. This is primarily useful for internal tasks + that benefit from a single-curve representation (e.g., length/abscissa queries + or parameter mapping along the entire wire). - Args: - start (float): 0.0 <= start < 1.0 - end (float): 0.0 < end <= 1.0 + Behavior & caveats: + - Orientation and section order follow the wire's topological sequence. + - Junctions with only C0 continuity are preserved as spline knots, not as + topological vertices. + - The returned edge's parameterization is that of the composite BSpline + (not a normalized [0,1] wire parameter). + - Failure to append any segment or to build the final edge raises an error. Raises: - ValueError: start >= end + RuntimeError: If any segment cannot be appended to the composite spline + or the final BSpline edge cannot be built. + ValueError: Empty Wire Returns: - Wire: trimmed wire + Edge: A single edge whose geometry is `GeomType.BSPLINE`. """ + # Build a single Geom_BSplineCurve from the wire, in *topological order* + builder = GeomConvert_CompCurveToBSplineCurve() + if self._wrapped is None: + raise ValueError("Can't convert an empty wire") + wire_explorer = BRepTools_WireExplorer(self.wrapped) - # pylint: disable=too-many-branches - if start >= end: - raise ValueError("start must be less than end") + while wire_explorer.More(): + topods_edge = wire_explorer.Current() + # Skip degenerate edges + if BRep_Tool.Degenerated_s(topods_edge): + wire_explorer.Next() + continue + param_min, param_max = BRep_Tool.Range_s(topods_edge) + new_curve = BRep_Tool.Curve_s(topods_edge, float(), float()) + trimmed_curve = Geom_TrimmedCurve(new_curve, param_min, param_max) - edges = self.edges() + # Append this edge's trimmed curve into the composite spline. + ok = builder.Add(trimmed_curve, TOLERANCE) + if not ok: + raise RuntimeError("Failed to build bspline.") + wire_explorer.Next() + + edge_builder = BRepBuilderAPI_MakeEdge(builder.BSplineCurve()) + if not edge_builder.IsDone(): + raise RuntimeError("Failed to build bspline.") + + return Edge(edge_builder.Edge()) + + def to_wire(self) -> Wire: + """Return Wire - used as a pair with Edge.to_wire when self is Wire | Edge""" + warnings.warn( + "to_wire is deprecated and will be removed in a future version. " + "Use 'Wire(Wire)' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self + + def trim(self: Wire, start: float | VectorLike, end: float | VectorLike) -> Wire: + """Trim a wire between [start, end] normalized over total length. + + Args: + start (float | VectorLike): normalized start position (0.0 to <1.0) or point + end (float | VectorLike): normalized end position (>0.0 to 1.0) or point + + Returns: + Wire: trimmed Wire + """ + start_u = Mixin1D._to_param(self, start, "start") + end_u = Mixin1D._to_param(self, end, "end") + + start_u, end_u = sorted([start_u, end_u]) + + # Extract the edges in order + ordered_edges = self.edges().sort_by(self) # If this is really just an edge, skip the complexity of a Wire - if len(edges) == 1: - return Wire([edges[0].trim(start, end)]) + if len(ordered_edges) == 1: + return Wire([ordered_edges[0].trim(start_u, end_u)]) - # For each Edge determine the beginning and end wire parameters - # Note that u, v values are parameters along the Wire - edges_uv_values: list[tuple[float, float, Edge]] = [] - found_end_of_wire = False # for finding ends of closed wires - - for edge in edges: - u = self.param_at_point(edge.position_at(0)) - v = self.param_at_point(edge.position_at(1)) - if self.is_closed: # Avoid two beginnings or ends - u = ( - 1 - u - if found_end_of_wire and (isclose_b(u, 0) or isclose_b(u, 1)) - else u - ) - v = ( - 1 - v - if found_end_of_wire and (isclose_b(v, 0) or isclose_b(v, 1)) - else v - ) - found_end_of_wire = ( - isclose_b(u, 0) - or isclose_b(u, 1) - or isclose_b(v, 0) - or isclose_b(v, 1) - or found_end_of_wire - ) - - # Edge might be reversed and require flipping parms - u, v = (v, u) if u > v else (u, v) - - edges_uv_values.append((u, v, edge)) + total_length = self.length + start_len = start_u * total_length + end_len = end_u * total_length trimmed_edges = [] - for u, v, edge in edges_uv_values: - if v < start or u > end: # Edge not needed - continue + cur_length = 0.0 - if start <= u and v <= end: # keep whole Edge - trimmed_edges.append(edge) + for edge in ordered_edges: + edge_len = edge.length + edge_start = cur_length + edge_end = cur_length + edge_len + cur_length = edge_end - elif start >= u and end <= v: # Wire trimmed to single Edge - u_edge = edge.param_at_point(self.position_at(start)) - v_edge = edge.param_at_point(self.position_at(end)) - u_edge, v_edge = ( - (v_edge, u_edge) if u_edge > v_edge else (u_edge, v_edge) - ) - trimmed_edges.append(edge.trim(u_edge, v_edge)) + if edge_end <= start_len or edge_start >= end_len: + continue # skip - elif start <= u: # keep start of Edge - u_edge = edge.param_at_point(self.position_at(end)) - if u_edge != 0: - trimmed_edges.append(edge.trim(0, u_edge)) + if edge_start >= start_len and edge_end <= end_len: + trimmed_edges.append(edge) # keep whole Edge + else: + # Normalize trim points relative to this edge + trim_start_len = max(start_len, edge_start) + trim_end_len = min(end_len, edge_end) - else: # v <= end keep end of Edge - v_edge = edge.param_at_point(self.position_at(start)) - if v_edge != 1: - trimmed_edges.append(edge.trim(v_edge, 1)) + u0 = (trim_start_len - edge_start) / edge_len + u1 = (trim_end_len - edge_start) / edge_len + + if abs(u1 - u0) > TOLERANCE: + trimmed_edges.append(edge.trim(u0, u1)) return Wire(trimmed_edges) @@ -3048,16 +4089,34 @@ def offset_topods_face(face: TopoDS_Face, amount: float) -> TopoDS_Shape: def topo_explore_connected_edges( - edge: Edge, parent: Shape | None = None + edge: Edge, + parent: Shape | None = None, + continuity: ContinuityLevel = ContinuityLevel.C0, ) -> ShapeList[Edge]: - """Given an edge extracted from a Shape, return the edges connected to it""" + """ + Find edges connected to the given edge with at least the requested continuity. + Args: + edge: The reference edge to explore from. + parent: Optional parent Shape. If None, uses edge.topo_parent. + continuity: Minimum required continuity (C0/G0, C1/G1, C2/G2). + + Returns: + ShapeList[Edge]: Connected edges meeting the continuity requirement. + """ + continuity_map = { + GeomAbs_C0: ContinuityLevel.C0, + GeomAbs_G1: ContinuityLevel.C1, + GeomAbs_C1: ContinuityLevel.C1, + GeomAbs_G2: ContinuityLevel.C2, + GeomAbs_C2: ContinuityLevel.C2, + } 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 @@ -3068,8 +4127,29 @@ def topo_explore_connected_edges( if given_topods_edge.IsSame(topods_edge): continue # If the edge shares a vertex with the given edge they are connected - if topo_explore_common_vertex(given_topods_edge, topods_edge) is not None: - connected_edges.add(topods_edge) + common_topods_vertex: Vertex | None = topo_explore_common_vertex( + given_topods_edge, topods_edge + ) + if ( + common_topods_vertex is not None + and common_topods_vertex.wrapped is not None + ): + # shared_vertex is the TopoDS_Vertex common to edge1 and edge2 + u1 = BRep_Tool.Parameter_s(common_topods_vertex.wrapped, given_topods_edge) + u2 = BRep_Tool.Parameter_s(common_topods_vertex.wrapped, topods_edge) + + # Build adaptors so OCCT can work on the curves + curve1 = BRepAdaptor_Curve(given_topods_edge) + curve2 = BRepAdaptor_Curve(topods_edge) + + # Get the GeomAbs_Shape enum continuity at the vertex + actual_continuity = BRepLProp.Continuity_s( + curve1, curve2, u1, u2, TOLERANCE, TOLERANCE + ) + actual_level = continuity_map.get(actual_continuity, ContinuityLevel.C2) + + if actual_level >= continuity: + connected_edges.add(topods_edge) return ShapeList(Edge(e) for e in connected_edges) @@ -3079,8 +4159,11 @@ def topo_explore_connected_faces( ) -> list[TopoDS_Face]: """Given an edge extracted from a Shape, return the topods_faces connected to it""" + if not edge: + raise ValueError("Can't explore from an empty edge") + parent = parent if parent is not None else edge.topo_parent - if parent is None: + if not parent: raise ValueError("edge has no valid parent") # make a edge --> faces mapping @@ -3089,14 +4172,13 @@ def topo_explore_connected_faces( parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map ) - # Query the map - faces = [] + # Query the map and select only unique faces + unique_face_map = TopTools_IndexedMapOfShape() + unique_faces = [] if edge_face_map.Contains(edge.wrapped): - face_list = edge_face_map.FindFromKey(edge.wrapped) - for face in face_list: - faces.append(TopoDS.Face_s(face)) + for face in edge_face_map.FindFromKey(edge.wrapped): + unique_face_map.Add(face) + for i in range(unique_face_map.Extent()): + unique_faces.append(TopoDS.Face_s(unique_face_map(i + 1))) - if len(faces) != 2: - raise RuntimeError("Invalid # of faces connected to this edge") - - return faces + return unique_faces diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 228dce5..6e84222 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -50,26 +50,29 @@ import copy import itertools import warnings from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable, Iterator +from functools import reduce from typing import ( - cast as tcast, + TYPE_CHECKING, Any, Generic, + Literal, Optional, Protocol, SupportsIndex, TypeVar, Union, - overload, - TYPE_CHECKING, ) - -from collections.abc import Callable, Iterable, Iterator +from typing import cast as tcast +from typing import overload import OCP.GeomAbs as ga import OCP.TopAbs as ta -from IPython.lib.pretty import pretty, RepresentationPrinter +from anytree import NodeMixin, RenderTree +from IPython.lib.pretty import RepresentationPrinter, pretty +from OCP.Bnd import Bnd_Box, Bnd_OBB from OCP.BOPAlgo import BOPAlgo_GlueEnum -from OCP.BRep import BRep_Tool +from OCP.BRep import BRep_TEdge, BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCP.BRepAlgoAPI import ( BRepAlgoAPI_BooleanOperation, @@ -97,12 +100,14 @@ 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.BRepTools import BRepTools -from OCP.Bnd import Bnd_Box -from OCP.GProp import GProp_GProps +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace +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 from OCP.GeomLib import GeomLib_IsPlanarSurface +from OCP.gp import gp_Ax1, gp_Ax2, gp_Ax3, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ +from OCP.GProp import GProp_GProps from OCP.ShapeAnalysis import ShapeAnalysis_Curve from OCP.ShapeCustom import ShapeCustom, ShapeCustom_RestrictionParameters from OCP.ShapeFix import ShapeFix_Shape @@ -110,26 +115,25 @@ from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum from OCP.TopExp import TopExp, TopExp_Explorer from OCP.TopLoc import TopLoc_Location -from OCP.TopTools import ( - TopTools_IndexedDataMapOfShapeListOfShape, - TopTools_ListOfShape, - TopTools_SequenceOfShape, -) from OCP.TopoDS import ( TopoDS, TopoDS_Compound, + TopoDS_Edge, TopoDS_Face, TopoDS_Iterator, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid, TopoDS_Vertex, - TopoDS_Edge, TopoDS_Wire, ) -from OCP.gce import gce_MakeLin -from OCP.gp import gp_Ax1, gp_Ax2, gp_Ax3, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec -from anytree import NodeMixin, RenderTree +from OCP.TopTools import ( + TopTools_IndexedDataMapOfShapeListOfShape, + TopTools_ListOfShape, + TopTools_SequenceOfShape, +) +from typing_extensions import Self + from build123d.build_enums import CenterOf, GeomType, Keep, SortBy, Transition from build123d.geometry import ( DEG2RAD, @@ -137,29 +141,29 @@ from build123d.geometry import ( Axis, BoundBox, Color, + ColorLike, Location, Matrix, + OrientedBoundBox, Plane, Vector, VectorLike, logger, ) -from typing_extensions import Self - -from typing import Literal - if TYPE_CHECKING: # pragma: no cover - from .zero_d import Vertex # pylint: disable=R0801 - from .one_d import Edge, Wire # pylint: disable=R0801 - from .two_d import Face, Shell # pylint: disable=R0801 - from .three_d import Solid # pylint: disable=R0801 - from .composite import Compound # pylint: disable=R0801 from build123d.build_part import BuildPart # pylint: disable=R0801 + from .composite import Compound # pylint: disable=R0801 + from .one_d import Edge, Wire # pylint: disable=R0801 + from .three_d import Solid # pylint: disable=R0801 + from .two_d import Face, Shell # pylint: disable=R0801 + from .zero_d import Vertex # pylint: disable=R0801 + 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]): @@ -170,7 +174,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Args: obj (TopoDS_Shape, optional): OCCT object. Defaults to None. label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. + color (ColorLike, optional): Defaults to None. parent (Compound, optional): assembly parent. Defaults to None. Attributes: @@ -194,7 +198,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ta.TopAbs_COMPSOLID: "CompSolid", } - shape_properties_LUT = { + shape_properties_LUT: dict[TopAbs_ShapeEnum, CalcFn | None] = { ta.TopAbs_VERTEX: None, ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, @@ -248,6 +252,8 @@ class Shape(NodeMixin, Generic[TOPODS]): Transition.RIGHT: BRepBuilderAPI_RightCorner, } + _color: Color | None + class _DisplayNode(NodeMixin): """Used to create anytree structures from TopoDS_Shapes""" @@ -266,6 +272,7 @@ class Shape(NodeMixin, Generic[TOPODS]): _ordered_shapes = [ TopAbs_ShapeEnum.TopAbs_COMPOUND, + TopAbs_ShapeEnum.TopAbs_COMPSOLID, TopAbs_ShapeEnum.TopAbs_SOLID, TopAbs_ShapeEnum.TopAbs_SHELL, TopAbs_ShapeEnum.TopAbs_FACE, @@ -279,15 +286,15 @@ class Shape(NodeMixin, Generic[TOPODS]): self, obj: TopoDS_Shape | None = None, label: str = "", - color: Color | None = None, + 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 self.label = label - self._color = color + self.color = color # parent must be set following children as post install accesses children self.parent = parent @@ -299,6 +306,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: @@ -307,7 +326,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) @@ -334,9 +353,9 @@ class Shape(NodeMixin, Generic[TOPODS]): return node_color @color.setter - def color(self, value): + def color(self, value: ColorLike | None) -> None: """Set the shape's color""" - self._color = value + self._color = Color(value) if value is not None else None @property def geom_type(self) -> GeomType: @@ -346,7 +365,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) @@ -375,7 +394,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) @@ -420,20 +439,57 @@ class Shape(NodeMixin, Generic[TOPODS]): return True + @property + def is_null(self) -> bool: + """Returns true if this shape is null. In other words, it references no + underlying shape with the potential to be given a location and an + orientation. + """ + 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) return is_face_planar.IsPlanar() @property - def location(self) -> Location | None: + def is_valid(self) -> bool: + """Returns True if no defect is detected on the shape S or any of its + subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full + description of what is checked. + """ + if self._wrapped is None: + return True + chk = BRepCheck_Analyzer(self.wrapped) + chk.SetParallel(True) + return chk.IsValid() + + @property + def global_location(self) -> Location: + """ + The location of this Shape relative to the global coordinate system. + + This property computes the composite transformation by traversing the + hierarchy from the root of the assembly to this node, combining the + location of each ancestor. It reflects the absolute position and + orientation of the shape in world space, even when the shape is deeply + nested within an assembly. + + Note: + This is only meaningful when the Shape is part of an assembly tree + where parent-child relationships define relative placements. + """ + return reduce(lambda loc, n: loc * n.location, self.path, Location()) + + @property + def location(self) -> Location: """Get this Shape's Location""" - if self.wrapped is None: - return None + if self._wrapped is None: + raise ValueError("Can't find the location of an empty shape") return Location(self.wrapped.Location()) @location.setter @@ -476,6 +532,8 @@ class Shape(NodeMixin, Generic[TOPODS]): - It is commonly used in structural analysis, mechanical simulations, and physics-based motion calculations. """ + if self._wrapped is None: + raise ValueError("Can't calculate matrix for empty shape") properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) inertia_matrix = properties.MatrixOfInertia() @@ -485,10 +543,10 @@ class Shape(NodeMixin, Generic[TOPODS]): return matrix @property - def orientation(self) -> Vector | None: + def orientation(self) -> Vector: """Get the orientation component of this Shape's Location""" if self.location is None: - return None + raise ValueError("Can't find the orientation of an empty shape") return self.location.orientation @orientation.setter @@ -500,10 +558,10 @@ class Shape(NodeMixin, Generic[TOPODS]): self.location = loc @property - def position(self) -> Vector | None: + def position(self) -> Vector: """Get the position component of this Shape's Location""" - if self.wrapped is None or self.location is None: - return None + 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 @position.setter @@ -531,6 +589,9 @@ class Shape(NodeMixin, Generic[TOPODS]): (Vector(0, 1, 0), 1000.0), (Vector(0, 0, 1), 300.0)] """ + if self._wrapped is None: + raise ValueError("Can't calculate properties for empty shape") + properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) principal_props = properties.PrincipalProperties() @@ -541,6 +602,11 @@ class Shape(NodeMixin, Generic[TOPODS]): (Vector(principal_props.ThirdAxisOfInertia()), principal_moments[2]), ] + @property + def shape_type(self) -> Shapes: + """Return the shape type string for this class""" + return tcast(Shapes, Shape.shape_LUT[shapetype(self.wrapped)]) + @property def static_moments(self) -> tuple[float, float, float]: """ @@ -563,6 +629,9 @@ class Shape(NodeMixin, Generic[TOPODS]): (150.0, 200.0, 50.0) """ + if self._wrapped is None: + raise ValueError("Can't calculate moments for empty shape") + properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) return properties.StaticMoments() @@ -658,15 +727,15 @@ class Shape(NodeMixin, Generic[TOPODS]): address = node.address name = "" loc = ( - "Center" + str(node.position.to_tuple()) + "Center" + str(tuple(node.position)) if show_center - else "Position" + str(node.position.to_tuple()) + else "Position" + str(tuple(node.position)) ) else: address = id(node) name = node.__class__.__name__.ljust(9) loc = ( - "Center" + str(node.center().to_tuple()) + "Center" + str(tuple(node.center())) if show_center else "Location" + repr(node.location) ) @@ -730,13 +799,13 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if obj.wrapped is None: + if not obj: return 0.0 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) @@ -750,13 +819,13 @@ 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)] ) for item in shape_list: - item.topo_parent = shape + item.topo_parent = shape if shape.topo_parent is None else shape.topo_parent return shape_list @staticmethod @@ -770,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, @@ -804,7 +875,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: @@ -821,7 +892,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) @@ -865,7 +936,10 @@ class Shape(NodeMixin, Generic[TOPODS]): if self.wrapped is not None: memo[id(self.wrapped)] = downcast(BRepBuilderAPI_Copy(self.wrapped).Shape()) for key, value in self.__dict__.items(): - setattr(result, key, copy.deepcopy(value, memo)) + if key == "topo_parent": + result.topo_parent = value + else: + setattr(result, key, copy.deepcopy(value, memo)) if key == "joints": for joint in result.joints.values(): joint.parent = result @@ -890,7 +964,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) @@ -908,7 +982,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 @@ -956,7 +1030,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) @@ -975,7 +1049,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) @@ -1054,7 +1128,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() @@ -1067,7 +1141,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): @@ -1097,14 +1171,14 @@ 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() 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() @@ -1113,27 +1187,28 @@ 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""" - if self.wrapped is None: + if self._wrapped is None: return [] return _topods_entities(self.wrapped, topo_type) 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, @@ -1151,7 +1226,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() @@ -1181,9 +1256,9 @@ 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(): + if not self.is_valid: shape_copy: Shape = copy.deepcopy(self, None) shape_copy.wrapped = tcast(TOPODS, fix(self.wrapped)) @@ -1223,7 +1298,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() @@ -1261,15 +1336,15 @@ 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) ) def intersect( - self, *to_intersect: Shape | Axis | Plane - ) -> None | Self | ShapeList[Self]: + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Self]: """Intersection of the arguments and this shape Args: @@ -1277,8 +1352,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: @@ -1322,15 +1397,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 @@ -1343,22 +1415,10 @@ 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) - def is_null(self) -> bool: - """Returns true if this shape is null. In other words, it references no - underlying shape with the potential to be given a location and an - orientation. - - Args: - - Returns: - - """ - return self.wrapped is None or self.wrapped.IsNull() - def is_same(self, other: Shape) -> bool: """Returns True if other and this shape are same, i.e. if they share the same TShape with the same Locations. Orientations may differ. Also see @@ -1370,26 +1430,10 @@ 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) - def is_valid(self) -> bool: - """Returns True if no defect is detected on the shape S or any of its - subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full - description of what is checked. - - Args: - - Returns: - - """ - if self.wrapped is None: - return True - chk = BRepCheck_Analyzer(self.wrapped) - chk.SetParallel(True) - return chk.IsValid() - def locate(self, loc: Location) -> Self: """Apply a location in absolute sense to self @@ -1399,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") @@ -1418,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") @@ -1436,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): @@ -1457,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( @@ -1475,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") @@ -1495,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") @@ -1503,6 +1547,16 @@ class Shape(NodeMixin, Generic[TOPODS]): shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped))) return shape_copy + def oriented_bounding_box(self) -> OrientedBoundBox: + """Create an oriented bounding box for this Shape. + + Returns: + OrientedBoundBox: A box oriented and sized to contain this Shape + """ + if self._wrapped is None: + return OrientedBoundBox(Bnd_OBB()) + return OrientedBoundBox(self) + def project_faces( self, faces: list[Face] | Compound, @@ -1601,6 +1655,9 @@ 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: + raise ValueError("Can't calculate radius of gyration for empty shape") + properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) return properties.RadiusOfGyration(axis.wrapped) @@ -1611,7 +1668,13 @@ class Shape(NodeMixin, Generic[TOPODS]): Args: loc (Location): new location to set for self """ - if self.wrapped is None: + warnings.warn( + "The 'relocate' method is deprecated and will be removed in a future version." + "Use move, moved, locate, or located instead", + DeprecationWarning, + stacklevel=2, + ) + 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") @@ -1662,17 +1725,13 @@ class Shape(NodeMixin, Generic[TOPODS]): return self._apply_transform(transformation) - def shape_type(self) -> Shapes: - """Return the shape type string for this class""" - return tcast(Shapes, Shape.shape_LUT[shapetype(self.wrapped)]) - 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, @@ -1726,11 +1785,157 @@ 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( + 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, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] + ) -> None: + """invalid split""" + + @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") + + if keep in [Keep.INSIDE, Keep.OUTSIDE]: + raise ValueError(f"{keep} is invalid") + + 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 @overload def split_by_perimeter( @@ -1810,7 +2015,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 @@ -1818,12 +2023,16 @@ 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 not perimeter_edge: + continue perimeter_edges.Append(perimeter_edge.wrapped) # Split the shells by the perimeter edges lefts: list[Shell] = [] rights: list[Shell] = [] for target_shell in self.shells(): + if not target_shell: + continue constructor = BRepFeat_SplitShape(target_shell.wrapped) constructor.Add(perimeter_edges) constructor.Build() @@ -1851,7 +2060,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) @@ -1901,7 +2110,9 @@ class Shape(NodeMixin, Generic[TOPODS]): ) -> Self: """to_splines - Approximate shape with b-splines of the specified degree. + A shape-processing utility that forces all geometry in a shape to be converted into + BSplines. It's useful when working with tools or export formats that require uniform + geometry, or for downstream processing that only understands BSpline representations. Args: degree (int, optional): Maximum degree. Defaults to 3. @@ -1911,7 +2122,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() @@ -1948,7 +2159,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( @@ -1971,7 +2182,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( @@ -2027,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 @@ -2044,7 +2255,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( @@ -2075,7 +2286,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 @@ -2129,7 +2344,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def _ocp_section( self: Shape, other: Vertex | Edge | Wire | Face - ) -> tuple[list[Vertex], list[Edge]]: + ) -> tuple[ShapeList[Vertex], ShapeList[Edge]]: """_ocp_section Create a BRepAlgoAPI_Section object @@ -2147,38 +2362,34 @@ class Shape(NodeMixin, Generic[TOPODS]): other (Union[Vertex, Edge, Wire, Face]): shape to section with Returns: - tuple[list[Vertex], list[Edge]]: section results + tuple[ShapeList[Vertex], ShapeList[Edge]]: section results """ - if self.wrapped is None or other.wrapped is None: - return ([], []) + if self._wrapped is None or not other: + return (ShapeList(), ShapeList()) - try: - section = BRepAlgoAPI_Section(other.geom_adaptor(), self.wrapped) - except (TypeError, AttributeError): - try: - section = BRepAlgoAPI_Section(self.geom_adaptor(), other.wrapped) - except (TypeError, AttributeError): - return ([], []) - - # Perform the intersection calculation + section = BRepAlgoAPI_Section(self.wrapped, other.wrapped) + section.SetRunParallel(True) + section.Approximation(True) + section.ComputePCurveOn1(True) + section.ComputePCurveOn2(True) section.Build() # Get the resulting shapes from the intersection - intersection_shape = section.Shape() + intersection_shape: TopoDS_Shape = section.Shape() - vertices = [] + vertices: list[Vertex] = [] # Iterate through the intersection shape to find intersection points/edges explorer = TopExp_Explorer(intersection_shape, TopAbs_ShapeEnum.TopAbs_VERTEX) while explorer.More(): vertices.append(self.__class__.cast(downcast(explorer.Current()))) explorer.Next() - edges = [] + edges: ShapeList[Edge] = ShapeList() explorer = TopExp_Explorer(intersection_shape, TopAbs_ShapeEnum.TopAbs_EDGE) while explorer.More(): edges.append(self.__class__.cast(downcast(explorer.Current()))) explorer.Next() - return (vertices, edges) + return (ShapeList(set(vertices)), edges) def _repr_html_(self): """Jupyter 3D representation support""" @@ -2187,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""" @@ -2200,9 +2419,14 @@ class Comparable(ABC): def __lt__(self, other: Any) -> bool: ... +class SupportsLessThan(Protocol): + def __lt__(self, other: Any) -> bool: ... + + # This TypeVar allows IDEs to see the type of objects within the ShapeList T = TypeVar("T", bound=Union[Shape, Vector]) -K = TypeVar("K", bound=Comparable) +# K = TypeVar("K", bound=Comparable) +K = TypeVar("K", bound=SupportsLessThan) class ShapePredicate(Protocol): @@ -2306,10 +2530,27 @@ class ShapeList(list[T]): # ---- Instance Methods ---- - def __add__(self, other: ShapeList) -> ShapeList[T]: # type: ignore - """Combine two ShapeLists together operator +""" - # return ShapeList(itertools.chain(self, other)) # breaks MacOS-13 - return ShapeList(list(self) + list(other)) + def __add__(self, other: Shape | Iterable[Shape]) -> ShapeList[T]: # type: ignore + """Return a new ShapeList that includes other""" + if isinstance(other, (Vector, Shape)): + return ShapeList(tcast(list[T], list(self) + [other])) + if isinstance(other, Iterable) and all( + isinstance(o, (Shape, Vector)) for o in other + ): + return ShapeList(list(self) + list(other)) + raise TypeError(f"Cannot add object of type {type(other)} to ShapeList") + + def __iadd__(self, other: Shape | Iterable[Shape]) -> Self: # type: ignore + """In-place addition to this ShapeList""" + if isinstance(other, (Vector, Shape)): + self.append(tcast(T, other)) + elif isinstance(other, Iterable) and all( + isinstance(o, (Shape, Vector)) for o in other + ): + self.extend(other) + else: + raise TypeError(f"Cannot add object of type {type(other)} to ShapeList") + return self def __and__(self, other: ShapeList) -> ShapeList[T]: """Intersect two ShapeLists operator &""" @@ -2477,9 +2718,9 @@ class ShapeList(list[T]): def plane_parallel_predicate(plane: Plane, tolerance: float): plane_axis = Axis(plane.origin, plane.z_dir) - plane_xyz = plane.z_dir.wrapped.XYZ() def pred(shape: Shape): + if shape.is_planar_face: assert shape.wrapped is not None and isinstance( shape.wrapped, TopoDS_Face @@ -2496,7 +2737,16 @@ class ShapeList(list[T]): if isinstance(shape.wrapped, TopoDS_Wire): return all(pred(e) for e in shape.edges()) if isinstance(shape.wrapped, TopoDS_Edge): - for curve in shape.wrapped.TShape().Curves(): + if shape.location is None: + return False + plane_xyz = tcast( + gp_XYZ, + ( + tcast(Plane, plane * Location(shape.location).inverse()) + ).z_dir.wrapped.XYZ(), + ) + t_edge = tcast(BRep_TEdge, shape.wrapped.TShape()) + for curve in t_edge.Curves(): if curve.IsCurve3D(): return ShapeAnalysis_Curve.IsPlanar_s( curve.Curve3D(), plane_xyz, tolerance @@ -2562,29 +2812,27 @@ class ShapeList(list[T]): if inclusive == (True, True): objects = filter( lambda o: minimum - <= axis.to_plane().to_local_coords(o).center().Z + <= Plane(axis).to_local_coords(o).center().Z <= maximum, self, ) elif inclusive == (True, False): objects = filter( lambda o: minimum - <= axis.to_plane().to_local_coords(o).center().Z + <= Plane(axis).to_local_coords(o).center().Z < maximum, self, ) elif inclusive == (False, True): objects = filter( lambda o: minimum - < axis.to_plane().to_local_coords(o).center().Z + < Plane(axis).to_local_coords(o).center().Z <= maximum, self, ) elif inclusive == (False, False): objects = filter( - lambda o: minimum - < axis.to_plane().to_local_coords(o).center().Z - < maximum, + lambda o: minimum < Plane(axis).to_local_coords(o).center().Z < maximum, self, ) @@ -2625,15 +2873,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: @@ -2739,22 +2988,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: @@ -2921,6 +3170,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 c776614..5b0fb30 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -54,21 +54,23 @@ license: 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, Literal +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 from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin -from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid +from OCP.BRepOffsetAPI import ( + BRepOffsetAPI_DraftAngle, + BRepOffsetAPI_MakePipeShell, + BRepOffsetAPI_MakeThickSolid, +) from OCP.BRepPrimAPI import ( BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, @@ -82,27 +84,46 @@ 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, Kind, Transition, Until +from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, + TOLERANCE, Axis, BoundBox, Color, Location, + OrientedBoundBox, Plane, Vector, VectorLike, ) -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, + _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, @@ -114,26 +135,14 @@ 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): +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 - 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 @@ -190,6 +199,7 @@ class Mixin3D(Shape): 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: @@ -254,7 +264,7 @@ class Mixin3D(Shape): try: new_shape = self.__class__(chamfer_builder.Shape()) - if not new_shape.is_valid(): + if not new_shape.is_valid: raise Standard_Failure except (StdFail_NotDone, Standard_Failure) as err: raise ValueError( @@ -338,7 +348,7 @@ class Mixin3D(Shape): try: new_shape = self.__class__(fillet_builder.Shape()) - if not new_shape.is_valid(): + if not new_shape.is_valid: raise Standard_Failure except (StdFail_NotDone, Standard_Failure) as err: raise ValueError( @@ -415,6 +425,132 @@ class Mixin3D(Shape): return return_value + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face | Solid]: + """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: + # 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 | Face | Solid] = ShapeList([self]) + target: Shape + for other in to_intersect: + # Conform target type + match other: + case Axis(): + # 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(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[Vertex | Edge | Wire | Face | Shell | Solid] = [] + result: ShapeList | None + for obj in common_set: + match (obj, target): + case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): + 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)) + ) 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(result) + + if 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] + ) + 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. @@ -430,7 +566,7 @@ class Mixin3D(Shape): """ solid_classifier = BRepClass3d_SolidClassifier(self.wrapped) - solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance) + solid_classifier.Perform(gp_Pnt(*Vector(point)), tolerance) return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace() @@ -480,9 +616,11 @@ class Mixin3D(Shape): # Do these numbers work? - if not try with the smaller window try: new_shape = self.__class__(fillet_builder.Shape()) - if not new_shape.is_valid(): - raise fillet_exception - except fillet_exception: + if not new_shape.is_valid: + # 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 @@ -494,17 +632,17 @@ class Mixin3D(Shape): ) return return_value - if not self.is_valid(): + if not self.is_valid: raise ValueError("Invalid Shape") native_edges = [e.wrapped for e in edge_list] # 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) @@ -576,16 +714,35 @@ class Mixin3D(Shape): 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, 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 @@ -660,7 +817,7 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): builder.SetMode(coordinate_system) rotate = True elif isinstance(binormal, (Wire, Edge)): - builder.SetMode(binormal.to_wire().wrapped, True) + builder.SetMode(Wire(binormal).wrapped, True) return rotate @@ -763,7 +920,17 @@ class Solid(Mixin3D, Shape[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( @@ -804,7 +971,7 @@ class Solid(Mixin3D, Shape[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)) @@ -843,115 +1010,127 @@ class Solid(Mixin3D, Shape[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) -> Solid: + def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid: """A box of the same dimensions and location""" - return Solid.make_box(*bbox.size).locate(Location(bbox.min)) + if isinstance(bbox, BoundBox): + return Solid.make_box(*bbox.size).locate(Location(bbox.min)) + else: + moved_plane: Plane = Plane(Location(-bbox.size / 2)).move(bbox.location) + return Solid.make_box( + bbox.size.X, bbox.size.Y, bbox.size.Z, plane=moved_plane + ) @classmethod def make_box( @@ -971,12 +1150,14 @@ class Solid(Mixin3D, Shape[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 @@ -1003,13 +1184,15 @@ class Solid(Mixin3D, Shape[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 @@ -1034,12 +1217,14 @@ class Solid(Mixin3D, Shape[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 @@ -1060,7 +1245,7 @@ class Solid(Mixin3D, Shape[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( @@ -1086,13 +1271,15 @@ class Solid(Mixin3D, Shape[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 @@ -1120,14 +1307,16 @@ class Solid(Mixin3D, Shape[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 @@ -1158,16 +1347,18 @@ class Solid(Mixin3D, Shape[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 @@ -1205,7 +1396,7 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): True, ) - return cls(revol_builder.Shape()) + return cls(TopoDS.Solid_s(revol_builder.Shape())) @classmethod def sweep( @@ -1222,6 +1413,21 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): Sweep the given cross section into a prismatic solid along the provided path + The is_frenet parameter controls how the profile orientation changes as it + follows along the sweep path. If is_frenet is False, the orientation of the + profile is kept consistent from point to point. The resulting shape has the + minimum possible twisting. Unintuitively, when a profile is swept along a + helix, this results in the orientation of the profile slowly creeping + (rotating) as it follows the helix. Setting is_frenet to True prevents this. + + If is_frenet is True the orientation of the profile is based on the local + curvature and tangency vectors of the path. This keeps the orientation of the + profile consistent when sweeping along a helix (because the curvature vector of + a straight helix always points to its axis). However, when path is not a helix, + the resulting shape can have strange looking twists sometimes. For more + information, see Frenet Serret formulas + http://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas. + Args: section (Union[Face, Wire]): cross section to sweep path (Union[Wire, Edge]): sweep path @@ -1243,9 +1449,9 @@ class Solid(Mixin3D, Shape[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(path.to_wire().wrapped) + builder = BRepOffsetAPI_MakePipeShell(Wire(path).wrapped) rotate = False @@ -1287,6 +1493,21 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): Sweep through a sequence of profiles following a path. + The is_frenet parameter controls how the profile orientation changes as it + follows along the sweep path. If is_frenet is False, the orientation of the + profile is kept consistent from point to point. The resulting shape has the + minimum possible twisting. Unintuitively, when a profile is swept along a + helix, this results in the orientation of the profile slowly creeping + (rotating) as it follows the helix. Setting is_frenet to True prevents this. + + If is_frenet is True the orientation of the profile is based on the local + curvature and tangency vectors of the path. This keeps the orientation of the + profile consistent when sweeping along a helix (because the curvature vector of + a straight helix always points to its axis). However, when path is not a helix, + the resulting shape can have strange looking twists sometimes. For more + information, see Frenet Serret formulas + http://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas. + Args: profiles (Iterable[Union[Wire, Face]]): list of profiles path (Union[Wire, Edge]): The wire to sweep the face resulting from the wires over @@ -1298,7 +1519,7 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): Returns: Solid: swept object """ - path_as_wire = path.to_wire().wrapped + path_as_wire = Wire(path).wrapped builder = BRepOffsetAPI_MakePipeShell(path_as_wire) @@ -1323,7 +1544,7 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): if make_solid: builder.MakeSolid() - return cls(builder.Shape()) + return cls(TopoDS.Solid_s(builder.Shape())) @classmethod def thicken( @@ -1379,8 +1600,67 @@ class Solid(Mixin3D, Shape[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 return result + + def draft(self, faces: Iterable[Face], neutral_plane: Plane, angle: float) -> Solid: + """Apply a draft angle to the given faces of the solid. + + Args: + faces: Faces to which the draft should be applied. + neutral_plane: Plane defining the neutral direction and position. + angle: Draft angle in degrees. + + Returns: + Solid with the specified draft angles applied. + + Raises: + RuntimeError: If draft application fails on any face or during build. + """ + valid_geom_types = {GeomType.PLANE, GeomType.CYLINDER, GeomType.CONE} + for face in faces: + if face.geom_type not in valid_geom_types: + raise ValueError( + f"Face {face} has unsupported geometry type {face.geom_type.name}. " + "Only PLANAR, CYLINDRICAL, and CONICAL faces are supported." + ) + + draft_angle_builder = BRepOffsetAPI_DraftAngle(self.wrapped) + + for face in faces: + draft_angle_builder.Add( + face.wrapped, + neutral_plane.z_dir.to_dir(), + radians(angle), + neutral_plane.wrapped, + Flag=True, + ) + if not draft_angle_builder.AddDone(): + raise DraftAngleError( + "Draft could not be added to a face.", + face=face, + problematic_shape=draft_angle_builder.ProblematicShape(), + ) + + try: + draft_angle_builder.Build() + result = Solid(TopoDS.Solid_s(draft_angle_builder.Shape())) + except StdFail_NotDone as err: + raise DraftAngleError( + "Draft build failed on the given solid.", + face=None, + problematic_shape=draft_angle_builder.ProblematicShape(), + ) from err + return result + + +class DraftAngleError(RuntimeError): + """Solid.draft custom exception""" + + def __init__(self, message, face=None, problematic_shape=None): + super().__init__(message) + self.face = face + self.problematic_shape = problematic_shape diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index bf4e1a6..279eb5c 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -56,93 +56,124 @@ license: from __future__ import annotations import copy +import sys import warnings -from typing import Any, Tuple, Union, overload, TYPE_CHECKING - +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_Tool -from OCP.BRepAdaptor import BRepAdaptor_Surface +from OCP.BRep import BRep_Builder, BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCP.BRepAlgo import BRepAlgo -from OCP.BRepAlgoAPI import BRepAlgoAPI_Common -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeShell +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, +) from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.BRepFill import BRepFill from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeShell +from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol from OCP.BRepTools import BRepTools, BRepTools_ReShape +from OCP.gce import gce_MakeLin +from OCP.Geom import ( + Geom_BezierSurface, + Geom_BSplineCurve, + Geom_RectangularTrimmedSurface, + Geom_Surface, + Geom_TrimmedCurve, +) +from OCP.GeomAbs import GeomAbs_C0, GeomAbs_CurveType, GeomAbs_G1, GeomAbs_G2 +from OCP.GeomAPI import ( + GeomAPI_ExtremaCurveCurve, + GeomAPI_PointsToBSplineSurface, + GeomAPI_ProjectPointOnSurf, +) +from OCP.GeomProjLib import GeomProjLib +from OCP.gp import gp_Pnt, gp_Vec from OCP.GProp import GProp_GProps -from OCP.Geom import Geom_BezierSurface, Geom_Surface -from OCP.GeomAPI import GeomAPI_PointsToBSplineSurface, GeomAPI_ProjectPointOnSurf -from OCP.GeomAbs import GeomAbs_C0 from OCP.Precision import Precision -from OCP.ShapeFix import ShapeFix_Solid +from OCP.ShapeFix import ShapeFix_Solid, ShapeFix_Wire from OCP.Standard import ( + Standard_ConstructionError, Standard_Failure, Standard_NoSuchObject, - Standard_ConstructionError, + Standard_TypeMismatch, ) from OCP.StdFail import StdFail_NotDone -from OCP.TColStd import TColStd_HArray2OfReal -from OCP.TColgp import TColgp_HArray2OfPnt +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt +from OCP.TColStd import ( + TColStd_Array1OfInteger, + TColStd_Array1OfReal, + TColStd_HArray2OfReal, +) from OCP.TopExp import TopExp -from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid -from OCP.gce import gce_MakeLin -from OCP.gp import gp_Pnt, gp_Vec -from build123d.build_enums import CenterOf, GeomType, SortBy, Transition +from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape +from ocp_gordon import interpolate_curve_network +from typing_extensions import Self + +from build123d.build_enums import ( + CenterOf, + ContinuityLevel, + GeomType, + Keep, + SortBy, + Transition, +) from build123d.geometry import ( + DEG2RAD, TOLERANCE, Axis, Color, Location, + OrientedBoundBox, Plane, Vector, VectorLike, ) -from typing_extensions import Self -from .one_d import Mixin1D, Edge, Wire +from .one_d import Edge, Mixin1D, Wire from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, - downcast, - get_top_level_topods_shapes, _sew_topods_faces, - shapetype, + _topods_bool_op, _topods_entities, _topods_face_normal_at, + downcast, + get_top_level_topods_shapes, + shapetype, ) from .utils import ( _extrude_topods_shape, - find_max_dimension, _make_loft, _make_topods_face_from_wires, - _topods_bool_op, + find_max_dimension, ) from .zero_d import Vertex - if TYPE_CHECKING: # pragma: no cover + from .composite import Compound, Curve # pylint: disable=R0801 from .three_d import Solid # pylint: disable=R0801 - from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 + +T = TypeVar("T", Edge, Wire, "Face") -class Mixin2D(Shape): +class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" - project_to_viewport = Mixin1D.project_to_viewport - split = Mixin1D.split + # project_to_viewport = Mixin1D.project_to_viewport - vertices = Mixin1D.vertices - vertex = Mixin1D.vertex - edges = Mixin1D.edges - edge = Mixin1D.edge - wires = Mixin1D.wires # ---- Properties ---- @property @@ -180,20 +211,23 @@ class Mixin2D(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()) + 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 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 @@ -208,7 +242,7 @@ class Mixin2D(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() @@ -219,7 +253,7 @@ class Mixin2D(Shape): while intersect_maker.More(): inter_pt = intersect_maker.Pnt() # Calculate distance along axis - distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z + distance = Plane(other).to_local_coords(Vector(inter_pt)).Z intersections.append( ( intersect_maker.Face(), # TopoDS_Face @@ -242,20 +276,304 @@ class Mixin2D(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: + # 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 | Face | Shell] = ShapeList([self]) + target: Shape + for other in to_intersect: + # Conform target type + match other: + case Axis(): + # 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(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[Vertex | Edge | Wire | Face | Shell] = [] + result: ShapeList | None + for obj in common_set: + match (obj, target): + case (_, Vertex() | Edge() | Wire() | Face() | Shell()): + 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) + ): + # 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(result) + + if 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 + + return ShapeList(common_set) + + @abstractmethod + def location_at(self, *args: Any, **kwargs: Any) -> Location: + """A location from a face or shell""" + pass + def offset(self, amount: float) -> Self: """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, + planar_edge: Edge, + surface_loc: Location, + snap_to_face: bool = True, + tolerance: float = 0.001, + ) -> Edge: + """_wrap_edge + + Helper method of wrap that handles wrapping edges on surfaces (Face or Shell). + + Args: + planar_edge (Edge): edge to wrap around surface + surface_loc (Location): location on surface to wrap + snap_to_face (bool,optional): ensure wrapped edge is tight against surface. + Defaults to True. + tolerance (float, optional): maximum allowed length error during initial wrapping + operation. Defaults to 0.001 + + Raises: + RuntimeError: wrapping over surface boundary, try difference surface_loc + Returns: + Edge: wrapped edge + """ + + def _intersect_surface_normal( + point: Vector, direction: Vector + ) -> tuple[Vector, Vector]: + """Return the intersection point and normal of the closest surface face + along direction""" + axis = Axis(point, direction) + face = self.faces_intersected_by_axis(axis).sort_by( + lambda f: f.distance_to(point) + )[0] + intersections = face.find_intersection_points(axis) + if not intersections: + raise RuntimeError( + "wrapping over surface boundary, try difference surface_loc" + ) + return min(intersections, key=lambda pair: abs(pair[0] - point)) + + def _find_point_on_surface( + current_point: Vector, normal: Vector, relative_position: Vector + ) -> tuple[Vector, Vector]: + """Project a 2D offset from a local surface frame onto the 3D surface""" + local_plane = Plane( + origin=current_point, + x_dir=surface_x_direction, + z_dir=normal, + ) + world_point = local_plane.from_local_coords(relative_position) + return _intersect_surface_normal( + world_point, world_point - target_object_center + ) + + if self._wrapped is None: + raise ValueError("Can't wrap around an empty face") + + # Initial setup + target_object_center = self.center(CenterOf.BOUNDING_BOX) + + surface_x_direction = surface_loc.x_axis.direction + + planar_edge_length = planar_edge.length + + # Start adaptive refinement + subdivisions = 3 + max_loops = 10 + loop_count = 0 + length_error = sys.float_info.max + + # Find the location on the surface to start + if planar_edge.position_at(0).length > tolerance: + # The start point isn't at the surface_loc so wrap a line to find it + to_start_edge = Edge.make_line((0, 0), planar_edge @ 0) + wrapped_to_start_edge = self._wrap_edge( + to_start_edge, surface_loc, snap_to_face=True, tolerance=tolerance + ) + start_pnt = wrapped_to_start_edge @ 1 + _, start_normal = _intersect_surface_normal( + start_pnt, (start_pnt - target_object_center) + ) + else: + # The start point is at the surface location + start_pnt = surface_loc.position + start_normal = surface_loc.z_axis.direction + + while length_error > tolerance and loop_count < max_loops: + # Seed the wrapped path + wrapped_edge_points: list[VectorLike] = [] + current_point, current_normal = start_pnt, start_normal + wrapped_edge_points.append(current_point) + + # Subdivide and propagate + for div in range(1, subdivisions + int(not planar_edge.is_closed)): + prev = planar_edge.position_at((div - 1) / subdivisions) + curr = planar_edge.position_at(div / subdivisions) + offset = curr - prev + current_point, current_normal = _find_point_on_surface( + current_point, current_normal, offset + ) + wrapped_edge_points.append(current_point) + + # Build and evaluate + wrapped_edge = Edge.make_spline( + wrapped_edge_points, periodic=planar_edge.is_closed + ) + length_error = abs(planar_edge_length - wrapped_edge.length) + + subdivisions *= 2 + loop_count += 1 + + if length_error > tolerance: + raise RuntimeError( + f"Length error of {length_error:.6f} exceeds tolerance {tolerance}" + ) + if not wrapped_edge or not wrapped_edge.is_valid: + raise RuntimeError("Wrapped edge is invalid") + + if not snap_to_face: + return wrapped_edge + + # Project the curve onto the surface + surface_handle = BRep_Tool.Surface_s(self.wrapped) + first_param: float = wrapped_edge.param_at(0) + last_param: float = wrapped_edge.param_at(1) + curve_handle = BRep_Tool.Curve_s(wrapped_edge.wrapped, first_param, last_param) + proj_curve_handle = GeomProjLib.Project_s(curve_handle, surface_handle) + if proj_curve_handle is None: + raise RuntimeError( + "Projection failed, try setting `snap_to_face` to False." + ) + + # Build a new projected edge + projected_edge = Edge(BRepBuilderAPI_MakeEdge(proj_curve_handle).Edge()) + + 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 @@ -270,7 +588,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, @@ -278,7 +596,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. @@ -304,11 +622,14 @@ class Face(Mixin2D, Shape[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: 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,) * ( @@ -337,6 +658,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 [] @@ -354,6 +678,174 @@ class Face(Mixin2D, Shape[TopoDS_Face]): # ---- Properties ---- + @property + def area_without_holes(self) -> float: + """ + Calculate the total surface area of the face, including the areas of any holes. + + This property returns the overall area of the face as if the inner boundaries (holes) + were filled in. + + Returns: + float: The total surface area, including the area of holes. Returns 0.0 if + the face is empty. + """ + if self._wrapped is None: + return 0.0 + + return self.without_holes().area + + @property + def axis_of_rotation(self) -> None | Axis: + """Get the rotational axis of a cylinder or torus""" + 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] + ) + + if self.geom_type == GeomType.TORUS: + return Axis(self.geom_adaptor().Torus().Axis()) # type:ignore[attr-defined] + + return None + + @property + def axes_of_symmetry(self) -> list[Axis]: + """Computes and returns the axes of symmetry for a planar face. + + The method determines potential symmetry axes by analyzing the face’s + geometry: + + - It first validates that the face is non-empty and planar. + + - For faces with inner wires (holes), it computes the centroid of the + holes and the face's overall center (COG). + + - If the holes' centroid significantly deviates from the COG (beyond + a specified tolerance), the symmetry axis is taken along the line + connecting these points; otherwise, each hole’s center is used to + generate a candidate axis. + + - For faces without holes, candidate directions are derived by sampling + midpoints along the outer wire's edges. + + - If curved edges are present, additional candidate directions are + obtained from an oriented bounding box (OBB) constructed around the + face. + + For each candidate direction, the face is split by a plane (defined + using the candidate direction and the face’s normal). The top half of the face + is then mirrored across this plane, and if the area of the intersection between + the mirrored half and the bottom half matches the bottom half’s area within a + small tolerance, the direction is accepted as an axis of symmetry. + + Returns: + list[Axis]: A list of Axis objects, each defined by the face's + center and a direction vector, representing the symmetry axes of + the face. + + Raises: + ValueError: If the face or its underlying representation is empty. + ValueError: If the face is not planar. + """ + if self._wrapped is None: + raise ValueError("Can't determine axes_of_symmetry of empty face") + + if not self.is_planar_face: + raise ValueError("axes_of_symmetry only supports for planar faces") + + cog = self.center() + normal = self.normal_at() + shape_inner_wires = self.inner_wires() + if shape_inner_wires: + hole_faces = [Face(w) for w in shape_inner_wires] + holes_centroid = Face.combined_center(hole_faces) + # If the holes aren't centered on the cog the axis of symmetry must be + # through the cog and hole centroid + if abs(holes_centroid - cog) > TOLERANCE: + cross_dirs = [(holes_centroid - cog).normalized()] + else: + # There may be an axis of symmetry through the center of the holes + cross_dirs = [(f.center() - cog).normalized() for f in hole_faces] + else: + curved_edges = ( + self.outer_wire().edges().filter_by(GeomType.LINE, reverse=True) + ) + shape_edges = self.outer_wire().edges() + if curved_edges: + obb = OrientedBoundBox(self) + corners = obb.corners + obb_edges = ShapeList( + [Edge.make_line(corners[i], corners[(i + 1) % 4]) for i in range(4)] + ) + mid_points = [ + e @ p for e in shape_edges + obb_edges for p in [0.0, 0.5, 1.0] + ] + else: + mid_points = [e @ p for e in shape_edges for p in [0.0, 0.5, 1.0]] + cross_dirs = [(mid_point - cog).normalized() for mid_point in mid_points] + + symmetry_dirs: set[Vector] = set() + for cross_dir in cross_dirs: + # Split the face by the potential axis and flip the top + split_plane = Plane( + origin=cog, + x_dir=cross_dir, + z_dir=cross_dir.cross(normal), + ) + # Split by plane + top, bottom = self.split(split_plane, keep=Keep.BOTH) + + if type(top) != type(bottom): # exit early if not same + continue + + if top is None or bottom is None: # Impossible to actually happen? + continue + + top_list = ShapeList(top if isinstance(top, list) else [top]) + bottom_list = ShapeList(bottom if isinstance(bottom, list) else [bottom]) + + if len(top_list) != len(bottom_list): # exit early unequal length + continue + + bottom_list = bottom_list.sort_by(Axis(cog, cross_dir)) + top_flipped_list = ShapeList( + f.mirror(split_plane) for f in top_list + ).sort_by(Axis(cog, cross_dir)) + + bottom_area = sum(f.area for f in bottom_list) + for flipped_face, bottom_face in zip(top_flipped_list, bottom_list): + intersection = flipped_face.intersect(bottom_face) + if intersection is None: + intersect_area = -1.0 + break + else: + intersect_area = sum(f.area for f in intersection.faces()) + + if intersect_area == -1.0: + continue + + # Are the top/bottom the same? + if abs(intersect_area - bottom_area) < TOLERANCE: + if not symmetry_dirs: + symmetry_dirs.add(cross_dir) + else: + opposite = any( + d.dot(cross_dir) < -1 + TOLERANCE for d in symmetry_dirs + ) + if not opposite: + symmetry_dirs.add(cross_dir) + + symmetry_axes = [Axis(cog, d) for d in symmetry_dirs] + return symmetry_axes + @property def center_location(self) -> Location: """Location at the center of face""" @@ -389,6 +881,72 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return result + @property + def _curvature_sign(self) -> float: + """ + Compute the signed dot product between the face normal and the vector from the + underlying geometry's reference point to the face center. + + For a cylinder, the reference is the cylinder’s axis position. + For a sphere, it is the sphere’s center. + For a torus, we derive a reference point on the central circle. + + Returns: + float: The signed value; positive indicates convexity, negative indicates concavity. + Returns 0 if the geometry type is unsupported. + """ + if ( + self.geom_type == GeomType.CYLINDER + and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface + ): + axis = self.axis_of_rotation + if axis is None: + raise ValueError("Can't find curvature of empty object") + return self.normal_at().dot(self.center() - axis.position) + + elif self.geom_type == GeomType.SPHERE: + loc = self.location # The sphere's center + if loc is None: + raise ValueError("Can't find curvature of empty object") + return self.normal_at().dot(self.center() - loc.position) + + elif self.geom_type == GeomType.TORUS: + # Here we assume that for a torus the rotational axis can be converted to a plane, + # and we then define the central (or core) circle using the first value of self.radii. + axis = self.axis_of_rotation + if axis is None or self.radii is None: + raise ValueError("Can't find curvature of empty object") + loc = Location(Plane(axis)) + axis_circle = Edge.make_circle(self.radii[0]).locate(loc) + _, pnt_on_axis_circle, _ = axis_circle.distance_to_with_closest_points( + self.center() + ) + return self.normal_at().dot(self.center() - pnt_on_axis_circle) + + return 0.0 + + @property + def is_circular_convex(self) -> bool: + """ + Determine whether a given face is convex relative to its underlying geometry + for supported geometries: cylinder, sphere, torus. + + Returns: + bool: True if convex; otherwise, False. + """ + return self._curvature_sign > TOLERANCE + + @property + def is_circular_concave(self) -> bool: + """ + Determine whether a given face is concave relative to its underlying geometry + for supported geometries: cylinder, sphere, torus. + + Returns: + bool: True if concave; otherwise, False. + """ + return self._curvature_sign < -TOLERANCE + @property def is_planar(self) -> bool: """Is the face planar even though its geom_type may not be PLANE""" @@ -406,21 +964,37 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return result @property - def total_area(self) -> float: - """ - Calculate the total surface area of the face, including the areas of any holes. + def radii(self) -> None | tuple[float, float]: + """Return the major and minor radii of a torus otherwise None""" + if self.geom_type == GeomType.TORUS: + return ( + self.geom_adaptor().MajorRadius(), # type:ignore[attr-defined] + self.geom_adaptor().MinorRadius(), # type:ignore[attr-defined] + ) - This property returns the overall area of the face as if the inner boundaries (holes) - were filled in. + return None - Returns: - float: The total surface area, including the area of holes. Returns 0.0 if - the face is empty. - """ - if self.wrapped is None: - return 0.0 + @property + def radius(self) -> None | float: + """Return the radius of a cylinder or sphere, otherwise None""" + if ( + self.geom_type in [GeomType.CYLINDER, GeomType.SPHERE] + and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface + ): + return self.geom_adaptor().Radius() # type:ignore[attr-defined] + else: + return None - return self.remove_holes().area + @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: @@ -456,6 +1030,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]): Returns: Face: extruded shape """ + if not obj: + raise ValueError("Can't extrude empty object") return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction))) @classmethod @@ -507,12 +1083,103 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return cls(BRepBuilderAPI_MakeFace(bezier, Precision.Confusion_s()).Face()) + @classmethod + def make_gordon_surface( + cls, + profiles: Iterable[VectorLike | Edge], + guides: Iterable[VectorLike | Edge], + tolerance: float = 3e-4, + ) -> Face: + """ + Constructs a Gordon surface from a network of profile and guide curves. + + Requirements: + 1. Profiles and guides may be defined as points or curves. + 2. Only the first or last profile or guide may be a point. + 3. At least one profile and one guide must be a non-point curve. + 4. Each profile must intersect with every guide. + 5. Both ends of every profile must lie on a guide. + 6. Both ends of every guide must lie on a profile. + + Args: + profiles (Iterable[VectorLike | Edge]): Profiles defined as points or edges. + guides (Iterable[VectorLike | Edge]): Guides defined as points or edges. + tolerance (float, optional): Tolerance used for surface construction and + intersection calculations. + + Raises: + ValueError: input Edge cannot be empty. + + Returns: + Face: the interpolated Gordon surface + """ + + def create_zero_length_bspline_curve( + point: gp_Pnt, degree: int = 1 + ) -> Geom_BSplineCurve: + control_points = TColgp_Array1OfPnt(1, 2) + control_points.SetValue(1, point) + control_points.SetValue(2, point) + + knots = TColStd_Array1OfReal(1, 2) + knots.SetValue(1, 0.0) + knots.SetValue(2, 1.0) + + multiplicities = TColStd_Array1OfInteger(1, 2) + multiplicities.SetValue(1, degree + 1) + multiplicities.SetValue(2, degree + 1) + + curve = Geom_BSplineCurve(control_points, knots, multiplicities, degree) + return curve + + def to_geom_curve(shape: VectorLike | Edge): + if isinstance(shape, (Vector, tuple, Sequence)): + _shape = Vector(shape) + single_point_curve = create_zero_length_bspline_curve( + gp_Pnt(_shape.wrapped.XYZ()) + ) + return single_point_curve + + if not shape: + raise ValueError("input Edge cannot be empty") + + adaptor = BRepAdaptor_Curve(shape.wrapped) + curve = BRep_Tool.Curve_s(shape.wrapped, 0, 1) + if not ( + (adaptor.IsPeriodic() and adaptor.IsClosed()) + or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BSplineCurve + or adaptor.GetType() == GeomAbs_CurveType.GeomAbs_BezierCurve + ): + curve = Geom_TrimmedCurve( + curve, adaptor.FirstParameter(), adaptor.LastParameter() + ) + return curve + + ocp_profiles = [to_geom_curve(shape) for shape in profiles] + ocp_guides = [to_geom_curve(shape) for shape in guides] + + gordon_bspline_surface = interpolate_curve_network( + ocp_profiles, ocp_guides, tolerance=tolerance + ) + + return cls( + BRepBuilderAPI_MakeFace( + gordon_bspline_surface, Precision.Confusion_s() + ).Face() + ) + @classmethod def make_plane( cls, 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) @@ -602,11 +1269,13 @@ class Face(Mixin2D, Shape[TopoDS_Face]): raise ValueError("exterior must be a Wire or list of Edges") for edge in outside_edges: + if not edge: + raise ValueError("exterior contains empty edges") surface.Add(edge.wrapped, GeomAbs_C0) try: surface.Build() - surface_face = Face(surface.Shape()) + surface_face = Face(surface.Shape()) # type:ignore[call-overload] except ( Standard_Failure, StdFail_NotDone, @@ -618,10 +1287,10 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err if surface_point_vectors: for point in surface_point_vectors: - surface.Add(gp_Pnt(*point.to_tuple())) + surface.Add(gp_Pnt(*point)) try: surface.Build() - surface_face = Face(surface.Shape()) + surface_face = Face(surface.Shape()) # type:ignore[call-overload] except StdFail_NotDone as err: raise RuntimeError( "Error building non-planar face with provided surface_points" @@ -631,6 +1300,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if interior_wires: makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped) for wire in interior_wires: + if not wire: + raise ValueError("interior_wires contain an empty wire") makeface_object.Add(wire.wrapped) try: surface_face = Face(makeface_object.Face()) @@ -640,7 +1311,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err surface_face = surface_face.fix() - if not surface_face.is_valid(): + if not surface_face.is_valid: raise RuntimeError("non planar face is invalid") return surface_face @@ -756,6 +1427,106 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return_value = cls.cast(BRepFill.Face_s(curve1.wrapped, curve2.wrapped)) return return_value + @classmethod + def make_surface_patch( + cls, + edge_face_constraints: ( + Iterable[tuple[Edge, Face, ContinuityLevel]] | None + ) = None, + edge_constraints: Iterable[Edge] | None = None, + point_constraints: Iterable[VectorLike] | None = None, + ) -> Face: + """make_surface_patch + + Create a potentially non-planar face patch bounded by exterior edges which can + be optionally refined using support faces to ensure e.g. tangent surface + continuity. Also can optionally refine the surface using surface points. + + Args: + edge_face_constraints (list[tuple[Edge, Face, ContinuityLevel]], optional): + Edges defining perimeter of face with adjacent support faces subject to + ContinuityLevel. Defaults to None. + edge_constraints (list[Edge], optional): Edges defining perimeter of face + without adjacent support faces. Defaults to None. + point_constraints (list[VectorLike], optional): Points on the surface that + refine the shape. Defaults to None. + + Raises: + RuntimeError: Error building non-planar face with provided constraints + RuntimeError: Generated face is invalid + + Returns: + Face: Potentially non-planar face + """ + continuity_dict = { + ContinuityLevel.C0: GeomAbs_C0, + ContinuityLevel.C1: GeomAbs_G1, + ContinuityLevel.C2: GeomAbs_G2, + } + patch = BRepOffsetAPI_MakeFilling() + + if edge_face_constraints: + for constraint in edge_face_constraints: + patch.Add( + constraint[0].wrapped, + constraint[1].wrapped, + continuity_dict[constraint[2]], + ) + if edge_constraints: + for edge in edge_constraints: + patch.Add(edge.wrapped, continuity_dict[ContinuityLevel.C0]) + + if point_constraints: + for point in point_constraints: + patch.Add(gp_Pnt(*point)) + + try: + patch.Build() + result = cls(TopoDS.Face_s(patch.Shape())) + except ( + Standard_Failure, + StdFail_NotDone, + Standard_NoSuchObject, + Standard_ConstructionError, + ) as err: + raise RuntimeError( + "Error building non-planar face with provided constraints" + ) from err + + result = result.fix() + if not result.is_valid or not result: + raise RuntimeError("Non planar face is invalid") + + return result + + @classmethod + def revolve( + cls, + profile: Edge, + angle: float, + axis: Axis, + ) -> Face: + """sweep + + Revolve an Edge around an axis. + + Args: + profile (Edge): the object to sweep + angle (float): the angle to revolve through + axis (Axis): rotation Axis + + Returns: + Face: resulting face + """ + revol_builder = BRepPrimAPI_MakeRevol( + profile.wrapped, + axis.wrapped, + angle * DEG2RAD, + True, + ) + + return cls(revol_builder.Shape()) # type:ignore[call-overload] + @classmethod def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]: """sew faces @@ -785,7 +1556,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]): elif isinstance(top_level_shape, TopoDS_Solid): sewn_faces.append( ShapeList( - Face(f) for f in _topods_entities(top_level_shape, "Face") + Face(f) # type:ignore[call-overload] + for f in _topods_entities(top_level_shape, "Face") ) ) else: @@ -826,13 +1598,17 @@ class Face(Mixin2D, Shape[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]) builder.Build() - result = Face(builder.Shape()) + result = Face(builder.Shape()) # type:ignore[call-overload] if SkipClean.clean: result = result.clean() @@ -851,6 +1627,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): Returns: Vector: center """ + center_point: Vector | gp_Pnt if (center_of == CenterOf.MASS) or ( center_of == CenterOf.GEOMETRY and self.is_planar ): @@ -910,7 +1687,10 @@ class Face(Mixin2D, Shape[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) @@ -951,8 +1731,10 @@ class Face(Mixin2D, Shape[TopoDS_Face]): def inner_wires(self) -> ShapeList[Wire]: """Extract the inner or hole wires from this Face""" outer = self.outer_wire() - - return ShapeList([w for w in self.wires() if not w.is_same(outer)]) + inners = [w for w in self.wires() if not w.is_same(outer)] + for w in inners: + w.topo_parent = self if self.topo_parent is None else self.topo_parent + return ShapeList(inners) def is_coplanar(self, plane: Plane) -> bool: """Is this planar face coplanar with the provided plane""" @@ -983,23 +1765,112 @@ class Face(Mixin2D, Shape[TopoDS_Face]): """ solid_classifier = BRepClass3d_SolidClassifier(self.wrapped) - solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance) + solid_classifier.Perform(gp_Pnt(*Vector(point)), tolerance) return solid_classifier.IsOnAFace() # surface = BRep_Tool.Surface_s(self.wrapped) # projector = GeomAPI_ProjectPointOnSurf(Vector(point).to_pnt(), surface) # return projector.LowerDistance() <= TOLERANCE + @overload def location_at( - self, u: float, v: float, x_dir: VectorLike | None = None - ) -> Location: - """Location at the u/v position of face""" - origin = self.position_at(u, v) - if x_dir is None: - pln = Plane(origin, z_dir=self.normal_at(origin)) + self, + surface_point: VectorLike | None = None, + *, + x_dir: VectorLike | None = None, + ) -> Location: ... + + @overload + def location_at( + self, u: float, v: float, *, x_dir: VectorLike | None = None + ) -> Location: ... + + def location_at(self, *args, **kwargs) -> Location: + """location_at + + Get the location (origin and orientation) on the surface of the face. + + This method supports two overloads: + + 1. `location_at(u: float, v: float, *, x_dir: VectorLike | None = None) -> Location` + - Specifies the point in normalized UV parameter space of the face. + - `u` and `v` are floats between 0.0 and 1.0. + - Optionally override the local X direction using `x_dir`. + + 2. `location_at(surface_point: VectorLike, *, x_dir: VectorLike | None = None) -> Location` + - Projects the given 3D point onto the face surface. + - The point must be reasonably close to the face. + - Optionally override the local X direction using `x_dir`. + + If no arguments are provided, the location at the center of the face + (u=0.5, v=0.5) is returned. + + Args: + u (float): Normalized horizontal surface parameter (optional). + v (float): Normalized vertical surface parameter (optional). + surface_point (VectorLike): A 3D point near the surface (optional). + x_dir (VectorLike, optional): Direction for the local X axis. If not given, + the tangent in the U direction is used. + + Returns: + Location: A full 3D placement at the specified point on the face surface. + + Raises: + ValueError: If only one of `u` or `v` is provided or invalid keyword args are passed. + """ + surface_point, u, v = None, -1.0, -1.0 + + if args: + if isinstance(args[0], (Vector, Sequence)): + surface_point = args[0] + elif isinstance(args[0], (int, float)): + u = args[0] + if len(args) == 2 and isinstance(args[1], (int, float)): + v = args[1] + + unknown_args = set(kwargs.keys()).difference( + {"surface_point", "u", "v", "x_dir"} + ) + if unknown_args: + raise ValueError(f"Unexpected argument(s) {', '.join(unknown_args)}") + + surface_point = kwargs.get("surface_point", surface_point) + u = kwargs.get("u", u) + v = kwargs.get("v", v) + user_x_dir = kwargs.get("x_dir", None) + + if surface_point is None and u < 0 and v < 0: + u, v = 0.5, 0.5 + elif surface_point is None and (u < 0 or v < 0): + raise ValueError("Both u & v values must be specified") + + geom_surface: Geom_Surface = self.geom_adaptor() + u_min, u_max, v_min, v_max = self._uv_bounds() + + if surface_point is None: + u_val = u_min + u * (u_max - u_min) + v_val = v_min + v * (v_max - v_min) else: - pln = Plane(origin, x_dir=Vector(x_dir), z_dir=self.normal_at(origin)) - return Location(pln) + projector = GeomAPI_ProjectPointOnSurf( + Vector(surface_point).to_pnt(), geom_surface + ) + u_val, v_val = projector.LowerDistanceParameters() + + # Evaluate point and partials + pnt = gp_Pnt() + du = gp_Vec() + dv = gp_Vec() + geom_surface.D1(u_val, v_val, pnt, du, dv) + + origin = Vector(pnt) + z_dir = Vector(du).cross(Vector(dv)).normalized() + x_dir = ( + Vector(user_x_dir).normalized() + if user_x_dir is not None + else Vector(du).normalized() + ) + + return Location(Plane(origin=origin, x_dir=x_dir, z_dir=z_dir)) def make_holes(self, interior_wires: list[Wire]) -> Face: """Make Holes in Face @@ -1039,7 +1910,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err surface_face = surface_face.fix() - # if not surface_face.is_valid(): + # if not surface_face.is_valid: # raise RuntimeError("non planar face is invalid") return surface_face @@ -1089,7 +1960,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): surface_point, u, v = None, -1.0, -1.0 if args: - if isinstance(args[0], Sequence): + if isinstance(args[0], (Vector, Sequence)): surface_point = args[0] elif isinstance(args[0], (int, float)): u = args[0] @@ -1115,8 +1986,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if surface_point is None: u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() - u_val = u * (u_val0 + u_val1) - v_val = v * (v_val0 + v_val1) + u_val = u_val0 + u * (u_val1 - u_val0) + v_val = v_val0 + v * (v_val1 - v_val0) else: # project point on surface projector = GeomAPI_ProjectPointOnSurf( @@ -1133,7 +2004,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): def outer_wire(self) -> Wire: """Extract the perimeter wire from this Face""" - return Wire(BRepTools.OuterWire_s(self.wrapped)) + outer = Wire(BRepTools.OuterWire_s(self.wrapped)) + outer.topo_parent = self if self.topo_parent is None else self.topo_parent + return outer def position_at(self, u: float, v: float) -> Vector: """position_at @@ -1196,7 +2069,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): (extruded_topods_self,), (target_object.wrapped,), BRepAlgoAPI_Common() ) if not topods_shape.IsNull(): - intersected_shapes.append(Face(topods_shape)) + intersected_shapes.append( + Face(topods_shape) # type:ignore[call-overload] + ) else: for target_shell in target_object.shells(): topods_shape = _topods_bool_op( @@ -1205,7 +2080,7 @@ class Face(Mixin2D, Shape[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() @@ -1218,15 +2093,40 @@ class Face(Mixin2D, Shape[TopoDS_Face]): projected_shapes.append(shape) return projected_shapes - def remove_holes(self) -> Face: - """remove_holes + def to_arcs(self, tolerance: float = 1e-3) -> Face: + """to_arcs + + Approximate planar face with arcs and straight line segments. + + This is a utility used internally to convert or adapt a face for Boolean operations. Its + purpose is not typically for general use, but rather as a helper within the Boolean kernel + to ensure input faces are in a compatible and canonical form. + + Args: + tolerance (float, optional): Approximation tolerance. Defaults to 1e-3. + + Returns: + Face: approximated face + """ + warnings.warn( + "The 'to_arcs' method is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if self._wrapped is None: + raise ValueError("Cannot approximate an empty shape") + + return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance)) + + def without_holes(self) -> Face: + """without_holes Remove all of the holes from this 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()): @@ -1237,25 +2137,9 @@ class Face(Mixin2D, Shape[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 to_arcs(self, tolerance: float = 1e-3) -> Face: - """to_arcs - - Approximate planar face with arcs and straight line segments. - - Args: - tolerance (float, optional): Approximation tolerance. Defaults to 1e-3. - - Returns: - Face: approximated face - """ - if self.wrapped is None: - raise ValueError("Cannot approximate an empty shape") - - return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance)) - def wire(self) -> Wire: """Return the outerwire, generate a warning if inner_wires present""" if self.inner_wires(): @@ -1265,12 +2149,358 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) return self.outer_wire() + @overload + def wrap( + self, + planar_shape: Edge, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> Edge: ... + @overload + def wrap( + self, + planar_shape: Wire, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> Wire: ... + @overload + def wrap( + self, + planar_shape: Face, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> Face: ... + + def wrap( + self, + planar_shape: T, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> T: + """wrap + + Wrap a planar 2D shape onto a 3D surface. + + This method conforms a 2D shape defined on the XY plane (Edge, + Wire, or Face) to the curvature of a non-planar 3D Face (the + target surface), starting at a specified surface location. The + operation attempts to preserve the original edge lengths and + shape as closely as possible while minimizing the geometric + distortion that naturally arises when mapping flat geometry onto + curved surfaces. + + The wrapping process follows the local orientation of the surface + and progressively fits each edge along the curvature. To help + ensure continuity, the first and last edges are extended and trimmed + to close small gaps introduced by distortion. The final shape is tightly + aligned to the surface geometry. + + This method is useful for applying flat features—such as + decorative patterns, cutouts, or boundary outlines—onto curved or + freeform surfaces while retaining their original proportions. + + Args: + planar_shape (Edge | Wire | Face): flat shape to wrap around surface + surface_loc (Location): location on surface to wrap + tolerance (float, optional): maximum allowed error. Defaults to 0.001 + extension_factor (float, optional): amount to extend the wrapped first + and last edges to allow them to cross. Defaults to 0.1 + + Raises: + ValueError: Invalid planar shape + + Returns: + Edge | Wire | Face: wrapped shape + + """ + + if isinstance(planar_shape, Edge): + return self._wrap_edge(planar_shape, surface_loc, True, tolerance) + elif isinstance(planar_shape, Wire): + return self._wrap_wire( + planar_shape, surface_loc, tolerance, extension_factor + ) + elif isinstance(planar_shape, Face): + return self._wrap_face( + planar_shape, surface_loc, tolerance, extension_factor + ) + else: + raise TypeError( + f"planar_shape must be of type Edge, Wire, Face not " + f"{type(planar_shape)}" + ) + + def wrap_faces( + self, + faces: Iterable[Face], + path: Wire | Edge, + start: float = 0.0, + ) -> ShapeList[Face]: + """wrap_faces + + Wrap a sequence of 2D faces onto a 3D surface, aligned along a guiding path. + + This method places multiple planar `Face` objects (defined in the XY plane) onto a + curved 3D surface (`self`), following a given path (Wire or Edge) that lies on or + closely follows the surface. Each face is spaced along the path according to its + original horizontal (X-axis) position, preserving the relative layout of the input + faces. + + The wrapping process attempts to maintain the shape and size of each face while + minimizing distortion. Each face is repositioned to the origin, then individually + wrapped onto the surface starting at a specific point along the path. The face's + new orientation is defined using the path's tangent direction and the surface normal + at that point. + + This is particularly useful for placing a series of features—such as embossed logos, + engraved labels, or patterned tiles—onto a freeform or cylindrical surface, aligned + along a reference edge or curve. + + Args: + faces (Iterable[Face]): An iterable of 2D planar faces to be wrapped. + path (Wire | Edge): A curve on the target surface that defines the alignment + direction. The X-position of each face is mapped to a relative position + along this path. + start (float, optional): The relative starting point on the path (between 0.0 + and 1.0) where the first face should be placed. Defaults to 0.0. + + Returns: + ShapeList[Face]: A list of wrapped face objects, aligned and conformed to the + surface. + """ + path_length = path.length + + face_list = list(faces) + first_face_min_x = face_list[0].bounding_box().min.X + + # Position each face at the origin and wrap onto surface + wrapped_faces: ShapeList[Face] = ShapeList() + for face in face_list: + bbox = face.bounding_box() + face_center_x = (bbox.min.X + bbox.max.X) / 2 + delta_x = face_center_x - first_face_min_x + relative_position_on_wire = start + delta_x / path_length + path_position = path.position_at(relative_position_on_wire) + surface_location = Location( + Plane( + path_position, + x_dir=path.tangent_at(relative_position_on_wire), + z_dir=self.normal_at(path_position), + ) + ) + assert isinstance(face.position, Vector) + face.position -= (delta_x, 0, 0) # Shift back to origin + wrapped_face = Face.wrap(self, face, surface_location) + wrapped_faces.append(wrapped_face) + + return wrapped_faces + def _uv_bounds(self) -> tuple[float, float, float, float]: """Return the u min, u max, v min, v max values""" return BRepTools.UVBounds_s(self.wrapped) + def _wrap_face( + self: Face, + planar_face: Face, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> Face: + """_wrap_face -class Shell(Mixin2D, Shape[TopoDS_Shell]): + Helper method of wrap that handles wrapping faces on surfaces. + + Args: + planar_face (Face): flat face to wrap around surface + surface_loc (Location): location on surface to wrap + tolerance (float, optional): maximum allowed error. Defaults to 0.001 + extension_factor (float, optional): amount to extend wrapped first + and last edges to allow them to cross. Defaults to 0.1 + + Returns: + Face: wrapped face + """ + wrapped_perimeter = self._wrap_wire( + planar_face.outer_wire(), surface_loc, tolerance, extension_factor + ) + wrapped_holes = [ + self._wrap_wire(w, surface_loc, tolerance, extension_factor) + for w in planar_face.inner_wires() + ] + wrapped_face = Face.make_surface( + wrapped_perimeter, + surface_points=[surface_loc.position], + interior_wires=wrapped_holes, + ) + + # Potentially flip the wrapped face to match the surface + surface_normal = surface_loc.z_axis.direction + wrapped_normal = wrapped_face.normal_at(surface_loc.position) + if surface_normal.dot(wrapped_normal) < 0: # are they opposite? + wrapped_face = -wrapped_face + return wrapped_face + + def _wrap_wire( + self: Face, + planar_wire: Wire, + surface_loc: Location, + tolerance: float = 0.001, + extension_factor: float = 0.1, + ) -> Wire: + """_wrap_wire + + Helper method of wrap that handles wrapping wires on surfaces. + + Args: + planar_wire (Wire): wire to wrap around surface + surface_loc (Location): location on surface to wrap + tolerance (float, optional): maximum allowed error. Defaults to 0.001 + extension_factor (float, optional): amount to extend wrapped first + and last edges to allow them to cross. Defaults to 0.1 + + Raises: + RuntimeError: wrapped wire is not valid + + Returns: + Wire: wrapped wire + """ + # + # Part 1: Preparation + # + surface_point = surface_loc.position + surface_x_direction = surface_loc.x_axis.direction + surface_geometry = BRep_Tool.Surface_s(self.wrapped) + + if len(planar_wire.edges()) == 1: + planar_edge = planar_wire.edge() + assert planar_edge is not None + return Wire([self._wrap_edge(planar_edge, surface_loc, True, tolerance)]) + + planar_edges = planar_wire.order_edges() + wrapped_edges: list[Edge] = [] + + # Need to keep track of the separation between adjacent edges + first_start_point = None + + # + # Part 2: Wrap the planar wires on the surface by creating a spline + # through points cast from the planar onto the surface. + # + # If the wire doesn't start at the origin, create an wrapped construction line + # to get to the beginning of the first edge + if planar_edges[0].position_at(0) == Vector(0, 0, 0): + edge_surface_point = surface_point + planar_edge_end_point = Vector(0, 0, 0) + else: + construction_line = Edge.make_line( + Vector(0, 0, 0), planar_edges[0].position_at(0) + ) + wrapped_construction_line: Edge = self._wrap_edge( + construction_line, surface_loc, True, tolerance + ) + edge_surface_point = wrapped_construction_line.position_at(1) + planar_edge_end_point = planar_edges[0].position_at(0) + edge_surface_location = Location( + Plane( + edge_surface_point, + x_dir=surface_x_direction, + z_dir=self.normal_at(edge_surface_point), + ) + ) + + # Wrap each edge and add them to the wire builder + for planar_edge in planar_edges: + local_planar_edge = planar_edge.translate(-planar_edge_end_point) + wrapped_edge: Edge = self._wrap_edge( + local_planar_edge, edge_surface_location, True, tolerance + ) + edge_surface_point = wrapped_edge.position_at(1) + edge_surface_location = Location( + Plane( + edge_surface_point, + x_dir=surface_x_direction, + z_dir=self.normal_at(edge_surface_point), + ) + ) + planar_edge_end_point = planar_edge.position_at(1) + if first_start_point is None: + first_start_point = wrapped_edge.position_at(0) + wrapped_edges.append(wrapped_edge) + + # For open wires we're finished + if not planar_wire.is_closed: + return Wire(wrapped_edges) + + # + # Part 3: The first and last edges likely don't meet at this point due to + # distortion caused by following the surface, so we'll need to join + # them. + # + + # Extend the first and last edge so that they cross + first_edge, first_curve = wrapped_edges[0]._extend_spline( + True, surface_geometry, extension_factor + ) + last_edge, last_curve = wrapped_edges[-1]._extend_spline( + False, surface_geometry, extension_factor + ) + + # Trim the extended edges at their intersection point + extrema = GeomAPI_ExtremaCurveCurve(first_curve, last_curve) + if extrema.NbExtrema() < 1: + raise RuntimeError( + "Extended first/last edges do not intersect; increase extension." + ) + param_first, param_last = extrema.Parameters(1) + + u_start_first: float = first_edge.param_at(0) + u_end_first: float = first_edge.param_at(1) + new_start = (param_first - u_start_first) / (u_end_first - u_start_first) + trimmed_first = first_edge.trim(new_start, 1.0) + + u_start_last: float = last_edge.param_at(0) + u_end_last: float = last_edge.param_at(1) + new_end = (param_last - u_start_last) / (u_end_last - u_start_last) + trimmed_last = last_edge.trim(0.0, new_end) + + # Replace the first and last edges with their modified versions + wrapped_edges[0] = trimmed_first + wrapped_edges[-1] = trimmed_last + + # + # Part 4: Build a wire from the edges and fix it to close gaps + # + closing_error = ( + trimmed_first.position_at(0) - trimmed_last.position_at(1) + ).length + wire_builder = BRepBuilderAPI_MakeWire() + combined_edges = TopTools_ListOfShape() + for edge in wrapped_edges: + combined_edges.Append(edge.wrapped) + wire_builder.Add(combined_edges) + wire_builder.Build() + raw_wrapped_wire = wire_builder.Wire() + wire_fixer = ShapeFix_Wire() + wire_fixer.SetPrecision(2 * closing_error) # enable fixing start/end gaps + wire_fixer.Load(raw_wrapped_wire) + wire_fixer.FixReorder() + wire_fixer.FixConnected() + wrapped_wire = Wire(wire_fixer.Wire()) + + # + # Part 5: Validate + # + if not wrapped_wire.is_valid: + raise RuntimeError("wrapped wire is not valid") + + return wrapped_wire + + +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 @@ -1302,12 +2532,18 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]): obj = obj_list[0] if isinstance(obj, Face): - builder = BRepBuilderAPI_MakeShell( - BRepAdaptor_Surface(obj.wrapped).Surface().Surface() - ) - obj = builder.Shape() + if not obj: + raise ValueError(f"Can't create a Shell from empty Face") + builder = BRep_Builder() + shell = TopoDS_Shell() + builder.MakeShell(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, @@ -1325,6 +2561,7 @@ class Shell(Mixin2D, Shape[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 @@ -1367,7 +2604,33 @@ class Shell(Mixin2D, Shape[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( + cls, + profile: Curve | Wire, + angle: float, + axis: Axis, + ) -> Face: + """sweep + + Revolve a 1D profile around an axis. + + Args: + profile (Curve | Wire): the object to revolve + angle (float): the angle to revolve through + axis (Axis): rotation Axis + + Returns: + Shell: resulting shell + """ + profile = Wire(profile.edges()) + revol_builder = BRepPrimAPI_MakeRevol( + profile.wrapped, axis.wrapped, angle * DEG2RAD, True + ) + + return cls(TopoDS.Shell_s(revol_builder.Shape())) @classmethod def sweep( @@ -1395,7 +2658,7 @@ class Shell(Mixin2D, Shape[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() @@ -1409,6 +2672,28 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]): BRepGProp.LinearProperties_s(self.wrapped, properties) return Vector(properties.CentreOfMass()) + def location_at( + self, + surface_point: VectorLike, + *, + x_dir: VectorLike | None = None, + ) -> Location: + """location_at + + Get the location (origin and orientation) on the surface of the shell. + + Args: + surface_point (VectorLike): A 3D point near the surface. + x_dir (VectorLike, optional): Direction for the local X axis. If not given, + the tangent in the U direction is used. + + Returns: + Location: A full 3D placement at the specified point on the shell surface. + """ + # Find the closest Face and get the location from it + face = self.faces().sort_by(lambda f: f.distance_to(surface_point))[0] + return face.location_at(surface_point, x_dir=x_dir) + def sort_wires_by_build_order(wire_list: list[Wire]) -> list[list[Wire]]: """Tries to determine how wires should be combined into faces. diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index c876ba2..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**: @@ -263,7 +262,10 @@ def _make_topods_face_from_wires( for inner_wire in inner_wires: if not BRep_Tool.IsClosed_s(inner_wire): raise ValueError("Cannot build face(s): inner wire is not closed") - face_builder.Add(inner_wire) + sf_s = ShapeFix_Shape(inner_wire) + sf_s.Perform() + fixed_inner_wire = TopoDS.Wire_s(sf_s.Shape()) + face_builder.Add(fixed_inner_wire) face_builder.Build() @@ -279,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) @@ -424,7 +387,7 @@ def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum: if len(shapetypes) == 1: result = shapetypes.pop() else: - result = shapetype(obj) + result = shapetype(obj.wrapped) else: result = shapetype(obj.wrapped) return result diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index 59518c7..dc536e9 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -54,9 +54,12 @@ license: from __future__ import annotations import itertools +import warnings + from typing import overload, TYPE_CHECKING from collections.abc import Iterable +from typing_extensions import Self import OCP.TopAbs as ta from OCP.BRep import BRep_Tool @@ -64,10 +67,9 @@ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge from OCP.gp import gp_Pnt -from build123d.geometry import Matrix, Vector, VectorLike -from typing_extensions import Self - -from .shape_core import Shape, ShapeList, downcast, shapetype +from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane +from build123d.build_enums import Keep +from .shape_core import Shape, ShapeList, TrimmingTool, downcast, shapetype if TYPE_CHECKING: # pragma: no cover @@ -132,7 +134,8 @@ class Vertex(Shape[TopoDS_Vertex]): ) super().__init__(ocp_vx) - self.X, self.Y, self.Z = self.to_tuple() + pnt = BRep_Tool.Pnt_s(self.wrapped) + self.X, self.Y, self.Z = pnt.X(), pnt.Y(), pnt.Z() # ---- Properties ---- @@ -158,13 +161,52 @@ 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: """extrude - invalid operation for Vertex""" raise NotImplementedError("Vertices can't be created by extrusion") + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> ShapeList[Vertex] | None: + """Intersection of vertex and geometric objects or shapes. + + Args: + to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]): + Objects(s) to intersect with + + Returns: + ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None + """ + common = Vector(self) + result: Shape | ShapeList[Shape] | Vector | None + for obj in to_intersect: + # Treat as Vector, otherwise call intersection from Shape + match obj: + case Vertex(): + result = common.intersect(Vector(obj)) + case Vector() | Location() | Axis() | Plane(): + result = obj.intersect(common) + case _ if issubclass(type(obj), Shape): + result = obj.intersect(self) + case _: + raise ValueError(f"Unsupported type to_intersect:: {type(obj)}") + + if isinstance(result, Vector) and result == common: + pass + elif ( + isinstance(result, list) + and len(result) == 1 + and Vector(result[0]) == common + ): + pass + else: + return None + + return ShapeList([self]) + # ---- Instance Methods ---- def __add__( # type: ignore @@ -239,7 +281,7 @@ class Vertex(Shape[TopoDS_Vertex]): def __sub__(self, other: Vertex | Vector | tuple) -> Vertex: # type: ignore """Subtract - Substract a Vertex with a Vertex, Vector or Tuple from self + Subtract a Vertex with a Vertex, Vector or Tuple from self Args: other: Value to add @@ -270,8 +312,18 @@ 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( + "to_tuple is deprecated and will be removed in a future version. " + "Use 'tuple(Vertex)' instead.", + DeprecationWarning, + stacklevel=2, + ) geom_point = BRep_Tool.Pnt_s(self.wrapped) return (geom_point.X(), geom_point.Y(), geom_point.Z()) diff --git a/src/build123d/utils.py b/src/build123d/utils.py new file mode 100644 index 0000000..7c43093 --- /dev/null +++ b/src/build123d/utils.py @@ -0,0 +1,57 @@ +""" +Helper Utilities + +name: utils.py +by: jwagenet +date: July 28th 2025 + +desc: + This python module contains helper utilities not related to object creation. + +""" + +from dataclasses import dataclass + +from build123d.build_enums import FontStyle +from OCP.Font import ( + Font_FA_Bold, + Font_FA_BoldItalic, + Font_FA_Italic, + Font_FA_Regular, + Font_FontMgr, +) + + +@dataclass(frozen=True) +class FontInfo: + name: str + styles: tuple[FontStyle, ...] + + def __repr__(self) -> str: + style_names = tuple(s.name for s in self.styles) + return f"Font(name={self.name!r}, styles={style_names})" + + +def available_fonts() -> list[FontInfo]: + """Get list of available fonts by name and available styles (also called aspects). + Note: on Windows, fonts must be installed with "Install for all users" to be found. + """ + + font_aspects = { + "REGULAR": Font_FA_Regular, + "BOLD": Font_FA_Bold, + "BOLDITALIC": Font_FA_BoldItalic, + "ITALIC": Font_FA_Italic, + } + + manager = Font_FontMgr.GetInstance_s() + font_list = [] + for f in manager.GetAvailableFonts(): + avail_aspects = tuple( + FontStyle[n] for n, a in font_aspects.items() if f.HasFontAspect(a) + ) + font_list.append(FontInfo(f.FontName().ToCString(), avail_aspects)) + + font_list.sort(key=lambda x: x.name) + + return font_list \ No newline at end of file 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) diff --git a/tests/test_airfoil.py b/tests/test_airfoil.py new file mode 100644 index 0000000..21c2e06 --- /dev/null +++ b/tests/test_airfoil.py @@ -0,0 +1,106 @@ +import pytest +import numpy as np +from build123d import Airfoil, Vector, Edge, Wire + + +# --- parse_naca4 tests ------------------------------------------------------ + + +@pytest.mark.parametrize( + "code, expected", + [ + ("2412", (0.02, 0.4, 0.12)), # standard NACA 2412 + ("0012", (0.0, 0.0, 0.12)), # symmetric section + ("2213.323", (0.02, 0.2, 0.13323)), # fractional thickness + ("NACA2412", (0.02, 0.4, 0.12)), # with prefix + ], +) +def test_parse_naca4_variants(code, expected): + m, p, t = Airfoil.parse_naca4(code) + np.testing.assert_allclose([m, p, t], expected, rtol=1e-6) + + +# --- basic construction tests ----------------------------------------------- + + +def test_airfoil_basic_construction(): + airfoil = Airfoil("2412", n_points=40) + assert isinstance(airfoil, Airfoil) + assert isinstance(airfoil.camber_line, Edge) + assert isinstance(airfoil._camber_points, list) + assert all(isinstance(p, Vector) for p in airfoil._camber_points) + + # Check metadata + assert airfoil.code == "2412" + assert pytest.approx(airfoil.max_camber, rel=1e-6) == 0.02 + assert pytest.approx(airfoil.camber_pos, rel=1e-6) == 0.4 + assert pytest.approx(airfoil.thickness, rel=1e-6) == 0.12 + assert airfoil.finite_te is False + + +def test_airfoil_finite_te_profile(): + """Finite trailing edge version should have a line closing the profile.""" + airfoil = Airfoil("2412", finite_te=True, n_points=40) + assert isinstance(airfoil, Wire) + assert airfoil.finite_te + assert len(list(airfoil.edges())) == 2 + + +def test_airfoil_infinite_te_profile(): + """Infinite trailing edge (periodic spline).""" + airfoil = Airfoil("2412", finite_te=False, n_points=40) + assert isinstance(airfoil, Wire) + # Should contain a single closed Edge + assert len(airfoil.edges()) == 1 + assert airfoil.edges()[0].is_closed + + +# --- geometric / numerical validity ----------------------------------------- + + +def test_camber_line_geometry_monotonic(): + """Camber x coordinates should increase monotonically along the chord.""" + af = Airfoil("2412", n_points=80) + x_coords = [p.X for p in af._camber_points] + assert np.all(np.diff(x_coords) >= 0) + + +def test_airfoil_chord_limits(): + """Airfoil should be bounded between x=0 and x=1.""" + af = Airfoil("2412", n_points=100) + all_points = af._camber_points + xs = np.array([p.X for p in all_points]) + assert xs.min() >= -1e-9 + assert xs.max() <= 1.0 + 1e-9 + + +def test_airfoil_thickness_scaling(): + """Check that airfoil thickness scales linearly with NACA last two digits.""" + af1 = Airfoil("0010", n_points=120) + af2 = Airfoil("0020", n_points=120) + + # Extract main surface edge (for finite_te=False it's just one edge) + edge1 = af1.edges()[0] + edge2 = af2.edges()[0] + + # Sample many points along each edge + n = 500 + ys1 = [(edge1 @ u).Y for u in np.linspace(0.0, 1.0, n)] + ys2 = [(edge2 @ u).Y for u in np.linspace(0.0, 1.0, n)] + + # Total height (max - min) + h1 = max(ys1) - min(ys1) + h2 = max(ys2) - min(ys2) + + # For symmetric NACA 00xx, thickness is proportional to 't' + assert (h1 / h2) == pytest.approx(0.5, rel=0.05) + + +def test_camber_line_is_centered(): + """Mean of upper and lower surfaces should approximate camber line.""" + af = Airfoil("2412", n_points=50) + # Extract central camber Y near mid-chord + mid_index = len(af._camber_points) // 2 + mid_point = af._camber_points[mid_index] + # Camber line should be roughly symmetric around y=0 for small m + assert abs(mid_point.Y) < 0.05 diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 7755ab2..23eac7c 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -1,684 +1,103 @@ import pytest -from math import sqrt +import sys from build123d import * +from pathlib import Path + +from unittest.mock import Mock +mock_module = Mock() +mock_module.show = Mock() +mock_module.show_object = Mock() +mock_module.show_all = Mock() +sys.modules["ocp_vscode"] = mock_module + +_ = pytest.importorskip("pytest_benchmark") -pytest_benchmark = pytest.importorskip("pytest_benchmark") +def _read_docs_ttt_code(name): + checkout_dir = Path(__file__).parent.parent + ttt_dir = checkout_dir / "docs/assets/ttt" + name = "ttt-" + name + ".py" + with open(ttt_dir / name, "r") as f: + return f.read() def test_ppp_0101(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-01 Bearing Bracket - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as p: - with BuildSketch() as s: - Rectangle(115, 50) - with Locations((5 / 2, 0)): - SlotOverall(90, 12, mode=Mode.SUBTRACT) - extrude(amount=15) - - with BuildSketch(Plane.XZ.offset(50 / 2)) as s3: - with Locations((-115 / 2 + 26, 15)): - SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90) - zz = extrude(amount=-12) - split(bisect_by=Plane.XY) - edgs = p.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2] - fillet(edgs, 9) - - with Locations(zz.faces().sort_by(Axis.Y)[0]): - with Locations((42 / 2 + 6, 0)): - CounterBoreHole(24 / 2, 34 / 2, 4) - mirror(about=Plane.XZ) - - with BuildSketch() as s4: - RectangleRounded(115, 50, 6) - extrude(amount=80, mode=Mode.INTERSECT) - # fillet does not work right, mode intersect is safer - - with BuildSketch(Plane.YZ) as s4: - with BuildLine() as bl: - l1 = Line((0, 0), (18 / 2, 0)) - l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL) - l3 = Line(l2 @ 1, (0, 8)) - mirror(about=Plane.YZ) - make_face() - extrude(amount=115 / 2, both=True, mode=Mode.SUBTRACT) - - print(f"\npart mass = {p.part.volume*densa:0.2f}") - assert p.part.volume * densa == pytest.approx(797.15, 0.01) - + exec(_read_docs_ttt_code("ppp0101")) benchmark(model) def test_ppp_0102(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-02 Post Cap - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - # TTT Party Pack 01: PPP0102, mass(abs) = 43.09g - with BuildPart() as p: - with BuildSketch(Plane.XZ) as sk1: - Rectangle(49, 48 - 8, align=(Align.CENTER, Align.MIN)) - Rectangle(9, 48, align=(Align.CENTER, Align.MIN)) - with Locations((9 / 2, 40)): - Ellipse(20, 8) - split(bisect_by=Plane.YZ) - revolve(axis=Axis.Z) - - with BuildSketch(Plane.YZ.offset(-15)) as xc1: - with Locations((0, 40 / 2 - 17)): - Ellipse(10 / 2, 4 / 2) - with BuildLine(Plane.XZ) as l1: - CenterArc((-15, 40 / 2), 17, 90, 180) - sweep(path=l1) - - fillet( - p.edges().filter_by(GeomType.CIRCLE, reverse=True).group_by(Axis.X)[0], - 1, - ) - - with BuildLine(mode=Mode.PRIVATE) as lc1: - PolarLine( - (42 / 2, 0), 37, 94, length_mode=LengthMode.VERTICAL - ) # construction line - - pts = [ - (0, 0), - (42 / 2, 0), - ((lc1.line @ 1).X, (lc1.line @ 1).Y), - (0, (lc1.line @ 1).Y), - ] - with BuildSketch(Plane.XZ) as sk2: - Polygon(*pts, align=None) - fillet(sk2.vertices().group_by(Axis.X)[1], 3) - revolve(axis=Axis.Z, mode=Mode.SUBTRACT) - - # print(f"\npart mass = {p.part.volume*densa:0.2f}") - assert p.part.volume * densc == pytest.approx(43.09, 0.01) - + exec(_read_docs_ttt_code("ppp0102")) benchmark(model) def test_ppp_0103(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-03 C Clamp Base - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as ppp0103: - with BuildSketch() as sk1: - RectangleRounded(34 * 2, 95, 18) - with Locations((0, -2)): - RectangleRounded((34 - 16) * 2, 95 - 18 - 14, 7, mode=Mode.SUBTRACT) - with Locations((-34 / 2, 0)): - Rectangle(34, 95, 0, mode=Mode.SUBTRACT) - extrude(amount=16) - with BuildSketch(Plane.XZ.offset(-95 / 2)) as cyl1: - with Locations((0, 16 / 2)): - Circle(16 / 2) - extrude(amount=18) - with BuildSketch(Plane.XZ.offset(95 / 2 - 14)) as cyl2: - with Locations((0, 16 / 2)): - Circle(16 / 2) - extrude(amount=23) - with Locations(Plane.XZ.offset(95 / 2 + 9)): - with Locations((0, 16 / 2)): - CounterSinkHole(5.5 / 2, 11.2 / 2, None, 90) - - assert ppp0103.part.volume * densb == pytest.approx(96.13, 0.01) - + exec(_read_docs_ttt_code("ppp0103")) benchmark(model) def test_ppp_0104(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-04 Angle Bracket - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - d1, d2, d3 = 38, 26, 16 - h1, h2, h3, h4 = 20, 8, 7, 23 - w1, w2, w3 = 80, 10, 5 - f1, f2, f3 = 4, 10, 5 - sloth1, sloth2 = 18, 12 - slotw1, slotw2 = 17, 14 - - with BuildPart() as p: - with BuildSketch() as s: - Circle(d1 / 2) - extrude(amount=h1) - with BuildSketch(Plane.XY.offset(h1)) as s2: - Circle(d2 / 2) - extrude(amount=h2) - with BuildSketch(Plane.YZ) as s3: - Rectangle(d1 + 15, h3, align=(Align.CENTER, Align.MIN)) - extrude(amount=w1 - d1 / 2) - # fillet workaround \/ - ped = p.part.edges().group_by(Axis.Z)[2].filter_by(GeomType.CIRCLE) - fillet(ped, f1) - with BuildSketch(Plane.YZ) as s3a: - Rectangle(d1 + 15, 15, align=(Align.CENTER, Align.MIN)) - Rectangle(d1, 15, mode=Mode.SUBTRACT, align=(Align.CENTER, Align.MIN)) - extrude(amount=w1 - d1 / 2, mode=Mode.SUBTRACT) - # end fillet workaround /\ - with BuildSketch() as s4: - Circle(d3 / 2) - extrude(amount=h1 + h2, mode=Mode.SUBTRACT) - with BuildSketch() as s5: - with Locations((w1 - d1 / 2 - w2 / 2, 0)): - Rectangle(w2, d1) - extrude(amount=-h4) - fillet(p.part.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[-1], f2) - fillet(p.part.edges().group_by(Axis.X)[-4].sort_by(Axis.Z)[-2], f3) - pln = Plane.YZ.offset(w1 - d1 / 2) - with BuildSketch(pln) as s6: - with Locations((0, -h4)): - SlotOverall(slotw1 * 2, sloth1, 90) - extrude(amount=-w3, mode=Mode.SUBTRACT) - with BuildSketch(pln) as s6b: - with Locations((0, -h4)): - SlotOverall(slotw2 * 2, sloth2, 90) - extrude(amount=-w2, mode=Mode.SUBTRACT) - - # print(f"\npart mass = {p.part.volume*densa:0.2f}") - assert p.part.volume * densa == pytest.approx(310.00, 0.01) - + exec(_read_docs_ttt_code("ppp0104")) benchmark(model) def test_ppp_0105(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-05 Paste Sleeve - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as p: - with BuildSketch() as s: - SlotOverall(45, 38) - offset(amount=3) - with BuildSketch(Plane.XY.offset(133 - 30)) as s2: - SlotOverall(60, 4) - offset(amount=3) - loft() - - with BuildSketch() as s3: - SlotOverall(45, 38) - with BuildSketch(Plane.XY.offset(133 - 30)) as s4: - SlotOverall(60, 4) - loft(mode=Mode.SUBTRACT) - - extrude(p.part.faces().sort_by(Axis.Z)[0], amount=30) - - # print(f"\npart mass = {p.part.volume*densc:0.2f}") - assert p.part.volume * densc == pytest.approx(57.08, 0.01) - + exec(_read_docs_ttt_code("ppp0105")) benchmark(model) def test_ppp_0106(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-06 Bearing Jig - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - r1, r2, r3, r4, r5 = 30 / 2, 13 / 2, 12 / 2, 10, 6 # radii used - x1 = 44 # lengths used - y1, y2, y3, y4, y_tot = 36, 36 - 22 / 2, 22 / 2, 42, 69 # widths used - - with BuildSketch(Location((0, -r1, y3))) as sk_body: - with BuildLine() as l: - c1 = Line((r1, 0), (r1, y_tot), mode=Mode.PRIVATE) # construction line - m1 = Line((0, y_tot), (x1 / 2, y_tot)) - m2 = JernArc(m1 @ 1, m1 % 1, r4, -90 - 45) - 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) - 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) - # Keyway - with Locations((0, r1)): - Circle(r3, mode=Mode.SUBTRACT) - Rectangle(4, 3 + 6, align=(Align.CENTER, Align.MIN), mode=Mode.SUBTRACT) - - with BuildPart() as p: - Box(200, 200, 22) # Oversized plate - # Cylinder underneath - Cylinder(r1, y2, align=(Align.CENTER, Align.CENTER, Align.MAX)) - fillet(p.edges(Select.NEW), r5) # Weld together - extrude(sk_body.sketch, amount=-y1, mode=Mode.INTERSECT) # Cut to shape - # Remove slot - with Locations((0, y_tot - r1 - y4, 0)): - Box( - y_tot, - y_tot, - 10, - align=(Align.CENTER, Align.MIN, Align.CENTER), - mode=Mode.SUBTRACT, - ) - - # print(f"\npart mass = {p.part.volume*densa:0.2f}") - assert p.part.volume * densa == pytest.approx(328.02, 0.01) - + exec(_read_docs_ttt_code("ppp0106")) benchmark(model) def test_ppp_0107(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-07 Flanged Hub - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as p: - with BuildSketch() as s: - Circle(130 / 2) - extrude(amount=8) - with BuildSketch(Plane.XY.offset(8)) as s2: - Circle(84 / 2) - extrude(amount=25 - 8) - with BuildSketch(Plane.XY.offset(25)) as s3: - Circle(35 / 2) - extrude(amount=52 - 25) - with BuildSketch() as s4: - Circle(73 / 2) - extrude(amount=18, mode=Mode.SUBTRACT) - pln2 = p.part.faces().sort_by(Axis.Z)[5] - with BuildSketch(Plane.XY.offset(52)) as s5: - Circle(20 / 2) - extrude(amount=-52, mode=Mode.SUBTRACT) - fillet( - p.part.edges() - .filter_by(GeomType.CIRCLE) - .sort_by(Axis.Z)[2:-2] - .sort_by(SortBy.RADIUS)[1:], - 3, - ) - pln = Plane(pln2) - pln.origin = pln.origin + Vector(20 / 2, 0, 0) - pln = pln.rotated((0, 45, 0)) - pln = pln.offset(-25 + 3 + 0.10) - with BuildSketch(pln) as s6: - Rectangle((73 - 35) / 2 * 1.414 + 5, 3) - zz = extrude(amount=15, taper=-20 / 2, mode=Mode.PRIVATE) - zz2 = split(zz, bisect_by=Plane.XY.offset(25), mode=Mode.PRIVATE) - zz3 = split(zz2, bisect_by=Plane.YZ.offset(35 / 2 - 1), mode=Mode.PRIVATE) - with PolarLocations(0, 3): - add(zz3) - with Locations(Plane.XY.offset(8)): - with PolarLocations(107.95 / 2, 6): - CounterBoreHole(6 / 2, 13 / 2, 4) - - # print(f"\npart mass = {p.part.volume*densb:0.2f}") - assert p.part.volume * densb == pytest.approx(372.99, 0.01) - + exec(_read_docs_ttt_code("ppp0107")) benchmark(model) def test_ppp_0108(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-08 Tie Plate - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as p: - with BuildSketch() as s1: - Rectangle(188 / 2 - 33, 162, align=(Align.MIN, Align.CENTER)) - with Locations((188 / 2 - 33, 0)): - SlotOverall(190, 33 * 2, rotation=90) - mirror(about=Plane.YZ) - with GridLocations(188 - 2 * 33, 190 - 2 * 33, 2, 2): - Circle(29 / 2, mode=Mode.SUBTRACT) - Circle(84 / 2, mode=Mode.SUBTRACT) - extrude(amount=16) - - with BuildPart() as p2: - with BuildSketch(Plane.XZ) as s2: - with BuildLine() as l1: - l1 = Polyline( - (222 / 2 + 14 - 40 - 40, 0), - (222 / 2 + 14 - 40, -35 + 16), - (222 / 2 + 14, -35 + 16), - (222 / 2 + 14, -35 + 16 + 30), - (222 / 2 + 14 - 40 - 40, -35 + 16 + 30), - close=True, - ) - make_face() - with Locations((222 / 2, -35 + 16 + 14)): - Circle(11 / 2, mode=Mode.SUBTRACT) - extrude(amount=20 / 2, both=True) - with BuildSketch() as s3: - with Locations(l1 @ 0): - Rectangle(40 + 40, 8, align=(Align.MIN, Align.CENTER)) - with Locations((40, 0)): - Rectangle(40, 20, align=(Align.MIN, Align.CENTER)) - extrude(amount=30, both=True, mode=Mode.INTERSECT) - mirror(about=Plane.YZ) - - # print(f"\npart mass = {p.part.volume*densa:0.2f}") - assert p.part.volume * densa == pytest.approx(3387.06, 0.01) - + exec(_read_docs_ttt_code("ppp0108")) benchmark(model) def test_ppp_0109(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-09 Corner Tie - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as ppp109: - with BuildSketch() as one: - Rectangle(69, 75, align=(Align.MAX, Align.CENTER)) - fillet(one.vertices().group_by(Axis.X)[0], 17) - extrude(amount=13) - centers = [ - arc.arc_center - for arc in ppp109.edges() - .filter_by(GeomType.CIRCLE) - .group_by(Axis.Z)[-1] - ] - with Locations(*centers): - CounterBoreHole( - radius=8 / 2, counter_bore_radius=15 / 2, counter_bore_depth=4 - ) - - with BuildSketch(Plane.YZ) as two: - with Locations((0, 45)): - Circle(15) - with BuildLine() as bl: - c = Line((75 / 2, 0), (75 / 2, 60), mode=Mode.PRIVATE) - u = two.edge().find_tangent(75 / 2 + 90)[ - 0 - ] # where is the slope 75/2? - l1 = IntersectingLine( - two.edge().position_at(u), -two.edge().tangent_at(u), other=c - ) - Line(l1 @ 0, (0, 45)) - Polyline((0, 0), c @ 0, l1 @ 1) - mirror(about=Plane.YZ) - make_face() - with Locations((0, 45)): - Circle(12 / 2, mode=Mode.SUBTRACT) - extrude(amount=-13) - - with BuildSketch( - Plane((0, 0, 0), x_dir=(1, 0, 0), z_dir=(1, 0, 1)) - ) as three: - Rectangle(45 * 2 / sqrt(2) - 37.5, 75, align=(Align.MIN, Align.CENTER)) - with Locations(three.edges().sort_by(Axis.X)[-1].center()): - Circle(37.5) - Circle(33 / 2, mode=Mode.SUBTRACT) - 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) - - # print(f"\npart mass = {ppp109.part.volume*densb:0.2f}") - assert ppp109.part.volume * densb == pytest.approx(307.23, 0.01) - + exec(_read_docs_ttt_code("ppp0109")) benchmark(model) def test_ppp_0110(benchmark): def model(): - """ - Too Tall Toby Party Pack 01-10 Light Cap - """ - - densa = 7800 / 1e6 # carbon steel density g/mm^3 - densb = 2700 / 1e6 # aluminum alloy - densc = 1020 / 1e6 # ABS - - with BuildPart() as p: - with BuildSketch() as s: - with BuildLine() as l: - n1 = JernArc((0, 46), (1, 0), 40, -95) - n2 = Line((0, 0), (42, 0)) - make_hull() - # hack to keep arc vertex off revolution axis - split(bisect_by=Plane.XZ.offset(-45.9999), keep=Keep.TOP) - - revolve(s.sketch, axis=Axis.Y, revolution_arc=90) - extrude(faces().sort_by(Axis.Z)[-1], amount=50) - mirror(about=Plane(faces().sort_by(Axis.Z)[-1])) - mirror(about=Plane.YZ) - - with BuildPart() as p2: - add(p.part) - offset(amount=-8) - - with BuildPart() as pzzz: - add(p2.part) - split(bisect_by=Plane.XZ.offset(-46 + 16), keep=Keep.TOP) - fillet(faces().sort_by(Axis.Y)[-1].edges(), 12) - - with BuildPart() as p3: - with BuildSketch(Plane.XZ) as s2: - add(p.part.faces().sort_by(Axis.Y)[0]) - offset(amount=-8) - loft([pzzz.part.faces().sort_by(Axis.Y)[0], s2.sketch.face()]) - - with BuildPart() as ppp0110: - add(p.part) - add(pzzz.part, mode=Mode.SUBTRACT) - add(p3.part, mode=Mode.SUBTRACT) - - # print(f"\npart mass = {ppp0110.part.volume*densc:0.2f}") # 211.30 g is correct - assert ppp0110.part.volume * densc == pytest.approx(211, 1.00) - + exec(_read_docs_ttt_code("ppp0110")) benchmark(model) def test_ttt_23_02_02(benchmark): def model(): - """ - Creation of a complex sheet metal part - - name: ttt_sm_hanger.py - by: Gumyr - date: July 17, 2023 - - desc: - This example implements the sheet metal part described in Too Tall Toby's - sm_hanger CAD challenge. - - Notably, a BuildLine/Curve object is filleted by providing all the vertices - and allowing the fillet operation filter out the end vertices. The - make_brake_formed operation is used both in Algebra and Builder mode to - create a sheet metal part from just an outline and some dimensions. - license: - - Copyright 2023 Gumyr - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - """ - densa = 7800 / 1e6 # carbon steel density g/mm^3 - sheet_thickness = 4 * MM - - # Create the main body from a side profile - with BuildPart() as side: - d = Vector(1, 0, 0).rotate(Axis.Y, 60) - with BuildLine(Plane.XZ) as side_line: - l1 = Line((0, 65), (170 / 2, 65)) - l2 = PolarLine( - l1 @ 1, length=65, direction=d, length_mode=LengthMode.VERTICAL - ) - l3 = Line(l2 @ 1, (170 / 2, 0)) - fillet(side_line.vertices(), 7) - make_brake_formed( - thickness=sheet_thickness, - station_widths=[40, 40, 40, 112.52 / 2, 112.52 / 2, 112.52 / 2], - side=Side.RIGHT, - ) - fe = side.edges().filter_by(Axis.Z).group_by(Axis.Z)[0].sort_by(Axis.Y)[-1] - fillet(fe, radius=7) - - # Create the "wings" at the top - with BuildPart() as wing: - with BuildLine(Plane.YZ) as wing_line: - l1 = Line((0, 65), (80 / 2 + 1.526 * sheet_thickness, 65)) - PolarLine( - l1 @ 1, 20.371288916, direction=Vector(0, 1, 0).rotate(Axis.X, -75) - ) - fillet(wing_line.vertices(), 7) - make_brake_formed( - thickness=sheet_thickness, - station_widths=110 / 2, - side=Side.RIGHT, - ) - bottom_edge = wing.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[0] - fillet(bottom_edge, radius=7) - - # Create the tab at the top in Algebra mode - tab_line = Plane.XZ * Polyline( - (20, 65 - sheet_thickness), (56 / 2, 65 - sheet_thickness), (56 / 2, 88) - ) - tab_line = fillet(tab_line.vertices(), 7) - tab = make_brake_formed(sheet_thickness, 8, tab_line, Side.RIGHT) - tab = fillet( - tab.edges().filter_by(Axis.X).group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1], 5 - ) - tab -= Pos((0, 0, 80)) * Rot(0, 90, 0) * Hole(5, 100) - - # Combine the parts together - with BuildPart() as sm_hanger: - add([side.part, wing.part]) - mirror(about=Plane.XZ) - with BuildSketch(Plane.XY.offset(65)) as h1: - with Locations((20, 0)): - Rectangle(30, 30, align=(Align.MIN, Align.CENTER)) - fillet(h1.vertices().group_by(Axis.X)[-1], 7) - SlotCenterPoint((154, 0), (154 / 2, 0), 20) - extrude(amount=-40, mode=Mode.SUBTRACT) - with BuildSketch() as h2: - SlotCenterPoint((206, 0), (206 / 2, 0), 20) - extrude(amount=40, mode=Mode.SUBTRACT) - add(tab) - mirror(about=Plane.YZ) - mirror(about=Plane.XZ) - - # print(f"Mass: {sm_hanger.part.volume*7800*1e-6:0.1f} g") - assert sm_hanger.part.volume * densa == pytest.approx(1028, 10) - + exec(_read_docs_ttt_code("23-02-02-sm_hanger")) benchmark(model) - -# def test_ttt_23_T_24(benchmark): -# excluding because it requires sympy - +def test_ttt_23_T_24(benchmark): + def model(): + exec(_read_docs_ttt_code("23-t-24-curved_support")) + benchmark(model) def test_ttt_24_SPO_06(benchmark): def model(): - densa = 7800 / 1e6 # carbon steel density g/mm^3 - - with BuildPart() as p: - with BuildSketch() as xy: - with BuildLine(): - l1 = ThreePointArc((5 / 2, -1.25), (5.5 / 2, 0), (5 / 2, 1.25)) - Polyline(l1 @ 0, (0, -1.25), (0, 1.25), l1 @ 1) - make_face() - extrude(amount=4) - - with BuildSketch(Plane.YZ) as yz: - Trapezoid(2.5, 4, 90 - 6, align=(Align.CENTER, Align.MIN)) - _, arc_center, arc_radius = full_round( - yz.edges().sort_by(SortBy.LENGTH)[0] - ) - extrude(amount=10, mode=Mode.INTERSECT) - - # To avoid OCCT problems, don't attempt to extend the top arc, remove instead - with BuildPart(mode=Mode.SUBTRACT) as internals: - y = p.edges().filter_by(Axis.X).sort_by(Axis.Z)[-1].center().Z - - with BuildSketch(Plane.YZ.offset(4.25 / 2)) as yz: - Trapezoid(2.5, y, 90 - 6, align=(Align.CENTER, Align.MIN)) - with Locations(arc_center): - Circle(arc_radius, mode=Mode.SUBTRACT) - extrude(amount=-(4.25 - 3.5) / 2) - - with BuildSketch(Plane.YZ.offset(3.5 / 2)) as yz: - Trapezoid(2.5, 4, 90 - 6, align=(Align.CENTER, Align.MIN)) - extrude(amount=-3.5 / 2) - - with BuildSketch(Plane.XZ.offset(-2)) as xz: - with Locations((0, 4)): - RectangleRounded(4.25, 7.5, 0.5) - extrude(amount=4, mode=Mode.INTERSECT) - - with Locations( - p.faces(Select.LAST).filter_by(GeomType.PLANE).sort_by(Axis.Z)[-1] - ): - CounterBoreHole(0.625 / 2, 1.25 / 2, 0.5) - - with BuildSketch(Plane.YZ) as rib: - with Locations((0, 0.25)): - Trapezoid(0.5, 1, 90 - 8, align=(Align.CENTER, Align.MIN)) - full_round(rib.edges().sort_by(SortBy.LENGTH)[0]) - extrude(amount=4.25 / 2) - - mirror(about=Plane.YZ) - - # part = scale(p.part, IN) - # print(f"\npart weight = {part.volume*7800e-6/LB:0.2f} lbs") - assert p.part.scale(IN).volume * densa / LB == pytest.approx(3.92, 0.03) - + exec(_read_docs_ttt_code("24-SPO-06-Buffer_Stand")) benchmark(model) + @pytest.mark.parametrize("test_input", [100, 1000, 10000, 100000]) def test_mesher_benchmark(benchmark, test_input): # in the 100_000 case test should take on the order of 0.2 seconds diff --git a/tests/test_blendcurve.py b/tests/test_blendcurve.py new file mode 100644 index 0000000..c3abafc --- /dev/null +++ b/tests/test_blendcurve.py @@ -0,0 +1,144 @@ +""" +build123d tests + +name: test_blendcurve.py +by: Gumyr +date: September 2, 2025 + +desc: + This python module contains pytests for the build123d BlendCurve object. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import pytest + +from build123d.objects_curve import BlendCurve, CenterArc, Spline, Line +from build123d.geometry import Vector, Pos, TOLERANCE +from build123d.build_enums import ContinuityLevel, GeomType + + +def _vclose(a: Vector, b: Vector, tol: float = TOLERANCE) -> bool: + return (a - b).length <= tol + + +def _either_close(p: Vector, a: Vector, b: Vector, tol: float = TOLERANCE) -> bool: + return _vclose(p, a, tol) or _vclose(p, b, tol) + + +def make_edges(): + """ + Arc + spline pair similar to the user demo: + - arc radius 5, moved left a bit, reversed so the join uses the arc's 'end' + - symmetric spline with a dip + """ + m1 = Pos(-10, 3) * CenterArc((0, 0), 5, -10, 200).reversed() + m2 = Pos(5, -13) * Spline((-3, 9), (0, 0), (3, 9)) + return m1, m2 + + +def test_c0_positions_match_endpoints(): + m1, m2 = make_edges() + + # No end_points passed -> should auto-pick closest pair of vertices. + bc = BlendCurve(m1, m2, continuity=ContinuityLevel.C0) + + # Start of connector must be one of m1's endpoints; end must be one of m2's endpoints. + m1_p0, m1_p1 = m1.position_at(0), m1.position_at(1) + m2_p0, m2_p1 = m2.position_at(0), m2.position_at(1) + + assert _either_close(bc.position_at(0), m1_p0, m1_p1) + assert _either_close(bc.position_at(1), m2_p0, m2_p1) + + # Geometry type should be a line for C0. + assert bc.geom_type == GeomType.LINE + + +@pytest.mark.parametrize("continuity", [ContinuityLevel.C1, ContinuityLevel.C2]) +def test_c1_c2_tangent_matches_with_scalars(continuity): + m1, m2 = make_edges() + + # Force a specific endpoint pairing to avoid ambiguity + start_pt = m1.position_at(1) # arc end + end_pt = m2.position_at(0) # spline start + s0, s1 = 1.7, 0.8 + + bc = BlendCurve( + m1, + m2, + continuity=continuity, + end_points=(start_pt, end_pt), + tangent_scalars=(s0, s1), + ) + + # Positions must match exactly at the ends + assert _vclose(bc.position_at(0), start_pt) + assert _vclose(bc.position_at(1), end_pt) + + # First-derivative (tangent) must match inputs * scalars + exp_d1_start = m1.derivative_at(1, 1) * s0 + exp_d1_end = m2.derivative_at(0, 1) * s1 + + got_d1_start = bc.derivative_at(0, 1) + got_d1_end = bc.derivative_at(1, 1) + + assert _vclose(got_d1_start, exp_d1_start) + assert _vclose(got_d1_end, exp_d1_end) + + # C1/C2 connectors are Bezier curves + assert bc.geom_type == GeomType.BEZIER + + if continuity == ContinuityLevel.C2: + # Second derivative must also match at both ends + exp_d2_start = m1.derivative_at(1, 2) + exp_d2_end = m2.derivative_at(0, 2) + + got_d2_start = bc.derivative_at(0, 2) + got_d2_end = bc.derivative_at(1, 2) + + assert _vclose(got_d2_start, exp_d2_start) + assert _vclose(got_d2_end, exp_d2_end) + + +def test_auto_select_closest_endpoints_simple_lines(): + # Construct two simple lines with an unambiguous closest-endpoint pair + a = Line((0, 0), (1, 0)) + b = Line((2, 0), (2, 1)) + + bc = BlendCurve(a, b, continuity=ContinuityLevel.C0) + + assert _vclose(bc.position_at(0), a.position_at(1)) # (1,0) + assert _vclose(bc.position_at(1), b.position_at(0)) # (2,0) + + +def test_invalid_tangent_scalars_raises(): + m1, m2 = make_edges() + with pytest.raises(ValueError): + BlendCurve(m1, m2, tangent_scalars=(1.0,), continuity=ContinuityLevel.C1) + + +def test_invalid_end_points_raises(): + m1, m2 = make_edges() + bad_point = m1.position_at(0.5) # not an endpoint + with pytest.raises(ValueError): + BlendCurve( + m1, + m2, + continuity=ContinuityLevel.C1, + end_points=(bad_point, m2.position_at(0)), + ) diff --git a/tests/test_build_common.py b/tests/test_build_common.py index a4c6e0e..419e433 100644 --- a/tests/test_build_common.py +++ b/tests/test_build_common.py @@ -237,18 +237,16 @@ class TestCommonOperations(unittest.TestCase): def test_matmul(self): self.assertTupleAlmostEquals( - (Edge.make_line((0, 0, 0), (1, 1, 1)) @ 0.5).to_tuple(), (0.5, 0.5, 0.5), 5 + Edge.make_line((0, 0, 0), (1, 1, 1)) @ 0.5, (0.5, 0.5, 0.5), 5 ) def test_mod(self): - self.assertTupleAlmostEquals( - (Wire.make_circle(10) % 0.5).to_tuple(), (0, -1, 0), 5 - ) + self.assertTupleAlmostEquals(Wire.make_circle(10) % 0.5, (0, -1, 0), 5) def test_xor(self): helix_loc = Edge.make_helix(2 * pi, 1, 1) ^ 0 - self.assertTupleAlmostEquals(helix_loc.position.to_tuple(), (1, 0, 0), 5) - self.assertTupleAlmostEquals(helix_loc.orientation.to_tuple(), (-45, 0, 180), 5) + self.assertTupleAlmostEquals(helix_loc.position, (1, 0, 0), 5) + self.assertTupleAlmostEquals(helix_loc.orientation, (-45, 0, 180), 5) class TestLocations(unittest.TestCase): @@ -256,11 +254,11 @@ class TestLocations(unittest.TestCase): locs = PolarLocations(1, 5, 45, 90, False).local_locations for i, angle in enumerate(range(45, 135, 18)): self.assertTupleAlmostEquals( - locs[i].position.to_tuple(), - Vector(1, 0).rotate(Axis.Z, angle).to_tuple(), + locs[i].position, + Vector(1, 0).rotate(Axis.Z, angle), 5, ) - self.assertTupleAlmostEquals(locs[i].orientation.to_tuple(), (0, 0, 0), 5) + self.assertTupleAlmostEquals(locs[i].orientation, (0, 0, 0), 5) def test_polar_endpoint(self): locs = PolarLocations( @@ -284,7 +282,7 @@ class TestLocations(unittest.TestCase): def test_no_centering(self): with BuildSketch(): with GridLocations(4, 4, 2, 2, align=(Align.MIN, Align.MIN)) as l: - pts = [loc.to_tuple()[0] for loc in l.locations] + pts = [tuple(loc)[0] for loc in l.locations] self.assertTupleAlmostEquals(pts[0], (0, 0, 0), 5) self.assertTupleAlmostEquals(pts[1], (0, 4, 0), 5) self.assertTupleAlmostEquals(pts[2], (4, 0, 0), 5) @@ -329,11 +327,11 @@ class TestLocations(unittest.TestCase): self.assertAlmostEqual(hloc.radius, 1, 7) self.assertAlmostEqual(hloc.diagonal, 2, 7) self.assertAlmostEqual(hloc.apothem, 3**0.5 / 2, 7) - + def test_centering(self): with BuildSketch(): with GridLocations(4, 4, 2, 2, align=(Align.CENTER, Align.CENTER)) as l: - pts = [loc.to_tuple()[0] for loc in l.locations] + pts = [tuple(loc)[0] for loc in l.locations] self.assertTupleAlmostEquals(pts[0], (-2, -2, 0), 5) self.assertTupleAlmostEquals(pts[1], (-2, 2, 0), 5) self.assertTupleAlmostEquals(pts[2], (2, -2, 0), 5) @@ -343,7 +341,7 @@ class TestLocations(unittest.TestCase): with BuildSketch(): with Locations((-2, -2), (2, 2)): with GridLocations(1, 1, 2, 2) as nested_grid: - pts = [loc.to_tuple()[0] for loc in nested_grid.local_locations] + pts = [tuple(loc)[0] for loc in nested_grid.local_locations] self.assertTupleAlmostEquals(pts[0], (-2.50, -2.50, 0.00), 5) self.assertTupleAlmostEquals(pts[1], (-2.50, -1.50, 0.00), 5) self.assertTupleAlmostEquals(pts[2], (-1.50, -2.50, 0.00), 5) @@ -357,8 +355,8 @@ class TestLocations(unittest.TestCase): with BuildSketch(): with PolarLocations(6, 3): with GridLocations(1, 1, 2, 2) as polar_grid: - pts = [loc.to_tuple()[0] for loc in polar_grid.local_locations] - ort = [loc.to_tuple()[1] for loc in polar_grid.local_locations] + pts = [tuple(loc)[0] for loc in polar_grid.local_locations] + ort = [tuple(loc)[1] for loc in polar_grid.local_locations] self.assertTupleAlmostEquals(pts[0], (5.50, -0.50, 0.00), 2) self.assertTupleAlmostEquals(pts[1], (5.50, 0.50, 0.00), 2) @@ -390,22 +388,18 @@ class TestLocations(unittest.TestCase): square = Face.make_rect(1, 1, Plane.XZ) with BuildPart(): loc = Locations(square).locations[0] - self.assertTupleAlmostEquals( - loc.position.to_tuple(), Location(Plane.XZ).position.to_tuple(), 5 - ) - self.assertTupleAlmostEquals( - loc.orientation.to_tuple(), Location(Plane.XZ).orientation.to_tuple(), 5 - ) + self.assertTupleAlmostEquals(loc.position, Location(Plane.XZ).position, 5) + self.assertTupleAlmostEquals(loc.orientation, Location(Plane.XZ).orientation, 5) def test_from_plane(self): with BuildPart(): loc = Locations(Plane.XY.offset(1)).locations[0] - self.assertTupleAlmostEquals(loc.position.to_tuple(), (0, 0, 1), 5) + self.assertTupleAlmostEquals(loc.position, (0, 0, 1), 5) def test_from_axis(self): with BuildPart(): loc = Locations(Axis((1, 1, 1), (0, 0, 1))).locations[0] - self.assertTupleAlmostEquals(loc.position.to_tuple(), (1, 1, 1), 5) + self.assertTupleAlmostEquals(loc.position, (1, 1, 1), 5) def test_multiplication(self): circles = GridLocations(2, 2, 2, 2) * Circle(1) @@ -416,25 +410,17 @@ class TestLocations(unittest.TestCase): def test_grid_attributes(self): grid = GridLocations(5, 10, 3, 4) - self.assertTupleAlmostEquals(grid.size.to_tuple(), (10, 30, 0), 5) - self.assertTupleAlmostEquals(grid.min.to_tuple(), (-5, -15, 0), 5) - self.assertTupleAlmostEquals(grid.max.to_tuple(), (5, 15, 0), 5) + self.assertTupleAlmostEquals(grid.size, (10, 30, 0), 5) + self.assertTupleAlmostEquals(grid.min, (-5, -15, 0), 5) + self.assertTupleAlmostEquals(grid.max, (5, 15, 0), 5) def test_mixed_sequence_list(self): locs = Locations((0, 1), [(2, 3), (4, 5)], (6, 7)) self.assertEqual(len(locs.locations), 4) - self.assertTupleAlmostEquals( - locs.locations[0].position.to_tuple(), (0, 1, 0), 5 - ) - self.assertTupleAlmostEquals( - locs.locations[1].position.to_tuple(), (2, 3, 0), 5 - ) - self.assertTupleAlmostEquals( - locs.locations[2].position.to_tuple(), (4, 5, 0), 5 - ) - self.assertTupleAlmostEquals( - locs.locations[3].position.to_tuple(), (6, 7, 0), 5 - ) + self.assertTupleAlmostEquals(locs.locations[0].position, (0, 1, 0), 5) + self.assertTupleAlmostEquals(locs.locations[1].position, (2, 3, 0), 5) + self.assertTupleAlmostEquals(locs.locations[2].position, (4, 5, 0), 5) + self.assertTupleAlmostEquals(locs.locations[3].position, (6, 7, 0), 5) class TestProperties(unittest.TestCase): @@ -449,27 +435,25 @@ class TestRotation(unittest.TestCase): def test_init(self): thirty_by_three = Rotation(30, 30, 30) box_vertices = Solid.make_box(1, 1, 1).moved(thirty_by_three).vertices() + self.assertTupleAlmostEquals(tuple(box_vertices[0]), (0.5, -0.4330127, 0.75), 5) + self.assertTupleAlmostEquals(tuple(box_vertices[1]), (0.0, 0.0, 0.0), 7) self.assertTupleAlmostEquals( - box_vertices[0].to_tuple(), (0.5, -0.4330127, 0.75), 5 - ) - self.assertTupleAlmostEquals(box_vertices[1].to_tuple(), (0.0, 0.0, 0.0), 7) - self.assertTupleAlmostEquals( - box_vertices[2].to_tuple(), (0.0669872, 0.191987, 1.399519), 5 + tuple(box_vertices[2]), (0.0669872, 0.191987, 1.399519), 5 ) self.assertTupleAlmostEquals( - box_vertices[3].to_tuple(), (-0.4330127, 0.625, 0.6495190), 5 + tuple(box_vertices[3]), (-0.4330127, 0.625, 0.6495190), 5 ) self.assertTupleAlmostEquals( - box_vertices[4].to_tuple(), (1.25, 0.2165063, 0.625), 5 + tuple(box_vertices[4]), (1.25, 0.2165063, 0.625), 5 ) self.assertTupleAlmostEquals( - box_vertices[5].to_tuple(), (0.75, 0.649519, -0.125), 5 + tuple(box_vertices[5]), (0.75, 0.649519, -0.125), 5 ) self.assertTupleAlmostEquals( - box_vertices[6].to_tuple(), (0.816987, 0.841506, 1.274519), 5 + tuple(box_vertices[6]), (0.816987, 0.841506, 1.274519), 5 ) self.assertTupleAlmostEquals( - box_vertices[7].to_tuple(), (0.3169872, 1.2745190, 0.52451905), 5 + tuple(box_vertices[7]), (0.3169872, 1.2745190, 0.52451905), 5 ) @@ -706,7 +690,7 @@ class TestShapeList(unittest.TestCase): def test_shapes(self): with BuildPart() as test: Box(1, 1, 1) - self.assertIsNone(test._shapes(Compound)) + self.assertEqual(test._shapes(Compound), []) def test_operators(self): with BuildPart() as test: @@ -744,12 +728,12 @@ class TestValidateInputs(unittest.TestCase): class TestVectorExtensions(unittest.TestCase): def test_vector_localization(self): self.assertTupleAlmostEquals( - (Vector(1, 1, 1) + (1, 2)).to_tuple(), + (Vector(1, 1, 1) + (1, 2)), (2, 3, 1), 5, ) self.assertTupleAlmostEquals( - (Vector(3, 3, 3) - (1, 2)).to_tuple(), + (Vector(3, 3, 3) - (1, 2)), (2, 1, 3), 5, ) @@ -759,16 +743,14 @@ class TestVectorExtensions(unittest.TestCase): Vector(1, 2, 3) - "four" with BuildLine(Plane.YZ): + self.assertTupleAlmostEquals(WorkplaneList.localize((1, 2)), (0, 1, 2), 5) self.assertTupleAlmostEquals( - WorkplaneList.localize((1, 2)).to_tuple(), (0, 1, 2), 5 - ) - self.assertTupleAlmostEquals( - WorkplaneList.localize(Vector(1, 1, 1) + (1, 2)).to_tuple(), + WorkplaneList.localize(Vector(1, 1, 1) + (1, 2)), (1, 2, 3), 5, ) self.assertTupleAlmostEquals( - WorkplaneList.localize(Vector(3, 3, 3) - (1, 2)).to_tuple(), + WorkplaneList.localize(Vector(3, 3, 3) - (1, 2)), (3, 2, 1), 5, ) @@ -780,7 +762,7 @@ class TestVectorExtensions(unittest.TestCase): with BuildLine(pln): n3 = Line((-50, -40), (0, 0)) n4 = Line(n3 @ 1, n3 @ 1 + (0, 10)) - self.assertTupleAlmostEquals((n4 @ 1).to_tuple(), (0, 0, -25), 5) + self.assertTupleAlmostEquals((n4 @ 1), (0, 0, -25), 5) class TestWorkplaneList(unittest.TestCase): @@ -794,8 +776,8 @@ class TestWorkplaneList(unittest.TestCase): def test_localize(self): with BuildLine(Plane.YZ): pnts = WorkplaneList.localize((1, 2), (2, 3)) - self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 5) - self.assertTupleAlmostEquals(pnts[1].to_tuple(), (0, 2, 3), 5) + self.assertTupleAlmostEquals(pnts[0], (0, 1, 2), 5) + self.assertTupleAlmostEquals(pnts[1], (0, 2, 3), 5) def test_invalid_workplane(self): with self.assertRaises(ValueError): diff --git a/tests/test_build_enums.py b/tests/test_build_enums.py index 7d06964..df7ba9b 100644 --- a/tests/test_build_enums.py +++ b/tests/test_build_enums.py @@ -55,6 +55,7 @@ class TestEnumRepr(unittest.TestCase): Side, SortBy, Transition, + TextAlign, Unit, Until, ] diff --git a/tests/test_build_generic.py b/tests/test_build_generic.py index 46a6c34..ac02ca5 100644 --- a/tests/test_build_generic.py +++ b/tests/test_build_generic.py @@ -72,7 +72,7 @@ class AddTests(unittest.TestCase): # Add Edge with BuildLine() as test: add(Edge.make_line((0, 0, 0), (1, 1, 1))) - self.assertTupleAlmostEquals((test.wires()[0] @ 1).to_tuple(), (1, 1, 1), 5) + self.assertTupleAlmostEquals(test.wires()[0] @ 1, (1, 1, 1), 5) # Add Wire with BuildLine() as wire: Polyline((0, 0, 0), (1, 1, 1), (2, 0, 0), (3, 1, 1)) @@ -94,13 +94,11 @@ class AddTests(unittest.TestCase): add(Solid.make_box(10, 10, 10), rotation=(0, 0, 45)) self.assertAlmostEqual(test.part.volume, 1000, 5) self.assertTupleAlmostEquals( - ( - test.part.edges() - .group_by(Axis.Z)[-1] - .group_by(Axis.X)[-1] - .sort_by(Axis.Y)[0] - % 1 - ).to_tuple(), + test.part.edges() + .group_by(Axis.Z)[-1] + .group_by(Axis.X)[-1] + .sort_by(Axis.Y)[0] + % 1, (sqrt(2) / 2, sqrt(2) / 2, 0), 5, ) @@ -405,7 +403,7 @@ class LocationsTests(unittest.TestCase): with BuildPart(): with Locations(Location(Vector())): self.assertTupleAlmostEquals( - LocationList._get_context().locations[0].to_tuple()[0], (0, 0, 0), 5 + tuple(LocationList._get_context().locations[0])[0], (0, 0, 0), 5 ) def test_errors(self): @@ -524,7 +522,7 @@ class OffsetTests(unittest.TestCase): def test_face_offset_with_holes(self): sk = Rectangle(100, 100) - GridLocations(80, 80, 2, 2) * Circle(5) sk2 = offset(sk, -5) - self.assertTrue(sk2.face().is_valid()) + self.assertTrue(sk2.face().is_valid) self.assertLess(sk2.area, sk.area) self.assertEqual(len(sk2), 1) @@ -680,12 +678,12 @@ class ProjectionTests(unittest.TestCase): def test_project_point(self): pnt: Vector = project(Vector(1, 2, 3), Plane.XY)[0] - self.assertTupleAlmostEquals(pnt.to_tuple(), (1, 2, 0), 5) + self.assertTupleAlmostEquals(pnt, (1, 2, 0), 5) pnt: Vector = project(Vertex(1, 2, 3), Plane.XZ)[0] - self.assertTupleAlmostEquals(pnt.to_tuple(), (1, 3, 0), 5) + self.assertTupleAlmostEquals(pnt, (1, 3, 0), 5) with BuildSketch(Plane.YZ) as s1: pnt = project(Vertex(1, 2, 3), mode=Mode.PRIVATE)[0] - self.assertTupleAlmostEquals(pnt.to_tuple(), (2, 3, 0), 5) + self.assertTupleAlmostEquals(pnt, (2, 3, 0), 5) def test_multiple_results(self): with BuildLine() as l1: @@ -883,7 +881,7 @@ class TestSweep(unittest.TestCase): Rectangle(2 * lip, 2 * lip, align=(Align.CENTER, Align.CENTER)) sweep(sections=sk2.sketch, path=topedgs, mode=Mode.SUBTRACT) - self.assertTrue(p.part.is_valid()) + self.assertTrue(p.part.is_valid) def test_path_error(self): e1 = Edge.make_line((0, 0), (1, 0)) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 9211a26..2255793 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): @@ -134,14 +134,16 @@ class BuildLineTests(unittest.TestCase): tuple(l5.tangent_at(p1)), tuple(l6.tangent_at(p2) * -1), 5 ) - l7 = Spline((15, 5), (5, 0), (15, -5), tangents=[(-1, 0), (1, 0)]) - l8 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l7, keep=Keep.BOTH) - self.assertEqual(len(l8.edges()), 2) + # l7 = Spline((15, 5), (5, 0), (15, -5), tangents=[(-1, 0), (1, 0)]) + # l8 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l7, keep=Keep.BOTH) + # self.assertEqual(len(l8.edges()), 2) l9 = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270) - l10 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l9, keep=Keep.BOTH) - self.assertEqual(len(l10.edges()), 2) - self.assertTrue(isinstance(l10, Edge)) + # l10 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l9, keep=Keep.BOTH) + # self.assertEqual(len(l10.edges()), 2) + # self.assertTrue(isinstance(l10, Edge)) + with self.assertRaises(ValueError): + l10 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l9, keep=Keep.BOTH) with self.assertRaises(ValueError): DoubleTangentArc((0, 0, 0), (0, 0, 1), l9) @@ -181,6 +183,61 @@ 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 BuildLine(Plane.YZ): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + 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( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2, 3, 4), + close=False, + ) + + 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 @@ -195,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)) @@ -203,7 +287,7 @@ class BuildLineTests(unittest.TestCase): l3 = Line((0, 0), (10, 10)) l4 = IntersectingLine((0, 10), (1, -1), l3) - self.assertTupleAlmostEquals((l4 @ 1).to_tuple(), (5, 5, 0), 5) + self.assertTupleAlmostEquals(l4 @ 1, (5, 5, 0), 5) self.assertTrue(isinstance(l4, Edge)) with self.assertRaises(ValueError): @@ -212,22 +296,20 @@ class BuildLineTests(unittest.TestCase): def test_jern_arc(self): with BuildLine() as jern: j1 = JernArc((1, 0), (0, 1), 1, 90) - self.assertTupleAlmostEquals((jern.line @ 1).to_tuple(), (0, 1, 0), 5) + self.assertTupleAlmostEquals(jern.line @ 1, (0, 1, 0), 5) self.assertAlmostEqual(j1.radius, 1) self.assertAlmostEqual(j1.length, pi / 2) with BuildLine(Plane.XY.offset(1)) as offset_l: off1 = JernArc((1, 0), (0, 1), 1, 90) - self.assertTupleAlmostEquals((offset_l.line @ 1).to_tuple(), (0, 1, 1), 5) + self.assertTupleAlmostEquals(offset_l.line @ 1, (0, 1, 1), 5) self.assertAlmostEqual(off1.radius, 1) self.assertAlmostEqual(off1.length, pi / 2) plane_iso = Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(1, -1, 1)) with BuildLine(plane_iso) as iso_l: iso1 = JernArc((0, 0), (0, 1), 1, 180) - self.assertTupleAlmostEquals( - (iso_l.line @ 1).to_tuple(), (-sqrt(2), -sqrt(2), 0), 5 - ) + self.assertTupleAlmostEquals(iso_l.line @ 1, (-sqrt(2), -sqrt(2), 0), 5) self.assertAlmostEqual(iso1.radius, 1) self.assertAlmostEqual(iso1.length, pi) @@ -238,44 +320,50 @@ class BuildLineTests(unittest.TestCase): self.assertFalse(l2.is_closed) circle_face = Face(Wire([l1])) self.assertAlmostEqual(circle_face.area, pi, 5) - self.assertTupleAlmostEquals(circle_face.center().to_tuple(), (0, 1, 0), 5) - self.assertTupleAlmostEquals(l1.vertex().to_tuple(), l2.start.to_tuple(), 5) + self.assertTupleAlmostEquals(circle_face.center(), (0, 1, 0), 5) + self.assertTupleAlmostEquals(l1.vertex(), l2.start, 5) l1 = JernArc((0, 0), (1, 0), 1, 90) - self.assertTupleAlmostEquals((l1 @ 1).to_tuple(), (1, 1, 0), 5) + self.assertTupleAlmostEquals(l1 @ 1, (1, 1, 0), 5) self.assertTrue(isinstance(l1, Edge)) def test_polar_line(self): """Test 2D and 3D polar lines""" - with BuildLine() as bl: - PolarLine((0, 0), sqrt(2), 45) - self.assertTupleAlmostEquals((bl.edges()[0] @ 1).to_tuple(), (1, 1, 0), 5) + with BuildLine(): + a1 = PolarLine((0, 0), sqrt(2), 45) + d1 = PolarLine((0, 0), sqrt(2), direction=(1, 1)) + self.assertTupleAlmostEquals(a1 @ 1, (1, 1, 0), 5) + self.assertTupleAlmostEquals(a1 @ 1, d1 @ 1, 5) + self.assertTrue(isinstance(a1, Edge)) + self.assertTrue(isinstance(d1, Edge)) - with BuildLine() as bl: - PolarLine((0, 0), 1, 30) - self.assertTupleAlmostEquals( - (bl.edges()[0] @ 1).to_tuple(), (sqrt(3) / 2, 0.5, 0), 5 - ) + with BuildLine(): + a2 = PolarLine((0, 0), 1, 30) + d2 = PolarLine((0, 0), 1, direction=(sqrt(3), 1)) + self.assertTupleAlmostEquals(a2 @ 1, (sqrt(3) / 2, 0.5, 0), 5) + self.assertTupleAlmostEquals(a2 @ 1, d2 @ 1, 5) - with BuildLine() as bl: - PolarLine((0, 0), 1, 150) - self.assertTupleAlmostEquals( - (bl.edges()[0] @ 1).to_tuple(), (-sqrt(3) / 2, 0.5, 0), 5 - ) + with BuildLine(): + a3 = PolarLine((0, 0), 1, 150) + d3 = PolarLine((0, 0), 1, direction=(-sqrt(3), 1)) + self.assertTupleAlmostEquals(a3 @ 1, (-sqrt(3) / 2, 0.5, 0), 5) + self.assertTupleAlmostEquals(a3 @ 1, d3 @ 1, 5) - with BuildLine() as bl: - PolarLine((0, 0), 1, angle=30, length_mode=LengthMode.HORIZONTAL) - self.assertTupleAlmostEquals( - (bl.edges()[0] @ 1).to_tuple(), (1, 1 / sqrt(3), 0), 5 - ) + with BuildLine(): + a4 = PolarLine((0, 0), 1, angle=30, length_mode=LengthMode.HORIZONTAL) + d4 = PolarLine( + (0, 0), 1, direction=(sqrt(3), 1), length_mode=LengthMode.HORIZONTAL + ) + self.assertTupleAlmostEquals(a4 @ 1, (1, 1 / sqrt(3), 0), 5) + self.assertTupleAlmostEquals(a4 @ 1, d4 @ 1, 5) - with BuildLine(Plane.XZ) as bl: - PolarLine((0, 0), 1, angle=30, length_mode=LengthMode.VERTICAL) - self.assertTupleAlmostEquals((bl.edges()[0] @ 1).to_tuple(), (sqrt(3), 0, 1), 5) - - l1 = PolarLine((0, 0), 10, direction=(1, 1)) - self.assertTupleAlmostEquals((l1 @ 1).to_tuple(), (10, 10, 0), 5) - self.assertTrue(isinstance(l1, Edge)) + with BuildLine(Plane.XZ): + a5 = PolarLine((0, 0), 1, angle=30, length_mode=LengthMode.VERTICAL) + d5 = PolarLine( + (0, 0), 1, direction=(sqrt(3), 1), length_mode=LengthMode.VERTICAL + ) + self.assertTupleAlmostEquals(a5 @ 1, (sqrt(3), 0, 1), 5) + self.assertTupleAlmostEquals(a5 @ 1, d5 @ 1, 5) with self.assertRaises(ValueError): PolarLine((0, 0), 1) @@ -284,7 +372,7 @@ class BuildLineTests(unittest.TestCase): """Test spline with no tangents""" with BuildLine() as test: s1 = Spline((0, 0), (1, 1), (2, 0)) - self.assertTupleAlmostEquals((test.edges()[0] @ 1).to_tuple(), (2, 0, 0), 5) + self.assertTupleAlmostEquals(test.edges()[0] @ 1, (2, 0, 0), 5) self.assertTrue(isinstance(s1, Edge)) def test_radius_arc(self): @@ -325,19 +413,17 @@ class BuildLineTests(unittest.TestCase): """Test center arc as arc and circle""" with BuildLine() as arc: CenterArc((0, 0), 10, 0, 180) - self.assertTupleAlmostEquals((arc.edges()[0] @ 1).to_tuple(), (-10, 0, 0), 5) + self.assertTupleAlmostEquals(arc.edges()[0] @ 1, (-10, 0, 0), 5) with BuildLine() as arc: CenterArc((0, 0), 10, 0, 360) - self.assertTupleAlmostEquals( - (arc.edges()[0] @ 0).to_tuple(), (arc.edges()[0] @ 1).to_tuple(), 5 - ) + self.assertTupleAlmostEquals(arc.edges()[0] @ 0, arc.edges()[0] @ 1, 5) with BuildLine(Plane.XZ) as arc: CenterArc((0, 0), 10, 0, 360) self.assertTrue(Face(arc.wires()[0]).is_coplanar(Plane.XZ)) with BuildLine(Plane.XZ) as arc: CenterArc((-100, 0), 100, -45, 90) - self.assertTupleAlmostEquals((arc.edges()[0] @ 0.5).to_tuple(), (0, 0, 0), 5) + self.assertTupleAlmostEquals(arc.edges()[0] @ 0.5, (0, 0, 0), 5) arc = CenterArc((-100, 0), 100, 0, 360) self.assertTrue(Face(Wire([arc])).is_coplanar(Plane.XY)) @@ -365,6 +451,481 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(test.edges()), 4) self.assertAlmostEqual(test.wires()[0].length, 4) + def test_point_arc_tangent_line(self): + """Test tangent line between point and arc + + Considerations: + - Should produce a GeomType.LINE located on and tangent to arc + - Should start on point + - Lines should always have equal length as long as point is same distance + - LEFT lines should always end on end arc left of midline (angle > 0) + - Arc should be GeomType.CIRCLE + - Point and arc must be coplanar + - Cannot make tangent from point inside arc + """ + # Test line properties in algebra mode + point = (0, 0) + separation = 10 + end_point = (0, separation) + end_r = 5 + end_arc = CenterArc(end_point, end_r, 0, 360) + + lines = [] + for side in [Side.LEFT, Side.RIGHT]: + l1 = PointArcTangentLine(point, end_arc, side=side) + self.assertEqual(l1.geom_type, GeomType.LINE) + + self.assertTupleAlmostEquals(tuple(point), tuple(l1 @ 0), 5) + + _, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + lines.append(l1) + + self.assertAlmostEqual(lines[0].length, lines[1].length, 5) + + # Test in off-axis builder mode at multiple angles and compare to prev result + workplane = Plane.XY.rotated((45, 45, 45)) + with BuildLine(workplane): + end_center = workplane.from_local_coords(end_point) + point_arc = CenterArc(end_center, separation, 0, 360) + end_arc = CenterArc(end_center, end_r, 0, 360) + + points = [1, 2, 3, 5, 7, 11, 13] + for point in points: + start_point = point_arc @ (point / 16) + mid_vector = end_center - start_point + mid_perp = mid_vector.cross(workplane.z_dir) + for side in [Side.LEFT, Side.RIGHT]: + l2 = PointArcTangentLine(start_point, end_arc, side=side) + self.assertAlmostEqual(lines[0].length, l2.length, 5) + + # Check side + coincident_dir = mid_perp.dot(l2 @ 1 - end_center) + if side == Side.LEFT: + self.assertLess(coincident_dir, 0) + + elif side == Side.RIGHT: + self.assertGreater(coincident_dir, 0) + + # Error Handling + bad_type = Line((0, 0), (0, 10)) + with self.assertRaises(ValueError): + PointArcTangentLine(start_point, bad_type) + + with self.assertRaises(ValueError): + PointArcTangentLine(start_point, CenterArc((0, 1, 1), end_r, 0, 360)) + + with self.assertRaises(ValueError): + PointArcTangentLine(start_point, CenterArc((0, 1), end_r, 0, 360)) + + def test_point_arc_tangent_arc(self): + """Test tangent arc between point and arc + + Considerations: + - Should produce a GeomType.CIRCLE located on and tangent to arc + - Should start on point tangent to direction + - LEFT lines should always end on end arc left of midline (angle > 0) + - Tangent should be GeomType.CIRCLE + - Point and arc must be coplanar + - Cannot make tangent arc from point/direction already tangent with arc + - (Due to minimizer limit) Cannot make tangent with very large radius + """ + # Test line properties in algebra mode + start_point = (0, 0) + direction = (0, 1) + separation = 10 + end_point = (0, separation) + end_r = 5 + end_arc = CenterArc(end_point, end_r, 0, 360) + lines = [] + for side in [Side.LEFT, Side.RIGHT]: + l1 = PointArcTangentArc(start_point, direction, end_arc, side=side) + self.assertEqual(l1.geom_type, GeomType.CIRCLE) + + self.assertTupleAlmostEquals(tuple(start_point), tuple(l1 @ 0), 5) + self.assertAlmostEqual(Vector(direction).cross(l1 % 0).length, 0, 5) + + _, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + lines.append(l1) + + # Test in off-axis builder mode at multiple angles and compare to prev result + workplane = Plane.XY.rotated((45, 45, 45)) + with BuildLine(workplane): + end_center = workplane.from_local_coords(end_point) + end_arc = CenterArc(end_center, end_r, 0, 360) + + # Assortment of points in different regimes + flip = separation * 2 + value = flip - end_r + points = [ + start_point, + (end_r - 0.1, 0), + (-end_r - 0.1, 0), + (end_r + 0.1, flip), + (-end_r + 0.1, flip), + (0, flip), + (flip, flip), + (-flip, -flip), + (value, -value), + (-value, value), + ] + for point in points: + mid_vector = end_center - point + mid_perp = mid_vector.cross(workplane.z_dir) + centers = {} + for side in [Side.LEFT, Side.RIGHT]: + l2 = PointArcTangentArc(point, direction, end_arc, side=side) + + centers[side] = l2.center() + if point == start_point: + self.assertAlmostEqual(lines[0].length, l2.length, 5) + + # Rudimentary side check. Somewhat surprised this works + center_dif = centers[Side.RIGHT] - centers[Side.LEFT] + self.assertGreater(mid_perp.dot(center_dif), 0) + + # Error Handling + end_arc = CenterArc(end_point, end_r, 0, 360) + + # GeomType + bad_type = Line((0, 0), (0, 10)) + with self.assertRaises(ValueError): + PointArcTangentArc(start_point, direction, bad_type) + + # Coplanar + with self.assertRaises(ValueError): + arc = CenterArc((0, 1, 1), end_r, 0, 360) + PointArcTangentArc(start_point, direction, arc) + + # Positional + with self.assertRaises(ValueError): + PointArcTangentArc((end_r, 0), direction, end_arc, side=Side.RIGHT) + + with self.assertRaises(RuntimeError): + PointArcTangentArc( + (end_r - 0.00001, 0), direction, end_arc, side=Side.RIGHT + ) + + def test_arc_arc_tangent_line(self): + """Test tangent line between arcs + + Considerations: + - Should produce a GeomType.LINE located on and tangent to arcs + - INSIDE arcs cross midline of arc centers + - INSIDE lines should always have equal length as long as arcs are same distance + - OUTSIDE lines should always have equal length as long as arcs are same distance + - LEFT lines should always start on start arc left of midline (angle > 0) + - Tangent should be GeomType.CIRCLE + - Arcs must be coplanar + - Cannot make tangent for concentric arcs + - Cannot make INSIDE tangent from overlapping or tangent arcs + """ + # Test line properties in algebra mode + start_r = 2 + end_r = 5 + separation = 10 + start_point = (0, 0) + end_point = (0, separation) + + start_arc = CenterArc(start_point, start_r, 0, 360) + end_arc = CenterArc(end_point, end_r, 0, 360) + lines = [] + for keep in [Keep.INSIDE, Keep.OUTSIDE]: + for side in [Side.LEFT, Side.RIGHT]: + l1 = ArcArcTangentLine(start_arc, end_arc, side=side, keep=keep) + self.assertEqual(l1.geom_type, GeomType.LINE) + + # Check coincidence, tangency with each arc + _, p1, p2 = start_arc.distance_to_with_closest_points(l1 @ 0) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + _, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + lines.append(l1) + + self.assertAlmostEqual(lines[-2].length, lines[-1].length, 5) + + # Test in off-axis builder mode at multiple angles and compare to prev result + workplane = Plane.XY.rotated((45, 45, 45)) + with BuildLine(workplane): + end_center = workplane.from_local_coords(end_point) + point_arc = CenterArc(end_center, separation, 0, 360) + end_arc = CenterArc(end_center, end_r, 0, 360) + + points = [1, 2, 3, 5, 7, 11, 13] + for point in points: + start_center = point_arc @ (point / 16) + start_arc = CenterArc(start_center, start_r, 0, 360) + midline = Line(start_center, end_center) + mid_vector = end_center - start_center + mid_perp = mid_vector.cross(workplane.z_dir) + for keep in [Keep.INSIDE, Keep.OUTSIDE]: + for side in [Side.LEFT, Side.RIGHT]: + l2 = ArcArcTangentLine(start_arc, end_arc, side=side, keep=keep) + + # Check length and cross/does not cross midline + d1 = midline.distance_to(l2) + if keep == Keep.INSIDE: + self.assertAlmostEqual(d1, 0, 5) + self.assertAlmostEqual(lines[0].length, l2.length, 5) + + elif keep == Keep.OUTSIDE: + self.assertNotAlmostEqual(d1, 0, 5) + self.assertAlmostEqual(lines[2].length, l2.length, 5) + + # Check side of midline + _, _, p2 = start_arc.distance_to_with_closest_points(l2) + coincident_dir = mid_perp.dot(p2 - start_center) + if side == Side.LEFT: + self.assertLess(coincident_dir, 0) + + elif side == Side.RIGHT: + self.assertGreater(coincident_dir, 0) + + ## Error Handling + start_arc = CenterArc(start_point, start_r, 0, 360) + end_arc = CenterArc(end_point, end_r, 0, 360) + + # GeomType + bad_type = Line((0, 0), (0, 10)) + with self.assertRaises(ValueError): + ArcArcTangentLine(start_arc, bad_type) + + with self.assertRaises(ValueError): + ArcArcTangentLine(bad_type, end_arc) + + # Coplanar + with self.assertRaises(ValueError): + ArcArcTangentLine(CenterArc((0, 0, 1), 5, 0, 360), end_arc) + + # Position conditions + with self.assertRaises(ValueError): + ArcArcTangentLine(CenterArc(end_point, start_r, 0, 360), end_arc) + + with self.assertRaises(ValueError): + arc = CenterArc(start_point, separation - end_r, 0, 360) + ArcArcTangentLine(arc, end_arc, keep=Keep.INSIDE) + + with self.assertRaises(ValueError): + arc = CenterArc(start_point, separation - end_r + 1, 0, 360) + ArcArcTangentLine(arc, end_arc, keep=Keep.INSIDE) + + def test_arc_arc_tangent_arc(self): + """Test tangent arc between arcs + + Considerations: + - Should produce a GeomType.CIRCLE located on and tangent to arcs + - Tangent arcs that share a side have arc centers on the same side of the midline + - LEFT arcs have centers to left of midline (for (INSIDE, *) case, non overlapping)) + - Mirrored arcs should always have equal length as long as arcs are same distance + - Tangent should be GeomType.CIRCLE + - Arcs must be coplanar + - Cannot make tangent for concentric arcs + """ + + # Test line properties in algebra mode + start_r = 2 + end_r = 5 + separation = 10 + start_point = (0, 0) + end_point = (0, separation) + + start_arc = CenterArc(start_point, start_r, 0, 360) + end_arc = CenterArc(end_point, end_r, 0, 360) + radius = 15 + lines = [] + for keep_placement in [Keep.INSIDE, Keep.OUTSIDE]: + keep = (keep_placement, Keep.OUTSIDE) + for side in [Side.LEFT, Side.RIGHT]: + l1 = ArcArcTangentArc(start_arc, end_arc, radius, side=side, keep=keep) + self.assertEqual(l1.geom_type, GeomType.CIRCLE) + self.assertAlmostEqual(l1.radius, radius) + + # Check coincidence, tangency with each arc + _, p1, p2 = start_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + _, p1, p2 = end_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + lines.append(l1) + + self.assertAlmostEqual(lines[-2].length, lines[-1].length, 5) + + # Test in off-axis builder mode at multiple angles and compare to prev result + workplane = Plane.XY.rotated((45, 45, 45)) + with BuildLine(workplane): + end_center = workplane.from_local_coords(end_point) + point_arc = CenterArc(end_center, separation, 0, 360) + end_arc = CenterArc(end_center, end_r, 0, 360) + + points = [1, 2, 3, 5, 7, 11, 13] + for point in points: + start_center = point_arc @ (point / 16) + start_arc = CenterArc(point_arc @ (point / 16), start_r, 0, 360) + mid_vector = end_center - start_center + mid_perp = mid_vector.cross(workplane.z_dir) + for keep_placement in [Keep.INSIDE, Keep.OUTSIDE]: + keep = (keep_placement, Keep.OUTSIDE) + for side in [Side.LEFT, Side.RIGHT]: + l2 = ArcArcTangentArc( + start_arc, end_arc, radius, side=side, keep=keep + ) + # Check length against algebraic length + if keep_placement == Keep.OUTSIDE: + self.assertAlmostEqual(lines[2].length, l2.length, 5) + side_sign = 1 + elif keep_placement == Keep.INSIDE: + self.assertAlmostEqual(lines[0].length, l2.length, 5) + side_sign = -1 + + # Check side of midline + _, _, p2 = start_arc.distance_to_with_closest_points(l2) + coincident_dir = mid_perp.dot(p2 - start_center) + center_dir = mid_perp.dot(l2.arc_center - start_center) + if side == Side.LEFT: + self.assertLess(side_sign * coincident_dir, 0) + self.assertLess(center_dir, 0) + elif side == Side.RIGHT: + self.assertGreater(side_sign * coincident_dir, 0) + self.assertGreater(center_dir, 0) + + # Verify arc is tangent for a reversed start arc + c1 = CenterArc((0, 80), 40, 0, -180) + c2 = CenterArc((80, 0), 40, 90, 180) + keep = (Keep.OUTSIDE, Keep.OUTSIDE) + arc = ArcArcTangentArc(c1, c2, 25, side=Side.RIGHT, keep=keep) + _, _, point = c1.distance_to_with_closest_points(arc) + self.assertAlmostEqual( + c1.tangent_at(point).cross(arc.tangent_at(point)).length, 0, 5 + ) + + ## Error Handling + start_arc = CenterArc(start_point, start_r, 0, 360) + end_arc = CenterArc(end_point, end_r, 0, 360) + + # GeomType + bad_type = Line((0, 0), (0, 10)) + with self.assertRaises(ValueError): + ArcArcTangentArc(start_arc, bad_type, radius) + + with self.assertRaises(ValueError): + ArcArcTangentArc(bad_type, end_arc, radius) + + # Keep.BOTH + with self.assertRaises(ValueError): + ArcArcTangentArc(bad_type, end_arc, radius, keep=(Keep.BOTH, Keep.OUTSIDE)) + + # Coplanar + with self.assertRaises(ValueError): + ArcArcTangentArc(CenterArc((0, 0, 1), 5, 0, 360), end_arc, radius) + + # Coincidence (already tangent) + with self.assertRaises(ValueError): + ArcArcTangentArc(start_arc, CenterArc((0, 2 * start_r), start_r, 0, 360), 3) + + with self.assertRaises(ValueError): + ArcArcTangentArc(start_arc, CenterArc(start_point, start_r, 0, 360), 3) + + with self.assertRaises(ValueError): + ArcArcTangentArc( + start_arc, CenterArc((0, end_r - start_r), end_r, 0, 360), 3 + ) + + ## Spot check all conditions + r1, r2 = 3, 8 + start_center = (0, 0) + start_arc = CenterArc(start_center, r1, 0, 360) + + end_y = { + "no_overlap": (r1 + r2) * 1.1, + "partial_overlap": (r1 + r2) / 2, + "full_overlap": (r2 - r1) * 0.9, + } + + # Test matrix: + # (separation, keep pair, [min_limit, max_limit]) + # actual limit will be (separation + min_limit) / 2 + cases = [ + (end_y["no_overlap"], (Keep.INSIDE, Keep.INSIDE), [r1 - r2, None]), + (end_y["no_overlap"], (Keep.OUTSIDE, Keep.INSIDE), [-r1 + r2, None]), + (end_y["no_overlap"], (Keep.INSIDE, Keep.OUTSIDE), [r1 + r2, None]), + (end_y["no_overlap"], (Keep.OUTSIDE, Keep.OUTSIDE), [-r1 - r2, None]), + (end_y["partial_overlap"], (Keep.INSIDE, Keep.INSIDE), [None, r1 - r2]), + (end_y["partial_overlap"], (Keep.OUTSIDE, Keep.INSIDE), [None, -r1 + r2]), + (end_y["partial_overlap"], (Keep.BOTH, Keep.INSIDE), [None, r1 + r2]), + (end_y["partial_overlap"], (Keep.INSIDE, Keep.OUTSIDE), [r1 + r2, None]), + (end_y["partial_overlap"], (Keep.OUTSIDE, Keep.OUTSIDE), [None, None]), + (end_y["full_overlap"], (Keep.INSIDE, Keep.INSIDE), [r1 + r2, r1 + r2]), + (end_y["full_overlap"], (Keep.OUTSIDE, Keep.INSIDE), [-r1 + r2, -r1 + r2]), + ] + + # Check min and max radii, tangency + for case in cases: + end_center = (0, case[0]) + end_arc = CenterArc(end_center, r2, 0, 360) + + flip_max = -1 if case[1] == (Keep.BOTH, Keep.INSIDE) else 1 + flip_min = -1 if case[0] == end_y["full_overlap"] else 1 + + 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) + + # Greater than min + l1 = ArcArcTangentArc(start_arc, end_arc, min_r + 0.01, keep=case[1]) + _, p1, p2 = start_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + _, p1, p2 = end_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + + # Less than max + l1 = ArcArcTangentArc(start_arc, end_arc, max_r - 0.01, keep=case[1]) + _, p1, p2 = start_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + _, p1, p2 = end_arc.distance_to_with_closest_points(l1) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertAlmostEqual( + end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5 + ) + + # Less than min + with self.assertRaises(ValueError): + ArcArcTangentArc(start_arc, end_arc, min_r * 0.99, keep=case[1]) + + # Greater than max + if max_r != 1e6: + with self.assertRaises(ValueError): + ArcArcTangentArc(start_arc, end_arc, max_r + 0.01, keep=case[1]) + def test_line_with_list(self): """Test line with a list of points""" l = Line([(0, 0), (10, 0)]) diff --git a/tests/test_build_part.py b/tests/test_build_part.py index 0ebc40d..d5dd6c7 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -28,6 +28,8 @@ license: import unittest from math import pi, sin +from unittest.mock import MagicMock, patch, PropertyMock + from build123d import * from build123d import LocationList, WorkplaneList @@ -56,7 +58,6 @@ class TestAlign(unittest.TestCase): class TestMakeBrakeFormed(unittest.TestCase): def test_make_brake_formed(self): - # TODO: Fix so this test doesn't raise a DeprecationWarning from NumPy with BuildPart() as bp: with BuildLine() as bl: Polyline((0, 0), (5, 6), (10, 1)) @@ -71,6 +72,67 @@ class TestMakeBrakeFormed(unittest.TestCase): self.assertAlmostEqual(sheet_metal.bounding_box().max.Z, 1, 2) +class TestPartOperationDraft(unittest.TestCase): + + def setUp(self): + self.box = Box(10, 10, 10).solid() + self.sides = self.box.faces().filter_by(Axis.Z, reverse=True) + self.bottom_face = self.box.faces().sort_by(Axis.Z)[0] + self.neutral_plane = Plane(self.bottom_face) + + def test_successful_draft(self): + """Test that a draft operation completes successfully""" + result = draft(self.sides, self.neutral_plane, 5) + self.assertIsInstance(result, Part) + self.assertLess(self.box.volume, result.volume) + + with BuildPart() as draft_box: + Box(10, 10, 10) + draft( + draft_box.faces().filter_by(Axis.Z, reverse=True), + Plane.XY.offset(-5), + 5, + ) + self.assertLess(draft_box.part.volume, 1000) + + def test_invalid_face_type(self): + """Test that a ValueError is raised for unsupported face types""" + torus = Torus(5, 1).solid() + with self.assertRaises(ValueError) as cm: + draft([torus.faces()[0]], self.neutral_plane, 5) + + def test_faces_from_multiple_solids(self): + """Test that using faces from different solids raises an error""" + box2 = Box(5, 5, 5).solid() + mixed = [self.sides[0], box2.faces()[0]] + with self.assertRaises(ValueError) as cm: + draft(mixed, self.neutral_plane, 5) + self.assertIn("same topological parent", str(cm.exception)) + + def test_faces_from_multiple_parts(self): + """Test that using faces from different solids raises an error""" + box2 = Box(5, 5, 5).solid() + part: Part = Part() + [self.box, Pos(X=10) * box2] + mixed = [part.faces().sort_by(Axis.X)[0], part.faces().sort_by(Axis.X)[-1]] + with self.assertRaises(ValueError) as cm: + draft(mixed, self.neutral_plane, 5) + + def test_bad_draft_faces(self): + with self.assertRaises(DraftAngleError): + draft(self.bottom_face, self.neutral_plane, 10) + + @patch("build123d.topology.three_d.BRepOffsetAPI_DraftAngle") + def test_draftangleerror_from_solid_draft(self, mock_draft_angle): + """Simulate a failure in AddDone and catch DraftAngleError""" + mock_builder = MagicMock() + mock_builder.AddDone.return_value = False + mock_builder.ProblematicShape.return_value = "ShapeX" + mock_draft_angle.return_value = mock_builder + + with self.assertRaises(DraftAngleError) as cm: + draft(self.sides, self.neutral_plane, 5) + + class TestBuildPart(unittest.TestCase): """Test the BuildPart Builder derived class""" @@ -171,7 +233,7 @@ class TestBuildPart(unittest.TestCase): def test_named_plane(self): with BuildPart(Plane.YZ) as test: self.assertTupleAlmostEquals( - WorkplaneList._get_context().workplanes[0].z_dir.to_tuple(), + WorkplaneList._get_context().workplanes[0].z_dir, (1, 0, 0), 5, ) @@ -268,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: @@ -349,6 +465,12 @@ class TestLoft(unittest.TestCase): test = loft(sections=[r.face(), v1], ruled=True) self.assertAlmostEqual(test.volume, 1, 5) + def test_loft_invalid_vertex(self): + lower_section = Face.make_rect(10, 10) - Face.make_rect(8, 8) + upper_section = Pos(Z=5) * lower_section + with self.assertRaises(ValueError): + loft([lower_section, Vertex(0, 0, 2.5), upper_section]) + def test_loft_no_sections_assert(self): with BuildPart() as test: with self.assertRaises(ValueError): @@ -370,6 +492,24 @@ class TestLoft(unittest.TestCase): with self.assertRaises(ValueError): loft(sections=[v1, v2, s.sketch]) + def test_loft_with_hole(self): + lower_section = Face.make_rect(10, 10) - Face.make_rect(8, 8) + upper_section = Pos(Z=5) * lower_section + loft_with_hole = loft([lower_section, upper_section]) + self.assertAlmostEqual(loft_with_hole.volume, 10 * 10 * 5 - 8 * 8 * 5, 5) + + def test_loft_with_two_holes(self): + lower_section = Text("B", font_size=10) + upper_section = Pos(Z=5) * lower_section + with self.assertRaises(ValueError): + loft([lower_section, upper_section]) + + def test_loft_with_inconsistent_holes(self): + lower_section = Text("B", font_size=10) + upper_section = Pos(Z=5) * Face.make_rect(10, 10) + with self.assertRaises(ValueError): + loft([lower_section, upper_section]) + class TestRevolve(unittest.TestCase): def test_simple_revolve(self): @@ -412,6 +552,37 @@ class TestRevolve(unittest.TestCase): self.assertLess(test.part.volume, 244 * pi * 20, 5) self.assertGreater(test.part.volume, 100 * pi * 20, 5) + def test_revolve_size(self): + """Verify revolution result matches revolution_arc size and direction""" + ax = Axis.X + profile = RegularPolygon(10, 4, align=(Align.CENTER, Align.MIN)) + full_volume = revolve(profile, ax, 360).volume + sizes = [30, 90, 150, 180, 200, 360, 500, 720, 750] + sizes = [x * -1 for x in sizes[::-1]] + [0] + sizes + for size in sizes: + solid = revolve(profile, axis=ax, revolution_arc=size) + + # Create a rotation edge and and the start tangent normal to the profile + edge = Edge.make_circle( + 1, + Plane.YZ, + 0, + size % 360, + ( + AngularDirection.COUNTER_CLOCKWISE + if size > 0 + else AngularDirection.CLOCKWISE + ), + ) + sign = (edge % 0).Z + + expected = size % (sign * 360) + expected = sign * 360 if expected == 0 else expected + result = edge.length / edge.radius / pi * 180 * sign + + self.assertAlmostEqual(solid.volume, full_volume * abs(expected) / 360) + self.assertAlmostEqual(expected, result) + # Invalid test # def test_invalid_axis_origin(self): # with BuildPart(): @@ -442,6 +613,12 @@ class TestSection(unittest.TestCase): s = section(section_by=Plane.XZ) self.assertAlmostEqual(s.area, 100 * pi, 5) + def test_moved_object(self): + sec = section(Pos(-100, 100) * Sphere(10), Plane.XY) + self.assertEqual(len(sec.faces()), 1) + self.assertAlmostEqual(sec.face().edge().radius, 10, 5) + self.assertAlmostEqual(sec.face().center(), (-100, 100, 0), 5) + class TestSplit(unittest.TestCase): def test_split(self): @@ -481,10 +658,10 @@ class TestThicken(unittest.TestCase): outer_sphere = thicken(non_planar, amount=0.1) self.assertAlmostEqual(outer_sphere.volume, (4 / 3) * pi * (1.1**3 - 1**3), 5) - wire = JernArc((0, 0), (-1, 0), 1, 180).edge().reversed() + JernArc( - (0, 0), (1, 0), 2, -90 - ) - part = thicken(sweep((wire ^ 0) * RadiusArc((0, 0), (0, -1), 1), wire), 0.4) + wire = JernArc((0, -2), (-1, 0), 1, -180) + JernArc((0, 0), (1, 0), 2, -90) + + surface = sweep((wire ^ 0) * RadiusArc((0, 0), (0, -1), 1), wire) + part = thicken(surface, 0.4) self.assertAlmostEqual(part.volume, 2.241583787221904, 5) part = thicken( diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index 205bebf..c00a504 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -92,9 +92,7 @@ class TestBuildSketch(unittest.TestCase): with BuildLine(): l1 = Line((0, 0), (10, 0)) Line(l1 @ 1, (10, 10)) - self.assertTupleAlmostEquals( - (test.consolidate_edges() @ 1).to_tuple(), (10, 10, 0), 5 - ) + self.assertTupleAlmostEquals(test.consolidate_edges() @ 1, (10, 10, 0), 5) def test_mode_intersect(self): with BuildSketch() as test: @@ -224,6 +222,11 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertAlmostEqual(test.sketch.area, 0.5, 5) self.assertEqual(p.faces()[0].normal_at(), Vector(0, 0, 1)) + # test iterable input + points_nervure = [(0.0, 0.0), (10.0, 0.0), (0.0, 5.0)] + riri = Polygon(points_nervure, align=Align.NONE) + self.assertEqual(len(riri.vertices()), 3) + def test_rectangle(self): with BuildSketch() as test: r = Rectangle(20, 10) @@ -263,9 +266,7 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertEqual(r.align, (Align.CENTER, Align.CENTER)) self.assertEqual(r.mode, Mode.ADD) self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 2) * 2**2, 5) - self.assertTupleAlmostEquals( - test.sketch.faces()[0].normal_at().to_tuple(), (0, 0, 1), 5 - ) + self.assertTupleAlmostEquals(test.sketch.faces()[0].normal_at(), (0, 0, 1), 5) self.assertAlmostEqual(r.apothem, 2 * sqrt(3) / 2) def test_regular_polygon_minor_radius(self): @@ -277,9 +278,7 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertEqual(r.align, (Align.CENTER, Align.CENTER)) self.assertEqual(r.mode, Mode.ADD) self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 4) * (0.5 * 2) ** 2, 5) - self.assertTupleAlmostEquals( - test.sketch.faces()[0].normal_at().to_tuple(), (0, 0, 1), 5 - ) + self.assertTupleAlmostEquals(test.sketch.faces()[0].normal_at(), (0, 0, 1), 5) def test_regular_polygon_align(self): with BuildSketch() as align: @@ -303,7 +302,7 @@ class TestBuildSketchObjects(unittest.TestCase): poly_pts = [Vector(v) for v in regular_poly.vertices()] polar_pts = [p.position for p in PolarLocations(1, side_count)] for poly_pt, polar_pt in zip(poly_pts, polar_pts): - self.assertTupleAlmostEquals(poly_pt.to_tuple(), polar_pt.to_tuple(), 5) + self.assertTupleAlmostEquals(poly_pt, polar_pt, 5) def test_regular_polygon_min_sides(self): with self.assertRaises(ValueError): @@ -325,8 +324,8 @@ class TestBuildSketchObjects(unittest.TestCase): def test_slot_center_point(self): with BuildSketch() as test: s = SlotCenterPoint((0, 0), (2, 0), 2) - self.assertTupleAlmostEquals(s.slot_center.to_tuple(), (0, 0, 0), 5) - self.assertTupleAlmostEquals(s.point.to_tuple(), (2, 0, 0), 5) + self.assertTupleAlmostEquals(s.slot_center, (0, 0, 0), 5) + self.assertTupleAlmostEquals(s.point, (2, 0, 0), 5) self.assertEqual(s.slot_height, 2) self.assertEqual(s.rotation, 0) self.assertEqual(s.mode, Mode.ADD) @@ -334,25 +333,39 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1)) def test_slot_center_to_center(self): + height = 2 with BuildSketch() as test: - s = SlotCenterToCenter(4, 2) + s = SlotCenterToCenter(4, height) self.assertEqual(s.center_separation, 4) - self.assertEqual(s.slot_height, 2) + self.assertEqual(s.slot_height, height) self.assertEqual(s.rotation, 0) self.assertEqual(s.mode, Mode.ADD) - self.assertAlmostEqual(test.sketch.area, pi + 4 * 2, 5) + self.assertAlmostEqual(test.sketch.area, pi + 4 * height, 5) self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1)) + # Circle degenerate + s1 = SlotCenterToCenter(0, height) + self.assertTrue(len(s1.edges()) == 1) + self.assertEqual(s1.edge().geom_type, GeomType.CIRCLE) + self.assertAlmostEqual(s1.edge().radius, height / 2) + def test_slot_overall(self): + height = 2 with BuildSketch() as test: - s = SlotOverall(6, 2) + s = SlotOverall(6, height) self.assertEqual(s.width, 6) - self.assertEqual(s.slot_height, 2) + self.assertEqual(s.slot_height, height) self.assertEqual(s.rotation, 0) self.assertEqual(s.mode, Mode.ADD) - self.assertAlmostEqual(test.sketch.area, pi + 4 * 2, 5) + self.assertAlmostEqual(test.sketch.area, pi + 4 * height, 5) self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1)) + # Circle degenerat + s1 = SlotOverall(2, height) + self.assertTrue(len(s1.edges()) == 1) + self.assertEqual(s1.edge().geom_type, GeomType.CIRCLE) + self.assertAlmostEqual(s1.edge().radius, height / 2) + def test_text(self): with BuildSketch() as test: t = Text("test", 2) @@ -361,7 +374,8 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertEqual(t.font, "Arial") self.assertIsNone(t.font_path) self.assertEqual(t.font_style, FontStyle.REGULAR) - self.assertEqual(t.align, (Align.CENTER, Align.CENTER)) + self.assertEqual(t.text_align, (TextAlign.CENTER, TextAlign.CENTER)) + self.assertIsNone(t.align) self.assertIsNone(t.text_path) self.assertEqual(t.position_on_path, 0) self.assertEqual(t.rotation, 0) @@ -369,6 +383,12 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertEqual(len(test.sketch.faces()), 4) self.assertEqual(t.faces()[0].normal_at(), Vector(0, 0, 1)) + with self.assertRaises(ValueError): + Text("test", 2, text_align=(TextAlign.BOTTOM, TextAlign.BOTTOM)) + + with self.assertRaises(ValueError): + Text("test", 2, text_align=(TextAlign.LEFT, TextAlign.LEFT)) + def test_trapezoid(self): with BuildSketch() as test: t = Trapezoid(6, 2, 63.434948823) @@ -412,6 +432,9 @@ class TestBuildSketchObjects(unittest.TestCase): self.assertTupleAlmostEquals(tri.vertex_A, (3, 4, 0), 5) self.assertTupleAlmostEquals(tri.vertex_B, (0, 0, 0), 5) self.assertTupleAlmostEquals(tri.vertex_C, (3, 0, 0), 5) + self.assertEqual(tri.vertex_A.topo_parent, tri) + self.assertEqual(tri.vertex_B.topo_parent, tri) + self.assertEqual(tri.vertex_C.topo_parent, tri) tri = Triangle(c=5, C=90, a=3) self.assertAlmostEqual(tri.area, (3 * 4) / 2, 5) @@ -494,27 +517,41 @@ class TestBuildSketchObjects(unittest.TestCase): with self.assertRaises(ValueError): full_round(trap.edges().sort_by(Axis.X)[-1]) + with self.assertRaises(ValueError): + full_round(Edge.make_line((0, 0), (1, 0))) + l1 = Edge.make_spline([(-1, 0), (1, 0)], tangents=((0, -8), (0, 8)), scale=True) l2 = Edge.make_line(l1 @ 0, l1 @ 1) face = Face(Wire([l1, l2])) with self.assertRaises(ValueError): full_round(face.edges()[0]) - positive, c1, r1 = full_round(trap.edges().sort_by(SortBy.LENGTH)[0]) - negative, c2, r2 = full_round( - trap.edges().sort_by(SortBy.LENGTH)[0], invert=True - ) - self.assertLess(negative.area, positive.area) - self.assertAlmostEqual(r1, r2, 2) - self.assertTupleAlmostEquals(tuple(c1), tuple(c2), 2) + positive = full_round(trap.edges().sort_by(SortBy.LENGTH)[0]) + negative = full_round(trap.edges().sort_by(SortBy.LENGTH)[0], invert=True) + self.assertLess(negative.face().area, positive.face().area) + + rect = Rectangle(34, 10) + convex_rect = full_round((rect.edges() << Axis.X)[0]) + concave_rect = full_round((rect.edges() << Axis.X)[0], invert=True) + self.assertLess(convex_rect.area, rect.area) + self.assertLess(concave_rect.area, convex_rect.area) + + tri = Triangle(a=10, b=10, c=10) + tri_round = full_round(tri.edges().sort_by(Axis.X)[0]) + self.assertLess(tri_round.area, tri.area) + + # Test flipping the face + flipped = -Face.make_rect(34, 10) + rounded = full_round((flipped.edges() << Axis.X)[0]).face() + self.assertEqual(flipped.normal_at(), rounded.normal_at()) @pytest.mark.parametrize( "slot,args", [ - (SlotOverall, (5, 10)), + (SlotOverall, (9, 10)), (SlotCenterToCenter, (-1, 10)), - (SlotCenterPoint, ((0, 0, 0), (2, 0, 0), 10)), + (SlotCenterPoint, ((0, 0, 0), (0, 0, 0), 10)), ], ) def test_invalid_slots(slot, args): diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py index 7ac43be..71e862b 100644 --- a/tests/test_direct_api/test_assembly.py +++ b/tests/test_direct_api/test_assembly.py @@ -49,7 +49,7 @@ class TestAssembly(unittest.TestCase): actual_topo_lines = actual_topo.splitlines() self.assertEqual(len(actual_topo_lines), len(expected_topo_lines)) for actual_line, expected_line in zip(actual_topo_lines, expected_topo_lines): - start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, 2, re.I) + start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, maxsplit=2, flags=re.I) self.assertTrue(actual_line.startswith(start)) self.assertTrue(actual_line.endswith(end)) diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py index 49144c4..c0bbd46 100644 --- a/tests/test_direct_api/test_axis.py +++ b/tests/test_direct_api/test_axis.py @@ -33,7 +33,7 @@ import unittest import numpy as np from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt from build123d.geometry import Axis, Location, Plane, Vector -from build123d.topology import Edge +from build123d.topology import Edge, Vertex class AlwaysEqual: @@ -65,10 +65,18 @@ class TestAxis(unittest.TestCase): self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5) self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5) + with self.assertRaises(ValueError): + Axis("one") with self.assertRaises(ValueError): Axis("one", "up") with self.assertRaises(ValueError): Axis(one="up") + with self.assertRaises(ValueError): + bad_edge = Edge() + bad_edge.wrapped = Vertex(0, 1, 2).wrapped + Axis(edge=bad_edge) + with self.assertRaises(ValueError): + Axis(gp_ax1=Edge.make_line((0, 0), (1, 0))) def test_axis_from_occt(self): occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0)) @@ -100,11 +108,16 @@ class TestAxis(unittest.TestCase): self.assertAlmostEqual(y_axis.position, (0, 0, 1), 5) self.assertAlmostEqual(y_axis.direction, (0, 1, 0), 5) - def test_axis_to_plane(self): - x_plane = Axis.X.to_plane() - self.assertTrue(isinstance(x_plane, Plane)) - self.assertAlmostEqual(x_plane.origin, (0, 0, 0), 5) - self.assertAlmostEqual(x_plane.z_dir, (1, 0, 0), 5) + def test_from_location(self): + axis = Axis(Location((1, 2, 3), (-90, 0, 0))) + self.assertAlmostEqual(axis.position, (1, 2, 3), 6) + self.assertAlmostEqual(axis.direction, (0, 1, 0), 6) + + # def test_axis_to_plane(self): + # x_plane = Axis.X.to_plane() + # self.assertTrue(isinstance(x_plane, Plane)) + # self.assertAlmostEqual(x_plane.origin, (0, 0, 0), 5) + # self.assertAlmostEqual(x_plane.z_dir, (1, 0, 0), 5) def test_axis_is_coaxial(self): self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0)))) @@ -179,7 +192,7 @@ class TestAxis(unittest.TestCase): self.assertIsNone(Axis.X.intersect(Axis((0, 1, 1), (0, 0, 1)))) intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY - self.assertAlmostEqual(intersection.to_tuple(), (1, 2, 0), 5) + self.assertAlmostEqual(intersection, (1, 2, 0), 5) arc = Edge.make_circle(20, start_angle=0, end_angle=180) ax0 = Axis((-20, 30, 0), (4, -3, 0)) @@ -213,10 +226,10 @@ class TestAxis(unittest.TestCase): # self.assertTrue(len(intersections.vertices(), 2)) # np.testing.assert_allclose( - # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5 + # intersection.vertices()[0], (-1, 0, 5), 5 # ) # np.testing.assert_allclose( - # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5 + # intersection.vertices()[1], (1, 0, 5), 5 # ) def test_axis_equal(self): @@ -230,6 +243,40 @@ class TestAxis(unittest.TestCase): random_obj = object() self.assertNotEqual(Axis.X, random_obj) + def test_set(self): + a0 = Axis((0, 1, 2), (3, 4, 5)) + for i in range(1, 8): + for j in range(1, 8): + a1 = Axis( + (a0.position.X + 1.0 / (10**i), a0.position.Y, a0.position.Z), + (a0.direction.X + 1.0 / (10**j), a0.direction.Y, a0.direction.Z), + ) + if a0 == a1: + self.assertEqual(len(set([a0, a1])), 1) + else: + self.assertEqual(len(set([a0, a1])), 2) + + def test_position_property(self): + axis = Axis.X + axis.position = 1, 2, 3 + self.assertAlmostEqual(axis.position, (1, 2, 3)) + + axis.position += 1, 2, 3 + self.assertAlmostEqual(axis.position, (2, 4, 6)) + + self.assertAlmostEqual(Axis(axis.wrapped).position, (2, 4, 6)) + + def test_direction_property(self): + axis = Axis.X + axis.direction = 1, 2, 3 + self.assertAlmostEqual(axis.direction, Vector(1, 2, 3).normalized()) + + axis.direction += 5, 3, 1 + expected = (Vector(1, 2, 3).normalized() + Vector(5, 3, 1)).normalized() + self.assertAlmostEqual(axis.direction, expected) + + self.assertAlmostEqual(Axis(axis.wrapped).direction, expected) + if __name__ == "__main__": unittest.main() 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)) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 6bd6b8f..9e50a8a 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -26,100 +26,296 @@ license: """ +import colorsys import copy -import unittest - +import math import numpy as np +import pytest + +from OCP.Quantity import Quantity_ColorRGBA from build123d.geometry import Color -class TestColor(unittest.TestCase): - def test_name1(self): - c = Color("blue") - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 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) - - def test_name3(self): - c = Color("blue", 0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) - - def test_rgb0(self): - c = Color(0.0, 1.0, 0.0) - np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 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) - - 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) - - 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) - - def test_bad_color_name(self): - with self.assertRaises(ValueError): - Color("build123d") - - 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_hex(self): - c = Color(0x996692) - np.testing.assert_allclose( - tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5 - ) - - c = Color(0x006692, 0x80) - np.testing.assert_allclose( - tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5 - ) - - c = Color(0x006692, alpha=0x80) - np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5) - - c = Color(color_code=0x996692, alpha=0xCC) - np.testing.assert_allclose( - tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5 - ) - - c = Color(0.0, 0.0, 1.0, 1.0) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) - - c = Color(0, 0, 1, 1) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 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) ~ RED") - self.assertEqual(repr(c), "Color(1.0, 0.0, 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_tuple=(0.1, 0.2, 0.3, 0.4)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 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) -if __name__ == "__main__": - unittest.main() +@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", + ), + ], +) +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"), + ], +) +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", 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.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, 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"), + ], +) +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.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) + 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 + + +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) diff --git a/tests/test_direct_api/test_compound.py b/tests/test_direct_api/test_compound.py index e4eb6f2..9f93460 100644 --- a/tests/test_direct_api/test_compound.py +++ b/tests/test_direct_api/test_compound.py @@ -51,10 +51,10 @@ class TestCompound(unittest.TestCase): box1 = Solid.make_box(1, 1, 1) box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0))) combined = Compound([box1]).fuse(box2, glue=True) - self.assertTrue(combined.is_valid()) + self.assertTrue(combined.is_valid) self.assertAlmostEqual(combined.volume, 2, 5) fuzzy = Compound([box1]).fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) + self.assertTrue(fuzzy.is_valid) self.assertAlmostEqual(fuzzy.volume, 2, 5) def test_remove(self): diff --git a/tests/test_direct_api/test_constrained_arcs.py b/tests/test_direct_api/test_constrained_arcs.py new file mode 100644 index 0000000..3eaab09 --- /dev/null +++ b/tests/test_direct_api/test_constrained_arcs.py @@ -0,0 +1,517 @@ +""" +build123d tests + +name: test_constrained_arcs.py +by: Gumyr +date: September 12, 2025 + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import pytest +from build123d.objects_curve import ( + CenterArc, + Line, + PolarLine, + JernArc, + IntersectingLine, + ThreePointArc, +) +from build123d.operations_generic import mirror +from build123d.topology import ( + Edge, + Face, + Solid, + Vertex, + Wire, + topo_explore_common_vertex, +) +from build123d.geometry import Axis, Plane, Vector, TOLERANCE +from build123d.build_enums import Tangency, Sagitta, LengthMode +from build123d.topology.constrained_lines import ( + _as_gcc_arg, + _param_in_trim, + _edge_to_qualified_2d, + _two_arc_edges_from_params, +) +from OCP.gp import gp_Ax2d, gp_Dir2d, gp_Circ2d, gp_Pnt2d + + +def test_edge_to_qualified_2d(): + e = Line((0, 0), (1, 0)) + e.position += (1, 1, 1) + qc, curve_2d, first, last, adaptor = _edge_to_qualified_2d( + e.wrapped, Tangency.UNQUALIFIED + ) + assert first < last + + +def test_two_arc_edges_from_params(): + circle = gp_Circ2d(gp_Ax2d(gp_Pnt2d(0, 0), gp_Dir2d(1.0, 0.0)), 1) + arcs = _two_arc_edges_from_params(circle, 0, TOLERANCE / 10) + assert len(arcs) == 0 + + +def test_param_in_trim(): + with pytest.raises(TypeError) as excinfo: + _param_in_trim(None, 0.0, 1.0, None) + assert "Invalid parameters to _param_in_trim" in str(excinfo.value) + + +def test_as_gcc_arg(): + e = Line((0, 0), (1, 0)) + e.wrapped = None + with pytest.raises(TypeError) as excinfo: + _as_gcc_arg(e, Tangency.UNQUALIFIED) + assert "Can't create a qualified curve from empty edge" in str(excinfo.value) + + +def test_constrained_arcs_arg_processing(): + """Test input error handling""" + with pytest.raises(TypeError): + Edge.make_constrained_arcs(Solid.make_box(1, 1, 1), (1, 0), radius=0.5) + with pytest.raises(TypeError): + Edge.make_constrained_arcs( + (Vector(0, 0), Tangency.UNQUALIFIED), (1, 0), radius=0.5 + ) + with pytest.raises(TypeError): + Edge.make_constrained_arcs(pnt1=(1, 1, 1), pnt2=(1, 0), radius=0.5) + with pytest.raises(TypeError): + Edge.make_constrained_arcs(radius=0.1) + with pytest.raises(ValueError): + Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5, center=(0, 0.25)) + with pytest.raises(ValueError): + Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=-0.5) + + +def test_tan2_rad_arcs_1(): + """2 edges & radius""" + e1 = Line((-2, 0), (2, 0)) + e2 = Line((0, -2), (0, 2)) + + tan2_rad_edges = Edge.make_constrained_arcs( + e1, e2, radius=0.5, sagitta=Sagitta.BOTH + ) + assert len(tan2_rad_edges) == 8 + + tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5) + assert len(tan2_rad_edges) == 4 + + tan2_rad_edges = Edge.make_constrained_arcs( + (e1, Tangency.UNQUALIFIED), (e2, Tangency.UNQUALIFIED), radius=0.5 + ) + assert len(tan2_rad_edges) == 4 + + +def test_tan2_rad_arcs_2(): + """2 edges & radius""" + e1 = CenterArc((0, 0), 1, 0, 90) + e2 = Line((1, 0), (2, 0)) + + tan2_rad_edges = Edge.make_constrained_arcs(e1, e2, radius=0.5) + assert len(tan2_rad_edges) == 1 + + +def test_tan2_rad_arcs_3(): + """2 points & radius""" + tan2_rad_edges = Edge.make_constrained_arcs((0, 0), (0, 0.5), radius=0.5) + assert len(tan2_rad_edges) == 2 + + tan2_rad_edges = Edge.make_constrained_arcs( + Vertex(0, 0), Vertex(0, 0.5), radius=0.5 + ) + assert len(tan2_rad_edges) == 2 + + tan2_rad_edges = Edge.make_constrained_arcs( + Vector(0, 0), Vector(0, 0.5), radius=0.5 + ) + assert len(tan2_rad_edges) == 2 + + +def test_tan2_rad_arcs_4(): + """edge & 1 points & radius""" + # the point should be automatically moved after the edge + e1 = Line((0, 0), (1, 0)) + tan2_rad_edges = Edge.make_constrained_arcs((0, 0.5), e1, radius=0.5) + assert len(tan2_rad_edges) == 1 + + +def test_tan2_rad_arcs_5(): + """no solution""" + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs((0, 0), (10, 0), radius=2) + assert "Unable to find a tangent arc" in str(excinfo.value) + + +def test_tan2_center_on_1(): + """2 tangents & center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + c2 = Line((4, -2), (4, 2)) + c3_center_on = Line((3, -2), (3, 2)) + tan2_on_edge = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=c3_center_on, + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_2(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + (0, 3), (5, 0), center_on=Line((0, -5), (0, 5)) + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_3(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), (5, 0), center_on=Line((0, -5), (0, 5)) + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_4(): + """2 tangents & center on""" + tan2_on_edge = Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), (5, 0), center_on=Axis.Y + ) + assert len(tan2_on_edge) == 1 + + +def test_tan2_center_on_5(): + """2 tangents & center on""" + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs( + Line((-5, 3), (5, 3)), + Line((-5, 0), (5, 0)), + center_on=Line((-5, -1), (5, -1)), + ) + assert "Unable to find a tangent arc with center_on constraint" in str( + excinfo.value + ) + + +def test_tan2_center_on_6(): + """2 tangents & center on""" + l1 = Line((0, 0), (5, 0)) + l2 = Line((0, 0), (0, 5)) + l3 = Line((20, 20), (22, 22)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(l1, l2, center_on=l3) + assert "Unable to find a tangent arc with center_on constraint" in str( + excinfo.value + ) + + +# --- Sagitta selection branches --- + + +def test_tan2_center_on_sagitta_both_returns_two_arcs(): + """ + TWO lines, center_on a line that crosses *both* angle bisectors → multiple + circle solutions; with Sagitta.BOTH we should get 2 arcs per solution. + Setup: x-axis & y-axis; center_on y=1. + """ + c1 = Line((-10, 0), (10, 0)) # y = 0 + c2 = Line((0, -10), (0, 10)) # x = 0 + center_on = Line((-10, 1), (10, 1)) # y = 1 + + arcs = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.BOTH, + ) + # Expect 2 solutions (centers at (1,1) and (-1,1)), each yielding 2 arcs → 4 + assert len(arcs) >= 2 # be permissive across kernels; typically 4 + # At least confirms BOTH path is covered and multiple solutions iterate + + +def test_tan2_center_on_sagitta_long_is_longer_than_short(): + """ + Verify LONG branch by comparing lengths against SHORT for the same geometry. + """ + c1 = Line((-10, 0), (10, 0)) # y = 0 + c2 = Line((0, -10), (0, 10)) # x = 0 + center_on = Line((3, -10), (3, 10)) # x = 3 (unique center) + + short_arc = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + long_arc = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.LONG, + ) + assert len(short_arc) == 2 + assert len(long_arc) == 2 + assert long_arc[0].length > short_arc[0].length + + +# --- Filtering branches inside the Solutions loop --- + + +def test_tan2_center_on_filters_outside_first_tangent_segment(): + """ + Cause _ok(0, u_arg1) to fail: + - First tangency is a *very short* horizontal segment near x∈[0, 0.01]. + - Second tangency is a vertical line far away. + - Center_on is x=5 (vertical). + The resulting tangency on the infinite horizontal line occurs near x≈center.x (≈5), + which lies *outside* the trimmed first segment → filtered out, no arcs. + """ + tiny_first = Line((0.0, 0.0), (0.01, 0.0)) # very short horizontal + c2 = Line((10.0, -10.0), (10.0, 10.0)) # vertical line + center_on = Line((5.0, -10.0), (5.0, 10.0)) # x = 5 + + arcs = Edge.make_constrained_arcs( + (tiny_first, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + # GCC likely finds solutions, but they should be filtered out by _ok(0) + assert len(arcs) == 0 + + +def test_tan2_center_on_filters_outside_second_tangent_segment(): + """ + Cause _ok(1, u_arg2) to fail: + - First tangency is a *point* (so _ok(0) is trivially True). + - Second tangency is a *very short* vertical segment around y≈0 on x=10. + - Center_on is y=2 (horizontal), and first point is at (0,2). + For a circle through (0,2) and tangent to x=10 with center_on y=2, + the center is at (5,2), radius=5, so tangency on x=10 occurs at y=2, + which is *outside* the tiny segment around y≈0 → filtered by _ok(1). + """ + first_point = (0.0, 2.0) # acts as a "point object" + tiny_second = Line((10.0, -0.005), (10.0, 0.005)) # very short vertical near y=0 + center_on = Line((-10.0, 2.0), (10.0, 2.0)) # y = 2 + + arcs = Edge.make_constrained_arcs( + first_point, + (tiny_second, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.SHORT, + ) + assert len(arcs) == 0 + + +# --- Multiple-solution loop coverage with BOTH again (robust geometry) --- + + +def test_tan2_center_on_multiple_solutions_both_counts(): + """ + Another geometry with 2+ GCC solutions: + c1: y=0, c2: y=4 (two non-intersecting parallels), center_on x=0. + Any circle tangent to both has radius=2 and center on y=2; with center_on x=0, + the center fixes at (0,2) — single center → two arcs (BOTH). + Use intersecting lines instead to guarantee >1 solutions: c1: y=0, c2: x=0, + center_on y=-2 (intersects both angle bisectors at (-2,-2) and (2,-2)). + """ + c1 = Line((-20, 0), (20, 0)) # y = 0 + c2 = Line((0, -20), (0, 20)) # x = 0 + center_on = Line((-20, -2), (20, -2)) # y = -2 + + arcs = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), + (c2, Tangency.UNQUALIFIED), + center_on=center_on, + sagitta=Sagitta.BOTH, + ) + # Expect at least 2 arcs (often 4); asserts loop over multiple i values + assert len(arcs) >= 2 + + +def test_tan_center_on_1(): + """1 tangent & center on""" + c5 = PolarLine((0, 0), 4, 60) + tan_center = Edge.make_constrained_arcs((c5, Tangency.UNQUALIFIED), center=(2, 1)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed + + +def test_tan_center_on_2(): + """1 tangent & center on""" + tan_center = Edge.make_constrained_arcs(Axis.X, center=(2, 1, 5)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed + + +def test_tan_center_on_3(): + """1 tangent & center on""" + l1 = CenterArc((0, 0), 1, 180, 5) + tan_center = Edge.make_constrained_arcs(l1, center=(2, 0)) + assert len(tan_center) == 1 + assert tan_center[0].is_closed + + +def test_pnt_center_1(): + """pnt & center""" + pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=(-2, 1)) + assert len(pnt_center) == 1 + assert pnt_center[0].is_closed + + pnt_center = Edge.make_constrained_arcs((-2.5, 1.5), center=Vertex(-2, 1)) + assert len(pnt_center) == 1 + assert pnt_center[0].is_closed + + +def test_tan_cen_arcs_center_equals_point_returns_empty(): + """ + If the fixed center coincides with the tangency point, + the computed radius is zero and no valid circle exists. + Function should return an empty ShapeList. + """ + center = (0, 0) + tangency_point = (0, 0) # same as center + + arcs = Edge.make_constrained_arcs(tangency_point, center=center) + + assert isinstance(arcs, list) # ShapeList subclass + assert len(arcs) == 0 + + +def test_tan_rad_center_on_1(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + c3_center_on = Line((3, -2), (3, 2)) + tan_rad_on = Edge.make_constrained_arcs( + (c1, Tangency.UNQUALIFIED), radius=1, center_on=c3_center_on + ) + assert len(tan_rad_on) == 1 + assert tan_rad_on[0].is_closed + + +def test_tan_rad_center_on_2(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + tan_rad_on = Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X) + assert len(tan_rad_on) == 1 + assert tan_rad_on[0].is_closed + + +def test_tan_rad_center_on_3(): + """tangent, radius, center on""" + c1 = PolarLine((0, 0), 4, -20, length_mode=LengthMode.HORIZONTAL) + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_arcs(c1, radius=1, center_on=Face.make_rect(1, 1)) + + +def test_tan_rad_center_on_4(): + """tangent, radius, center on""" + c1 = Line((0, 10), (10, 10)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(c1, radius=1, center_on=Axis.X) + + +def test_tan3_1(): + """3 tangents""" + c5 = PolarLine((0, 0), 4, 60) + c6 = PolarLine((0, 0), 4, 40) + c7 = CenterArc((0, 0), 4, 0, 90) + tan3 = Edge.make_constrained_arcs( + (c5, Tangency.UNQUALIFIED), + (c6, Tangency.UNQUALIFIED), + (c7, Tangency.UNQUALIFIED), + ) + assert len(tan3) == 1 + assert not tan3[0].is_closed + + tan3b = Edge.make_constrained_arcs(c5, c6, c7, sagitta=Sagitta.BOTH) + assert len(tan3b) == 2 + + +def test_tan3_2(): + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs( + Line((0, 0), (0, 1)), + Line((0, 0), (1, 0)), + Line((0, 0), (0, -1)), + ) + assert "Unable to find a circle tangent to all three objects" in str(excinfo.value) + + +def test_tan3_3(): + l1 = Line((0, 0), (10, 0)) + l2 = Line((0, 2), (10, 2)) + l3 = Line((0, 5), (10, 5)) + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_arcs(l1, l2, l3) + assert "Unable to find a circle tangent to all three objects" in str(excinfo.value) + + +def test_tan3_4(): + l1 = Line((-1, 0), (-1, 2)) + l2 = Line((1, 0), (1, 2)) + l3 = Line((-1, 0), (-0.75, 0)) + tan3 = Edge.make_constrained_arcs(l1, l2, l3) + assert len(tan3) == 0 + + +def test_eggplant(): + """complex set of 4 arcs""" + r_left, r_right = 0.75, 1.0 + r_bottom, r_top = 6, 8 + con_circle_left = CenterArc((-2, 0), r_left, 0, 360) + con_circle_right = CenterArc((2, 0), r_right, 0, 360) + egg_bottom = Edge.make_constrained_arcs( + (con_circle_right, Tangency.OUTSIDE), + (con_circle_left, Tangency.OUTSIDE), + radius=r_bottom, + ).sort_by(Axis.Y)[0] + egg_top = Edge.make_constrained_arcs( + (con_circle_right, Tangency.ENCLOSING), + (con_circle_left, Tangency.ENCLOSING), + radius=r_top, + ).sort_by(Axis.Y)[-1] + egg_right = ThreePointArc( + egg_bottom.vertices().sort_by(Axis.X)[-1], + con_circle_right @ 0, + egg_top.vertices().sort_by(Axis.X)[-1], + ) + egg_left = ThreePointArc( + egg_bottom.vertices().sort_by(Axis.X)[0], + con_circle_left @ 0.5, + egg_top.vertices().sort_by(Axis.X)[0], + ) + + egg_plant = Wire([egg_left, egg_top, egg_right, egg_bottom]) + assert egg_plant.is_closed + egg_plant_edges = egg_plant.edges().sort_by(egg_plant) + common_vertex_cnt = sum( + topo_explore_common_vertex(egg_plant_edges[i], egg_plant_edges[(i + 1) % 4]) + is not None + for i in range(4) + ) + assert common_vertex_cnt == 4 + + # C1 continuity + assert all( + (egg_plant_edges[i] % 1 - egg_plant_edges[(i + 1) % 4] % 0).length < TOLERANCE + for i in range(4) + ) diff --git a/tests/test_direct_api/test_constrained_lines.py b/tests/test_direct_api/test_constrained_lines.py new file mode 100644 index 0000000..dc32dff --- /dev/null +++ b/tests/test_direct_api/test_constrained_lines.py @@ -0,0 +1,267 @@ +""" +build123d tests + +name: test_constrained_lines.py +by: Gumyr +date: October 8, 2025 + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import math +import pytest +from OCP.gp import gp_Pnt2d, gp_Dir2d, gp_Lin2d +from build123d import Edge, Axis, Vector, Tangency, Plane +from build123d.topology.constrained_lines import ( + _make_2tan_lines, + _make_tan_oriented_lines, + _edge_from_line, +) +from build123d.geometry import TOLERANCE + + +@pytest.fixture +def unit_circle() -> Edge: + """A simple unit circle centered at the origin on XY.""" + return Edge.make_circle(1.0, Plane.XY) + + +# --------------------------------------------------------------------------- +# utility tests +# --------------------------------------------------------------------------- + + +def test_edge_from_line(): + line = _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(1, 0)) + assert Edge(line).length == 1 + + with pytest.raises(RuntimeError) as excinfo: + _edge_from_line(gp_Pnt2d(0, 0), gp_Pnt2d(0, 0)) + assert "Failed to build edge from line contacts" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# _make_2tan_lines tests +# --------------------------------------------------------------------------- + + +def test_two_circles_tangents(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines(c1, c2, edge_factory=Edge) + # There should be 4 external/internal tangents + assert len(lines) in (4, 2) + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_two_constrained_circles_tangents1(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines((c1, Tangency.ENCLOSING), c2, edge_factory=Edge) + # There should be 2 external/internal tangents + assert len(lines) == 2 + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_two_constrained_circles_tangents2(unit_circle): + """Tangent lines between two separated circles should yield four results.""" + c1 = unit_circle + c2 = unit_circle.translate((3, 0, 0)) # displaced along X + lines = _make_2tan_lines( + (c1, Tangency.ENCLOSING), (c2, Tangency.ENCLOSING), edge_factory=Edge + ) + # There should be 1 external/external tangents + assert len(lines) == 1 + for ln in lines: + assert isinstance(ln, Edge) + # Tangent lines should not intersect the circle interior + dmin = c1.distance_to(ln) + assert dmin >= -1e-6 + + +def test_curve_and_point_tangent(unit_circle): + """A line tangent to a circle and passing through a point should exist.""" + pt = Vector(2.0, 0.0) + lines = _make_2tan_lines(unit_circle, pt, edge_factory=Edge) + assert len(lines) == 2 + for ln in lines: + # The line must pass through the given point (approximately) + dist_to_point = ln.distance_to(pt) + assert math.isclose(dist_to_point, 0.0, abs_tol=1e-6) + # It should also touch the circle at exactly one point + dist_to_circle = unit_circle.distance_to(ln) + assert math.isclose(dist_to_circle, 0.0, abs_tol=TOLERANCE) + + +def test_invalid_tangent_raises(unit_circle): + """Non-intersecting degenerate input result in no output.""" + lines = _make_2tan_lines(unit_circle, unit_circle, edge_factory=Edge) + assert len(lines) == 0 + + with pytest.raises(RuntimeError) as excinfo: + _make_2tan_lines(unit_circle, Vector(0, 0), edge_factory=Edge) + assert "Unable to find common tangent line(s)" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# _make_tan_oriented_lines tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("angle_deg", [math.radians(30), -math.radians(30)]) +def test_oriented_tangents_with_x_axis(unit_circle, angle_deg): + """Lines tangent to a circle at ±30° from the X-axis.""" + lines = _make_tan_oriented_lines(unit_circle, Axis.X, angle_deg, edge_factory=Edge) + assert all(isinstance(e, Edge) for e in lines) + # The tangent lines should all intersect the X axis (red line) + for ln in lines: + p = ln.position_at(0.5) + assert abs(p.Z) < 1e-9 + + lines = _make_tan_oriented_lines(unit_circle, Axis.X, 0, edge_factory=Edge) + assert len(lines) == 0 + + lines = _make_tan_oriented_lines( + unit_circle, Axis((0, -2), (1, 0)), 0, edge_factory=Edge + ) + assert len(lines) == 0 + + +def test_oriented_tangents_with_y_axis(unit_circle): + """Lines tangent to a circle and 30° from Y-axis should exist.""" + angle = math.radians(30) + lines = _make_tan_oriented_lines(unit_circle, Axis.Y, angle, edge_factory=Edge) + assert len(lines) >= 1 + # They should roughly touch the circle (tangent distance ≈ 0) + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_oriented_constrained_tangents_with_y_axis(unit_circle): + angle = math.radians(30) + lines = _make_tan_oriented_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, angle, edge_factory=Edge + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_invalid_oriented_tangent_raises(unit_circle): + """Non-intersecting degenerate input result in no output.""" + + with pytest.raises(ValueError) as excinfo: + _make_tan_oriented_lines(unit_circle, Axis.Z, 1, edge_factory=Edge) + assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value) + + with pytest.raises(ValueError) as excinfo: + _make_tan_oriented_lines( + unit_circle, Axis((1, 2, 3), (0, 0, -1)), 1, edge_factory=Edge + ) + assert "reference Axis can't be perpendicular to Plane.XY" in str(excinfo.value) + + +def test_invalid_oriented_tangent(unit_circle): + lines = _make_tan_oriented_lines( + unit_circle, Axis((1, 0), (0, 1)), 0, edge_factory=Edge + ) + assert len(lines) == 0 + + lines = _make_tan_oriented_lines( + unit_circle.translate((0, 1 + 1e-7)), Axis.X, 0, edge_factory=Edge + ) + assert len(lines) == 0 + + +def test_make_constrained_lines0(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, unit_circle.translate((3, 0, 0))) + assert len(lines) == 4 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines1(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, (3, 0)) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines3(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, Axis.X, angle=30) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + assert abs((ln @ 1).Y) < 1e-6 + + +def test_make_constrained_lines4(unit_circle): + lines = Edge.make_constrained_lines(unit_circle, Axis.Y, angle=30) + assert len(lines) == 2 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + assert abs((ln @ 1).X) < 1e-6 + + +def test_make_constrained_lines5(unit_circle): + lines = Edge.make_constrained_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, angle=30 + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines6(unit_circle): + lines = Edge.make_constrained_lines( + (unit_circle, Tangency.ENCLOSING), Axis.Y, direction=(1, 1) + ) + assert len(lines) == 1 + for ln in lines: + assert unit_circle.distance_to(ln) < 1e-6 + + +def test_make_constrained_lines_raises(unit_circle): + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle, Axis.Z, ref_angle=1) + assert "Unexpected argument(s): ref_angle" in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle) + assert "Provide exactly 2 tangency targets." in str(excinfo.value) + + with pytest.raises(RuntimeError) as excinfo: + Edge.make_constrained_lines(Axis.X, Axis.Y) + assert "Unable to find common tangent line(s)" in str(excinfo.value) + + with pytest.raises(TypeError) as excinfo: + Edge.make_constrained_lines(unit_circle, ("three", 0)) + assert "Invalid tangency:" in str(excinfo.value) diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py index 3eb2da5..6f06f68 100644 --- a/tests/test_direct_api/test_edge.py +++ b/tests/test_direct_api/test_edge.py @@ -27,14 +27,18 @@ license: """ import math +import numpy as np import unittest -from build123d.build_enums import AngularDirection, GeomType, Transition +from unittest.mock import patch, PropertyMock + +from build123d.build_enums import AngularDirection, GeomType, PositionMode, Transition from build123d.geometry import Axis, Plane, Vector from build123d.objects_curve import CenterArc, EllipticalCenterArc from build123d.objects_sketch import Circle, Rectangle, RegularPolygon from build123d.operations_generic import sweep -from build123d.topology import Edge +from build123d.topology import Edge, Face, Wire, Vertex +from OCP.GeomProjLib import GeomProjLib class TestEdge(unittest.TestCase): @@ -118,7 +122,7 @@ class TestEdge(unittest.TestCase): for end in [0, 1]: self.assertAlmostEqual( edge.position_at(end), - edge.to_wire().position_at(end), + Wire(edge).position_at(end), 5, ) @@ -157,6 +161,10 @@ class TestEdge(unittest.TestCase): with self.assertRaises(ValueError): line.find_intersection_points(Plane.YZ) + circle.wrapped = None + with self.assertRaises(ValueError): + circle.find_intersection_points(line) + # def test_intersections_tolerance(self): # Multiple operands not currently supported @@ -175,8 +183,27 @@ class TestEdge(unittest.TestCase): line = Edge.make_line((-2, 0), (2, 0)) self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5) self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5) + + l1 = CenterArc((0, 0), 1, 0, 180) + l2 = l1.trim(0, l1 @ 0.5) + self.assertAlmostEqual(l2 @ 0, (1, 0, 0), 5) + self.assertAlmostEqual(l2 @ 1, (0, 1, 0), 5) + + l3 = l1.trim((1, 0), (0, 1)) + self.assertAlmostEqual(l3 @ 0, (1, 0, 0), 5) + self.assertAlmostEqual(l3 @ 1, (0, 1, 0), 5) + + l4 = l1.trim(0.5, (-1, 0)) + self.assertAlmostEqual(l4 @ 0, (0, 1, 0), 5) + self.assertAlmostEqual(l4 @ 1, (-1, 0, 0), 5) + + l5 = l1.trim(0.5, Vertex(-1, 0)) + self.assertAlmostEqual(l5 @ 0, (0, 1, 0), 5) + self.assertAlmostEqual(l5 @ 1, (-1, 0, 0), 5) + + line.wrapped = None with self.assertRaises(ValueError): - line.trim(0.75, 0.25) + line.trim(0.1, 0.9) def test_trim_to_length(self): @@ -201,6 +228,14 @@ class TestEdge(unittest.TestCase): e4_trim = Edge(a4).trim_to_length(0.5, 2) self.assertAlmostEqual(e4_trim.length, 2, 5) + e5 = e1.trim_to_length((5, 5), 1) + self.assertAlmostEqual(e5 @ 0, (5, 5), 5) + self.assertAlmostEqual(e5.length, 1, 5) + + e1.wrapped = None + with self.assertRaises(ValueError): + e1.trim_to_length(0.1, 2) + def test_bezier(self): with self.assertRaises(ValueError): Edge.make_bezier((1, 1)) @@ -230,7 +265,7 @@ class TestEdge(unittest.TestCase): for i, loc in enumerate(locs): self.assertAlmostEqual( loc.position, - Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(), + Vector(1, 0, 0).rotate(Axis.Z, i * 90), 5, ) self.assertAlmostEqual(loc.orientation, (0, 0, 0), 5) @@ -269,6 +304,31 @@ class TestEdge(unittest.TestCase): with self.assertRaises(ValueError): edge.param_at_point((-1, 1)) + ea.wrapped = None + with self.assertRaises(ValueError): + ea.param_at_point((15, 5)) + + def test_param_at_point_bspline(self): + # Define a complex spline with inflections and non-monotonic behavior + curve = Edge.make_spline( + [ + (-2, 0, 0), + (-10, 1, 0), + (0, 0, 0), + (1, -2, 0), + (2, 0, 0), + (1, 1, 0), + ] + ) + + # Sample N points along the curve using position_at and check that + # param_at_point returns approximately the same param (inverted) + N = 20 + for u in np.linspace(0.0, 1.0, N): + p = curve.position_at(u) + u_back = curve.param_at_point(p) + self.assertAlmostEqual(u, u_back, delta=1e-6, msg=f"u={u}, u_back={u_back}") + def test_conical_helix(self): helix = Edge.make_helix(1, 4, 1, normal=(-1, 0, 0), angle=10, lefthand=True) self.assertAlmostEqual(helix.bounding_box().min.X, -4, 5) @@ -282,6 +342,13 @@ class TestEdge(unittest.TestCase): e2r = e2.reversed() self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5) + e2r = e2.reversed(reconstruct=True) + self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5) + + e2.wrapped = None + with self.assertRaises(ValueError): + e2.reversed() + def test_init(self): with self.assertRaises(TypeError): Edge(direction=(1, 0, 0)) @@ -294,6 +361,88 @@ class TestEdge(unittest.TestCase): self.assertEqual(len(inside_edges), 5) self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in inside_edges)) + def test_position_at(self): + line = Edge.make_line((1, 1), (2, 2)) + self.assertEqual(line @ 0, Vector(1, 1, 0)) + self.assertEqual(line @ 1, Vector(2, 2, 0)) + self.assertEqual(line.reversed() @ 0, Vector(2, 2, 0)) + self.assertEqual(line.reversed() @ 1, Vector(1, 1, 0)) + + self.assertEqual( + line.position_at(1, position_mode=PositionMode.LENGTH), + Vector(1, 1) + Vector(math.sqrt(2) / 2, math.sqrt(2) / 2), + ) + self.assertEqual( + line.reversed().position_at(1, position_mode=PositionMode.LENGTH), + Vector(2, 2) - Vector(math.sqrt(2) / 2, math.sqrt(2) / 2), + ) + + def test_tangent_at(self): + arc = Edge.make_circle(1, start_angle=0, end_angle=180) + self.assertEqual(arc % 0, Vector(0, 1, 0)) + self.assertEqual(arc % 1, Vector(0, -1, 0)) + self.assertEqual(arc.reversed() % 0, Vector(0, 1, 0)) + self.assertEqual(arc.reversed() % 1, Vector(0, -1, 0)) + self.assertEqual(arc.reversed() @ 0, Vector(-1, 0, 0)) + self.assertEqual(arc.reversed() @ 1, Vector(1, 0, 0)) + + self.assertEqual( + arc.tangent_at(math.pi, position_mode=PositionMode.LENGTH), Vector(0, -1, 0) + ) + self.assertEqual( + arc.reversed().tangent_at(math.pi / 2, position_mode=PositionMode.LENGTH), + Vector(1, 0, 0), + ) + + def test_location_at(self): + arc = Edge.make_circle(1, start_angle=0, end_angle=180) + self.assertEqual(arc.location_at(0).position, Vector(1, 0, 0)) + self.assertEqual(arc.location_at(1).position, Vector(-1, 0, 0)) + self.assertEqual(arc.location_at(0).z_axis.direction, Vector(0, 1, 0)) + self.assertEqual(arc.location_at(1).z_axis.direction, Vector(0, -1, 0)) + + self.assertEqual(arc.reversed().location_at(0).position, Vector(-1, 0, 0)) + self.assertEqual(arc.reversed().location_at(1).position, Vector(1, 0, 0)) + self.assertEqual( + arc.reversed().location_at(0).z_axis.direction, Vector(0, 1, 0) + ) + self.assertEqual( + arc.reversed().location_at(1).z_axis.direction, Vector(0, -1, 0) + ) + + self.assertEqual( + arc.location_at(math.pi, position_mode=PositionMode.LENGTH).position, + Vector(-1, 0, 0), + ) + self.assertEqual( + arc.reversed() + .location_at(math.pi, position_mode=PositionMode.LENGTH) + .position, + Vector(1, 0, 0), + ) + + def test_extend_spline(self): + geom_surface = Face.make_rect(4, 4).geom_adaptor() + with self.assertRaises(TypeError): + Edge.make_line((0, 0), (1, 0))._extend_spline(True, geom_surface) + spline = Edge.make_spline([(0, 0), (1,), (2, 0)]) + spline.wrapped = None + with self.assertRaises(ValueError): + spline._extend_spline(True, geom_surface) + + @patch.object(GeomProjLib, "Project_s", return_value=None) + def test_extend_spline_failed_snap(self, mock_is_valid): + geom_surface = Face.make_rect(4, 4).geom_adaptor() + spline = Edge.make_spline([(0, 0), (1, 0), (2, 0)]) + with self.assertRaises(RuntimeError): + spline._extend_spline(True, geom_surface) + + def test_geom_adaptor(self): + line = Edge.make_line((0, 0), (1, 0)) + line.wrapped = None + with self.assertRaises(ValueError): + line.geom_adaptor() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 6f16eca..2b71763 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -31,22 +31,34 @@ import os import platform import random import unittest +from unittest.mock import PropertyMock, patch -from build123d.build_common import Locations -from build123d.build_enums import Align, CenterOf, GeomType +from OCP.Geom import Geom_RectangularTrimmedSurface +from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve + +from build123d.build_common import Locations, PolarLocations +from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType from build123d.build_line import BuildLine from build123d.build_part import BuildPart from build123d.build_sketch import BuildSketch from build123d.exporters3d import export_stl from build123d.geometry import Axis, Location, Plane, Pos, Vector from build123d.importers import import_stl -from build123d.objects_curve import Polyline -from build123d.objects_part import Box, Cylinder -from build123d.objects_sketch import Rectangle, RegularPolygon -from build123d.operations_generic import fillet +from build123d.objects_curve import JernArc, Line, Polyline, Spline, ThreePointArc +from build123d.objects_part import Box, Cone, Cylinder, Sphere, Torus +from build123d.objects_sketch import ( + Circle, + Ellipse, + Polygon, + Rectangle, + RegularPolygon, + Text, + Triangle, +) +from build123d.operations_generic import fillet, offset from build123d.operations_part import extrude from build123d.operations_sketch import make_face -from build123d.topology import Edge, Face, Solid, Wire +from build123d.topology import Edge, Face, Shell, Solid, Wire class TestFace(unittest.TestCase): @@ -54,7 +66,7 @@ class TestFace(unittest.TestCase): bottom_edge = Edge.make_circle(radius=1, end_angle=90) top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90) curved = Face.make_surface_from_curves(bottom_edge, top_edge) - self.assertTrue(curved.is_valid()) + self.assertTrue(curved.is_valid) self.assertAlmostEqual(curved.area, math.pi / 2, 5) self.assertAlmostEqual( curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 @@ -63,7 +75,7 @@ class TestFace(unittest.TestCase): bottom_wire = Wire.make_circle(1) top_wire = Wire.make_circle(1, Plane((0, 0, 1))) curved = Face.make_surface_from_curves(bottom_wire, top_wire) - self.assertTrue(curved.is_valid()) + self.assertTrue(curved.is_valid) self.assertAlmostEqual(curved.area, 2 * math.pi, 5) def test_center(self): @@ -118,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): @@ -158,6 +170,13 @@ class TestFace(unittest.TestCase): flipped_square = -square self.assertAlmostEqual(flipped_square.normal_at(), (0, 0, -1), 5) + # Ensure the topo_parent is cleared when a face is negated + # (otherwise the original Rectangle would be the topo_parent) + flipped = -Rectangle(34, 10).face() + left_edge = flipped.edges().sort_by(Axis.X)[0] + parent_face = left_edge.topo_parent + self.assertAlmostEqual(flipped.normal_at(), parent_face.normal_at(), 5) + def test_offset(self): bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box() self.assertAlmostEqual(bbox.min, (-1, -1, 5), 5) @@ -172,7 +191,7 @@ class TestFace(unittest.TestCase): happy = Face(outer, inners) self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5) - outer = Edge.make_circle(10, end_angle=180).to_wire() + outer = Wire(Edge.make_circle(10, end_angle=180)) with self.assertRaises(ValueError): Face(outer, inners) with self.assertRaises(ValueError): @@ -181,7 +200,7 @@ class TestFace(unittest.TestCase): outer = Wire.make_circle(10) inners = [ Wire.make_circle(1).locate(Location((-2, 2, 0))), - Edge.make_circle(1, end_angle=180).to_wire().locate(Location((2, 2, 0))), + Wire(Edge.make_circle(1, end_angle=180)).locate(Location((2, 2, 0))), ] with self.assertRaises(ValueError): Face(outer, inners) @@ -292,7 +311,7 @@ class TestFace(unittest.TestCase): for j in range(4 - i % 2) ] cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires) - self.assertTrue(cylinder_walls_with_holes.is_valid()) + self.assertTrue(cylinder_walls_with_holes.is_valid) self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area) def test_is_inside(self): @@ -341,6 +360,231 @@ class TestFace(unittest.TestCase): self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5) self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5) + def test_make_gordon_surface(self): + def create_test_curves( + num_profiles: int = 3, + num_guides: int = 4, + u_range: float = 1.0, + v_range: float = 1.0, + ): + profiles: list[Edge] = [] + guides: list[Edge] = [] + + intersection_points = [ + [(0.0, 0.0, 0.0) for _ in range(num_guides)] + for _ in range(num_profiles) + ] + + for i in range(num_profiles): + for j in range(num_guides): + u = i * u_range / (num_profiles - 1) + v = j * v_range / (num_guides - 1) + z = 0.2 * math.sin(u * math.pi) * math.cos(v * math.pi) + intersection_points[i][j] = (u, v, z) + + for i in range(num_profiles): + points = [intersection_points[i][j] for j in range(num_guides)] + profiles.append(Spline(points)) + + for j in range(num_guides): + points = [intersection_points[i][j] for i in range(num_profiles)] + guides.append(Spline(points)) + + return profiles, guides + + profiles, guides = create_test_curves() + + tolerance = 3e-4 + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + + self.assertIsInstance( + gordon_surface, Face, "The returned object should be a Face." + ) + + def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): + point_at_uv = gordon_surface.position_at(u, v) + self.assertAlmostEqual( + point_at_uv.X, + expected_point.X, + delta=tolerance, + msg=f"X coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Y, + expected_point.Y, + delta=tolerance, + msg=f"Y coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Z, + expected_point.Z, + delta=tolerance, + msg=f"Z coordinate mismatch at ({u},{v})", + ) + + point_at_uv_against_expected( + u=0.0, v=0.0, expected_point=guides[0].position_at(0.0) + ) + point_at_uv_against_expected( + u=1.0, v=0.0, expected_point=profiles[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=0.0, v=1.0, expected_point=guides[0].position_at(1.0) + ) + point_at_uv_against_expected( + u=1.0, v=1.0, expected_point=profiles[-1].position_at(1.0) + ) + + temp_curve = profiles[0] + profiles[0] = Edge() + with self.assertRaises(ValueError): + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + + profiles[0] = temp_curve + guides[0] = Edge() + with self.assertRaises(ValueError): + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + + def test_make_gordon_surface_input_types(self): + tolerance = 3e-4 + + def point_at_uv_against_expected(u: float, v: float, expected_point: Vector): + point_at_uv = gordon_surface.position_at(u, v) + self.assertAlmostEqual( + point_at_uv.X, + expected_point.X, + delta=tolerance, + msg=f"X coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Y, + expected_point.Y, + delta=tolerance, + msg=f"Y coordinate mismatch at ({u},{v})", + ) + self.assertAlmostEqual( + point_at_uv.Z, + expected_point.Z, + delta=tolerance, + msg=f"Z coordinate mismatch at ({u},{v})", + ) + + points = [ + Vector(0, 0, 0), + Vector(10, 0, 0), + Vector(12, 20, 1), + Vector(4, 22, -1), + ] + + profiles = [Line(points[0], points[1]), Line(points[3], points[2])] + guides = [Line(points[0], points[3]), Line(points[1], points[2])] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected( + u=0.5, + v=0.5, + expected_point=(points[0] + points[1] + points[2] + points[3]) / 4, + ) + + profiles = [ + ThreePointArc( + points[0], (points[0] + points[1]) / 2 + Vector(0, 0, 2), points[1] + ), + ThreePointArc( + points[3], (points[3] + points[2]) / 2 + Vector(0, 0, 3), points[2] + ), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 1, profiles[1] @ 1), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5) + + profiles = [ + Edge.make_bezier( + points[0], + points[0] + Vector(1, 0, 1), + points[1] - Vector(1, 0, 1), + points[1], + ), + Edge.make_bezier( + points[3], + points[3] + Vector(1, 0, 1), + points[2] - Vector(1, 0, 1), + points[2], + ), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 1, profiles[1] @ 1), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[1] @ 0.5) + + profiles = [ + Edge.make_ellipse(10, 6), + Edge.make_ellipse(8, 7).translate((1, 2, 10)), + ] + guides = [ + Line(profiles[0] @ 0, profiles[1] @ 0), + Line(profiles[0] @ 0.5, profiles[1] @ 0.5), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=0.5, expected_point=guides[0] @ 0.5) + point_at_uv_against_expected(u=1.0, v=0.5, expected_point=guides[0] @ 0.5) + + profiles = [ + points[0], + ThreePointArc( + points[1], (points[1] + points[3]) / 2 + Vector(0, 0, 2), points[3] + ), + points[2], + ] + guides = [ + Spline( + points[0], + profiles[1] @ 0, + points[2], + ), + Spline( + points[0], + profiles[1] @ 1, + points[2], + ), + ] + gordon_surface = Face.make_gordon_surface(profiles, guides, tolerance=tolerance) + point_at_uv_against_expected(u=0.0, v=1.0, expected_point=guides[0] @ 1) + point_at_uv_against_expected(u=1.0, v=1.0, expected_point=guides[1] @ 1) + point_at_uv_against_expected(u=1.0, v=0.0, expected_point=points[0]) + + profiles = [ + Line(points[0], points[1]), + (points[0] + points[2]) / 2, + Line(points[3], points[2]), + ] + guides = [ + Spline( + profiles[0] @ 0, + profiles[1], + profiles[2] @ 0, + ), + Spline( + profiles[0] @ 1, + profiles[1], + profiles[2] @ 1, + ), + ] + with self.assertRaises(ValueError): + gordon_surface = Face.make_gordon_surface( + profiles, guides, tolerance=tolerance + ) + def test_make_surface(self): corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] net_exterior = Wire( @@ -366,7 +610,7 @@ class TestFace(unittest.TestCase): surface_points=[Vector(0, 0, -5)], interior_wires=[hole], ) - self.assertTrue(surface.is_valid()) + self.assertTrue(surface.is_valid) self.assertEqual(surface.geom_type, GeomType.BSPLINE) bbox = surface.bounding_box() self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -5.113393280136395), 5) @@ -412,15 +656,87 @@ class TestFace(unittest.TestCase): with self.assertRaises(ValueError): Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1))) - def test_to_arcs(self): - with BuildSketch() as bs: - with BuildLine() as bl: - Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0)) - fillet(bl.vertices(), radius=0.1) - make_face() - smooth = bs.faces()[0] - fragmented = smooth.to_arcs() - self.assertLess(len(smooth.edges()), len(fragmented.edges())) + def test_make_surface_patch(self): + m1 = Spline((0, 0), (1, 0), (10, 0, -10)) + m2 = Spline((0, 0), (0, 1), (0, 10, -10)) + m3 = Spline(m1 @ 1, (7, 7, -10), m2 @ 1) + + patch = Face.make_surface_patch( + edge_constraints=[ + m1.edge(), + m2.edge(), + m3.edge(), + ] + ) + self.assertAlmostEqual(patch.area, 157.186, 3) + + f1 = Face.extrude(m1.edge(), (0, -1, 0)) + f2 = Face.extrude(m2.edge(), (-1, 0, 0)) + f3 = Face.extrude(m3.edge(), (0, 0, -1)) + + patch2 = Face.make_surface_patch( + edge_face_constraints=[ + (m1.edge(), f1, ContinuityLevel.C1), + (m2.edge(), f2, ContinuityLevel.C1), + (m3.edge(), f3, ContinuityLevel.C1), + ] + ) + + self.assertAlmostEqual(patch2.area, 152.670, 3) + + mid_edge = Spline(m1 @ 0.5, (5, 5, -3), m2 @ 0.5) + + patch3 = -Face.make_surface_patch( + edge_face_constraints=[ + (m1.edge(), f1, ContinuityLevel.C1), + (m2.edge(), f2, ContinuityLevel.C1), + (m3.edge(), f3, ContinuityLevel.C1), + ], + edge_constraints=[ + mid_edge.edge(), + ], + ) + + self.assertAlmostEqual(patch3.area, 152.643, 3) + + point = patch.position_at(0.5, 0.5) + (0.5, 0.5) + patch4 = -Face.make_surface_patch( + edge_constraints=[ + m1.edge(), + m2.edge(), + m3.edge(), + ], + point_constraints=[ + point, + ], + ) + + self.assertAlmostEqual(patch4.area, 164.618, 3) + + def test_make_surface_patch_error_checking(self): + with self.assertRaises(RuntimeError): + Face.make_surface_patch(edge_constraints=[Edge.make_line((0, 0), (1, 0))]) + + with self.assertRaises(RuntimeError): + Face.make_surface_patch(edge_constraints=[]) + + with self.assertRaises(RuntimeError): + Face.make_surface_patch( + edge_constraints=[ + Edge.make_line((0, 0), (1, 0)), + Edge.make_line((0, 0), (0, 1)), + ] + ) + + # def test_to_arcs(self): + # with BuildSketch() as bs: + # with BuildLine() as bl: + # Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0)) + # fillet(bl.vertices(), radius=0.1) + # make_face() + # smooth = bs.faces()[0] + # fragmented = smooth.to_arcs() + # self.assertLess(len(smooth.edges()), len(fragmented.edges())) def test_outer_wire(self): face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face() @@ -447,10 +763,41 @@ class TestFace(unittest.TestCase): face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0] self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5) - def test_remove_holes(self): + def test_location_at(self): + face = Face.make_rect(1, 1) + + # Default center (u=0, v=0) + loc = face.location_at(0, 0) + self.assertAlmostEqual(loc.position, (-0.5, -0.5, 0), 5) + self.assertAlmostEqual(loc.z_axis.direction, (0, 0, 1), 5) + + # Using surface_point instead of u,v + point = face.position_at(0, 0) + loc2 = face.location_at(point) + self.assertAlmostEqual(loc2.position, (-0.5, -0.5, 0), 5) + self.assertAlmostEqual(loc2.z_axis.direction, (0, 0, 1), 5) + + # Bad args + with self.assertRaises(ValueError): + face.location_at(0) + with self.assertRaises(ValueError): + face.location_at(center=(0, 0)) + + # Curved surface: verify z-direction is outward normal + face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0] + loc3 = face.location_at(0, 1) + self.assertAlmostEqual(loc3.z_axis.direction, (1, 0, 0), 5) + + # Curved surface: verify center + face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0] + loc4 = face.location_at() + self.assertAlmostEqual(loc4.position, (-1, 0, 0), 5) + self.assertAlmostEqual(loc4.z_axis.direction, (-1, 0, 0), 5) + + def test_without_holes(self): # Planar test frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face() - filled = frame.remove_holes() + filled = frame.without_holes() self.assertEqual(len(frame.inner_wires()), 1) self.assertEqual(len(filled.inner_wires()), 0) self.assertAlmostEqual(frame.area, 0.75, 5) @@ -459,11 +806,11 @@ class TestFace(unittest.TestCase): # Errors frame.wrapped = None with self.assertRaises(ValueError): - frame.remove_holes() + frame.without_holes() # No holes rect = Face.make_rect(1, 1) - self.assertEqual(rect, rect.remove_holes()) + self.assertEqual(rect, rect.without_holes()) # Non-planar test cyl_face = ( @@ -471,16 +818,463 @@ class TestFace(unittest.TestCase): .faces() .sort_by(Face.area)[-1] ) - filled = cyl_face.remove_holes() + filled = cyl_face.without_holes() self.assertEqual(len(cyl_face.inner_wires()), 2) self.assertEqual(len(filled.inner_wires()), 0) self.assertTrue(cyl_face.area < filled.area) - self.assertAlmostEqual(cyl_face.total_area, filled.area, 5) + self.assertAlmostEqual(cyl_face.area_without_holes, filled.area, 5) - def test_total_area(self): + def test_area_without_holes(self): frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face() frame.wrapped = None - self.assertAlmostEqual(frame.total_area, 0.0, 5) + self.assertAlmostEqual(frame.area_without_holes, 0.0, 5) + + def test_axes_of_symmetry(self): + # Empty shape + shape = Face.make_rect(1, 1) + shape.wrapped = None + with self.assertRaises(ValueError): + shape.axes_of_symmetry + + # Non planar + shape = Solid.make_cylinder(1, 2).faces().filter_by(GeomType.CYLINDER)[0] + with self.assertRaises(ValueError): + shape.axes_of_symmetry + + # Test a variety of shapes + shapes = [ + Rectangle(1, 1), + Rectangle(1, 2, align=Align.MIN), + Rectangle(1, 2, rotation=10), + Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2), + (Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2)).rotate( + Axis.Z, 10 + ), + Triangle(a=1, b=0.5, C=90), + Circle(2) - Pos(0.1) * Rectangle(0.5, 0.5), + Circle(2) - Pos(0.1, 0.1) * Rectangle(0.5, 0.5), + Circle(2) - (Pos(0.1, 0.1) * PolarLocations(1, 3)) * Circle(0.3), + Circle(2) - (Pos(0.5) * PolarLocations(1, 3)) * Circle(0.3), + Circle(2) - PolarLocations(1, 3) * Circle(0.3), + Ellipse(1, 2, rotation=10), + ] + shape_dir = [ + [(-1, 1), (-1, 0), (-1, -1), (0, -1)], + [(-1, 0), (0, -1)], + [Vector(-1, 0).rotate(Axis.Z, 10), Vector(0, -1).rotate(Axis.Z, 10)], + [(0, -1)], + [Vector(0, -1).rotate(Axis.Z, 10)], + [], + [(1, 0)], + [(1, 1)], + [], + [(1, 0)], + [ + (1, 0), + Vector(1, 0).rotate(Axis.Z, 120), + Vector(1, 0).rotate(Axis.Z, 240), + ], + [Vector(1, 0).rotate(Axis.Z, 10), Vector(0, 1).rotate(Axis.Z, 10)], + ] + + for i, shape in enumerate(shapes): + test_face: Face = shape.face() + cog = test_face.center() + axes = test_face.axes_of_symmetry + target_axes = [Axis(cog, d) for d in shape_dir[i]] + self.assertEqual(len(target_axes), len(axes)) + axes_dirs = sorted(tuple(a.direction) for a in axes) + target_dirs = sorted(tuple(a.direction) for a in target_axes) + self.assertTrue(all(a == t) for a, t in zip(axes_dirs, target_dirs)) + self.assertTrue(all(a.position == cog) for a in axes) + + # Fast abort code paths + s1 = Spline( + (0.0293923441471, 1.9478225275438), + (0.0293923441471, 1.2810839877038), + (0, -0.0521774724562), + (0.0293923441471, -1.3158620329962), + (0.0293923441471, -1.9478180575162), + ) + l1 = Line(s1 @ 1, s1 @ 0) + self.assertEqual(len(Face(Wire([s1, l1])).axes_of_symmetry), 0) + + with BuildSketch() as skt: + with BuildLine(): + Line( + (-13.186467340991, 2.3737403364651), + (-5.1864673409911, 2.3737403364651), + ) + Line( + (-13.186467340991, 2.3737403364651), + (-13.186467340991, -2.4506956262169), + ) + ThreePointArc( + (-13.186467340991, -2.4506956262169), + (-13.479360559805, -3.1578024074034), + (-14.186467340991, -3.4506956262169), + ) + Line( + (-17.186467340991, -3.4506956262169), + (-14.186467340991, -3.4506956262169), + ) + ThreePointArc( + (-17.186467340991, -3.4506956262169), + (-17.893574122178, -3.1578024074034), + (-18.186467340991, -2.4506956262169), + ) + Line( + (-18.186467340991, 7.6644400497781), + (-18.186467340991, -2.4506956262169), + ) + Line( + (-51.186467340991, 7.6644400497781), + (-18.186467340991, 7.6644400497781), + ) + Line( + (-51.186467340991, 7.6644400497781), + (-51.186467340991, -5.5182296356389), + ) + Line( + (-51.186467340991, -5.5182296356389), + (-33.186467340991, -5.5182296356389), + ) + Line( + (-33.186467340991, -5.5182296356389), + (-33.186467340991, -5.3055423052429), + ) + Line( + (-33.186467340991, -5.3055423052429), + (53.813532659009, -5.3055423052429), + ) + Line( + (53.813532659009, -5.3055423052429), + (53.813532659009, -5.7806956262169), + ) + Line( + (66.813532659009, -5.7806956262169), + (53.813532659009, -5.7806956262169), + ) + Line( + (66.813532659009, -2.7217530775369), + (66.813532659009, -5.7806956262169), + ) + Line( + (54.813532659009, -2.7217530775369), + (66.813532659009, -2.7217530775369), + ) + Line( + (54.813532659009, 7.6644400497781), + (54.813532659009, -2.7217530775369), + ) + Line( + (38.813532659009, 7.6644400497781), + (54.813532659009, 7.6644400497781), + ) + Line( + (38.813532659009, 7.6644400497781), + (38.813532659009, -2.4506956262169), + ) + ThreePointArc( + (38.813532659009, -2.4506956262169), + (38.520639440195, -3.1578024074034), + (37.813532659009, -3.4506956262169), + ) + Line( + (37.813532659009, -3.4506956262169), + (34.813532659009, -3.4506956262169), + ) + ThreePointArc( + (34.813532659009, -3.4506956262169), + (34.106425877822, -3.1578024074034), + (33.813532659009, -2.4506956262169), + ) + Line( + (33.813532659009, 2.3737403364651), + (33.813532659009, -2.4506956262169), + ) + Line( + (25.813532659009, 2.3737403364651), + (33.813532659009, 2.3737403364651), + ) + Line( + (25.813532659009, 2.3737403364651), + (25.813532659009, -2.4506956262169), + ) + ThreePointArc( + (25.813532659009, -2.4506956262169), + (25.520639440195, -3.1578024074034), + (24.813532659009, -3.4506956262169), + ) + Line( + (24.813532659009, -3.4506956262169), + (21.813532659009, -3.4506956262169), + ) + ThreePointArc( + (21.813532659009, -3.4506956262169), + (21.106425877822, -3.1578024074034), + (20.813532659009, -2.4506956262169), + ) + Line( + (20.813532659009, 2.3737403364651), + (20.813532659009, -2.4506956262169), + ) + Line( + (12.813532659009, 2.3737403364651), + (20.813532659009, 2.3737403364651), + ) + Line( + (12.813532659009, 2.3737403364651), + (12.813532659009, -2.4506956262169), + ) + ThreePointArc( + (12.813532659009, -2.4506956262169), + (12.520639440195, -3.1578024074034), + (11.813532659009, -3.4506956262169), + ) + Line( + (8.8135326590089, -3.4506956262169), + (11.813532659009, -3.4506956262169), + ) + ThreePointArc( + (8.8135326590089, -3.4506956262169), + (8.1064258778223, -3.1578024074034), + (7.8135326590089, -2.4506956262169), + ) + Line( + (7.8135326590089, 2.3737403364651), + (7.8135326590089, -2.4506956262169), + ) + Line( + (-0.1864673409911, 2.3737403364651), + (7.8135326590089, 2.3737403364651), + ) + Line( + (-0.1864673409911, 2.3737403364651), + (-0.1864673409911, -2.4506956262169), + ) + ThreePointArc( + (-0.1864673409911, -2.4506956262169), + (-0.4793605598046, -3.1578024074034), + (-1.1864673409911, -3.4506956262169), + ) + Line( + (-4.1864673409911, -3.4506956262169), + (-1.1864673409911, -3.4506956262169), + ) + ThreePointArc( + (-4.1864673409911, -3.4506956262169), + (-4.8935741221777, -3.1578024074034), + (-5.1864673409911, -2.4506956262169), + ) + Line( + (-5.1864673409911, 2.3737403364651), + (-5.1864673409911, -2.4506956262169), + ) + make_face() + self.assertEqual(len(skt.face().axes_of_symmetry), 0) + + def test_radius_property(self): + c = Cylinder(1.5, 2).faces().filter_by(GeomType.CYLINDER)[0] + s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0] + b = Box(1, 1, 1).faces()[0] + self.assertAlmostEqual(c.radius, 1.5, 5) + self.assertAlmostEqual(s.radius, 3, 5) + self.assertIsNone(b.radius) + + def test_axis_of_rotation_property(self): + c = ( + Cylinder(1.5, 2, rotation=(90, 0, 0)) + .faces() + .filter_by(GeomType.CYLINDER)[0] + ) + s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0] + self.assertAlmostEqual(c.axis_of_rotation.direction, (0, -1, 0), 5) + self.assertAlmostEqual(c.axis_of_rotation.position, (0, 1, 0), 5) + self.assertIsNone(s.axis_of_rotation) + + @patch.object( + Face, + "geom_adaptor", + return_value=Geom_RectangularTrimmedSurface( + Face.make_rect(1, 1).geom_adaptor(), 0.0, 1.0, True + ), + ) + def test_axis_of_rotation_property_error(self, mock_is_valid): + c = ( + Cylinder(1.5, 2, rotation=(90, 0, 0)) + .faces() + .filter_by(GeomType.CYLINDER)[0] + ) + self.assertIsNone(c.axis_of_rotation) + # Verify is_valid was called + mock_is_valid.assert_called_once() + + def test_is_convex_concave(self): + + with BuildPart() as open_box: + Box(20, 20, 5) + offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1]) + fillet(open_box.edges(), 0.5) + + outside_fillets = open_box.faces().filter_by(Face.is_circular_convex) + inside_fillets = open_box.faces().filter_by(Face.is_circular_concave) + self.assertEqual(len(outside_fillets), 28) + self.assertEqual(len(inside_fillets), 12) + + @patch.object( + Face, "axis_of_rotation", new_callable=PropertyMock, return_value=None + ) + def test_is_convex_concave_error0(self, mock_is_valid): + with BuildPart() as open_box: + Box(20, 20, 5) + offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1]) + fillet(open_box.edges(), 0.5) + + with self.assertRaises(ValueError): + open_box.faces().filter_by(Face.is_circular_convex) + + # Verify is_valid was called + mock_is_valid.assert_called_once() + + @patch.object(Face, "radii", new_callable=PropertyMock, return_value=None) + def test_is_convex_concave_error1(self, mock_is_valid): + with BuildPart() as open_box: + Box(20, 20, 5) + offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1]) + fillet(open_box.edges(), 0.5) + + with self.assertRaises(ValueError): + open_box.faces().filter_by(Face.is_circular_convex) + + # Verify is_valid was called + mock_is_valid.assert_called_once() + + @patch.object(Face, "location", new_callable=PropertyMock, return_value=None) + def test_is_convex_concave_error2(self, mock_is_valid): + with BuildPart() as open_box: + Box(20, 20, 5) + offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1]) + fillet(open_box.edges(), 0.5) + + with self.assertRaises(ValueError): + open_box.faces().filter_by(Face.is_circular_convex) + + # Verify is_valid was called + mock_is_valid.assert_called_once() + + def test_radii(self): + t = Torus(5, 1).face() + self.assertAlmostEqual(t.radii, (5, 1), 5) + s = Sphere(1).face() + self.assertIsNone(s.radii) + + def test_wrap(self): + surfaces = [ + part.faces().filter_by(GeomType.PLANE, reverse=True)[0] + for part in (Cylinder(5, 10), Sphere(5), Cone(5, 2, 10)) + ] + inner = PolarLocations(1, 5, -18).local_locations + outer = PolarLocations(3, 5, -18 + 36).local_locations + points = [p.position for pair in zip(inner, outer) for p in pair] + star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face() + planar_edge = Edge.make_line((0, 0), (3, 3)) + planar_wire = Wire([planar_edge, Edge.make_line(planar_edge @ 1, (3, 0))]) + for surface in surfaces: + with self.subTest(surface=surface): + target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0)) + + wrapped_face: Face = surface.wrap(star, target) + self.assertTrue(isinstance(wrapped_face, Face)) + self.assertFalse(wrapped_face.is_planar_face) + self.assertTrue(wrapped_face.inner_wires()) + + wrapped_edge = surface.wrap(planar_edge, target) + self.assertTrue(wrapped_edge.geom_type == GeomType.BSPLINE) + self.assertAlmostEqual(planar_edge.length, wrapped_edge.length, 2) + self.assertAlmostEqual(wrapped_edge @ 0, target.position, 5) + + wrapped_wire = surface.wrap(planar_wire, target) + self.assertAlmostEqual(planar_wire.length, wrapped_wire.length, 2) + self.assertAlmostEqual(wrapped_wire @ 0, target.position, 5) + + with self.assertRaises(TypeError): + surface.wrap(Solid.make_box(1, 1, 1), target) + + @patch.object(GeomAPI_ExtremaCurveCurve, "NbExtrema", return_value=0) + def test_wrap_intersect_error(self, mock_is_valid): + surface = Cone(5, 2, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] + target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0)) + inner = PolarLocations(1, 5, -18).local_locations + outer = PolarLocations(3, 5, -18 + 36).local_locations + points = [p.position for pair in zip(inner, outer) for p in pair] + star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face() + + with self.assertRaises(RuntimeError): + surface.wrap(star.outer_wire(), target) + + @patch.object(Wire, "is_valid", new_callable=PropertyMock, return_value=False) + def test_wrap_invalid_wire(self, mock_is_valid): + surface = Cone(5, 2, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] + target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0)) + inner = PolarLocations(1, 5, -18).local_locations + outer = PolarLocations(3, 5, -18 + 36).local_locations + points = [p.position for pair in zip(inner, outer) for p in pair] + star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face() + + with self.assertRaises(RuntimeError): + surface.wrap(star, target) + + def test_wrap_faces(self): + sphere = Solid.make_sphere(50, angle1=-90).face() + surface = sphere.face() + path: Edge = ( + sphere.cut( + Solid.make_cylinder(80, 100, Plane.YZ).locate(Location((-50, 0, -70))) + ) + .edges() + .sort_by(Axis.Z)[0] + .reversed() + ) + text = Text(txt="ei", font_size=15, align=(Align.MIN, Align.CENTER)) + wrapped_faces = surface.wrap_faces(text.faces(), path, 0.2) + self.assertEqual(len(wrapped_faces), 3) + self.assertTrue(all(not f.is_planar_face for f in wrapped_faces)) + + def test_revolve(self): + l1 = Edge.make_line((3, 0), (3, 2)) + revolved = Face.revolve(l1, 360, Axis.Y) + self.assertTrue(isinstance(revolved, Face)) + self.assertAlmostEqual(revolved.area, 2 * math.pi * 3 * 2, 5) + + l2 = JernArc(l1 @ 1, l1 % 1, 1, 90) + w1 = Wire([l1, l2]) + revolved = Shell.revolve(w1, 180, Axis.Y) + self.assertTrue(isinstance(revolved, Shell)) + self.assertAlmostEqual(revolved.edges().sort_by(Axis.Y)[-1].radius, 2, 5) + + +class TestAxesOfSymmetrySplitNone(unittest.TestCase): + def test_split_returns_none(self): + # Create a rectangle face for testing. + rect = Rectangle(10, 5).face() + + # Monkey-patch the split method to simulate the degenerate case: + # Force split to return (None, rect) for any splitting plane. + original_split = Face.split # Save the original split method. + Face.split = lambda self, plane, keep: (None, None) + + # Call axes_of_symmetry. With our patch, every candidate axis is skipped, + # so we expect no symmetry axes to be found. + axes = rect.axes_of_symmetry + + # Verify that the result is an empty list. + self.assertEqual( + axes, [], "Expected no symmetry axes when split returns None for one half." + ) + + # Restore the original split method (cleanup). + Face.split = original_split if __name__ == "__main__": diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py index 9b22dd5..8f9f29e 100644 --- a/tests/test_direct_api/test_import_export.py +++ b/tests/test_direct_api/test_import_export.py @@ -40,11 +40,11 @@ class TestImportExport(unittest.TestCase): original_box = Solid.make_box(1, 1, 1) export_step(original_box, "test_box.step") step_box = import_step("test_box.step") - self.assertTrue(step_box.is_valid()) + self.assertTrue(step_box.is_valid) self.assertAlmostEqual(step_box.volume, 1, 5) export_brep(step_box, "test_box.brep") brep_box = import_brep("test_box.brep") - self.assertTrue(brep_box.is_valid()) + self.assertTrue(brep_box.is_valid) self.assertAlmostEqual(brep_box.volume, 1, 5) os.remove("test_box.step") os.remove("test_box.brep") diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py new file mode 100644 index 0000000..758fd6f --- /dev/null +++ b/tests/test_direct_api/test_intersection.py @@ -0,0 +1,492 @@ +import pytest +from collections import Counter +from dataclasses import dataclass +from build123d import * +from build123d.topology.shape_core import Shape + +INTERSECT_DEBUG = False +if INTERSECT_DEBUG: + from ocp_vscode import show + + +@dataclass +class Case: + object: Shape | Vector | Location | Axis | Plane + target: Shape | Vector | Location | Axis | Plane + expected: list | Vector | Location | Axis | Plane + name: str + xfail: None | str = None + + +@pytest.mark.skip +def run_test(obj, target, expected): + if isinstance(target, list): + result = obj.intersect(*target) + else: + result = obj.intersect(target) + if INTERSECT_DEBUG: + show([obj, target, result]) + if expected is None: + assert result == expected, f"Expected None, but got {result}" + else: + e_type = ShapeList if isinstance(expected, list) else expected + assert isinstance(result, e_type), f"Expected {e_type}, but got {result}" + if e_type == ShapeList: + assert len(result) == len(expected), f"Expected {len(expected)} objects, but got {len(result)}" + + actual_counts = Counter(type(obj) for obj in result) + expected_counts = Counter(expected) + assert all(actual_counts[t] >= count for t, count in expected_counts.items()), f"Expected {expected}, but got {[type(r) for r in result]}" + + +@pytest.mark.skip +def make_params(matrix): + params = [] + for case in matrix: + obj_type = type(case.object).__name__ + tar_type = type(case.target).__name__ + i = len(params) + if case.xfail and not INTERSECT_DEBUG: + marks = [pytest.mark.xfail(reason=case.xfail)] + else: + marks = [] + uid = f"{i} {obj_type}, {tar_type}, {case.name}" + params.append(pytest.param(case.object, case.target, case.expected, marks=marks, id=uid)) + if tar_type != obj_type and not isinstance(case.target, list): + uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}" + params.append(pytest.param(case.target, case.object, case.expected, marks=marks, id=uid)) + + return params + + +# Geometric test objects +ax1 = Axis.X +ax2 = Axis.Y +ax3 = Axis((0, 0, 5), (1, 0, 0)) +pl1 = Plane.YZ +pl2 = Plane.XY +pl3 = Plane.XY.offset(5) +pl4 = Plane((0, 5, 0)) +pl5 = Plane.YZ.offset(1) +vl1 = Vector(2, 0, 0) +vl2 = Vector(2, 0, 5) +lc1 = Location((2, 0, 0)) +lc2 = Location((2, 0, 5)) +lc3 = Location((0, 0, 0), (0, 90, 90)) +lc4 = Location((2, 0, 0), (0, 90, 90)) + +# Geometric test matrix +geometry_matrix = [ + Case(ax1, ax3, None, "parallel/skew", None), + Case(ax1, ax1, Axis, "collinear", None), + Case(ax1, ax2, Vector, "intersecting", None), + + Case(ax1, pl3, None, "parallel", None), + Case(ax1, pl2, Axis, "coplanar", None), + Case(ax1, pl1, Vector, "intersecting", None), + + Case(ax1, vl2, None, "non-coincident", None), + Case(ax1, vl1, Vector, "coincident", None), + + Case(ax1, lc2, None, "non-coincident", None), + Case(ax1, lc4, Location, "intersecting, co-z", None), + Case(ax1, lc1, Vector, "intersecting", None), + + Case(pl2, pl3, None, "parallel", None), + Case(pl2, pl4, Plane, "coplanar", None), + Case(pl1, pl2, Axis, "intersecting", None), + + Case(pl3, ax1, None, "parallel", None), + Case(pl2, ax1, Axis, "coplanar", None), + Case(pl1, ax1, Vector, "intersecting", None), + + Case(pl1, vl2, None, "non-coincident", None), + Case(pl2, vl1, Vector, "coincident", None), + + Case(pl1, lc2, None, "non-coincident", None), + Case(pl1, lc3, Location, "intersecting, co-z", None), + Case(pl2, lc4, Vector, "coincident", None), + + Case(vl1, vl2, None, "non-coincident", None), + Case(vl1, vl1, Vector, "coincident", None), + + Case(vl1, lc2, None, "non-coincident", None), + Case(vl1, lc1, Vector, "coincident", None), + + Case(lc1, lc2, None, "non-coincident", None), + Case(lc1, lc4, Vector, "coincident", None), + Case(lc1, lc1, Location, "coincident, co-z", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(geometry_matrix)) +def test_geometry(obj, target, expected): + run_test(obj, target, expected) + + +# Shape test matrices +vt1 = Vertex(2, 0, 0) +vt2 = Vertex(2, 0, 5) + +shape_0d_matrix = [ + Case(vt1, vt2, None, "non-coincident", None), + Case(vt1, vt1, [Vertex], "coincident", None), + + Case(vt1, vl2, None, "non-coincident", None), + Case(vt1, vl1, [Vertex], "coincident", None), + + Case(vt1, lc2, None, "non-coincident", None), + Case(vt1, lc1, [Vertex], "coincident", None), + + Case(vt2, ax1, None, "non-coincident", None), + Case(vt1, ax1, [Vertex], "coincident", None), + + Case(vt2, pl1, None, "non-coincident", None), + Case(vt1, pl2, [Vertex], "coincident", None), + + Case(vt1, [vt2, lc1], None, "multi to_intersect, non-coincident", None), + Case(vt1, [vt1, lc1], [Vertex], "multi to_intersect, coincident", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_0d_matrix)) +def test_shape_0d(obj, target, expected): + run_test(obj, target, expected) + + +# 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() +ed4 = CenterArc((3, 1), 2, 0, 360).edge() +ed5 = CenterArc((3, 1), 5, 0, 360).edge() + +ed6 = Edge.make_line((0, -1), (2, 1)) +ed7 = Edge.make_line((0, 1), (2, -1)) +ed8 = Edge.make_line((0, 0), (2, 0)) + +wi1 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 1.5), 2)] +wi2 = wi1 + Line((3, 1.5), (3, -1)) +wi3 = Wire() + [Line((0, 0), (1, 0)), RadiusArc((1, 0), (3, 0), 2), Line((3, 0), (5, 0))] +wi4 = Wire() + [Line((0, 1), (2, -1)) , Line((2, -1), (3, -1))] +wi5 = wi4 + Line((3, -1), (4, 1)) +wi6 = Wire() + [Line((0, 1, 1), (2, -1, 1)), Line((2, -1, 1), (4, 1, 1))] + +shape_1d_matrix = [ + Case(ed1, vl2, None, "non-coincident", None), + Case(ed1, vl1, [Vertex], "coincident", None), + + Case(ed1, lc2, None, "non-coincident", None), + Case(ed1, lc1, [Vertex], "coincident", None), + + Case(ed3, ax1, None, "parallel/skew", None), + Case(ed2, ax1, [Vertex], "intersecting", None), + Case(ed1, ax1, [Edge], "collinear", None), + Case(ed4, ax1, [Vertex, Vertex], "multi intersect", None), + + Case(ed1, pl3, None, "parallel/skew", None), + Case(ed1, pl1, [Vertex], "intersecting", None), + Case(ed1, pl2, [Edge], "collinear", None), + Case(ed5, pl1, [Vertex, Vertex], "multi intersect", None), + + Case(ed1, vt2, None, "non-coincident", None), + Case(ed1, vt1, [Vertex], "coincident", None), + + Case(ed3, ed1, None, "parallel/skew", None), + Case(ed2, ed1, [Vertex], "intersecting", None), + Case(ed1, ed1, [Edge], "collinear", None), + Case(ed4, ed1, [Vertex, Vertex], "multi intersect", None), + + Case(ed6, [ed7, ed8], [Vertex], "multi to_intersect, intersect", None), + Case(ed6, [ed7, pl5], [Vertex], "multi to_intersect, intersect", None), + Case(ed6, [ed7, Vector(1, 0)], [Vertex], "multi to_intersect, intersect", None), + + Case(wi6, ax1, None, "parallel/skew", None), + Case(wi4, ax1, [Vertex], "intersecting", None), + Case(wi1, ax1, [Edge], "collinear", None), + Case(wi5, ax1, [Vertex, Vertex], "multi intersect", None), + Case(wi2, ax1, [Vertex, Edge], "intersect + collinear", None), + Case(wi3, ax1, [Edge, Edge], "2 collinear", None), + + Case(wi6, ed1, None, "parallel/skew", None), + Case(wi4, ed1, [Vertex], "intersecting", None), + Case(wi1, ed1, [Edge], "collinear", None), + Case(wi5, ed1, [Vertex, Vertex], "multi intersect", None), + Case(wi2, ed1, [Vertex, Edge], "intersect + collinear", None), + Case(wi3, ed1, [Edge, Edge], "2 collinear", None), + + Case(wi5, [ed1, Vector(1, 0)], [Vertex], "multi to_intersect, multi intersect", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(shape_1d_matrix)) +def test_shape_1d(obj, target, expected): + run_test(obj, target, expected) + + +# 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(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(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)) +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].edges()[0].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", "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), + + 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), + + 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)) +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() +c2 = CenterArc((19, 0), 10, 0, 360).edge() +skew = Line((-12, 0), (30, 10)).edge() +vert = Line((10, 0), (10, 20)).edge() +horz = Line((0, 10), (30, 10)).edge() +e1 = EllipticalCenterArc((5, 0), 5, 10, 0, 360).edge() + +freecad_matrix = [ + Case(c1, skew, [Vertex, Vertex], "circle, skew, intersect", None), + Case(c2, skew, [Vertex, Vertex], "circle, skew, intersect", None), + Case(c1, e1, [Vertex, Vertex, Vertex], "circle, ellipse, intersect + tangent", None), + Case(c2, e1, [Vertex, Vertex], "circle, ellipse, intersect", None), + Case(skew, e1, [Vertex, Vertex], "skew, ellipse, intersect", None), + Case(skew, horz, [Vertex], "skew, horizontal, coincident", None), + Case(skew, vert, [Vertex], "skew, vertical, intersect", None), + Case(horz, vert, [Vertex], "horizontal, vertical, intersect", None), + Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None), + Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None), + + Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", 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), + Case(c2, vert, [Vertex], "circle, vert, intersect", None), +] + +@pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix)) +def test_freecad(obj, target, expected): + run_test(obj, target, expected) + + +# Issue tests +t = Sketch() + GridLocations(5, 0, 2, 1) * Circle(2) +s = Circle(10).face() +l = Line(-20, 20).edge() +a = Rectangle(10,10).face() +b = (Plane.XZ * a).face() +e1 = Edge.make_line((-1, 0), (1, 0)) +w1 = Wire.make_circle(0.5) +f1 = Face(Wire.make_circle(0.5)) + +issues_matrix = [ + Case(t, t, [Face, Face], "issue #1015", 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)) +def test_issues(obj, target, expected): + run_test(obj, target, expected) + + +# Exceptions +exception_matrix = [ + Case(vt1, Color(), None, "Unsupported type", None), + Case(ed1, Color(), None, "Unsupported type", None), + Case(fc1, Color(), None, "Unsupported type", None), + Case(sl1, Color(), None, "Unsupported type", None), + Case(cp1, Color(), None, "Unsupported type", None), +] + +@pytest.mark.skip +def make_exception_params(matrix): + params = [] + for case in matrix: + obj_type = type(case.object).__name__ + tar_type = type(case.target).__name__ + i = len(params) + uid = f"{i} {obj_type}, {tar_type}, {case.name}" + params.append(pytest.param(case.object, case.target, case.expected, id=uid)) + + return params + +@pytest.mark.parametrize("obj, target, expected", make_exception_params(exception_matrix)) +def test_exceptions(obj, target, expected): + with pytest.raises(Exception): + obj.intersect(target) \ No newline at end of file diff --git a/tests/test_direct_api/test_json.py b/tests/test_direct_api/test_json.py new file mode 100644 index 0000000..b18be83 --- /dev/null +++ b/tests/test_direct_api/test_json.py @@ -0,0 +1,91 @@ +""" +build123d tests + +name: test_json.py +by: Gumyr +date: February 24, 2025 + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import json +import unittest +from build123d.geometry import ( + Axis, + Color, + GeomEncoder, + Location, + LocationEncoder, + Matrix, + Plane, + Rotation, + Vector, +) + + +class TestGeomEncode(unittest.TestCase): + + def test_as_json(self): + + a_json = json.dumps(Axis.Y, cls=GeomEncoder) + axis = json.loads(a_json, object_hook=GeomEncoder.geometry_hook) + self.assertEqual(Axis.Y, axis) + + c_json = json.dumps(Color("red"), cls=GeomEncoder) + color = json.loads(c_json, object_hook=GeomEncoder.geometry_hook) + self.assertEqual(tuple(Color("red")), tuple(color)) + + loc = Location((0, 1, 2), (4, 8, 16)) + l_json = json.dumps(loc, cls=GeomEncoder) + loc_json = json.loads(l_json, object_hook=GeomEncoder.geometry_hook) + self.assertAlmostEqual(loc.position, loc_json.position, 5) + self.assertAlmostEqual(loc.orientation, loc_json.orientation, 5) + + with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"): + loc_legacy = json.loads(l_json, object_hook=LocationEncoder.location_hook) + self.assertAlmostEqual(loc.position, loc_legacy.position, 5) + self.assertAlmostEqual(loc.orientation, loc_legacy.orientation, 5) + + p_json = json.dumps(Plane.XZ, cls=GeomEncoder) + plane = json.loads(p_json, object_hook=GeomEncoder.geometry_hook) + self.assertEqual(Plane.XZ, plane) + + rot = Rotation((0, 1, 4)) + r_json = json.dumps(rot, cls=GeomEncoder) + rotation = json.loads(r_json, object_hook=GeomEncoder.geometry_hook) + self.assertAlmostEqual(rot.position, rotation.position, 5) + self.assertAlmostEqual(rot.orientation, rotation.orientation, 5) + + v_json = json.dumps(Vector(1, 2, 4), cls=GeomEncoder) + vector = json.loads(v_json, object_hook=GeomEncoder.geometry_hook) + self.assertEqual(Vector(1, 2, 4), vector) + + def test_as_json_error(self): + with self.assertRaises(TypeError): + json.dumps(Matrix(), cls=GeomEncoder) + + v_json = '{"Vector": [1.0, 2.0, 4.0], "Color": [0, 0, 0, 0]}' + with self.assertRaises(ValueError): + json.loads(v_json, object_hook=GeomEncoder.geometry_hook) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index 95fd94c..d22cb6c 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -26,7 +26,6 @@ license: """ -# Always equal to any other object, to test that __eq__ cooperation is working import copy import json import math @@ -34,7 +33,6 @@ import os import unittest from random import uniform -import numpy as np from OCP.gp import ( gp_Ax1, gp_Dir, @@ -51,6 +49,8 @@ from build123d.topology import Edge, Solid, Vertex class AlwaysEqual: + """Always equal to any other object, to test that __eq__ cooperation is working""" + def __eq__(self, other): return True @@ -59,7 +59,7 @@ class TestLocation(unittest.TestCase): def test_location(self): loc0 = Location() T = loc0.wrapped.Transformation().TranslationPart() - np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 0), 1e-6) + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 0), 5) angle = math.degrees( loc0.wrapped.Transformation().GetRotation().GetRotationAngle() ) @@ -69,19 +69,19 @@ class TestLocation(unittest.TestCase): loc0 = Location((0, 0, 1)) T = loc0.wrapped.Transformation().TranslationPart() - np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6) + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) # List loc0 = Location([0, 0, 1]) T = loc0.wrapped.Transformation().TranslationPart() - np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6) + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) # Vector loc1 = Location(Vector(0, 0, 1)) T = loc1.wrapped.Transformation().TranslationPart() - np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6) + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) # rotation + translation loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45) @@ -103,13 +103,8 @@ class TestLocation(unittest.TestCase): # Test creation from the OCP.gp.gp_Trsf object loc4 = Location(gp_Trsf()) - np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 0), 1e-7) - np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7) - - # Test creation from Plane and Vector - loc4 = Location(Plane.XY, (0, 0, 1)) - np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 1), 1e-7) - np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7) + self.assertAlmostEqual(tuple(loc4)[0], (0, 0, 0), 5) + self.assertAlmostEqual(tuple(loc4)[1], (0, 0, 0), 5) # Test composition loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15) @@ -119,7 +114,7 @@ class TestLocation(unittest.TestCase): loc7 = loc4**2 T = loc5.wrapped.Transformation().TranslationPart() - np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6) + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) angle5 = math.degrees( loc5.wrapped.Transformation().GetRotation().GetRotationAngle() @@ -165,21 +160,46 @@ class TestLocation(unittest.TestCase): t.SetRotationPart(q) loc2 = Location(t) - np.testing.assert_allclose(loc1.to_tuple()[0], loc2.to_tuple()[0], 1e-6) - np.testing.assert_allclose(loc1.to_tuple()[1], loc2.to_tuple()[1], 1e-6) + self.assertAlmostEqual(tuple(loc1)[0], tuple(loc2)[0], 5) + self.assertAlmostEqual(tuple(loc1)[1], tuple(loc2)[1], 5) loc1 = Location((1, 2), 34) - np.testing.assert_allclose(loc1.to_tuple()[0], (1, 2, 0), 1e-6) - np.testing.assert_allclose(loc1.to_tuple()[1], (0, 0, 34), 1e-6) + self.assertAlmostEqual(tuple(loc1)[0], (1, 2, 0), 5) + self.assertAlmostEqual(tuple(loc1)[1], (0, 0, 34), 5) rot_angles = (-115.00, 35.00, -135.00) loc2 = Location((1, 2, 3), rot_angles) - np.testing.assert_allclose(loc2.to_tuple()[0], (1, 2, 3), 1e-6) - np.testing.assert_allclose(loc2.to_tuple()[1], rot_angles, 1e-6) + self.assertAlmostEqual(tuple(loc2)[0], (1, 2, 3), 5) + self.assertAlmostEqual(tuple(loc2)[1], rot_angles, 5) loc3 = Location(loc2) - np.testing.assert_allclose(loc3.to_tuple()[0], (1, 2, 3), 1e-6) - np.testing.assert_allclose(loc3.to_tuple()[1], rot_angles, 1e-6) + self.assertAlmostEqual(tuple(loc3)[0], (1, 2, 3), 5) + self.assertAlmostEqual(tuple(loc3)[1], rot_angles, 5) + + def test_location_kwarg_parameters(self): + loc = Location(position=(10, 20, 30)) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + + loc = Location(position=(10, 20, 30), orientation=(10, 20, 30)) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5) + + loc = Location( + position=(10, 20, 30), orientation=(90, 0, 90), ordering=Extrinsic.XYZ + ) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (0, 90, 90), 5) + + loc = Location((10, 20, 30), orientation=(10, 20, 30)) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5) + + loc = Location(plane=Plane.isometric) + self.assertAlmostEqual(loc.position, (0, 0, 0), 5) + self.assertAlmostEqual(loc.orientation, (45.00, 35.26, 30.00), 2) + + loc = Location(location=Location()) + self.assertAlmostEqual(loc.position, (0, 0, 0), 5) def test_location_parameters(self): loc = Location((10, 20, 30)) @@ -240,15 +260,16 @@ class TestLocation(unittest.TestCase): loc1 = Location((1, 2, 3), (90, 45, 22.5)) loc2 = copy.copy(loc1) loc3 = copy.deepcopy(loc1) - self.assertAlmostEqual(loc1.position, loc2.position.to_tuple(), 6) - self.assertAlmostEqual(loc1.orientation, loc2.orientation.to_tuple(), 6) - self.assertAlmostEqual(loc1.position, loc3.position.to_tuple(), 6) - self.assertAlmostEqual(loc1.orientation, loc3.orientation.to_tuple(), 6) + self.assertAlmostEqual(loc1.position, loc2.position, 6) + self.assertAlmostEqual(loc1.orientation, loc2.orientation, 6) + self.assertAlmostEqual(loc1.position, loc3.position, 6) + self.assertAlmostEqual(loc1.orientation, loc3.orientation, 6) - def test_to_axis(self): - axis = Location((1, 2, 3), (-90, 0, 0)).to_axis() - self.assertAlmostEqual(axis.position, (1, 2, 3), 6) - self.assertAlmostEqual(axis.direction, (0, 1, 0), 6) + # deprecated + # def test_to_axis(self): + # axis = Location((1, 2, 3), (-90, 0, 0)).to_axis() + # self.assertAlmostEqual(axis.position, (1, 2, 3), 6) + # self.assertAlmostEqual(axis.direction, (0, 1, 0), 6) def test_equal(self): loc = Location((1, 2, 3), (4, 5, 6)) @@ -266,6 +287,23 @@ class TestLocation(unittest.TestCase): self.assertNotEqual(loc, diff_orientation) self.assertNotEqual(loc, object()) + def test_set(self): + l0 = Location((0, 1, 2), (3, 4, 5)) + for i in range(1, 8): + for j in range(1, 8): + l1 = Location( + (l0.position.X + 1.0 / (10**i), l0.position.Y, l0.position.Z), + ( + l0.orientation.X + 1.0 / (10**j), + l0.orientation.Y, + l0.orientation.Z, + ), + ) + if l0 == l1: + self.assertEqual(len(set([l0, l1])), 1) + else: + self.assertEqual(len(set([l0, l1])), 2) + def test_neg(self): loc = Location((1, 2, 3), (0, 35, 127)) n_loc = -loc @@ -290,7 +328,8 @@ class TestLocation(unittest.TestCase): } # Serializing json with custom Location encoder - json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder) + with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"): + json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder) # Writing to sample.json with open("sample.json", "w") as outfile: @@ -298,7 +337,8 @@ class TestLocation(unittest.TestCase): # Reading from sample.json with open("sample.json") as infile: - read_json = json.load(infile, object_hook=LocationEncoder.location_hook) + with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"): + read_json = json.load(infile, object_hook=LocationEncoder.location_hook) # Validate locations for key, value in read_json.items(): @@ -348,8 +388,8 @@ class TestLocation(unittest.TestCase): e3 = Edge.make_line((0, 0), (2, 0)) i = e1.intersect(e2, e3) - self.assertTrue(isinstance(i, Vertex)) - self.assertAlmostEqual(Vector(i), (1, 0, 0), 5) + self.assertTrue(isinstance(i, list)) + self.assertAlmostEqual(Vector(i[0]), (1, 0, 0), 5) e4 = Edge.make_line((1, -1), (1, 1)) e5 = Edge.make_line((2, -1), (2, 1)) @@ -395,6 +435,31 @@ class TestLocation(unittest.TestCase): self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3)) self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3)) + def test_center(self): + self.assertEqual(Location((2, 4, 8), (1, 2, 3)).center(), Vector(2, 4, 8)) + + def test_mirror_location(self): + # Original location: positioned at (10, 0, 5) with a rotated orientation + loc = Location((10, 0, 5), (30, 45, 60)) + + # Mirror across the YZ plane (X-flip) + mirror_plane = Plane.YZ + mirrored = loc.mirror(mirror_plane) + + # Check mirrored position + expected_position = Vector(-10, 0, 5) + self.assertEqual( + mirrored.position, + expected_position, + msg=f"Expected position {expected_position}, got {mirrored.position}", + ) + + # Check that the mirrored orientation is still right-handed + plane = Plane(mirrored) + cross = plane.x_dir.cross(plane.y_dir) + dot = cross.dot(plane.z_dir) + self.assertGreater(dot, 0.999, "Orientation is not right-handed") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py index ebd1744..864711b 100644 --- a/tests/test_direct_api/test_mixin1_d.py +++ b/tests/test_direct_api/test_mixin1_d.py @@ -28,11 +28,22 @@ license: import math import unittest +from unittest.mock import patch -from build123d.build_enums import CenterOf, GeomType, PositionMode, Side, SortBy -from build123d.geometry import Axis, Location, Plane, Vector +from build123d.build_enums import ( + CenterOf, + FrameMethod, + GeomType, + PositionMode, + Side, + SortBy, +) +from build123d.geometry import Axis, Location, Plane, Rot, Vector, TOLERANCE +from build123d.objects_curve import CenterArc, Line, Polyline from build123d.objects_part import Box, Cylinder -from build123d.topology import Compound, Edge, Face, Wire +from build123d.operations_part import extrude +from build123d.operations_generic import fillet +from build123d.topology import Compound, Edge, Face, Solid, Vertex, Wire class TestMixin1D(unittest.TestCase): @@ -45,10 +56,8 @@ class TestMixin1D(unittest.TestCase): 5, ) # Not sure what PARAMETER mode returns - but it's in the ballpark - point = ( - Edge.make_line((0, 0, 0), (1, 1, 1)) - .position_at(0.5, position_mode=PositionMode.PARAMETER) - .to_tuple() + point = Edge.make_line((0, 0, 0), (1, 1, 1)).position_at( + 0.5, position_mode=PositionMode.PARAMETER ) self.assertTrue(all([0.0 < v < 1.0 for v in point])) @@ -98,23 +107,81 @@ 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.topology.one_d.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), (-1, 0, 0), 5, ) - tangent = ( - Edge.make_circle(1, start_angle=0, end_angle=90) - .tangent_at(0.0, position_mode=PositionMode.PARAMETER) - .to_tuple() + tangent = Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at( + 0.0, position_mode=PositionMode.PARAMETER ) self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent])) @@ -177,8 +244,12 @@ class TestMixin1D(unittest.TestCase): (0, 0, 1), 5, ) + line = Edge.make_line((0, 0, 0), (1, 1, 1)) with self.assertRaises(ValueError): - Edge.make_line((0, 0, 0), (1, 1, 1)).normal() + line.normal() + line.wrapped = None + with self.assertRaises(ValueError): + line.normal() def test_center(self): c = Edge.make_circle(1, start_angle=0, end_angle=180) @@ -189,6 +260,9 @@ class TestMixin1D(unittest.TestCase): 5, ) self.assertAlmostEqual(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5) + c.wrapped = None + with self.assertRaises(ValueError): + c.center() def test_location_at(self): loc = Edge.make_circle(1).location_at(0.25) @@ -201,6 +275,18 @@ class TestMixin1D(unittest.TestCase): self.assertAlmostEqual(loc.position, (0, 1, 0), 5) self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5) + def test_location_at_x_dir(self): + path = Polyline((-50, -40), (50, -40), (50, 40), (-50, 40), close=True) + l1 = path.location_at(0) + l2 = path.location_at(0, x_dir=(0, 1, 0)) + self.assertAlmostEqual(l1.position, l2.position, 5) + self.assertAlmostEqual(l1.z_axis, l2.z_axis, 5) + self.assertNotEqual(l1.x_axis, l2.x_axis, 5) + self.assertAlmostEqual(l2.x_axis, Axis(path @ 0, (0, 1, 0)), 5) + + with self.assertRaises(ValueError): + path.location_at(0, x_dir=(1, 0, 0)) + def test_locations(self): locs = Edge.make_circle(1).locations([i / 4 for i in range(4)]) self.assertAlmostEqual(locs[0].position, (1, 0, 0), 5) @@ -212,6 +298,37 @@ class TestMixin1D(unittest.TestCase): self.assertAlmostEqual(locs[3].position, (0, -1, 0), 5) self.assertAlmostEqual(locs[3].orientation, (0, 90, 90), 5) + def test_location_at_corrected_frenet(self): + # A polyline with sharp corners — problematic for classic Frenet + path = Polyline((0, 0), (10, 0), (10, 10), (0, 10)) + + # Request multiple locations along the curve + locations = [ + path.location_at(t, frame_method=FrameMethod.CORRECTED) + for t in [0.0, 0.25, 0.5, 0.75, 1.0] + ] + # Ensure all locations were created and have consistent orientation + self.assertTrue( + all( + locations[0].x_axis.direction == l.x_axis.direction + for l in locations[1:] + ) + ) + + # Check that Z-axis is approximately orthogonal to X-axis + for loc in locations: + self.assertLess(abs(loc.z_axis.direction.dot(loc.x_axis.direction)), 1e-6) + + # Check continuity of rotation (not flipping wildly) + # Check angle between x_axes doesn't flip more than ~90 degrees + angles = [] + for i in range(len(locations) - 1): + a1 = locations[i].x_axis.direction + a2 = locations[i + 1].x_axis.direction + angle = a1.get_angle(a2) + angles.append(angle) + self.assertTrue(all(abs(angle) < 90 for angle in angles)) + def test_project(self): target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0))) circle = Edge.make_circle(1).locate(Location((0, 0, 10))) @@ -219,6 +336,9 @@ class TestMixin1D(unittest.TestCase): bbox = ellipse.bounding_box() self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5) self.assertAlmostEqual(bbox.max, (1, 1, 1), 5) + circle.wrapped = None + with self.assertRaises(ValueError): + circle.project(target, (0, 0, -1)) def test_project2(self): target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] @@ -233,6 +353,10 @@ class TestMixin1D(unittest.TestCase): hole_edges = plate.edges().filter_by(GeomType.CIRCLE) self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward) self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward) + e = Edge.make_line((0, 0), (1, 0)) + e.wrapped = None + with self.assertRaises(ValueError): + e.is_forward def test_offset_2d(self): base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False) @@ -313,6 +437,126 @@ class TestMixin1D(unittest.TestCase): wire = Wire.make_rect(1, 1) self.assertAlmostEqual(wire.volume, 0, 5) + def test_edges(self): + box = Solid.make_box(1, 1, 1) + top_x = box.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.X)[-1] + self.assertEqual(top_x.topo_parent, box) + self.assertTrue(isinstance(top_x, Edge)) + self.assertAlmostEqual(top_x.center(), (1, 0.5, 1), 5) + + def test_edges_topo_parent(self): + phone_case_plan = Face.make_rect(80, 150) - Face.make_rect( + 25, 25, Plane((-20, 55)) + ) + phone_case = extrude(phone_case_plan, 2) + window_edges = phone_case.faces().sort_by(Axis.Z)[-1].inner_wires()[0].edges() + for e in window_edges: + self.assertEqual(e.topo_parent, phone_case) + phone_case_f = fillet(window_edges, 1) + self.assertLess(phone_case_f.volume, phone_case.volume) + perimeter = phone_case_f.faces().sort_by(Axis.Z)[-1].outer_wire().edges() + for e in perimeter: + self.assertEqual(e.topo_parent, phone_case_f) + phone_case_ff = fillet(perimeter, 1) + self.assertLess(phone_case_ff.volume, phone_case_f.volume) + + def test_is_closed(self): + self.assertTrue(Edge.make_circle(1).is_closed) + self.assertTrue(Face.make_rect(1, 1).outer_wire().is_closed) + self.assertFalse(Edge.make_line((0, 0), (1, 0)).is_closed) + e = Edge.make_circle(1) + e.wrapped = None + with self.assertRaises(ValueError): + e.is_closed + + def test_add(self): + e = Edge.make_line((0, 0), (1, 0)) + e_plus = e + None + self.assertTrue(e.is_same(e_plus)) + + def test_derivative_at(self): + self.assertAlmostEqual( + Edge.make_line((0, 0), (1, 0)).derivative_at((0, 0), 2), (0, 0, 0), 5 + ) + + def test_project_to_viewport(self): + line = Edge.make_line((0, 0), (1, 0)) + line.wrapped = None + with self.assertRaises(ValueError): + line.project_to_viewport((0, 0, 0)) + + def test_split(self): + line = Edge.make_line((0, 0), (1, 0)) + line.wrapped = None + with self.assertRaises(ValueError): + line.split(Plane.XZ.offset(0.5)) + + def test_extrude(self): + pnt = Vertex(1, 0, 0) + pnt.wrapped = None + with self.assertRaises(ValueError): + Edge.extrude(pnt, (0, 0, 1)) + + +class TestCurvatureComb(unittest.TestCase): + def test_raises_if_not_on_XY(self): + line_xz = Polyline((0, 0, 0), (1, 0, 0), (0, 0, 1)) + with self.assertRaises(ValueError): + _ = line_xz.curvature_comb() + + def test_empty_curve(self): + c = CenterArc((0, 0), 1, 0, 360) + c.wrapped = None + with self.assertRaises(ValueError): + c.curvature_comb() + + def test_circle_constant_height_and_count(self): + radius = 5.0 + count = 64 + max_tooth = 2.0 + + # A closed circle in the XY plane + c = CenterArc((0, 0), radius, 0, 360) + comb = c.curvature_comb(count=count, max_tooth_size=max_tooth) + + # For a closed curve, endpoint is excluded but the method still returns `count` samples. + self.assertEqual(len(comb), count) + + # On a circle, kappa = 1/R => all teeth should have the same length = max_tooth + lengths = [edge.length for edge in comb] + self.assertTrue(all(abs(L - max_tooth) <= TOLERANCE for L in lengths)) + + # Direction check: teeth should be radial (perpendicular to tangent), + # i.e., aligned with (start_point - center). For Circle(...) center is (0,0,0). + center = Vector(0, 0, 0) + for edge in comb[:: max(1, len(comb) // 8)]: # sample a few + p0 = edge.position_at(0.0) + p1 = edge.position_at(1.0) + tooth_dir = (p1 - p0).normalized() + radial = (p0 - center).normalized() + # allow either direction (outward/inward), check colinearity + cross_len = tooth_dir.cross(radial).length + self.assertLessEqual(cross_len, 1e-3) + + def test_line_near_zero_teeth_and_count(self): + # Straight segment in XY => curvature = 0 everywhere + line = Line((0, 0), (10, 0)) + + count = 25 + comb = line.curvature_comb(count=count, max_tooth_size=3.0) + + self.assertEqual(len(comb), 0) # They are 0 length so skipped + + def test_open_arc_count_and_variation(self): + # Open arc: teeth count == requested count; lengths not constant in general + arc = CenterArc((0, 0), 5, 0, 180) # open, CCW half-circle + count = 40 + comb = arc.curvature_comb(count=count, max_tooth_size=1.0) + self.assertEqual(len(comb), count) + # For a circular arc, curvature is constant, so lengths should still be constant + lengths = [e.length for e in comb] + self.assertLessEqual(max(lengths) - min(lengths), 1e-6) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_mixin3_d.py b/tests/test_direct_api/test_mixin3_d.py index 5e04e7b..1bee8fc 100644 --- a/tests/test_direct_api/test_mixin3_d.py +++ b/tests/test_direct_api/test_mixin3_d.py @@ -27,7 +27,7 @@ license: """ import unittest -from unittest.mock import patch +from unittest.mock import patch, PropertyMock from build123d.build_enums import CenterOf, Kind from build123d.geometry import Axis, Plane @@ -67,7 +67,7 @@ class TestMixin3D(unittest.TestCase): face = box.faces().sort_by(Axis.Z)[0] self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face) - @patch.object(Shape, "is_valid", return_value=False) + @patch.object(Shape, "is_valid", new_callable=PropertyMock, return_value=False) def test_chamfer_invalid_shape_raises_error(self, mock_is_valid): box = Solid.make_box(1, 1, 1) @@ -111,7 +111,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) # face with depth @@ -119,7 +119,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], depth=0.5, thru_all=False, additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) # face until @@ -128,7 +128,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], up_to_face=limit, thru_all=False, additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) # wire @@ -136,7 +136,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [w], additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) def test_center(self): diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py new file mode 100644 index 0000000..a083f7b --- /dev/null +++ b/tests/test_direct_api/test_oriented_bound_box.py @@ -0,0 +1,300 @@ +""" +build123d tests + +name: test_oriented_bound_box.py +by: Gumyr +date: February 4, 2025 + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import math +import re +import unittest + +from build123d.geometry import Axis, Location, OrientedBoundBox, Plane, Pos, Rot, Vector +from build123d.topology import Edge, Face, Solid +from build123d.objects_part import Box +from build123d.objects_sketch import Polygon + + +class TestOrientedBoundBox(unittest.TestCase): + def test_size_and_diagonal(self): + # Create a unit cube (with one corner at the origin). + cube = Solid.make_box(1, 1, 1) + obb = OrientedBoundBox(cube) + + # The size property multiplies half-sizes by 2. For a unit cube, expect (1, 1, 1). + size = obb.size + self.assertAlmostEqual(size.X, 1.0, places=6) + self.assertAlmostEqual(size.Y, 1.0, places=6) + self.assertAlmostEqual(size.Z, 1.0, places=6) + + # The full body diagonal should be sqrt(1^2+1^2+1^2) = sqrt(3). + expected_diag = math.sqrt(3) + self.assertAlmostEqual(obb.diagonal, expected_diag, places=6) + + obb.wrapped = None + self.assertAlmostEqual(obb.diagonal, 0.0, places=6) + + def test_center(self): + # For a cube made at the origin, the center should be at (0.5, 0.5, 0.5) + cube = Solid.make_box(1, 1, 1) + obb = OrientedBoundBox(cube) + center = obb.center() + self.assertAlmostEqual(center.X, 0.5, places=6) + self.assertAlmostEqual(center.Y, 0.5, places=6) + self.assertAlmostEqual(center.Z, 0.5, places=6) + + def test_directions_are_unit_vectors(self): + # Create a rotated cube so the direction vectors are non-trivial. + cube = Rot(45, 45, 0) * Solid.make_box(1, 1, 1) + obb = OrientedBoundBox(cube) + + # Check that each primary direction is a unit vector. + for direction in (obb.x_direction, obb.y_direction, obb.z_direction): + self.assertAlmostEqual(direction.length, 1.0, places=6) + + def test_is_outside(self): + # For a unit cube, test a point inside and a point clearly outside. + cube = Solid.make_box(1, 1, 1) + obb = OrientedBoundBox(cube) + + # Use the cube's center as an "inside" test point. + center = obb.center() + self.assertFalse(obb.is_outside(center)) + + # A point far away should be outside. + outside_point = Vector(10, 10, 10) + self.assertTrue(obb.is_outside(outside_point)) + + outside_point._wrapped = None + with self.assertRaises(ValueError): + obb.is_outside(outside_point) + + def test_is_completely_inside(self): + # Create a larger cube and a smaller cube that is centered within it. + large_cube = Solid.make_box(2, 2, 2) + small_cube = Solid.make_box(1, 1, 1) + # Translate the small cube by (0.5, 0.5, 0.5) so its center is at (1,1,1), + # which centers it within the 2x2x2 cube (whose center is also at (1,1,1)). + small_cube = Pos(0.5, 0.5, 0.5) * small_cube + + large_obb = OrientedBoundBox(large_cube) + small_obb = OrientedBoundBox(small_cube) + + # The small box should be completely inside the larger box. + self.assertTrue(large_obb.is_completely_inside(small_obb)) + # Conversely, the larger box cannot be completely inside the smaller one. + self.assertFalse(small_obb.is_completely_inside(large_obb)) + + large_obb.wrapped = None + with self.assertRaises(ValueError): + small_obb.is_completely_inside(large_obb) + + def test_init_from_bnd_obb(self): + # Test that constructing from an already computed Bnd_OBB works as expected. + cube = Solid.make_box(1, 1, 1) + obb1 = OrientedBoundBox(cube) + # Create a new instance by passing the underlying wrapped object. + obb2 = OrientedBoundBox(obb1.wrapped) + + # Compare diagonal, size, and center. + self.assertAlmostEqual(obb1.diagonal, obb2.diagonal, places=6) + size1 = obb1.size + size2 = obb2.size + self.assertAlmostEqual(size1.X, size2.X, places=6) + self.assertAlmostEqual(size1.Y, size2.Y, places=6) + self.assertAlmostEqual(size1.Z, size2.Z, places=6) + center1 = obb1.center() + center2 = obb2.center() + self.assertAlmostEqual(center1.X, center2.X, places=6) + self.assertAlmostEqual(center1.Y, center2.Y, places=6) + self.assertAlmostEqual(center1.Z, center2.Z, places=6) + + def test_plane(self): + # Note: Orientation of plan may not be consistent across platforms + rect = Rot(Z=10) * Face.make_rect(1, 2) + obb = rect.oriented_bounding_box() + pln = obb.plane + self.assertAlmostEqual(abs(pln.z_dir.dot(Vector(0, 0, 1))), 1.0, places=6) + self.assertTrue( + any( + abs(d.dot(Vector(1, 0).rotate(Axis.Z, 10))) > 0.999 + for d in [pln.x_dir, pln.y_dir, pln.z_dir] + ) + ) + + def test_repr(self): + # Create a simple unit cube OBB. + obb = OrientedBoundBox(Solid.make_box(1, 1, 1)) + rep = repr(obb) + + # Check that the repr string contains expected substrings. + self.assertIn("OrientedBoundBox(center=Vector(", rep) + self.assertIn("size=Vector(", rep) + self.assertIn("plane=Plane(", rep) + + # Use a regular expression to extract numbers. + 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\.]+)\)\)\)" + ) + m = re.match(pattern, rep) + self.assertIsNotNone( + m, "The __repr__ string did not match the expected format." + ) + + # Convert extracted strings to floats. + center = Vector( + float(m.group("c0")), float(m.group("c1")), float(m.group("c2")) + ) + size = Vector(float(m.group("s0")), float(m.group("s1")), float(m.group("s2"))) + # For a unit cube, we expect the center to be (0.5, 0.5, 0.5) + self.assertAlmostEqual(center.X, 0.5, places=6) + self.assertAlmostEqual(center.Y, 0.5, places=6) + self.assertAlmostEqual(center.Z, 0.5, places=6) + # And the full size to be approximately (1, 1, 1) (floating-point values may vary slightly). + self.assertAlmostEqual(size.X, 1.0, places=6) + self.assertAlmostEqual(size.Y, 1.0, places=6) + self.assertAlmostEqual(size.Z, 1.0, places=6) + + def test_rotated_cube_corners(self): + # Create a cube of size 2x2x2 rotated by 45 degrees around each axis. + rotated_cube = Rot(45, 45, 45) * Box(2, 2, 2) + + # Compute the oriented bounding box. + obb = OrientedBoundBox(rotated_cube) + corners = obb.corners + + # There should be eight unique corners. + self.assertEqual(len(corners), 8) + + # The center of the cube should be at or near the origin. + center = obb.center() + + # For a cube with full side lengths 2, the half-size is 1, + # so the distance from the center to any corner is sqrt(1^2 + 1^2 + 1^2) = sqrt(3). + expected_distance = math.sqrt(3) + + # Verify that each corner is at the expected distance from the center. + for corner in corners: + distance = (corner - center).length + self.assertAlmostEqual(distance, expected_distance, places=6) + + def test_planar_face_corners(self): + """ + Test that a planar face returns four unique corner points. + """ + # Create a square face of size 2x2 (centered at the origin). + face = Face.make_rect(2, 2) + # Compute the oriented bounding box from the face. + obb = OrientedBoundBox(face) + corners = obb.corners + + # Convert each Vector to a tuple (rounded for tolerance reasons) + unique_points = { + (round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners + } + # For a planar (2D) face, we expect 4 unique corners. + self.assertEqual( + len(unique_points), + 4, + f"Expected 4 unique corners for a planar face but got {len(unique_points)}", + ) + # Check orientation + for pln in [Plane.XY, Plane.XZ, Plane.YZ]: + rect = Face.make_rect(1, 2, pln) + obb = OrientedBoundBox(rect) + corners = obb.corners + poly = Polygon(*corners, align=None) + 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) + area = sum(f.area for f in face.intersect(poly).faces()) + self.assertAlmostEqual(area, face.area, 5) + + def test_line_corners(self): + """ + Test that a straight line returns two unique endpoints. + """ + # Create a straight line from (0, 0, 0) to (1, 0, 0). + line = Edge.make_line(Vector(0, 0, 0), Vector(1, 0, 0)) + # Compute the oriented bounding box from the line. + obb = OrientedBoundBox(line) + corners = obb.corners + + # Convert each Vector to a tuple (rounded for tolerance) + unique_points = { + (round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners + } + # For a line, we expect only 2 unique endpoints. + self.assertEqual( + len(unique_points), + 2, + f"Expected 2 unique corners for a line but got {len(unique_points)}", + ) + # Check orientation + for end in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]: + line = Edge.make_line((0, 0, 0), end) + obb = OrientedBoundBox(line) + corners = obb.corners + self.assertEqual(len(corners), 2) + self.assertTrue(Vector(end) in corners) + + def test_location(self): + # Create a unit cube. + cube = Solid.make_box(1, 1, 1) + obb = OrientedBoundBox(cube) + + # Get the location property (constructed from the plane). + loc = obb.location + + # Check that loc is a Location instance. + self.assertIsInstance(loc, Location) + + # Compare the location's origin with the oriented bounding box center. + center = obb.center() + self.assertAlmostEqual(loc.position.X, center.X, places=6) + self.assertAlmostEqual(loc.position.Y, center.Y, places=6) + self.assertAlmostEqual(loc.position.Z, center.Z, places=6) + + # Optionally, if the Location preserves the plane's orientation, + # check that the x and z directions match those of the obb's plane. + plane = obb.plane + self.assertAlmostEqual(loc.x_axis.direction.X, plane.x_dir.X, places=6) + self.assertAlmostEqual(loc.x_axis.direction.Y, plane.x_dir.Y, places=6) + self.assertAlmostEqual(loc.x_axis.direction.Z, plane.x_dir.Z, places=6) + + self.assertAlmostEqual(loc.z_axis.direction.X, plane.z_dir.X, places=6) + self.assertAlmostEqual(loc.z_axis.direction.Y, plane.z_dir.Y, places=6) + self.assertAlmostEqual(loc.z_axis.direction.Z, plane.z_dir.Z, places=6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py index 3a2f899..e9e7faa 100644 --- a/tests/test_direct_api/test_plane.py +++ b/tests/test_direct_api/test_plane.py @@ -123,6 +123,8 @@ class TestPlane(unittest.TestCase): Plane() with self.assertRaises(TypeError): Plane(o, z_dir="up") + with self.assertRaises(TypeError): + Plane(o, forward="up") # rotated location around z loc = Location((0, 0, 0), (0, 0, 45)) @@ -211,6 +213,54 @@ class TestPlane(unittest.TestCase): self.assertAlmostEqual(p.y_dir, expected[i][1], 6) self.assertAlmostEqual(p.z_dir, expected[i][2], 6) + def test_plane_from_axis(self): + origin = Vector(1, 2, 3) + direction = Vector(0, 0, 1) + axis = Axis(origin, direction) + plane = Plane(axis) + + self.assertEqual(plane.origin, origin) + self.assertTrue(plane.z_dir, direction.normalized()) + self.assertAlmostEqual(plane.x_dir.length, 1.0, places=12) + self.assertAlmostEqual(plane.y_dir.length, 1.0, places=12) + self.assertAlmostEqual(plane.z_dir.length, 1.0, places=12) + + def test_plane_from_axis_with_x_dir(self): + origin = Vector(0, 0, 0) + z_dir = Vector(0, 0, 1) + x_dir = Vector(1, 0, 0) + axis = Axis(origin, z_dir) + plane = Plane(axis, x_dir) + + self.assertEqual(plane.origin, origin) + self.assertEqual(plane.z_dir, z_dir.normalized()) + self.assertEqual(plane.x_dir, x_dir.normalized()) + self.assertEqual(plane.y_dir, z_dir.cross(x_dir).normalized()) + + def test_plane_from_axis_with_kwargs(self): + axis = Axis((0, 0, 0), (0, 1, 0)) + x_dir = Vector(1, 0, 0) + plane = Plane(axis=axis, x_dir=x_dir) + + self.assertEqual(plane.z_dir, Vector(0, 1, 0)) + self.assertEqual(plane.x_dir, x_dir.normalized()) + + def test_plane_from_axis_without_x_dir(self): + axis = Axis((0, 0, 0), (1, 0, 0)) + plane = Plane(axis) + + self.assertEqual(plane.z_dir, Vector(1, 0, 0)) + self.assertAlmostEqual(plane.x_dir.length, 1.0, places=12) + self.assertAlmostEqual(plane.y_dir.length, 1.0, places=12) + self.assertGreater(plane.z_dir.cross(plane.x_dir).dot(plane.y_dir), 0.99) + + def test_plane_from_axis_invalid_x_dir(self): + axis = Axis((0, 0, 0), (0, 0, 1)) + with self.assertRaises(ValueError): + Plane(axis, x_dir=(0, 0, 0)) + with self.assertRaises(TypeError): + Plane(axis, "front") + def test_plane_neg(self): p = Plane( origin=(1, 2, 3), @@ -273,11 +323,13 @@ class TestPlane(unittest.TestCase): np.testing.assert_allclose(target_point, local_box_vertices[i], 1e-7) def test_localize_vertex(self): - vertex = Vertex(random.random(), random.random(), random.random()) - np.testing.assert_allclose( - Plane.YZ.to_local_coords(vertex).to_tuple(), - Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(), - 5, + v_x, v_y, v_z = (random.random(), random.random(), random.random()) + vertex = Vertex(v_x, v_y, v_z) + self.assertAlmostEqual( + Plane.YZ.to_local_coords(Vector(vertex)), (v_y, v_z, v_x), 5 + ) + self.assertAlmostEqual( + Vector(Plane.YZ.to_local_coords(vertex)), (v_y, v_z, v_x), 5 ) def test_repr(self): @@ -408,6 +460,21 @@ class TestPlane(unittest.TestCase): Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), ) + def test_set(self): + p0 = Plane((0, 1, 2), (3, 4, 5), (6, 7, 8)) + for i in range(1, 8): + for j in range(1, 8): + for k in range(1, 8): + p1 = Plane( + (p0.origin.X + 1.0 / (10**i), p0.origin.Y, p0.origin.Z), + (p0.x_dir.X + 1.0 / (10**j), p0.x_dir.Y, p0.x_dir.Z), + (p0.z_dir.X + 1.0 / (10**k), p0.z_dir.Y, p0.z_dir.Z), + ) + if p0 == p1: + self.assertEqual(len(set([p0, p1])), 1) + else: + self.assertEqual(len(set([p0, p1])), 2) + def test_to_location(self): loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location self.assertAlmostEqual(loc.position, (1, 2, 3), 5) diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py index 5fbb7bd..8b0da03 100644 --- a/tests/test_direct_api/test_projection.py +++ b/tests/test_direct_api/test_projection.py @@ -94,10 +94,6 @@ 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_to_axis(self): - with self.assertRaises(ValueError): - Edge.make_circle(1, end_angle=30).to_axis() - if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 835b70b..a261f8f 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -29,10 +29,11 @@ license: # Always equal to any other object, to test that __eq__ cooperation is working import unittest from random import uniform -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import numpy as np -from build123d.build_enums import CenterOf, Keep +from anytree import PreOrderIter +from build123d.build_enums import CenterOf, GeomType, Keep from build123d.geometry import ( Axis, Color, @@ -43,7 +44,7 @@ from build123d.geometry import ( Rotation, Vector, ) -from build123d.objects_part import Box, Cylinder +from build123d.objects_part import Box, Cone, Cylinder, Sphere from build123d.objects_sketch import Circle from build123d.operations_part import extrude from build123d.topology import ( @@ -100,7 +101,7 @@ class TestShape(unittest.TestCase): Shape.combined_center(objs, center_of=CenterOf.GEOMETRY) def test_shape_type(self): - self.assertEqual(Vertex().shape_type(), "Vertex") + self.assertEqual(Vertex().shape_type, "Vertex") def test_scale(self): self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5) @@ -109,10 +110,10 @@ class TestShape(unittest.TestCase): box1 = Solid.make_box(1, 1, 1) box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0))) combined = box1.fuse(box2, glue=True) - self.assertTrue(combined.is_valid()) + self.assertTrue(combined.is_valid) self.assertAlmostEqual(combined.volume, 2, 5) fuzzy = box1.fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) + self.assertTrue(fuzzy.is_valid) self.assertAlmostEqual(fuzzy.volume, 2, 5) def test_faces_intersected_by_axis(self): @@ -171,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 @@ -245,7 +247,7 @@ class TestShape(unittest.TestCase): # invalid_object = box.fillet(0.75, box.edges()) # invalid_object.max_fillet(invalid_object.edges()) - @patch.object(Shape, "is_valid", return_value=False) + @patch.object(Shape, "is_valid", new_callable=PropertyMock, return_value=False) def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid): box = Solid.make_box(1, 1, 1) @@ -298,7 +300,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))) @@ -317,8 +320,8 @@ class TestShape(unittest.TestCase): c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) c1 = Edge.make_circle(1) closest = c0.closest_points(c1) - self.assertAlmostEqual(closest[0], c0.position_at(0.75).to_tuple(), 5) - self.assertAlmostEqual(closest[1], c1.position_at(0.25).to_tuple(), 5) + self.assertAlmostEqual(closest[0], c0.position_at(0.75), 5) + self.assertAlmostEqual(closest[1], c1.position_at(0.25), 5) def test_distance_to(self): c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) @@ -347,19 +350,19 @@ class TestShape(unittest.TestCase): obj = Solid() self.assertIs(obj, obj.clean()) - def test_relocate(self): - box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5))) - cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0))) + # def test_relocate(self): + # box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5))) + # cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0))) - box_with_hole = box.cut(cylinder) - box_with_hole.relocate(box.location) + # box_with_hole = box.cut(cylinder) + # box_with_hole.relocate(box.location) - self.assertEqual(box.location, box_with_hole.location) + # self.assertEqual(box.location, box_with_hole.location) - bbox1 = box.bounding_box() - bbox2 = box_with_hole.bounding_box() - self.assertAlmostEqual(bbox1.min, bbox2.min, 5) - self.assertAlmostEqual(bbox1.max, bbox2.max, 5) + # bbox1 = box.bounding_box() + # bbox2 = box_with_hole.bounding_box() + # self.assertAlmostEqual(bbox1.min, bbox2.min, 5) + # self.assertAlmostEqual(bbox1.max, bbox2.max, 5) def test_project_to_viewport(self): # Basic test @@ -450,53 +453,67 @@ class TestShape(unittest.TestCase): b = Box(1, 1, 1).locate(Pos(2, 2, 0)) b.color = Color("blue") # Blue c = Cylinder(1, 1).locate(Pos(-2, 2, 0)) + c.color = "red" a = Compound(children=[b, c]) a.color = Color(0, 1, 0) - # Check that assigned colors stay and iheritance works + # Check that assigned colors stay and inheritance works np.testing.assert_allclose(tuple(a.color), (0, 1, 0, 1), 1e-5) np.testing.assert_allclose(tuple(b.color), (0, 0, 1, 1), 1e-5) + np.testing.assert_allclose(tuple(c.color), (1, 0, 0, 1), 1e-5) def test_ocp_section(self): # Vertex verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0)) - self.assertListEqual(verts, []) # ? - self.assertListEqual(edges, []) + self.assertEqual(len(verts), 1) + self.assertEqual(len(edges), 0) + self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4))) - self.assertListEqual(verts, []) # ? - self.assertListEqual(edges, []) + self.assertEqual(len(verts), 1) + self.assertEqual(len(edges), 0) + self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5)) - np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5) + 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)) - np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5) + verts, edges = Vertex(1, 2, 0)._ocp_section(Face(Plane.XY)) + self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) - # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5)) - # cylinder = Pos(Z=-10) * extrude(Circle(5), 20) - # cylinder2 = (Rot((0, 90, 0)) * cylinder).face() - # pln = Plane.XY - # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN)) - # box2 = Pos(Z=-10) * box1 + cylinder = Face.extrude(Edge.make_circle(5, Plane.XY.offset(-10)), (0, 0, 20)) + cylinder2 = Face.extrude(Edge.make_circle(5, Plane.YZ.offset(-10)), (20, 0, 0)) + pln = Plane.XY - # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln)) - # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln)) - # print(vertices1, edges1) + v_edge = Edge.make_line((-5, 0, -20), (-5, 0, 20)) + vertices1, edges1 = cylinder._ocp_section(v_edge) + vertices1 = ShapeList(vertices1).sort_by(Axis.Z) + self.assertEqual(len(vertices1), 2) - # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln)) - # print(vertices2, edges2) + self.assertAlmostEqual(Vector(vertices1[0]), (-5, 0, -10), 5) + self.assertAlmostEqual(Vector(vertices1[1]), (-5, 0, 10), 5) + self.assertEqual(len(edges1), 1) + self.assertAlmostEqual(edges1[0].length, 20, 5) - # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln)) - # print(vertices3, edges3) + 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) + self.assertEqual(edges2[0].geom_type, GeomType.CIRCLE) + self.assertAlmostEqual(edges2[0].radius, 5, 5) - # # vertices4, edges4 = cylinder2.ocp_section(cylinder) + vertices4, edges4 = cylinder2._ocp_section(cylinder) + self.assertGreaterEqual(len(vertices4), 0) + self.assertGreaterEqual(len(edges4), 2) + self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in edges4)) - # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln)) - # print(vertices5, edges5) + cylinder3 = Cylinder(5, 20).solid() + cylinder4 = Rotation(0, 90, 0) * cylinder3 - # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1]) + vertices5, edges5 = cylinder3._ocp_section(cylinder4) + self.assertGreaterEqual(len(vertices5), 0) + self.assertGreaterEqual(len(edges5), 2) + self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in edges5)) def test_copy_attributes_to(self): box = Box(1, 1, 1) @@ -516,9 +533,12 @@ class TestShape(unittest.TestCase): def test_empty_shape(self): empty = Solid() box = Solid.make_box(1, 1, 1) - self.assertIsNone(empty.location) - self.assertIsNone(empty.position) - self.assertIsNone(empty.orientation) + with self.assertRaises(ValueError): + empty.location + with self.assertRaises(ValueError): + empty.position + with self.assertRaises(ValueError): + empty.orientation self.assertFalse(empty.is_manifold) with self.assertRaises(ValueError): empty.geom_type @@ -526,7 +546,7 @@ class TestShape(unittest.TestCase): self.assertEqual(hash(empty), 0) self.assertFalse(empty.is_same(Solid())) self.assertFalse(empty.is_equal(Solid())) - self.assertTrue(empty.is_valid()) + self.assertTrue(empty.is_valid) empty_bbox = empty.bounding_box() self.assertEqual(tuple(empty_bbox.size), (0, 0, 0)) self.assertIs(empty, empty.mirror(Plane.XY)) @@ -560,17 +580,17 @@ class TestShape(unittest.TestCase): empty.moved(Location()) with self.assertRaises(ValueError): box.moved(empty_loc) - with self.assertRaises(ValueError): - empty.relocate(Location()) - with self.assertRaises(ValueError): - box.relocate(empty_loc) + # with self.assertRaises(ValueError): + # empty.relocate(Location()) + # with self.assertRaises(ValueError): + # box.relocate(empty_loc) with self.assertRaises(ValueError): empty.distance_to(Vector(1, 1, 1)) with self.assertRaises(ValueError): 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()) @@ -615,5 +635,55 @@ class TestShape(unittest.TestCase): self.assertIsNone(Vertex(1, 1, 1).compound()) +class TestGlobalLocation(unittest.TestCase): + def test_global_location_hierarchy(self): + # Create a hierarchy: root → child → grandchild + root = Box(1, 1, 1) + root.location = Location((10, 0, 0)) + + child = Box(1, 1, 1) + child.location = Location((0, 20, 0)) + child.parent = root + + grandchild = Box(1, 1, 1) + grandchild.location = Location((0, 0, 30)) + grandchild.parent = child + + # Compute expected global location manually + expected_location = root.location * child.location * grandchild.location + + self.assertAlmostEqual( + grandchild.global_location.position, expected_location.position + ) + self.assertAlmostEqual( + grandchild.global_location.orientation, expected_location.orientation + ) + + def test_global_location_in_assembly(self): + cone = Cone(2, 1, 3) + cone.label = "Cone" + box = Box(1, 2, 3) + box.label = "Box" + sphere = Sphere(1) + sphere.label = "Sphere" + + assembly1 = Compound(label="Assembly1", children=[cone]) + assembly1.move(Location((3, 3, 3), (90, 0, 0))) + assembly2 = Compound(label="Assembly2", children=[assembly1, box]) + assembly2.move(Location((2, 4, 6), (0, 0, 90))) + assembly3 = Compound(label="Assembly3", children=[assembly2, sphere]) + assembly3.move(Location((3, 6, 9))) + deep_shape: Shape = next( + iter(PreOrderIter(assembly3, filter_=lambda n: n.label in ("Cone"))) + ) + # print(deep_shape.path) + self.assertAlmostEqual( + deep_shape.global_location.position, (2, 13, 18), places=6 + ) + self.assertAlmostEqual( + deep_shape.global_location.orientation, (0, 90, 90), places=6 + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py index c1c967f..24091b7 100644 --- a/tests/test_direct_api/test_shape_list.py +++ b/tests/test_direct_api/test_shape_list.py @@ -32,7 +32,6 @@ import math import re import unittest -import numpy as np from IPython.lib import pretty from build123d.build_common import GridLocations, PolarLocations from build123d.build_enums import GeomType, SortBy @@ -64,14 +63,16 @@ class TestShapeList(unittest.TestCase): actual_lines = actual.splitlines() self.assertEqual(len(actual_lines), len(expected_lines)) for actual_line, expected_line in zip(actual_lines, expected_lines): - start, end = re.split(r"at 0x[0-9a-f]+", expected_line, 2, re.I) + start, end = re.split( + r"at 0x[0-9a-f]+", expected_line, maxsplit=2, flags=re.I + ) self.assertTrue(actual_line.startswith(start)) self.assertTrue(actual_line.endswith(end)) def assertDunderReprEqual(self, actual: str, expected: str): splitter = r"at 0x[0-9a-f]+" - actual_split_list = re.split(splitter, actual, 0, re.I) - expected_split_list = re.split(splitter, expected, 0, re.I) + actual_split_list = re.split(splitter, actual, maxsplit=0, flags=re.I) + expected_split_list = re.split(splitter, expected, maxsplit=0, flags=re.I) for actual_split, expected_split in zip(actual_split_list, expected_split_list): self.assertEqual(actual_split, expected_split) @@ -117,6 +118,24 @@ class TestShapeList(unittest.TestCase): self.assertEqual(len(box.edges().filter_by(Axis.X)), 4) self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0) + def test_filter_by_plane(self): + c1 = Cylinder(1, 3) + c2 = Cylinder(1, 3, rotation=(90, 0, 0)) + + sel1 = c1.faces().filter_by(Plane.XY) + sel2 = c1.edges().filter_by(Plane.XY) + sel3 = c2.faces().filter_by(Plane.XZ) + sel4 = c2.edges().filter_by(Plane.XZ) + sel5 = c1.wires().filter_by(Plane.XY) + sel6 = c2.wires().filter_by(Plane.XZ) + + self.assertEqual(len(sel1), 2) + self.assertEqual(len(sel2), 2) + self.assertEqual(len(sel3), 2) + self.assertEqual(len(sel4), 2) + self.assertEqual(len(sel5), 2) + self.assertEqual(len(sel6), 2) + def test_filter_by_callable_predicate(self): boxes = [Solid.make_box(1, 1, 1) for _ in range(3)] boxes[0].label = "A" @@ -302,7 +321,7 @@ class TestShapeList(unittest.TestCase): def test_vertex(self): sl = ShapeList([Edge.make_circle(1)]) - np.testing.assert_allclose(sl.vertex().to_tuple(), (1, 0, 0), 1e-5) + self.assertAlmostEqual(tuple(sl.vertex()), (1, 0, 0), 5) sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) with self.assertWarns(UserWarning): sl.vertex() @@ -403,5 +422,59 @@ class TestShapeList(unittest.TestCase): ) +class TestShapeListAddition(unittest.TestCase): + def setUp(self): + # Create distinct faces to test with + self.face1 = Box(1, 1, 1).faces().sort_by(Axis.Z)[0] # bottom face + self.face2 = Box(1, 1, 1).faces().sort_by(Axis.Z)[-1] # top face + self.face3 = Box(1, 1, 1).faces().sort_by(Axis.X)[0] # side face + + def test_add_single_shape(self): + sl = ShapeList([self.face1]) + result = sl + self.face2 + self.assertIsInstance(result, ShapeList) + self.assertEqual(len(result), 2) + self.assertIn(self.face1, result) + self.assertIn(self.face2, result) + + def test_add_shape_list(self): + sl1 = ShapeList([self.face1]) + sl2 = ShapeList([self.face2, self.face3]) + result = sl1 + sl2 + self.assertIsInstance(result, ShapeList) + self.assertEqual(len(result), 3) + self.assertListEqual(result, [self.face1, self.face2, self.face3]) + + def test_iadd_single_shape(self): + sl = ShapeList([self.face1]) + sl_id_before = id(sl) + sl += self.face2 + self.assertEqual(id(sl), sl_id_before) # in-place mutation + self.assertEqual(len(sl), 2) + self.assertListEqual(sl, [self.face1, self.face2]) + + def test_iadd_shape_list(self): + sl = ShapeList([self.face1]) + sl += ShapeList([self.face2, self.face3]) + self.assertEqual(len(sl), 3) + self.assertListEqual(sl, [self.face1, self.face2, self.face3]) + + def test_add_vector(self): + vector = Vector(1, 2, 3) + sl = ShapeList([vector]) + sl += Vector(4, 5, 6) + self.assertEqual(len(sl), 2) + self.assertIsInstance(sl[0], Vector) + self.assertIsInstance(sl[1], Vector) + + def test_add_invalid_type(self): + sl = ShapeList([self.face1]) + with self.assertRaises(TypeError): + _ = sl + 123 # type: ignore + + with self.assertRaises(TypeError): + sl += "not a shape" # type: ignore + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py index d78de7f..bd81945 100644 --- a/tests/test_direct_api/test_shells.py +++ b/tests/test_direct_api/test_shells.py @@ -29,6 +29,7 @@ license: import math import unittest +from build123d.build_enums import GeomType from build123d.geometry import Plane, Rot, Vector from build123d.objects_curve import JernArc, Polyline, Spline from build123d.objects_sketch import Circle @@ -40,7 +41,12 @@ class TestShells(unittest.TestCase): def test_shell_init(self): box_faces = Solid.make_box(1, 1, 1).faces() box_shell = Shell(box_faces) - self.assertTrue(box_shell.is_valid()) + self.assertTrue(box_shell.is_valid) + + def test_shell_init_single_face(self): + face = Solid.make_cone(1, 0, 2).faces().filter_by(GeomType.CONE).first + shell = Shell(face) + self.assertTrue(shell.is_valid) def test_center(self): box_faces = Solid.make_box(1, 1, 1).faces() @@ -65,9 +71,9 @@ class TestShells(unittest.TestCase): x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5)) surface = sweep(x_section, Circle(5).wire()) single_face = Shell(surface.face()) - self.assertTrue(single_face.is_valid()) + self.assertTrue(single_face.is_valid) single_face = Shell(surface.faces()) - self.assertTrue(single_face.is_valid()) + self.assertTrue(single_face.is_valid) def test_sweep(self): path_c1 = JernArc((0, 0), (-1, 0), 1, 180) @@ -110,6 +116,13 @@ class TestShells(unittest.TestCase): outer_vol = 3 * 12 * 7 self.assertAlmostEqual(thick.volume, outer_vol - inner_vol) + def test_location_at(self): + shell = Solid.make_cylinder(1, 2).shell() + top_center = shell.location_at((0, 0, 2)) + self.assertAlmostEqual(top_center.position, (0, 0, 2), 5) + self.assertAlmostEqual(top_center.z_axis.direction, (0, 0, 1), 5) + self.assertAlmostEqual(top_center.x_axis.direction, (1, 0, 0), 5) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py index 2c67642..75fad74 100644 --- a/tests/test_direct_api/test_solid.py +++ b/tests/test_direct_api/test_solid.py @@ -29,11 +29,27 @@ license: import math import unittest +# Mocks for testing failure cases +from unittest.mock import MagicMock, patch + from build123d.build_enums import GeomType, Kind, Until from build123d.geometry import Axis, Location, Plane, Pos, Vector from build123d.objects_curve import Spline +from build123d.objects_part import Box, Torus from build123d.objects_sketch import Circle, Rectangle -from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire +from build123d.topology import ( + Compound, + DraftAngleError, + Edge, + Face, + Shell, + Solid, + Vertex, + Wire, +) +import build123d +from OCP.BRepOffsetAPI import BRepOffsetAPI_DraftAngle +from OCP.StdFail import StdFail_NotDone class TestSolid(unittest.TestCase): @@ -43,7 +59,7 @@ class TestSolid(unittest.TestCase): box = Solid(box_shell) self.assertAlmostEqual(box.area, 6, 5) self.assertAlmostEqual(box.volume, 1, 5) - self.assertTrue(box.is_valid()) + self.assertTrue(box.is_valid) def test_extrude(self): v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1)) @@ -137,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( @@ -146,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( @@ -233,6 +253,79 @@ class TestSolid(unittest.TestCase): ) self.assertEqual(len(r.faces()), 6) + def test_from_bounding_box(self): + cyl = Solid.make_cylinder(0.001, 10).locate(Location(Plane.isometric)) + cyl2 = Solid.make_cylinder(1, 10).locate(Location(Plane.isometric)) + + rbb = Solid.from_bounding_box(cyl.bounding_box()) + obb = Solid.from_bounding_box(cyl.oriented_bounding_box()) + obb2 = Solid.from_bounding_box(cyl2.oriented_bounding_box()) + + self.assertAlmostEqual(rbb.volume, (10**3) * (3**0.5) / 9, 0) + self.assertTrue(rbb.volume > obb.volume) + self.assertAlmostEqual(obb2.volume, 40, 4) + + +class TestSolidDraft(unittest.TestCase): + + def setUp(self): + # Create a simple box to test draft + self.box: Solid = Box(10, 10, 10).solid() + self.sides = self.box.faces().filter_by(Axis.Z, reverse=True) + self.bottom_face: Face = self.box.faces().sort_by(Axis.Z)[0] + self.neutral_plane = Plane(self.bottom_face) + + def test_successful_draft(self): + """Test that a draft operation completes successfully on a planar face""" + drafted = self.box.draft(self.sides, self.neutral_plane, 5) + self.assertIsInstance(drafted, Solid) + self.assertNotEqual(drafted.volume, self.box.volume) + + def test_unsupported_geometry(self): + """Test that a ValueError is raised on unsupported face geometry""" + # Create toroidal face to simulate unsupported geometry + torus = Torus(5, 1).solid() + with self.assertRaises(ValueError) as cm: + torus.draft([torus.faces()[0]], self.neutral_plane, 5) + self.assertIn("unsupported geometry type", str(cm.exception)) + + @patch("build123d.topology.three_d.BRepOffsetAPI_DraftAngle") + def test_adddone_failure_raises_draftangleerror(self, mock_draft_api): + """Test that failure of AddDone() raises DraftAngleError""" + mock_builder = MagicMock() + mock_builder.AddDone.return_value = False + mock_builder.ProblematicShape.return_value = "BadShape" + mock_draft_api.return_value = mock_builder + + with self.assertRaises(DraftAngleError) as cm: + self.box.draft(self.sides, self.neutral_plane, 5) + self.assertEqual(cm.exception.face, self.sides[0]) + self.assertEqual(cm.exception.problematic_shape, "BadShape") + self.assertIn("Draft could not be added", str(cm.exception)) + + @patch.object( + build123d.topology.three_d.BRepOffsetAPI_DraftAngle, + "Build", + side_effect=StdFail_NotDone, + ) + def test_build_failure_raises_draftangleerror(self, mock_draft_api): + """Test that Build() failure raises DraftAngleError""" + + with self.assertRaises(DraftAngleError) as cm: + self.box.draft(self.sides, self.neutral_plane, 5) + self.assertIsNone(cm.exception.face) + self.assertEqual( + cm.exception.problematic_shape, cm.exception.problematic_shape + ) # Not None + self.assertIn("Draft build failed", str(cm.exception)) + + def test_draftangleerror_contents(self): + """Test that DraftAngleError stores face and problematic shape""" + err = DraftAngleError("msg", face="face123", problematic_shape="shape456") + self.assertEqual(str(err), "msg") + self.assertEqual(err.face, "face123") + self.assertEqual(err.problematic_shape, "shape456") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py index a140f01..521c47c 100644 --- a/tests/test_direct_api/test_vector.py +++ b/tests/test_direct_api/test_vector.py @@ -156,6 +156,16 @@ class TestVector(unittest.TestCase): self.assertNotEqual(a, b) self.assertNotEqual(a, object()) + def test_vector_sets(self): + # Check that equal and hash work the same way to enable sets + a = Vector(1, 2, 3) + for i in range(1, 8): + v = Vector(a.X + 1.0 / (10**i), a.Y, a.Z) + if v == a: + self.assertEqual(len(set([a, v])), 1) + else: + self.assertEqual(len(set([a, v])), 2) + def test_vector_distance(self): """ Test line distance from plane. diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py index c3295e3..bbfb6fc 100644 --- a/tests/test_direct_api/test_wire.py +++ b/tests/test_direct_api/test_wire.py @@ -31,11 +31,16 @@ import random import unittest import numpy as np -from build123d.build_enums import Side -from build123d.geometry import Axis, Color, Location -from build123d.objects_curve import Polyline, Spline +from build123d.topology.shape_core import TOLERANCE + +from build123d.build_enums import GeomType, Side +from build123d.build_line import BuildLine +from build123d.geometry import Axis, Color, Location, Plane, Vector +from build123d.objects_curve import Curve, Line, PolarLine, Polyline, Spline from build123d.objects_sketch import Circle, Rectangle, RegularPolygon +from build123d.operations_generic import fillet from build123d.topology import Edge, Face, Wire +from OCP.BRepAdaptor import BRepAdaptor_CompCurve class TestWire(unittest.TestCase): @@ -62,6 +67,9 @@ class TestWire(unittest.TestCase): self.assertAlmostEqual( squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5 ) + square.wrapped = None + with self.assertRaises(ValueError): + square.fillet_2d(0.1, square.vertices()) def test_chamfer_2d(self): square = Wire.make_rect(1, 1) @@ -69,6 +77,18 @@ class TestWire(unittest.TestCase): self.assertAlmostEqual( squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5 ) + verts = square.vertices() + verts[0].wrapped = None + three_corners = square.chamfer_2d(0.1, 0.1, verts) + self.assertEqual(len(three_corners.edges()), 7) + + square.wrapped = None + with self.assertRaises(ValueError): + square.chamfer_2d(0.1, 0.1, square.vertices()) + + def test_close(self): + t = Polyline((0, 0), (1, 0), (0, 1), close=True) + self.assertIs(t, t.close()) def test_chamfer_2d_edge(self): square = Wire.make_rect(1, 1) @@ -96,7 +116,15 @@ class TestWire(unittest.TestCase): hull_wire = Wire.make_convex_hull(adjoining_edges) self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4) - # def test_fix_degenerate_edges(self): + def test_fix_degenerate_edges(self): + e0 = Edge.make_line((0, 0), (1, 0)) + e1 = Edge.make_line((2, 0), (1, 0)) + + w = Wire([e0, e1]) + w.wrapped = None + with self.assertRaises(ValueError): + w.fix_degenerate_edges(0.1) + # # Can't find a way to create one # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0)) # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0)) @@ -127,8 +155,10 @@ class TestWire(unittest.TestCase): t4 = o.trim(0.5, 0.75) self.assertAlmostEqual(t4.length, o.length * 0.25, 5) - with self.assertRaises(ValueError): - o.trim(0.75, 0.25) + w0 = Polyline((0, 0), (0, 1), (1, 1), (1, 0)) + w2 = w0.trim(0, (0.5, 1)) + self.assertAlmostEqual(w2 @ 1, (0.5, 1), 5) + spline = Spline( (0, 0, 0), (0, 10, 0), @@ -173,6 +203,38 @@ class TestWire(unittest.TestCase): with self.assertRaises(ValueError): w1.param_at_point((20, 20, 20)) + w1.wrapped = None + with self.assertRaises(ValueError): + w1.param_at_point((0, 0)) + + def test_param_at_point_reversed_edges(self): + with BuildLine(Plane.YZ) as wing_line: + l1 = Line((0, 65), (80 / 2 + 1.526 * 4, 65)) + PolarLine( + l1 @ 1, 20.371288916, direction=Vector(0, 1, 0).rotate(Axis.X, -75) + ) + fillet(wing_line.vertices(), 7) + + w = wing_line.wire() + params = [w.param_at_point(w @ (i / 20)) for i in range(21)] + self.assertTrue(params == sorted(params)) + for i, param in enumerate(params): + self.assertAlmostEqual(param, i / 20, 6) + + def test_tangent_at_reversed_edges(self): + with BuildLine(Plane.YZ) as wing_line: + l1 = Line((0, 65), (80 / 2 + 1.526 * 4, 65)) + PolarLine( + l1 @ 1, 20.371288916, direction=Vector(0, 1, 0).rotate(Axis.X, -75) + ) + fillet(wing_line.vertices(), 7) + + w = wing_line.wire() + self.assertAlmostEqual( + w.tangent_at(0), (0, -0.2588190451025, 0.9659258262891), 6 + ) + self.assertAlmostEqual(w.tangent_at(1), (0, -1, 0), 6) + def test_order_edges(self): w1 = Wire( [ @@ -182,40 +244,116 @@ class TestWire(unittest.TestCase): ] ) ordered_edges = w1.order_edges() - self.assertFalse(all(e.is_forward for e in w1.edges())) - self.assertTrue(all(e.is_forward for e in ordered_edges)) self.assertAlmostEqual(ordered_edges[0] @ 0, (0, 0, 0), 5) self.assertAlmostEqual(ordered_edges[1] @ 0, (1, 0, 0), 5) self.assertAlmostEqual(ordered_edges[2] @ 0, (1, 1, 0), 5) + def test_geom_adaptor(self): + w = Polyline((0, 0), (1, 0), (1, 1)) + self.assertTrue(isinstance(w.geom_adaptor(), BRepAdaptor_CompCurve)) + w.wrapped = None + with self.assertRaises(ValueError): + w.geom_adaptor() + def test_constructor(self): e0 = Edge.make_line((0, 0), (1, 0)) e1 = Edge.make_line((1, 0), (1, 1)) w0 = Wire.make_circle(1) w1 = Wire(e0) - self.assertTrue(w1.is_valid()) + self.assertTrue(w1.is_valid) w2 = Wire([e0]) self.assertAlmostEqual(w2.length, 1, 5) - self.assertTrue(w2.is_valid()) + self.assertTrue(w2.is_valid) w3 = Wire([e0, e1]) - self.assertTrue(w3.is_valid()) + self.assertTrue(w3.is_valid) self.assertAlmostEqual(w3.length, 2, 5) w4 = Wire(w0.wrapped) - self.assertTrue(w4.is_valid()) + self.assertTrue(w4.is_valid) w5 = Wire(obj=w0.wrapped) - self.assertTrue(w5.is_valid()) + self.assertTrue(w5.is_valid) w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red")) - self.assertTrue(w6.is_valid()) + self.assertTrue(w6.is_valid) self.assertEqual(w6.label, "w6") np.testing.assert_allclose(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 1e-5) w7 = Wire(w6) - self.assertTrue(w7.is_valid()) + self.assertTrue(w7.is_valid) c0 = Polyline((0, 0), (1, 0), (1, 1)) w8 = Wire(c0) - self.assertTrue(w8.is_valid()) + self.assertTrue(w8.is_valid) + w9 = Wire(Curve([e0, e1])) + self.assertTrue(w9.is_valid) with self.assertRaises(ValueError): Wire(bob="fred") +class TestWireToBSpline(unittest.TestCase): + def setUp(self): + # A simple rectilinear, multi-segment wire: + # p0 ── p1 + # │ + # p2 ── p3 + self.p0 = Vector(0, 0, 0) + self.p1 = Vector(20, 0, 0) + self.p2 = Vector(20, 10, 0) + self.p3 = Vector(35, 10, 0) + + e01 = Edge.make_line(self.p0, self.p1) + e12 = Edge.make_line(self.p1, self.p2) + e23 = Edge.make_line(self.p2, self.p3) + + self.wire = Wire([e01, e12, e23]) + + def test_to_bspline_basic_properties(self): + bs = self.wire._to_bspline() + + # 1) Type/geom check + self.assertIsInstance(bs, Edge) + self.assertEqual(bs.geom_type, GeomType.BSPLINE) + + # 2) Endpoint preservation + self.assertLess((Vector(bs.vertices()[0]) - self.p0).length, TOLERANCE) + self.assertLess((Vector(bs.vertices()[-1]) - self.p3).length, TOLERANCE) + + # 3) Length preservation (within numerical tolerance) + self.assertAlmostEqual(bs.length, self.wire.length, delta=1e-6) + + # 4) Topology collapse: single edge has only 2 vertices (start/end) + self.assertEqual(len(bs.vertices()), 2) + + # 5) The composite BSpline should pass through former junctions + for junction in (self.p1, self.p2): + self.assertLess(bs.distance_to(junction), 1e-6) + + # 6) Normalized parameter increases along former junctions + u_p1 = bs.param_at_point(self.p1) + u_p2 = bs.param_at_point(self.p2) + self.assertGreater(u_p1, 0.0) + self.assertLess(u_p2, 1.0) + self.assertLess(u_p1, u_p2) + + # 7) Re-evaluating at those parameters should be close to the junctions + self.assertLess((bs.position_at(u_p1) - self.p1).length, 1e-6) + self.assertLess((bs.position_at(u_p2) - self.p2).length, 1e-6) + + w = self.wire + w.wrapped = None + with self.assertRaises(ValueError): + w._to_bspline() + + def test_to_bspline_orientation(self): + # Ensure the BSpline follows the wire's topological order + bs = self.wire._to_bspline() + + # Start ~ p0, end ~ p3 + self.assertLess((bs.position_at(0.0) - self.p0).length, 1e-6) + self.assertLess((bs.position_at(1.0) - self.p3).length, 1e-6) + + # Parameters at interior points should sit between 0 and 1 + u0 = bs.param_at_point(self.p1) + u1 = bs.param_at_point(self.p2) + self.assertTrue(0.0 < u0 < 1.0) + self.assertTrue(0.0 < u1 < 1.0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_drafting.py b/tests/test_drafting.py index 3d6ab41..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) @@ -260,6 +261,11 @@ class DimensionLineTestCase(unittest.TestCase): with self.assertRaises(ValueError): DimensionLine([(0, 0, 0), (5, 0, 0)], draft=metric, arrows=(False, False)) + def test_vertical(self): + d_line = DimensionLine([(0, 0), (0, 100)], Draft()) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.size.Y, 100, 5) # numbers within + class ExtensionLineTestCase(unittest.TestCase): def test_min_x(self): diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..5db4bf9 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,86 @@ +""" +build123d Example tests + +name: test_examples.py +by: fischman +date: February 21 2025 + +desc: Unit tests for the build123d examples, ensuring they don't raise. +""" + +from pathlib import Path + +import os +import subprocess +import sys +import tempfile +import unittest + + +_examples_dir = Path(os.path.abspath(os.path.dirname(__file__))).parent / "examples" +_ttt_dir = Path(os.path.abspath(os.path.dirname(__file__))).parent / "docs/assets/ttt" + +_MOCK_OCP_VSCODE_CONTENTS = """ +from pathlib import Path + +import re +import sys +from unittest.mock import Mock +mock_module = Mock() +mock_module.show = Mock() +mock_module.show_object = Mock() +mock_module.show_all = Mock() +sys.modules["ocp_vscode"] = mock_module +""" + + +def generate_example_test(path: Path): + """Generate and return a function to test the example at `path`.""" + name = path.name + + def assert_example_does_not_raise(self): + with tempfile.TemporaryDirectory( + prefix=f"build123d_test_examples_{name}" + ) as tmpdir: + # More examples emit output files than read input files, + # so default to running with a temporary directory to + # avoid cluttering the git working directory. For + # examples that want to read assets from the examples + # directory, use that. If an example is added in the + # future that wants to both read assets from the examples + # directory and write output files, deal with it then. + cwd = tmpdir if 'benchy' not in path.name else _examples_dir + mock_ocp_vscode = Path(tmpdir) / "_mock_ocp_vscode.py" + with open(mock_ocp_vscode, "w", encoding="utf-8") as f: + f.write(_MOCK_OCP_VSCODE_CONTENTS) + got = subprocess.run( + [ + sys.executable, + "-c", + f"exec(open(r'{mock_ocp_vscode}').read()); exec(open(r'{path}').read())", + ], + capture_output=True, + cwd=cwd, + check=False, + ) + self.assertEqual( + 0, got.returncode, f"stdout/stderr: {got.stdout} / {got.stderr}" + ) + + return assert_example_does_not_raise + + +class TestExamples(unittest.TestCase): + """Tests build123d examples.""" + +for example in sorted(list(_examples_dir.iterdir()) + list(_ttt_dir.iterdir())): + if example.name.startswith("_") or not example.name.endswith(".py"): + continue + setattr( + TestExamples, + f"test_{example.name.replace('.', '_')}", + generate_example_test(example), + ) + +if __name__ == "__main__": + unittest.main() 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 9ca11b7..bf1c1bd 100644 --- a/tests/test_exporters3d.py +++ b/tests/test_exporters3d.py @@ -30,8 +30,10 @@ import json import os import re import unittest -from typing import Optional +from datetime import datetime from pathlib import Path +from typing import Optional +from zoneinfo import ZoneInfo import pytest @@ -39,7 +41,7 @@ from build123d.build_common import GridLocations from build123d.build_enums import Unit from build123d.build_line import BuildLine from build123d.build_sketch import BuildSketch -from build123d.exporters3d import export_gltf, export_step, export_brep, export_stl +from build123d.exporters3d import export_brep, export_gltf, export_step, export_stl from build123d.geometry import Color, Pos, Vector, VectorLike from build123d.objects_curve import Line from build123d.objects_part import Box, Sphere @@ -144,6 +146,29 @@ class TestExportStep(DirectApiTestCase): os.chmod("box_read_only.step", 0o777) # Make the file read/write os.remove("box_read_only.step") + def test_export_step_timestamp_datetime(self): + b = Box(1, 1, 1) + t = datetime(2025, 5, 6, 21, 30, 25) + self.assertTrue(export_step(b, "box.step", timestamp=t)) + with open("box.step", "r") as file: + step_data = file.read() + os.remove("box.step") + self.assertEqual( + re.findall("FILE_NAME\\('[^']*','([^']*)'", step_data), + ["2025-05-06T21:30:25"], + ) + + def test_export_step_timestamp_str(self): + b = Box(1, 1, 1) + self.assertTrue(export_step(b, "box.step", timestamp="0000-00-00T00:00:00")) + with open("box.step", "r") as file: + step_data = file.read() + os.remove("box.step") + self.assertEqual( + re.findall("FILE_NAME\\('[^']*','([^']*)'", step_data), + ["0000-00-00T00:00:00"], + ) + class TestExportGltf(DirectApiTestCase): def test_export_gltf(self): @@ -181,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_importers.py b/tests/test_importers.py index 0fa7088..f246ef3 100644 --- a/tests/test_importers.py +++ b/tests/test_importers.py @@ -16,10 +16,10 @@ from build123d.importers import ( import_step, import_stl, ) -from build123d.geometry import Pos +from build123d.geometry import Pos, Vector from build123d.exporters import ExportSVG from build123d.exporters3d import export_brep, export_step -from build123d.build_enums import GeomType +from build123d.build_enums import Align, GeomType class ImportSVG(unittest.TestCase): @@ -116,6 +116,38 @@ class ImportSVG(unittest.TestCase): self.assertEqual(str(svg[1].color), str(Color(1, 0, 0, 1))) self.assertEqual(str(svg[2].color), str(Color(0, 0, 0, 1))) + def test_import_svg_origin(self): + svg_src = ( + '' + '' + "" + ) + + svg = import_svg(StringIO(svg_src), align=None, flip_y=False) + self.assertAlmostEqual(svg[0].bounding_box().center(), Vector(2.0, +3.0)) + + svg = import_svg(StringIO(svg_src), align=None, flip_y=True) + self.assertAlmostEqual(svg[0].bounding_box().center(), Vector(2.0, -3.0)) + + def test_import_svg_align(self): + svg_src = ( + '' + '' + "" + ) + + svg = import_svg(StringIO(svg_src), align=Align.MIN, flip_y=False) + self.assertAlmostEqual(svg[0].bounding_box().min, Vector(0.0, 0.0)) + + svg = import_svg(StringIO(svg_src), align=Align.MIN, flip_y=True) + self.assertAlmostEqual(svg[0].bounding_box().min, Vector(0, 0)) + + svg = import_svg(StringIO(svg_src), align=Align.MAX, flip_y=False) + self.assertAlmostEqual(svg[0].bounding_box().max, Vector(0.0, 0.0)) + + svg = import_svg(StringIO(svg_src), align=Align.MAX, flip_y=True) + self.assertAlmostEqual(svg[0].bounding_box().max, Vector(0, 0)) + class ImportBREP(unittest.TestCase): def test_bad_filename(self): diff --git a/tests/test_mesher.py b/tests/test_mesher.py index 9547d08..ef3a2af 100644 --- a/tests/test_mesher.py +++ b/tests/test_mesher.py @@ -1,8 +1,10 @@ import unittest, uuid +from io import BytesIO from packaging.specifiers import SpecifierSet from pathlib import Path from os import fsdecode, fsencode -import time +import sys +import tempfile import pytest @@ -18,6 +20,12 @@ from build123d.geometry import Axis, Color, Location, Vector, VectorLike from build123d.mesher import Mesher +def temp_3mf_file(): + caller = sys._getframe(1) + prefix = f"build123d_{caller.f_locals.get('self').__class__.__name__}_{caller.f_code.co_name}" + return tempfile.mktemp(suffix=".3mf", prefix=prefix) + + class DirectApiTestCase(unittest.TestCase): def assertTupleAlmostEquals( self, @@ -47,11 +55,12 @@ class TestProperties(unittest.TestCase): def test_units(self): for unit in Unit: + filename = temp_3mf_file() exporter = Mesher(unit=unit) exporter.add_shape(Solid.make_box(1, 1, 1)) - exporter.write("test.3mf") + exporter.write(filename) importer = Mesher() - _shape = importer.read("test.3mf") + _shape = importer.read(filename) self.assertEqual(unit, importer.model_unit) def test_vertex_and_triangle_counts(self): @@ -73,9 +82,10 @@ class TestMetaData(unittest.TestCase): exporter.add_shape(Solid.make_box(1, 1, 1)) exporter.add_meta_data("test_space", "test0", "some data", "str", True) exporter.add_meta_data("test_space", "test1", "more data", "str", True) - exporter.write("test.3mf") + filename = temp_3mf_file() + exporter.write(filename) importer = Mesher() - _shape = importer.read("test.3mf") + _shape = importer.read(filename) imported_meta_data: list[dict] = importer.get_meta_data() self.assertEqual(imported_meta_data[0]["name_space"], "test_space") self.assertEqual(imported_meta_data[0]["name"], "test0") @@ -90,9 +100,10 @@ class TestMetaData(unittest.TestCase): exporter = Mesher() exporter.add_shape(Solid.make_box(1, 1, 1)) exporter.add_code_to_metadata() - exporter.write("test.3mf") + filename = temp_3mf_file() + exporter.write(filename) importer = Mesher() - _shape = importer.read("test.3mf") + _shape = importer.read(filename) source_code = importer.get_meta_data_by_key("build123d", "test_mesher.py") self.assertEqual(len(source_code), 2) self.assertEqual(source_code["type"], "python") @@ -118,9 +129,10 @@ class TestMeshProperties(unittest.TestCase): part_number=str(mesh_type.value), uuid_value=test_uuid, ) - exporter.write("test.3mf") + filename = temp_3mf_file() + exporter.write(filename) importer = Mesher() - shape = importer.read("test.3mf") + shape = importer.read(filename) self.assertEqual(shape[0].label, name) self.assertEqual(importer.mesh_count, 1) properties = importer.get_mesh_properties() @@ -141,9 +153,10 @@ class TestAddShape(DirectApiTestCase): red_shape.color = Color("red") red_shape.label = "red" exporter.add_shape([blue_shape, red_shape]) - exporter.write("test.3mf") + filename = temp_3mf_file() + exporter.write(filename) importer = Mesher() - box, cone = importer.read("test.3mf") + box, cone = importer.read(filename) self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2) self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2) self.assertEqual(len(box.clean().faces()), 6) @@ -158,9 +171,10 @@ class TestAddShape(DirectApiTestCase): cone = Solid.make_cone(1, 0, 2).locate(Location((0, -1, 0))) shape_assembly = Compound([box, cone]) exporter.add_shape(shape_assembly) - exporter.write("test.3mf") + filename = temp_3mf_file() + exporter.write(filename) importer = Mesher() - shapes = importer.read("test.3mf") + shapes = importer.read(filename) self.assertEqual(importer.mesh_count, 2) @@ -195,7 +209,8 @@ class TestHollowImport(unittest.TestCase): export_stl(test_shape, "test.stl") importer = Mesher() stl = importer.read("test.stl") - self.assertTrue(stl[0].is_valid()) + self.assertTrue(stl[0].is_valid) + self.assertAlmostEqual(test_shape.volume, stl[0].volume, 0) class TestImportDegenerateTriangles(unittest.TestCase): @@ -208,7 +223,7 @@ class TestImportDegenerateTriangles(unittest.TestCase): stl = importer.read("cyl_w_rect_hole.stl")[0] self.assertEqual(type(stl), Solid) self.assertTrue(stl.is_manifold) - self.assertTrue(stl.is_valid()) + self.assertTrue(stl.is_valid) self.assertEqual(sum(f.area == 0 for f in stl.faces()), 0) @@ -216,12 +231,21 @@ class TestImportDegenerateTriangles(unittest.TestCase): "format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"] ) def test_pathlike_mesher(tmp_path, format): - path = format(tmp_path / "test.3mf") + filename = temp_3mf_file() + path = format(tmp_path / filename) exporter, importer = Mesher(), Mesher() exporter.add_shape(Solid.make_box(1, 1, 1)) exporter.write(path) 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() diff --git a/tests/test_pack.py b/tests/test_pack.py index 56f64f7..1d733d5 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -39,7 +39,7 @@ class TestPack(unittest.TestCase): test_boxes = [ Box(random.randint(1, 20), random.randint(1, 20), 1) for _ in range(50) ] - # Not raising in this call shows successfull non-overlap. + # Not raising in this call shows successful non-overlap. packed = pack(test_boxes, 1) self.assertEqual( "bbox: 0.0 <= x <= 94.0, 0.0 <= y <= 86.0, -0.5 <= z <= 0.5", @@ -54,7 +54,7 @@ class TestPack(unittest.TestCase): widths = [random.randint(2, 20) for _ in range(50)] heights = [random.randint(1, width - 1) for width in widths] inputs = [SlotOverall(width, height) for width, height in zip(widths, heights)] - # Not raising in this call shows successfull non-overlap. + # Not raising in this call shows successful non-overlap. packed = pack(inputs, 1) bb = (Sketch() + packed).bounding_box() self.assertEqual(bb.min, Vector(0, 0, 0)) diff --git a/tests/test_topo_explore.py b/tests/test_topo_explore.py index 8524795..add8721 100644 --- a/tests/test_topo_explore.py +++ b/tests/test_topo_explore.py @@ -6,7 +6,7 @@ from OCP.GProp import GProp_GProps from OCP.BRepGProp import BRepGProp from OCP.gp import gp_Pnt, gp_Pln from OCP.TopoDS import TopoDS_Face, TopoDS_Shape -from build123d.build_enums import SortBy +from build123d.build_enums import ContinuityLevel, GeomType, SortBy from build123d.objects_part import Box from build123d.geometry import ( @@ -17,6 +17,7 @@ from build123d.geometry import ( from build123d.topology import ( Edge, Face, + ShapeList, Shell, Wire, offset_topods_face, @@ -97,6 +98,87 @@ class TestTopoExplore(DirectApiTestCase): with self.assertRaises(ValueError): topo_explore_connected_edges(null_edge) + def test_topo_explore_connected_edges_continuity(self): + # Create a 3-edge wire: straight line + smooth spline + sharp corner + + # First edge: straight line + e1 = Edge.make_line((0, 0), (1, 0)) + + # Second edge: spline tangent-aligned to e1 (G1 continuous) + e2 = Edge.make_spline([e1 @ 1, (1, 1)], tangents=[(1, 0), (-1, 0)]) + + # Third edge: sharp corner from e2 (no G1 continuity) + e3 = Edge.make_line(e2 @ 1, e1 @ 0) + + face = Face(Wire([e1, e2, e3])) + + extracted_e1 = face.edges().sort_by(Axis.Y)[0] + extracted_e2 = face.edges().filter_by(GeomType.LINE, reverse=True)[0] + + # Test C0: Should find both e2 and e3 connected to e1 and e2 respectively + connected_c0 = topo_explore_connected_edges( + extracted_e1, continuity=ContinuityLevel.C0 + ) + self.assertEqual(len(connected_c0), 2) + self.assertTrue( + connected_c0.filter_by(GeomType.LINE, reverse=True)[0].is_same(extracted_e2) + ) + + # Test C1: Should still find e2 connected to e1 (they're tangent aligned) + connected_c1 = topo_explore_connected_edges( + extracted_e1, continuity=ContinuityLevel.C1 + ) + self.assertEqual(len(connected_c1), 1) + self.assertTrue(connected_c1[0].is_same(extracted_e2)) + + # Test C2: No edges are curvature continuous at the junctions + connected_c2 = topo_explore_connected_edges( + extracted_e1, continuity=ContinuityLevel.C2 + ) + self.assertEqual(len(connected_c2), 0) + + # Also test e2 to e3 continuity + connected_e2_c0 = topo_explore_connected_edges( + extracted_e2, continuity=ContinuityLevel.C0 + ) + self.assertEqual(len(connected_e2_c0), 2) # e1 and e3 connected by vertex + + connected_e2_c1 = topo_explore_connected_edges( + extracted_e2, continuity=ContinuityLevel.C1 + ) + # e3 should be excluded due to sharp corner + self.assertEqual(len(connected_e2_c1), 1) + self.assertTrue(connected_e2_c1[0].is_same(extracted_e1)) + + connected_e2_c2 = topo_explore_connected_edges( + extracted_e2, continuity=ContinuityLevel.C2 + ) + self.assertEqual(len(connected_e2_c2), 0) + + def test_topo_explore_connected_edges_continuity_loop(self): + # Perfect circle: all edges G2 continuous at their junctions + + circle = Edge.make_circle(1) + edges = ShapeList([circle.edge().trim(0, 0.5), circle.edge().trim(0.5, 1.0)]) + circle = Face(Wire(edges)) + edges = circle.edges() + + for e in edges: + connected_c2 = topo_explore_connected_edges( + e, parent=circle, continuity=ContinuityLevel.C2 + ) + self.assertEqual(len(connected_c2), 1) + + connected_c1 = topo_explore_connected_edges( + e, parent=circle, continuity=ContinuityLevel.C1 + ) + self.assertEqual(len(connected_c1), 1) + + connected_c0 = topo_explore_connected_edges( + e, parent=circle, continuity=ContinuityLevel.C0 + ) + self.assertEqual(len(connected_c0), 1) + def test_topo_explore_common_vertex(self): triangle = Face( Wire( @@ -173,10 +255,6 @@ class TestTopoExploreConnectedFaces(unittest.TestCase): self.assertEqual(len(faces), 2) def test_topo_explore_connected_faces_invalid(self): - # Test with an edge that is not connected to two faces - with self.assertRaises(RuntimeError): - topo_explore_connected_faces(self.unconnected_edge) - # No parent case with self.assertRaises(ValueError): topo_explore_connected_faces(Edge()) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9d5ba9c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,46 @@ +""" +build123d Helper Utilities tests + +name: test_utils.py +by: jwagenet +date: July 28th 2025 + +desc: Unit tests for the build123d helper utilities module +""" + +import unittest + +from build123d import * +from build123d.utils import FontInfo + + +class TestFontHelpers(unittest.TestCase): + """Tests for font helpers.""" + + def test_font_info(self): + """Test expected FontInfo repr.""" + name = "Arial" + styles = tuple(member for member in FontStyle) + font = FontInfo(name, styles) + + self.assertEqual( + repr(font), f"Font(name={name!r}, styles={tuple(s.name for s in styles)})" + ) + + def test_available_fonts(self): + """Test expected output for available fonts.""" + fonts = available_fonts() + self.assertIsInstance(fonts, list) + for font in fonts: + self.assertIsInstance(font, FontInfo) + self.assertIsInstance(font.name, str) + self.assertIsInstance(font.styles, tuple) + for style in font.styles: + self.assertIsInstance(style, FontStyle) + + names = [font.name for font in fonts] + self.assertEqual(names, sorted(names)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/deglob.py b/tools/deglob.py new file mode 100755 index 0000000..37e1063 --- /dev/null +++ b/tools/deglob.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python +""" +name: deglob.py +by: Gumyr +date: April 12th 2025 + +desc: + + A command-line script (deglob.py) that scans a Python file for references to + symbols from the build123d library and outputs a 'from build123d import ...' + line listing only the symbols that are actually used by that file. + + This is useful to replace wildcard imports like 'from build123d import *' + with a more explicit import statement. By relying on Python's AST, this + script can detect which build123d names are referenced, then generate + an import statement listing only those names. This practice can help + prevent polluting the global namespace and improve clarity. + + Examples: + python deglob.py my_build123d_script.py + python deglob.py -h + + Usage: + deglob.py [-h] [--write] [--verbose] build123d_file + Find all the build123d symbols in module. + + positional arguments: + build123d_file Path to the build123d file + + options: + -h, --help show this help message and exit + --write Overwrite glob import in input file, defaults to read-only and + printed to stdout + --verbose Increase verbosity when write is enabled, defaults to silent + + After parsing my_build123d_script.py, the script optionally prints a line such as: + from build123d import Workplane, Solid + + Which you can then paste back into the file to replace the glob import. + + Module Contents: + - parse_args(): Parse the command-line argument for the input file path. + - count_glob_imports(): Count the number of occurences of a glob import. + - find_used_symbols(): Parse Python source code to find referenced names. + - main(): Orchestrates reading the file, analyzing symbols, and printing + the replacement import line. + + +license: + + Copyright 2025 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + +import argparse +import ast +import sys +from pathlib import Path +import re + +import build123d + + +def parse_args(): + """ + Parse command-line arguments for the deglob tool. + + Returns: + argparse.Namespace: An object containing the parsed command-line arguments: + - build123d_file (Path): Path to the input build123d file. + """ + parser = argparse.ArgumentParser( + description="Find all the build123d symbols in module." + ) + + # Required positional argument + parser.add_argument("build123d_file", type=Path, help="Path to the build123d file") + parser.add_argument( + "--write", + help="Overwrite glob import in input file, defaults to read-only and printed to stdout", + action="store_true", + ) + parser.add_argument( + "--verbose", + help="Increase verbosity when write is enabled, defaults to silent", + action="store_true", + ) + + args = parser.parse_args() + + return args + + +def count_glob_imports(source_code: str) -> int: + """count_glob_imports + + Count the number of occurences of a glob import e.g. (from build123d import *) + + Args: + source_code (str): contents of build123d program + + Returns: + int: build123d glob import occurence count + """ + tree = ast.parse(source_code) + + # count instances of glob usage + glob_count = list( + isinstance(node, ast.ImportFrom) + and node.module == "build123d" + and any(alias.name == "*" for alias in node.names) + for node in ast.walk(tree) + ).count(True) + + return glob_count + + +def find_used_symbols(source_code: str) -> set[str]: + """find_used_symbols + + Extract all of the symbols from the source code into a set of strings. + + Args: + source_code (str): contents of build123d program + + Returns: + set[str]: extracted symbols + """ + tree = ast.parse(source_code) + + symbols = set() + + # Create a custom version of visit_Name that records the symbol + class SymbolFinder(ast.NodeVisitor): + def visit_Name(self, node): + # node.id is the variable name or symbol + symbols.add(node.id) + self.generic_visit(node) + + SymbolFinder().visit(tree) + return symbols + + +def main(): + """ + Main entry point for the deglob script. + + Steps: + 1. Parse and validate command-line arguments for the target Python file. + 2. Read the file's source code. + 3. Use an AST-based check to confirm whether there is at least one + 'from build123d import *' statement in the code. + 4. Collect all referenced symbol names from the file's abstract syntax tree. + 5. Intersect these names with those found in build123d.__all__ to identify + which build123d symbols are actually used. + 6A. Optionally print an import statement that explicitly imports only the used symbols. + 6B. Or optionally write the glob import replacement back to file + + Behavior: + - If no 'from build123d import *' import is found, the script prints + a message and exits. + - If multiple glob imports appear, only a single explicit import line + is generated regardless of the number of glob imports in the file. + - Pre-existing non-glob imports are left unchanged in the user's code; + they may result in redundant imports if the user chooses to keep them. + + Raises: + SystemExit: If the file does not exist or if a glob import statement + isn't found. + """ + # Get the command line arguments + args = parse_args() + + # Check that the build123d file is valid + if not args.build123d_file.exists(): + print(f"Error: file not found - {args.build123d_file}", file=sys.stderr) + sys.exit(1) + + # Read the code + with open(args.build123d_file, "r", encoding="utf-8") as f: + code = f.read() + + # Get the glob import count + glob_count = count_glob_imports(code) + + # Exit if no glob import was found + if not glob_count: + print("Glob import from build123d not found") + sys.exit(0) + + # Extract the symbols + used_symbols = find_used_symbols(code) + + # Find the imported build123d symbols + actual_imports = sorted(used_symbols.intersection(set(build123d.__all__))) + + # Create the import statement to replace the glob import + import_line = f"from build123d import {', '.join(actual_imports)}" + + if args.write: + # Replace only the first instance + updated_code = re.sub(r"from build123d import\s*\*", import_line, code, count=1) + + # Try to write code back to target file + try: + with open(args.build123d_file, "w", encoding="utf-8") as f: + f.write(updated_code) + except (PermissionError, OSError) as e: + print(f"Error: Unable to write to file '{args.build123d_file}'. {e}") + sys.exit(1) + + if glob_count and args.verbose: + print(f"Replaced build123d glob import with '{import_line}'") + + if glob_count > 1: + # NOTE: always prints warning if more than one glob import is found + print( + "Warning: more than one instance of glob import was detected " + f"(count: {glob_count}), only the first instance was replaced" + ) + else: + print(import_line) + + +if __name__ == "__main__": + main() diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py index 8aadbb1..be56a28 100644 --- a/tools/refactor_topo.py +++ b/tools/refactor_topo.py @@ -7,7 +7,7 @@ date: Dec 05, 2024 desc: This python script refactors the very large topology.py module into several - files based on the topological heirarchical order: + files based on the topological hierarchical order: + shape_core.py - base classes Shape, ShapeList + utils.py - utility classes & functions + zero_d.py - Vertex