diff --git a/.github/actions/setup-macos-arm64/action.yml b/.github/actions/setup-macos-arm64/action.yml deleted file mode 100644 index 48c0c8a..0000000 --- a/.github/actions/setup-macos-arm64/action.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: 'Setup' -inputs: - python-version: # id of input - description: 'Python version' - required: true - -runs: - using: "composite" - steps: - - name: python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: install requirements - shell: bash - run: | - pip install wheel - pip install mypy - pip install pytest - pip install pylint - pip install https://github.com/jdegenstein/ocp-build-system/releases/download/7.7.2_macos_arm64_cp310/cadquery_ocp-7.7.2-cp310-cp310-macosx_11_0_arm64.whl - pip install . diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index ae1abc7..2af009a 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,15 +7,12 @@ inputs: runs: using: "composite" steps: - - name: python - uses: actions/setup-python@v5 + - name: Setup Python + uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} - - name: install requirements + enable-cache: false + python-version: ${{ inputs.python-version }} + - name: Install Requirements shell: bash run: | - pip install wheel - pip install mypy - pip install pytest - pip install pylint - pip install . + uv pip install .[development] diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..ff389dc --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,31 @@ +name: benchmarks + +on: [push, pull_request, workflow_dispatch] +jobs: + + benchmarks: + strategy: + fail-fast: false + matrix: + python-version: [ + # "3.10", + # "3.11", + "3.12", + ] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup/ + with: + python-version: ${{ matrix.python-version }} + - name: benchmark + run: | + 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/coverage.yml b/.github/workflows/coverage.yml index ca7272b..25bc1f2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,14 +6,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 + - name: Setup + uses: ./.github/actions/setup/ with: - python-version: '3.10' - - name: Install dependencies - run: pip install -r requirements.txt + python-version: "3.10" - name: Run tests and collect coverage run: pytest --cov=build123d - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - + uses: codecov/codecov-action@v5 diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index f056e70..79f10a2 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -7,18 +7,20 @@ jobs: fail-fast: false matrix: python-version: [ - "3.9", "3.10", - #"3.11" + # "3.11", + # "3.12", + "3.13", ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/setup + - uses: actions/checkout@v4 + - name: Setup + uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} - - name: typecheck + - name: Typecheck run: | - mypy --config-file mypy.ini src/build123d \ No newline at end of file + mypy --config-file mypy.ini src/build123d diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a5387e5..8f173b0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,7 @@ jobs: run: | pwd ls -lR + python3 -V python3 -m pip install --upgrade pip python3 -m pip -V python3 -m pip install build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0827ce..0f9dabc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,16 +3,17 @@ name: tests on: [push, pull_request, workflow_dispatch] jobs: - tests_x86_64: + tests: strategy: fail-fast: false matrix: python-version: [ - "3.9", "3.10", - #"3.11" + # "3.11", + # "3.12", + "3.13", ] - os: [macos-13, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-14, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -22,25 +23,4 @@ jobs: python-version: ${{ matrix.python-version }} - name: test run: | - python -m pytest - - tests_arm64: - strategy: - fail-fast: false - matrix: - python-version: [ - #"3.9", - "3.10", - #"3.11" - ] - os: [macos-14] - - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-macos-arm64/ - with: - 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/.pylintrc b/.pylintrc index f097be2..91a9fac 100644 --- a/.pylintrc +++ b/.pylintrc @@ -16,3 +16,5 @@ disable= ignore-paths= ./src/build123d/_version.py # Generated + +ignored-modules=OCP,vtkmodules,scipy.spatial,ezdxf,anytree,IPython,trianglesolver,scipy,numpy \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 33266c4..44248cf 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,15 +7,21 @@ formats: build: os: "ubuntu-22.04" tools: - python: "3.9" + 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: configuration: docs/conf.py -# Explicitly set the version of Python and its requirements python: install: - - requirements: docs/requirements.txt + - method: pip + path: . + extra_requirements: + - docs 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 0b8e7be..cb7c309 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,56 @@

- build123d logo + build123d logo

[![Documentation Status](https://readthedocs.org/projects/build123d/badge/?version=latest)](https://build123d.readthedocs.io/en/latest/?badge=latest) [![tests](https://github.com/gumyr/build123d/actions/workflows/test.yml/badge.svg)](https://github.com/gumyr/build123d/actions/workflows/test.yml) [![pylint](https://github.com/gumyr/build123d/actions/workflows/lint.yml/badge.svg)](https://github.com/gumyr/build123d/actions/workflows/lint.yml) +[![mypy](https://github.com/gumyr/build123d/actions/workflows/mypy.yml/badge.svg)](https://github.com/gumyr/build123d/actions/workflows/mypy.yml) [![codecov](https://codecov.io/gh/gumyr/build123d/branch/dev/graph/badge.svg)](https://codecov.io/gh/gumyr/build123d) -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. +![Python Versions](https://img.shields.io/badge/python-3.10%20|%203.11%20|%203.12%20|%203.13-blue) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -Build123d could be considered as an evolution of [CadQuery](https://cadquery.readthedocs.io/en/latest/index.html) 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. +[![PyPI version](https://img.shields.io/pypi/v/build123d.svg)](https://pypi.org/project/build123d/) +[![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) -The documentation for **build123d** can found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html). -There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with CadQuery) where you can ask for help in the build123d channel. +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). + +There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with [CadQuery]) where you can ask for help in the build123d channel. + +The recommended method for most users to install **build123d** is: -The recommended method for most users is to install **build123d** is: ``` pip install build123d ``` -To get the latest non-released version of **build123d*** one can install from GitHub using one of the following two commands: +To get the latest non-released version of **build123d** one can install from GitHub using one of the following two commands: + +Linux/MacOS: -In Linux/MacOS, use the following command: ``` python3 -m pip install git+https://github.com/gumyr/build123d ``` -In Windows, use the following command: + +Windows: + ``` python -m pip install git+https://github.com/gumyr/build123d ``` @@ -36,11 +60,21 @@ If you receive errors about conflicting dependencies, you can retry the installa python3 -m pip install --upgrade pip ``` -Development install +Development install: + ``` git clone https://github.com/gumyr/build123d.git cd build123d python3 -m pip install -e . ``` -Further installation instructions are available (e.g. Poetry, Apple Silicon) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html). +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/ +[Open Cascade]: https://dev.opencascade.org/ diff --git a/docs/OpenSCAD.rst b/docs/OpenSCAD.rst new file mode 100644 index 0000000..3c64382 --- /dev/null +++ b/docs/OpenSCAD.rst @@ -0,0 +1,202 @@ +Transitioning from OpenSCAD +=========================== + +Welcome to build123d! If you're familiar with OpenSCAD, you'll notice key differences in +how models are constructed. This guide is designed to help you adapt your design approach +and understand the fundamental differences in modeling philosophies. While OpenSCAD relies +heavily on Constructive Solid Geometry (CSG) to combine primitive 3D shapes like cubes and +spheres, build123d encourages a more flexible and efficient workflow based on building +lower-dimensional objects. + +Why Transition to build123d? +---------------------------- + +Transitioning to build123d allows you to harness a modern and efficient approach to 3D modeling. +By starting with lower-dimensional objects and leveraging powerful transformation tools, you can +create precise, complex designs with ease. This workflow emphasizes modularity and maintainability, +enabling quick modifications and reducing computational complexity. + +Moving Beyond Constructive Solid Geometry (CSG) +----------------------------------------------- + +OpenSCAD's modeling paradigm heavily relies on Constructive Solid Geometry (CSG) to build +models by combining and subtracting 3D solids. While build123d supports similar operations, +its design philosophy encourages a fundamentally different, often more efficient approach: +starting with lower-dimensional entities like faces and edges and then transforming them +into solids. + +Why Transition Away from CSG? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +CSG is a powerful method for creating 3D models, but it has limitations when dealing with +complex designs. build123d’s approach offers several advantages: + +- **Simplified Complexity Management**: + Working with 2D profiles and faces instead of directly manipulating 3D solids simplifies + your workflow. In large models, the number of operations on solids can grow exponentially, + making it difficult to manage and debug. Building with 2D profiles helps keep designs + modular and organized. + +- **Improved Robustness**: + Operations on 2D profiles are inherently less computationally intensive and + less error-prone than equivalent operations on 3D solids. This robustness ensures smoother + workflows and reduces the likelihood of failing operations in complex models. + +- **Enhanced Efficiency**: + Constructing models from 2D profiles using operations like **extruding**, **lofting**, + **sweeping**, or **revolving** is computationally faster. These methods also provide + greater design flexibility, enabling you to create intricate forms with ease. + +- **Better Precision and Control**: + Starting with 2D profiles allows for more precise geometric control. Constraints, dimensions, + and relationships between entities can be established more effectively in 2D, ensuring a solid + foundation for your 3D design. + +Using a More Traditional CAD Design Workflow +-------------------------------------------- + +Most industry-standard CAD packages recommend starting with a sketch (a 2D object) and +transforming it into a 3D model—a design philosophy that is central to build123d. + +In build123d, the design process typically begins with defining the outline of an object. +This might involve creating a complex 1D object using **BuildLine**, which provides tools +for constructing intricate wireframe geometries. The next step involves converting these +1D objects into 2D sketches using **BuildSketch**, which offers a wide range of 2D primitives +and advanced capabilities, such as: + +- **make_face**: Converts a 1D **BuildLine** object into a planar 2D face. +- **make_hull**: Generates a convex hull from a 1D **BuildLine** object. + +Once a 2D profile is created, it can be transformed into 3D objects in a **BuildPart** context +using operations such as: + +- **Extrusion**: Extends a 2D profile along a straight path to create a 3D shape. +- **Revolution**: Rotates a 2D profile around an axis to form a symmetrical 3D object. +- **Lofting**: Connects multiple 2D profiles along a path to create smooth transitions + between shapes. +- **Sweeping**: Moves a 2D profile along a defined path to create a 3D form. + +Refining the Model +^^^^^^^^^^^^^^^^^^ + +After creating the initial 3D shape, you can refine the model by adding details or making +modifications using build123d's advanced features, such as: + +- **Fillets and Chamfers**: Smooth or bevel edges to enhance the design. +- **Boolean Operations**: Combine, subtract, or intersect 3D shapes to achieve the desired + geometry. + +Example Comparison +^^^^^^^^^^^^^^^^^^ + +To illustrate the advantages of this approach, compare a simple model in OpenSCAD and +build123d of a piece of angle iron: + +**OpenSCAD Approach** + +.. code-block:: openscad + + $fn = 100; // Increase the resolution for smooth fillets + + // Dimensions + length = 100; // 10 cm long + width = 30; // 3 cm wide + thickness = 4; // 4 mm thick + fillet = 5; // 5 mm fillet radius + delta = 0.001; // a small number + + // Create the angle iron + difference() { + // Outer shape + cube([width, length, width], center = false); + // Inner shape + union() { + translate([thickness+fillet,-delta,thickness+fillet]) + rotate([-90,0,0]) + cylinder(length+2*delta, fillet,fillet); + translate([thickness,-delta,thickness+fillet]) + cube([width-thickness,length+2*delta,width-fillet],center=false); + translate([thickness+fillet,-delta,thickness]) + cube([width-fillet,length+2*delta,width-thickness],center=false); + + } + } + +**build123d Approach** + +.. code-block:: build123d + + # Builder mode + with BuildPart() as angle_iron: + with BuildSketch() as profile: + Rectangle(3 * CM, 4 * MM, align=Align.MIN) + Rectangle(4 * MM, 3 * CM, align=Align.MIN) + extrude(amount=10 * CM) + fillet(angle_iron.edges().filter_by(lambda e: e.is_interior), 5 * MM) + + +.. code-block:: build123d + + # Algebra mode + profile = Rectangle(3 * CM, 4 * MM, align=Align.MIN) + profile += Rectangle(4 * MM, 3 * CM, align=Align.MIN) + angle_iron = extrude(profile, 10 * CM) + angle_iron = fillet(angle_iron.edges().filter_by(lambda e: e.is_interior), 5 * MM) + +.. image:: ./assets/AngleIron.png + +OpenSCAD and build123d offer distinct paradigms for creating 3D models, as demonstrated +by the angle iron example. OpenSCAD relies on Constructive Solid Geometry (CSG) operations, +combining and subtracting 3D shapes like cubes and cylinders. Fillets are approximated by +manually adding high-resolution cylinders, making adjustments cumbersome and less precise. +This static approach can handle simple models but becomes challenging for complex or iterative designs. + +In contrast, build123d emphasizes a profile-driven workflow. It starts with a 2D sketch, +defining the geometry’s outline, which is then extruded or otherwise transformed into a +3D model. Features like fillets are applied dynamically by querying topological elements, +such as edges, using intuitive filtering methods. This approach ensures precision and +flexibility, making changes straightforward without the need for manual repositioning or realignment. + +The build123d methodology is computationally efficient, leveraging mathematical precision +for features like fillets. By separating the design into manageable steps—sketching, extruding, +and refining—it aligns with traditional CAD practices and enhances readability, modularity, +and maintainability. Unlike OpenSCAD, build123d’s dynamic querying of topological features +allows for easy updates and adjustments, making it better suited for modern, complex, and +iterative design workflows. + +In summary, build123d’s sketch-based paradigm and topological querying capabilities provide +superior precision, flexibility, and efficiency compared to OpenSCAD’s static, CSG-centric +approach, making it a better choice for robust and adaptable CAD modeling. + +Tips for Transitioning +---------------------- + +- **Think in Lower Dimensions**: Begin with 1D curves or 2D sketches as the foundation + and progressively build upwards into 3D shapes. + +- **Leverage Topological References**: Use build123d's powerful selector system to + reference features of existing objects for creating new ones. For example, apply + inside or outside fillets and chamfers to vertices and edges of an existing part + with precision. + +- **Operational Equivalency and Beyond**: Build123d provides equivalents to almost all + features available in OpenSCAD, with the exception of the 3D **minkowski** operation. + However, a 2D equivalent, **make_hull**, is available in build123d. Beyond operational + equivalency, build123d offers a wealth of additional functionality, including advanced + features like topological queries, dynamic filtering, and robust tools for creating complex + geometries. By exploring build123d's extensive operations, you can unlock new possibilities + and take your designs far beyond the capabilities of OpenSCAD. + +- **Explore the Documentation**: Dive into build123d’s comprehensive API documentation + to unlock its full potential and discover advanced features. + +By shifting your design mindset from solid-based CSG to a profile-driven approach, you +can fully harness build123d's capabilities to create precise, efficient, and complex models. +Welcome aboard, and happy designing! + +Conclusion +---------- +While OpenSCAD and build123d share the goal of empowering users to create parametric 3D +models, their approaches differ significantly. Embracing build123d’s workflow of building +with lower-dimensional objects and applying extrusion, lofting, sweeping, or revolution +will unlock its full potential and lead to better design outcomes. \ No newline at end of file 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/AngleIron.png b/docs/assets/AngleIron.png new file mode 100644 index 0000000..1a734f9 Binary files /dev/null and b/docs/assets/AngleIron.png differ 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/direct_api_reference.rst b/docs/direct_api_reference.rst index 7243789..02b6cfc 100644 --- a/docs/direct_api_reference.rst +++ b/docs/direct_api_reference.rst @@ -1,3 +1,4 @@ + #################### Direct API Reference #################### @@ -52,7 +53,7 @@ supplementary functionality specific to 1D `~topology.Solid`) objects respectively. Note that a :class:`~topology.Compound` may be contain only 1D, 2D (:class:`~topology.Face`) or 3D objects. -.. inheritance-diagram:: topology +.. inheritance-diagram:: topology.shape_core topology.zero_d topology.one_d topology.two_d topology.three_d topology.composite topology.utils :parts: 1 .. py:module:: topology @@ -63,6 +64,7 @@ Note that a :class:`~topology.Compound` may be contain only 1D, 2D (:class:`~top :special-members: __neg__ .. autoclass:: Mixin1D :special-members: __matmul__, __mod__ +.. autoclass:: Mixin2D .. autoclass:: Mixin3D .. autoclass:: Shape :special-members: __add__, __sub__, __and__, __rmul__, __eq__, __copy__, __deepcopy__, __hash__ @@ -84,18 +86,6 @@ Import/Export ************* Methods and functions specific to exporting and importing build123d objects are defined below. -.. py:module:: topology - :noindex: - -.. automethod:: Shape.export_brep - :noindex: -.. automethod:: Shape.export_stl - :noindex: -.. automethod:: Shape.export_step - :noindex: -.. automethod:: Shape.export_stl - :noindex: - .. py:module:: importers :noindex: 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 f5bf239..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 @@ -106,11 +92,15 @@ Table Of Contents introduction.rst installation.rst key_concepts.rst + key_concepts_builder.rst key_concepts_algebra.rst + moving_objects.rst + OpenSCAD.rst introductory_examples.rst tutorials.rst objects.rst operations.rst + topology_selection.rst builders.rst joints.rst assemblies.rst diff --git a/docs/installation.rst b/docs/installation.rst index 3a54c1d..c794857 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -2,7 +2,7 @@ Installation ############ -The recommended method for most users is to install **build123d** is: +The recommended method for most users to install **build123d** is: .. doctest:: @@ -110,35 +110,6 @@ Which should return something similar to: ├── Face at 0x165e88218f0, Center(0.5, 1.0, 0.0) └── Face at 0x165eb21ee70, Center(0.5, 1.0, 3.0) -Special notes on Apple Silicon installs ----------------------------------------------- - -Due to some dependencies not being available via pip, there is a bit of a hacky work around for Apple Silicon installs (M1 or M2 ARM64 architecture machines - if you aren't sure, try `uname -p` in a terminal and see if it returns arm). Specifically the cadquery-ocp dependency fails to resolve at install time. The error looks something like this: - -.. doctest:: - - └[~]> python3 -m pip install build123d - Collecting build123d - ... - INFO: pip is looking at multiple versions of build123d to determine which version is compatible with other requirements. This could take a while. - ERROR: Could not find a version that satisfies the requirement cadquery-ocp~=7.7.1 (from build123d) (from versions: none) - ERROR: No matching distribution found for cadquery-ocp~=7.7.1 - -A procedure for avoiding this issue is to install in a conda environment, which does have the missing dependency (substituting for the environment name you want to use for this install): - -.. doctest:: - - conda create -n python=3.10 - conda activate - conda install -c cadquery -c conda-forge cadquery=master - pip install svgwrite svgpathtools anytree scipy ipython trianglesolver \ - ocp_tessellate webcolors==1.12 "numpy>=2,<3" cachetools==5.2.0 \ - ocp_vscode requests orjson urllib3 certifi py-lib3mf \ - "svgpathtools>=1.5.1,<2" "svgelements>=1.9.1,<2" "ezdxf>=1.1.0,<2" - pip install --no-deps build123d ocpsvg - -`You can track the issue here `_ - Adding a nicer GUI ---------------------------------------------- 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.rst b/docs/key_concepts.rst index e8a7756..1b2f11b 100644 --- a/docs/key_concepts.rst +++ b/docs/key_concepts.rst @@ -1,14 +1,6 @@ -########################### -Key Concepts (builder mode) -########################### - -There are two primary APIs provided by build123d: builder and algebra. The builder -API may be easier for new users as it provides some assistance and shortcuts; however, -if you know what a Quaternion is you might prefer the algebra API which allows -CAD objects to be created in the style of mathematical equations. Both API can -be mixed in the same model with the exception that the algebra API can't be used -from within a builder context. As with music, there is no "best" genre or API, -use the one you prefer or both if you like. +############ +Key Concepts +############ The following key concepts will help new users understand build123d quickly. @@ -120,118 +112,6 @@ topology of a shape as shown here for a unit cube: Users of build123d will often reference topological objects as part of the process of creating the object as described below. -Builders -======== - -The three builders, ``BuildLine``, ``BuildSketch``, and ``BuildPart`` are tools to create -new objects - not the objects themselves. Each of the objects and operations applicable -to these builders create objects of the standard CadQuery Direct API, most commonly -``Compound`` objects. This is opposed to CadQuery's Fluent API which creates objects -of the ``Workplane`` class which frequently needed to be converted back to base -class for further processing. - -One can access the objects created by these builders by referencing the appropriate -instance variable. For example: - -.. code-block:: python - - with BuildPart() as my_part: - ... - - show_object(my_part.part) - -.. code-block:: python - - with BuildSketch() as my_sketch: - ... - - show_object(my_sketch.sketch) - -.. code-block:: python - - with BuildLine() as my_line: - ... - - show_object(my_line.line) - -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 - - with BuildPart() as part_builder: - Box(part_builder, 10,10,10) - -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 - - with BuildPart() as part_builder: - Box(10,10,10) - with BuildSketch() as sketch_builder: - Circle(2) - -In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle`` -is in the scope of ``sketch_builder``. - -Workplanes -========== - -As build123d is a 3D CAD package one must be able to position objects anywhere. As one -frequently will work in the same plane for a sequence of operations, the first parameter(s) -of the builders is a (sequence of) workplane(s) which is (are) used -to aid in the location of features. The default workplane in most cases is the ``Plane.XY`` -where a tuple of numbers represent positions on the x and y axes. However workplanes can -be generated on any plane which allows users to put a workplane where they are working -and then work in local 2D coordinate space. - - -.. code-block:: python - - with BuildPart(Plane.XY) as example: - ... # a 3D-part - with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom: - ... - with BuildSketch(Plane.XZ) as vertical: - ... - with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top: - ... - -When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a -default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the -normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ``) sketch. -All objects or operations within the scope of a workplane will automatically be orientated with -respect to this plane so the user only has to work with local coordinates. - -As shown above, workplanes can be created from faces as well. The ``top`` sketch is -positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value. - -One is not limited to a single workplane at a time. In the following example all six -faces of the first box are used to define workplanes which are then used to position -rotated boxes. - -.. code-block:: python - - import build123d as bd - - with bd.BuildPart() as bp: - bd.Box(3, 3, 3) - with bd.BuildSketch(*bp.faces()): - bd.Rectangle(1, 2, rotation=45) - bd.extrude(amount=0.1) - -This is the result: - -.. image:: boxes_on_faces.svg - :align: center - -.. _location_context_link: - Location ======== @@ -286,182 +166,7 @@ There are also four methods that are used to change the location of objects: Locations can be combined with the ``*`` operator and have their direction flipped with the ``-`` operator. -Locations Context -================= - -When positioning objects or operations within a builder Location Contexts are used. These -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 - - with BuildPart(): - with Locations((0,10),(0,-10)): - Box(1,1,1) - with GridLocations(x_spacing=5, y_spacing=5, x_count=2, y_count=2): - Sphere(1) - Cylinder(1,1) - -In this example ``Locations`` creates two positions on the current workplane at (0,10) and (0,-10). -Since ``Box`` is within the scope of ``Locations``, two boxes are created at these locations. The -``GridLocations`` context creates four positions which apply to the ``Sphere``. The ``Cylinder`` is -out of the scope of ``GridLocations`` but in the scope of ``Locations`` so two cylinders are created. - -Note that these contexts are creating Location objects not just simple points. The difference -isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within -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 - - with Locations(Plane.XY, Plane.XZ): - locs = GridLocations(1, 1, 2, 2) - for l in locs: - print(l) - -.. code-block:: - - Location(p=(-0.50,-0.50,0.00), o=(0.00,-0.00,0.00)) - Location(p=(-0.50,0.50,0.00), o=(0.00,-0.00,0.00)) - Location(p=(0.50,-0.50,0.00), o=(0.00,-0.00,0.00)) - Location(p=(0.50,0.50,0.00), o=(0.00,-0.00,0.00)) - Location(p=(-0.50,-0.00,-0.50), o=(90.00,-0.00,0.00)) - Location(p=(-0.50,0.00,0.50), o=(90.00,-0.00,0.00)) - Location(p=(0.50,0.00,-0.50), o=(90.00,-0.00,0.00)) - Location(p=(0.50,0.00,0.50), o=(90.00,-0.00,0.00)) - - -Operation Inputs -================ - -When one is operating on an existing object, e.g. adding a fillet to a part, -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 - - def fillet( - objects: Union[Union[Edge, Vertex], Iterable[Union[Edge, Vertex]]], - radius: float, - ): - -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 - - with BuildPart() as pipes: - Box(10, 10, 10, rotation=(10, 20, 30)) - ... - fillet(pipes.edges(Select.LAST), radius=0.2) - -Here the fillet accepts the iterable ShapeList of edges from the last operation of -the ``pipes`` builder and a radius is provided as a keyword argument. - -Combination Modes -================= - -Almost all objects or operations have a ``mode`` parameter which is defined by the -``Mode`` Enum class as follows: - -.. code-block:: python - - class Mode(Enum): - ADD = auto() - SUBTRACT = auto() - INTERSECT = auto() - REPLACE = auto() - PRIVATE = auto() - -The ``mode`` parameter describes how the user would like the object or operation to -interact with the object within the builder. For example, ``Mode.ADD`` will -integrate a new object(s) in with an existing ``part``. Note that a part doesn't -necessarily have to be a single object so multiple distinct objects could be added -resulting is multiple objects stored as a ``Compound`` object. As one might expect -``Mode.SUBTRACT``, ``Mode.INTERSECT``, and ``Mode.REPLACE`` subtract, intersect, or replace -(from) the builder's object. ``Mode.PRIVATE`` instructs the builder that this object -should not be combined with the builder's object in any way. - -Most commonly, the default ``mode`` is ``Mode.ADD`` but this isn't always true. -For example, the ``Hole`` classes use a default ``Mode.SUBTRACT`` as they remove -a volume from the part under normal circumstances. However, the ``mode`` used in -the ``Hole`` classes can be specified as ``Mode.ADD`` or ``Mode.INTERSECT`` to -help in inspection or debugging. - Selectors ========= .. include:: selectors.rst - -Using Locations & Rotating Objects -================================== - -build123d stores points (to be specific ``Location`` (s)) internally to be used as -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 - - with BuildPart() as pipes: - Box(10, 10, 10, rotation=(10, 20, 30)) - -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 - - with BuildPart() as pipes: - with Locations((-10, -10, -10), (10, 10, 10)): - Box(10, 10, 10, rotation=(10, 20, 30)) - -which will create two boxes. - -To orient a part, a ``rotation`` parameter is available on ``BuildSketch``` and -``BuildPart`` APIs. When working in a sketch, the rotation is a single angle in -degrees so the parameter is a float. When working on a part, the rotation is -a three dimensional ``Rotation`` object of the form -``Rotation(, , )`` although a simple three tuple of -floats can be used as input. As 3D rotations are not cumulative, one can -combine rotations with the `*` operator like this: -``Rotation(10, 20, 30) * Rotation(0, 90, 0)`` to generate any desired rotation. - -.. hint:: - Experts Only - - ``Locations`` will accept ``Location`` objects for input which allows one - to specify both the position and orientation. However, the orientation - is often determined by the ``Plane`` that an object was created on. - ``Rotation`` is a subclass of ``Location`` and therefore will also accept - a position component. - -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 - - height, width, thickness, f_rad = 60, 80, 20, 10 - - with BuildPart() as pillow_block: - with BuildSketch() as plan: - Rectangle(width, height) - fillet(plan.vertices(), radius=f_rad) - extrude(amount=thickness) - -``BuildSketch`` exits after the ``fillet`` operation and when doing so it transfers -the sketch to the ``pillow_block`` instance of ``BuildPart`` as the internal instance variable -``pending_faces``. This allows the ``extrude`` operation to be immediately invoked as it -extrudes these pending faces into ``Solid`` objects. Likewise, ``loft`` would take all of the -``pending_faces`` and attempt to create a single ``Solid`` object from them. - -Normally the user will not need to interact directly with pending objects; however, -one can see pending Edges and Faces with ``.pending_edges`` and -``.pending_faces`` attributes. In the above example, by adding a -``print(pillow_block.pending_faces)`` prior to the ``extrude(amount=thickness)`` the -pending ``Face`` from the ``BuildSketch`` will be displayed. 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 new file mode 100644 index 0000000..076882d --- /dev/null +++ b/docs/key_concepts_builder.rst @@ -0,0 +1,393 @@ +########################### +Key Concepts (builder mode) +########################### + +There are two primary APIs provided by build123d: builder and algebra. The builder +API may be easier for new users as it provides some assistance and shortcuts; however, +if you know what a Quaternion is you might prefer the algebra API which allows +CAD objects to be created in the style of mathematical equations. Both API can +be mixed in the same model with the exception that the algebra API can't be used +from within a builder context. As with music, there is no "best" genre or API, +use the one you prefer or both if you like. + +The following key concepts will help new users understand build123d quickly. + +Understanding the Builder Paradigm +================================== + +The **Builder** paradigm in build123d provides a powerful and intuitive way to construct +complex geometric models. At its core, the Builder works like adding a column of numbers +on a piece of paper: a running "total" is maintained internally as each new object is +added or modified. This approach simplifies the process of constructing models by breaking +it into smaller, incremental steps. + +How the Builder Works +---------------------- + +When using a Builder (such as **BuildLine**, **BuildSketch**, or **BuildPart**), the +following principles apply: + +1. **Running Total**: + - The Builder maintains an internal "total," which represents the current state of + the object being built. + - Each operation updates this total by combining the new object with the existing one. + +2. **Combination Modes**: + - Just as numbers in a column may have a `+` or `-` sign to indicate addition or + subtraction, Builders use **modes** to control how each object is combined with + the current total. + - Common modes include: + + - **ADD**: Adds the new object to the current total. + - **SUBTRACT**: Removes the new object from the current total. + - **INTERSECT**: Keeps only the overlapping regions of the new object and the current total. + - **REPLACE**: Entirely replace the running total. + - **PRIVATE**: Don't change the running total at all. + + - The mode can be set dynamically for each operation, allowing for flexible and precise modeling. + +3. **Extracting the Result**: + - At the end of the building process, the final object is accessed through the + Builder's attributes, such as ``.line``, ``.sketch``, or ``.part``, depending on + the Builder type. + - For example: + + - **BuildLine**: Use ``.line`` to retrieve the final wireframe geometry. + - **BuildSketch**: Use ``.sketch`` to extract the completed 2D profile. + - **BuildPart**: Use ``.part`` to obtain the 3D solid. + +Example Workflow +----------------- + +Here is an example of using a Builder to create a simple part: + +.. code-block:: build123d + + from build123d import * + + # Using BuildPart to create a 3D model + with BuildPart() as example_part: + with BuildSketch() as base_sketch: + Rectangle(20, 20) + extrude(amount=10) # Create a base block + with BuildSketch(Plane(example_part.faces().sort_by(Axis.Z).last)) as cut_sketch: + Circle(5) + extrude(amount=-5, mode=Mode.SUBTRACT) # Subtract a cylinder + + # Access the final part + result_part = example_part.part + +Key Concepts +------------ + +- **Incremental Construction**: + Builders allow you to build objects step-by-step, maintaining clarity and modularity. + +- **Dynamic Mode Switching**: + The **mode** parameter gives you precise control over how each operation modifies + the current total. + +- **Seamless Extraction**: + The Builder paradigm simplifies the retrieval of the final object, ensuring that you + always have access to the most up-to-date result. + +Analogy: Adding Numbers on Paper +-------------------------------- + +Think of the Builder as a running tally when adding numbers on a piece of paper: + +- Each number represents an operation or object. +- The ``+`` or ``-`` sign corresponds to the **ADD** or **SUBTRACT** mode. +- At the end, the total is the sum of all operations, which you can retrieve by referencing + the Builder’s output. + +By adopting this approach, build123d ensures a natural, intuitive workflow for constructing +2D and 3D models. + +Builders +======== + +The three builders, ``BuildLine``, ``BuildSketch``, and ``BuildPart`` are tools to create +new objects - not the objects themselves. Each of the objects and operations applicable +to these builders create objects of the standard CadQuery Direct API, most commonly +``Compound`` objects. This is opposed to CadQuery's Fluent API which creates objects +of the ``Workplane`` class which frequently needed to be converted back to base +class for further processing. + +One can access the objects created by these builders by referencing the appropriate +instance variable. For example: + +.. code-block:: build123d + + with BuildPart() as my_part: + ... + + show_object(my_part.part) + +.. code-block:: build123d + + with BuildSketch() as my_sketch: + ... + + show_object(my_sketch.sketch) + +.. code-block:: build123d + + with BuildLine() as my_line: + ... + + show_object(my_line.line) + +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:: build123d + + with BuildPart() as part_builder: + Box(part_builder, 10,10,10) + +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:: build123d + + with BuildPart() as part_builder: + Box(10,10,10) + with BuildSketch() as sketch_builder: + Circle(2) + +In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle`` +is in the scope of ``sketch_builder``. + +Workplanes +========== + +As build123d is a 3D CAD package one must be able to position objects anywhere. As one +frequently will work in the same plane for a sequence of operations, the first parameter(s) +of the builders is a (sequence of) workplane(s) which is (are) used +to aid in the location of features. The default workplane in most cases is the ``Plane.XY`` +where a tuple of numbers represent positions on the x and y axes. However workplanes can +be generated on any plane which allows users to put a workplane where they are working +and then work in local 2D coordinate space. + + +.. code-block:: build123d + + with BuildPart(Plane.XY) as example: + ... # a 3D-part + with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom: + ... + with BuildSketch(Plane.XZ) as vertical: + ... + with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top: + ... + +When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a +default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the +normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ``) sketch. +All objects or operations within the scope of a workplane will automatically be orientated with +respect to this plane so the user only has to work with local coordinates. + +As shown above, workplanes can be created from faces as well. The ``top`` sketch is +positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value. + +One is not limited to a single workplane at a time. In the following example all six +faces of the first box are used to define workplanes which are then used to position +rotated boxes. + +.. code-block:: build123d + + import build123d as bd + + with bd.BuildPart() as bp: + bd.Box(3, 3, 3) + with bd.BuildSketch(*bp.faces()): + bd.Rectangle(1, 2, rotation=45) + bd.extrude(amount=0.1) + +This is the result: + +.. image:: boxes_on_faces.svg + :align: center + +.. _location_context_link: + +Locations Context +================= + +When positioning objects or operations within a builder Location Contexts are used. These +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:: build123d + + with BuildPart(): + with Locations((0,10),(0,-10)): + Box(1,1,1) + with GridLocations(x_spacing=5, y_spacing=5, x_count=2, y_count=2): + Sphere(1) + Cylinder(1,1) + +In this example ``Locations`` creates two positions on the current workplane at (0,10) and (0,-10). +Since ``Box`` is within the scope of ``Locations``, two boxes are created at these locations. The +``GridLocations`` context creates four positions which apply to the ``Sphere``. The ``Cylinder`` is +out of the scope of ``GridLocations`` but in the scope of ``Locations`` so two cylinders are created. + +Note that these contexts are creating Location objects not just simple points. The difference +isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within +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:: build123d + + with Locations(Plane.XY, Plane.XZ): + locs = GridLocations(1, 1, 2, 2) + for l in locs: + print(l) + +.. code-block:: + + Location(p=(-0.50,-0.50,0.00), o=(0.00,-0.00,0.00)) + Location(p=(-0.50,0.50,0.00), o=(0.00,-0.00,0.00)) + Location(p=(0.50,-0.50,0.00), o=(0.00,-0.00,0.00)) + Location(p=(0.50,0.50,0.00), o=(0.00,-0.00,0.00)) + Location(p=(-0.50,-0.00,-0.50), o=(90.00,-0.00,0.00)) + Location(p=(-0.50,0.00,0.50), o=(90.00,-0.00,0.00)) + Location(p=(0.50,0.00,-0.50), o=(90.00,-0.00,0.00)) + Location(p=(0.50,0.00,0.50), o=(90.00,-0.00,0.00)) + + +Operation Inputs +================ + +When one is operating on an existing object, e.g. adding a fillet to a part, +an iterable of objects is often required (often a ShapeList). + +Here is the definition of :meth:`~operations_generic.fillet` to help illustrate: + +.. code-block:: build123d + + def fillet( + objects: Union[Union[Edge, Vertex], Iterable[Union[Edge, Vertex]]], + radius: float, + ): + +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:: build123d + + with BuildPart() as pipes: + Box(10, 10, 10, rotation=(10, 20, 30)) + ... + fillet(pipes.edges(Select.LAST), radius=0.2) + +Here the fillet accepts the iterable ShapeList of edges from the last operation of +the ``pipes`` builder and a radius is provided as a keyword argument. + +Combination Modes +================= + +Almost all objects or operations have a ``mode`` parameter which is defined by the +``Mode`` Enum class as follows: + +.. code-block:: build123d + + class Mode(Enum): + ADD = auto() + SUBTRACT = auto() + INTERSECT = auto() + REPLACE = auto() + PRIVATE = auto() + +The ``mode`` parameter describes how the user would like the object or operation to +interact with the object within the builder. For example, ``Mode.ADD`` will +integrate a new object(s) in with an existing ``part``. Note that a part doesn't +necessarily have to be a single object so multiple distinct objects could be added +resulting is multiple objects stored as a ``Compound`` object. As one might expect +``Mode.SUBTRACT``, ``Mode.INTERSECT``, and ``Mode.REPLACE`` subtract, intersect, or replace +(from) the builder's object. ``Mode.PRIVATE`` instructs the builder that this object +should not be combined with the builder's object in any way. + +Most commonly, the default ``mode`` is ``Mode.ADD`` but this isn't always true. +For example, the ``Hole`` classes use a default ``Mode.SUBTRACT`` as they remove +a volume from the part under normal circumstances. However, the ``mode`` used in +the ``Hole`` classes can be specified as ``Mode.ADD`` or ``Mode.INTERSECT`` to +help in inspection or debugging. + + +Using Locations & Rotating Objects +================================== + +build123d stores points (to be specific ``Location`` (s)) internally to be used as +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:: build123d + + with BuildPart() as pipes: + Box(10, 10, 10, rotation=(10, 20, 30)) + +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:: build123d + + with BuildPart() as pipes: + with Locations((-10, -10, -10), (10, 10, 10)): + Box(10, 10, 10, rotation=(10, 20, 30)) + +which will create two boxes. + +To orient a part, a ``rotation`` parameter is available on ``BuildSketch``` and +``BuildPart`` APIs. When working in a sketch, the rotation is a single angle in +degrees so the parameter is a float. When working on a part, the rotation is +a three dimensional ``Rotation`` object of the form +``Rotation(, , )`` although a simple three tuple of +floats can be used as input. As 3D rotations are not cumulative, one can +combine rotations with the `*` operator like this: +``Rotation(10, 20, 30) * Rotation(0, 90, 0)`` to generate any desired rotation. + +.. hint:: + Experts Only + + ``Locations`` will accept ``Location`` objects for input which allows one + to specify both the position and orientation. However, the orientation + is often determined by the ``Plane`` that an object was created on. + ``Rotation`` is a subclass of ``Location`` and therefore will also accept + a position component. + +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:: build123d + + height, width, thickness, f_rad = 60, 80, 20, 10 + + with BuildPart() as pillow_block: + with BuildSketch() as plan: + Rectangle(width, height) + fillet(plan.vertices(), radius=f_rad) + extrude(amount=thickness) + +``BuildSketch`` exits after the ``fillet`` operation and when doing so it transfers +the sketch to the ``pillow_block`` instance of ``BuildPart`` as the internal instance variable +``pending_faces``. This allows the ``extrude`` operation to be immediately invoked as it +extrudes these pending faces into ``Solid`` objects. Likewise, ``loft`` would take all of the +``pending_faces`` and attempt to create a single ``Solid`` object from them. + +Normally the user will not need to interact directly with pending objects; however, +one can see pending Edges and Faces with ``.pending_edges`` and +``.pending_faces`` attributes. In the above example, by adding a +``print(pillow_block.pending_faces)`` prior to the ``extrude(amount=thickness)`` the +pending ``Face`` from the ``BuildSketch`` will be displayed. 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 new file mode 100644 index 0000000..b36dde4 --- /dev/null +++ b/docs/moving_objects.rst @@ -0,0 +1,130 @@ +Moving Objects +============== + +In build123d, there are several methods to move objects. These methods vary +based on the mode of operation and provide flexibility for object placement +and orientation. Below, we outline the three main approaches to moving objects: +builder mode, algebra mode, and direct manipulation methods. + +Builder Mode +------------ +In builder mode, object locations are defined before the objects themselves are +created. This approach ensures that objects are positioned correctly during the +construction process. The following tools are commonly used to specify locations: + +1. :class:`~build_common.Locations` Use this to define a specific location for the objects within the `with` block. +2. :class:`~build_common.GridLocations` Arrange objects in a grid pattern. +3. :class:`~build_common.PolarLocations` Position objects in a circular pattern. +4. :class:`~build_common.HexLocations` Arrange objects in a hexagonal grid. + +.. note:: + The location(s) of an object must be defined prior to its creation when using builder mode. + +Example: + +.. code-block:: build123d + + with Locations((10, 20, 30)): + Box(5, 5, 5) + +Algebra Mode +------------ +In algebra mode, object movement is expressed using algebraic operations. The +:class:`~geometry.Pos` function, short for Position, represents a location, which can be combined +with objects or planes to define placement. + +1. ``Pos() * shape``: Applies a position to a shape. +2. ``Plane() * Pos() * shape``: Combines a plane with a position and applies it to a shape. + +Rotation is an important concept in this mode. A :class:`~geometry.Rotation` represents a location +with orientation values set, which can be used to define a new location or modify +an existing one. + +Example: + +.. code-block:: build123d + + rotated_box = Rotation(45, 0, 0) * box + +Direct Manipulation Methods +--------------------------- +The following methods allow for direct manipulation of a shape's location and orientation +after it has been created. These methods offer a mix of absolute and relative transformations. + +Position +^^^^^^^^ +- **Absolute Position:** Set the position directly. + +.. code-block:: build123d + + shape.position = (x, y, z) + +- **Relative Position:** Adjust the position incrementally. + +.. code-block:: build123d + + shape.position += (x, y, z) + shape.position -= (x, y, z) + + +Orientation +^^^^^^^^^^^ +- **Absolute Orientation:** Set the orientation directly. + +.. code-block:: build123d + + shape.orientation = (X, Y, Z) + +- **Relative Orientation:** Adjust the orientation incrementally. + +.. code-block:: build123d + + shape.orientation += (X, Y, Z) + shape.orientation -= (X, Y, Z) + +Movement Methods +^^^^^^^^^^^^^^^^ +- **Relative Move:** + +.. code-block:: build123d + + shape.move(Location) + +- **Relative Move of Copy:** + +.. code-block:: build123d + + relocated_shape = shape.moved(Location) + +- **Absolute Move:** + +.. code-block:: build123d + + shape.locate(Location) + +- **Absolute Move of Copy:** + +.. code-block:: build123d + + relocated_shape = shape.located(Location) + + +Transformation a.k.a. Translation and Rotation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. note:: + These methods don't work in the same way as the previous methods in that they don't just change + the object's internal :class:`~geometry.Location` but transform the base object itself which + is quite slow and potentially problematic. + +- **Translation:** Move a shape relative to its current position. + +.. code-block:: build123d + + relocated_shape = shape.translate(x, y, z) + +- **Rotation:** Rotate a shape around a specified axis by a given angle. + +.. 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/requirements.txt b/docs/requirements.txt deleted file mode 100644 index feec70d..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -# Defining the exact version will make sure things don't break -sphinx==5.3.0 -sphinx_rtd_theme>=0.5.1 -docutils<0.17 -readthedocs-sphinx-search>=0.3.2 -sphinx_autodoc_typehints==1.12.0 -sphinx_copybutton -sphinx-hoverxref -sphinx_design --e git+https://github.com/gumyr/build123d.git#egg=build123d 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 9938434..e6a7c3d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,14 +1,45 @@ # Global options: [mypy] -no_implicit_optional=False [mypy-anytree.*] ignore_missing_imports = True +[mypy-build123d.topology.jupyter_tools.*] +ignore_missing_imports = True + +[mypy-cadquery-ocp-stubs.*] +ignore_missing_imports = True + +[mypy-IPython.*] +ignore_missing_imports = True + +[mypy-numpy.*] +ignore_missing_imports = True + +[mypy-OCP.*] +ignore_missing_imports = True + +[mypy-ocpsvg.*] +ignore_missing_imports = True + +[mypy-scipy.*] +ignore_missing_imports = True + [mypy-svgpathtools.*] ignore_missing_imports = True +[mypy-trianglesolver.*] +ignore_missing_imports = True + [mypy-vtkmodules.*] ignore_missing_imports = True +[mypy-ezdxf.*] +ignore_missing_imports = True + +[mypy-setuptools_scm.*] +ignore_missing_imports = True + +[mypy-lib3mf.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 037194e..22f6440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ authors = [ ] description = "A python CAD programming library" readme = "README.md" -requires-python = ">= 3.9, < 3.13" +requires-python = ">= 3.10, < 3.14" keywords = [ "3d models", "3d printing", @@ -24,7 +24,7 @@ keywords = [ "brep", "cad", "cadquery", - "opencscade", + "opencascade", "python", ] license = {text = "Apache-2.0"} @@ -35,22 +35,68 @@ classifiers = [ ] dependencies = [ - "cadquery-ocp >= 7.7.0", - "typing_extensions >= 4.6.0, <5", - "numpy >= 2, <3", - "svgpathtools >= 1.5.1, <2", - "anytree >= 2.8.0, <3", + "cadquery-ocp >= 7.8, < 7.9", + "typing_extensions >= 4.6.0, < 5", + "numpy >= 2, < 3", + "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", - "trianglesolver" + "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 +ocp_vscode = [ + "ocp_vscode", +] + +# development dependencies +development = [ + "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 +stubs = [ + "cadquery-ocp-stubs >= 7.8, < 7.9", +] + +# dependencies to build the docs +docs = [ + "sphinx==8.1.3", # pin for stability of docs builds + "sphinx-design", + "sphinx-copybutton", + "sphinx-hoverxref", + "sphinx-rtd-theme", + "sphinx_autodoc_typehints", +] + +# all dependencies +all = [ + "build123d[ocp_vscode]", + "build123d[development]", + "build123d[docs]", + # "build123d[stubs]", # excluded for now as mypy fails +] [tool.setuptools.packages.find] where = ["src"] @@ -61,5 +107,5 @@ exclude = ["build123d._dev"] write_to = "src/build123d/_version.py" [tool.black] -target-version = ["py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313"] line-length = 88 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 62c8eab..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest-cov --e . diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index a16a83d..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__ @@ -28,6 +29,7 @@ modify_copyreg() __all__ = [ # Length Constants + "MC", "MM", "CM", "M", @@ -44,6 +46,7 @@ __all__ = [ "ApproxOption", "AngularDirection", "CenterOf", + "ContinuityLevel", "Extrinsic", "FontStyle", "FrameMethod", @@ -52,16 +55,19 @@ __all__ = [ "Intrinsic", "Keep", "Kind", + "Sagitta", "LengthMode", "MeshType", "Mode", "NumberDisplay", "PageSize", + "Tangency", "PositionMode", "PrecisionMode", "Select", "Side", "SortBy", + "TextAlign", "Transition", "Unit", "Until", @@ -75,7 +81,9 @@ __all__ = [ "BuildSketch", # 1D Curve Objects "BaseLineObject", + "Airfoil", "Bezier", + "BlendCurve", "CenterArc", "DoubleTangentArc", "EllipticalCenterArc", @@ -92,6 +100,10 @@ __all__ = [ "TangentArc", "JernArc", "ThreePointArc", + "PointArcTangentLine", + "ArcArcTangentLine", + "PointArcTangentArc", + "ArcArcTangentArc", # 2D Sketch Objects "ArrowHead", "Arrow", @@ -126,6 +138,7 @@ __all__ = [ "Wedge", # Direct API Classes "BoundBox", + "OrientedBoundBox", "Rotation", "Rot", "Pos", @@ -148,6 +161,7 @@ __all__ = [ "Compound", "Location", "LocationEncoder", + "GeomEncoder", "Joint", "RigidJoint", "RevoluteJoint", @@ -155,6 +169,7 @@ __all__ = [ "LinearJoint", "CylindricalJoint", "BallJoint", + "DraftAngleError", # Exporter classes "Export2D", "ExportDXF", @@ -174,6 +189,7 @@ __all__ = [ "new_edges", "pack", "polar", + "available_fonts", # Context aware selectors "solids", "faces", @@ -189,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 28bada2..d14b556 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -50,16 +50,26 @@ import functools from abc import ABC, abstractmethod from itertools import product from math import sqrt, cos, pi -from typing import Any, Callable, Iterable, Optional, Union, TypeVar -from typing_extensions import Self, ParamSpec, Concatenate +from typing import Any, cast, overload, Protocol, Type, TypeVar, Generic + +from collections.abc import Callable, Iterable +from typing_extensions import Self from build123d.build_enums import Align, Mode, Select, Unit -from build123d.geometry import Axis, Location, Plane, Vector, VectorLike +from build123d.geometry import ( + Axis, + Location, + Plane, + Vector, + VectorLike, + to_align_offset, +) from build123d.topology import ( Compound, Curve, Edge, Face, + Joint, Part, Shape, ShapeList, @@ -126,10 +136,10 @@ def _is_point(obj): T = TypeVar("T", Any, list[Any]) -def flatten_sequence(*obj: T) -> list[Any]: +def flatten_sequence(*obj: T) -> ShapeList[Any]: """Convert a sequence of object potentially containing iterables into a flat list""" - flat_list = [] + flat_list: ShapeList[Any] = ShapeList() for item in obj: # Note: an Iterable can't be used here as it will match with Vector & Vertex # and break them into a list of floats. @@ -145,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"], @@ -164,8 +175,14 @@ operations_apply_to = { "thicken": ["BuildPart"], } +B = TypeVar("B", bound="Builder") +"""Builder type hint""" -class Builder(ABC): +ShapeT = TypeVar("ShapeT", bound=Shape) +"""Builder's are generic shape creators""" + + +class Builder(ABC, Generic[ShapeT]): """Builder Base class for the build123d Builders. @@ -184,36 +201,19 @@ class Builder(ABC): # pylint: disable=too-many-instance-attributes # Context variable used to by Objects and Operations to link to current builder instance - _current: contextvars.ContextVar["Builder"] = contextvars.ContextVar( + _current: contextvars.ContextVar[Builder] = contextvars.ContextVar( "Builder._current" ) # Abstract class variables _tag = "Builder" _obj_name = "None" - _shape = None - _sub_class = None - - @property - @abstractmethod - def _obj(self) -> Shape: - """Object to pass to parent""" - raise NotImplementedError # pragma: no cover - - @property - def max_dimension(self) -> float: - """Maximum size of object in all directions""" - return self._obj.bounding_box().diagonal if self._obj else 0.0 - - @property - def new_edges(self) -> ShapeList[Edge]: - """Edges that changed during last operation""" - before_list = [] if self.obj_before is None else [self.obj_before] - return new_edges(*(before_list + self.to_combine), combined=self._obj) + # _shape: Shape # The type of the shape the builder creates + # _sub_class: Curve | Sketch | Part # The class of the shape the builder creates def __init__( self, - *workplanes: Union[Face, Plane, Location], + *workplanes: Face | Plane | Location, mode: Mode = Mode.ADD, ): self.mode = mode @@ -224,15 +224,38 @@ class Builder(ABC): assert current_frame is not None assert current_frame.f_back is not None self._python_frame = current_frame.f_back.f_back - self._python_frame_code = self._python_frame.f_code self.parent_frame = None self.builder_parent = None self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []} self.workplanes_context = None - self.exit_workplanes = None - self.obj_before: Optional[Shape] = None + self.exit_workplanes: list[Plane] = [] + self.obj_before: Shape | None = None self.to_combine: list[Shape] = [] + @property + @abstractmethod + def _obj(self) -> Shape | None: + """Object to pass to parent""" + raise NotImplementedError # pragma: no cover + + @_obj.setter + @abstractmethod + def _obj(self, value: Part) -> None: + raise NotImplementedError # pragma: no cover + + @property + def max_dimension(self) -> float: + """Maximum size of object in all directions""" + return self._obj.bounding_box().diagonal if self._obj else 0.0 + + @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) + def __enter__(self): """Upon entering record the parent and a token to restore contextvars""" @@ -298,12 +321,16 @@ class Builder(ABC): logger.info("Exiting %s", type(self).__name__) @abstractmethod - def _add_to_pending(self, *objects: Union[Edge, Face], face_plane: Plane = None): + def _add_to_pending(self, *objects: Edge | Face, face_plane: Plane | None = None): """Integrate a sequence of objects into existing builder object""" return NotImplementedError # pragma: no cover @classmethod - def _get_context(cls, caller: Union[Builder, str] = None, log: bool = True) -> Self: + def _get_context( + cls: Type[B], + caller: Builder | Shape | Joint | str | None = None, + log: bool = True, + ) -> B | None: """Return the instance of the current builder""" result = cls._current.get(None) context_name = "None" if result is None else type(result).__name__ @@ -317,11 +344,11 @@ class Builder(ABC): caller_name = "None" logger.info("%s context requested by %s", context_name, caller_name) - return result + return cast(B, result) def _add_to_context( self, - *objects: Union[Edge, Wire, Face, Solid, Compound], + *objects: Edge | Wire | Face | Solid | Compound, faces_to_pending: bool = True, clean: bool = True, mode: Mode = Mode.ADD, @@ -354,8 +381,11 @@ class Builder(ABC): self.obj_before = self._obj self.to_combine = list(objects) if mode != Mode.PRIVATE and len(objects) > 0: - # Categorize the input objects by type - typed = {} + # Typed dictionary: keys are classes, values are lists of instances of those classes + typed: dict[ + Type[Edge | Wire | Face | Solid | Compound], + list[Edge | Wire | Face | Solid | Compound], + ] = {cls: [] for cls in [Edge, Wire, Face, Solid, Compound]} for cls in [Edge, Wire, Face, Solid, Compound]: typed[cls] = [obj for obj in objects if isinstance(obj, cls)] @@ -363,7 +393,7 @@ class Builder(ABC): num_stored = sum(len(t) for t in typed.values()) # Generate an exception if not processing exceptions if len(objects) != num_stored and not sys.exc_info()[1]: - unsupported = set(objects) - set(v for l in typed.values() for v in l) + unsupported = set(objects) - {v for l in typed.values() for v in l} if unsupported != {None}: raise ValueError(f"{self._tag} doesn't accept {unsupported}") @@ -418,27 +448,42 @@ class Builder(ABC): len(typed[self._shape]), mode, ) - + combined: Shape | list[Shape] | None if mode == Mode.ADD: if self._obj is None: if len(typed[self._shape]) == 1: - self._obj = typed[self._shape][0] + combined = typed[self._shape][0] else: - self._obj = ( + combined = ( typed[self._shape].pop().fuse(*typed[self._shape]) ) else: - self._obj = self._obj.fuse(*typed[self._shape]) + combined = self._obj.fuse(*typed[self._shape]) elif mode == Mode.SUBTRACT: if self._obj is None: raise RuntimeError("Nothing to subtract from") - self._obj = self._obj.cut(*typed[self._shape]) + combined = self._obj.cut(*typed[self._shape]) elif mode == Mode.INTERSECT: if self._obj is None: raise RuntimeError("Nothing to intersect with") - self._obj = self._obj.intersect(*typed[self._shape]) + combined = self._obj.intersect(Compound(typed[self._shape])) elif mode == Mode.REPLACE: - self._obj = Compound(list(typed[self._shape])) + combined = self._sub_class(list(typed[self._shape])) + + if combined is None: # empty intersection result + self._obj = self._sub_class() + elif isinstance( + combined, list + ): # If the boolean operation created a list, convert back + self._obj = self._sub_class(combined) + else: + self._obj = combined + # If the boolean operation created a list, convert back + # self._obj = ( + # self._sub_class(combined) + # if isinstance(combined, list) + # else combined + # ) if self._obj is not None and clean: self._obj = self._obj.clean() @@ -495,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] @@ -539,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: @@ -582,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: @@ -625,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: @@ -668,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: @@ -699,23 +745,27 @@ class Builder(ABC): ) return all_solids[0] - def _shapes(self, obj_type: Union[Vertex, Edge, Face, Solid] = None) -> ShapeList: + def _shapes( + self, + obj_type: Type[Vertex] | Type[Edge] | Type[Face] | Type[Solid] | None = None, + ) -> 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: Union[Shape, Iterable[Shape]] = None + self, validating_class, objects: Shape | Iterable[Shape] | None = None ): """Validate that objects/operations and parameters apply""" @@ -765,15 +815,15 @@ class Builder(ABC): def __add__(self, _other) -> Self: """Invalid add""" - self._invalid_combine() + return self._invalid_combine() def __sub__(self, _other) -> Self: """Invalid sub""" - self._invalid_combine() + return self._invalid_combine() def __and__(self, _other) -> Self: """Invalid and""" - self._invalid_combine() + return self._invalid_combine() def __getattr__(self, name): """The user is likely trying to reference the builder's object""" @@ -784,7 +834,7 @@ class Builder(ABC): def validate_inputs( - context: Builder, validating_class, objects: Iterable[Shape] = None + context: Builder | None, validating_class, objects: Iterable[Shape] | None = None ): """A function to wrap the method when used outside of a Builder context""" if context is None: @@ -807,7 +857,7 @@ class LocationList: """ # Context variable used to link to LocationList instance - _current: contextvars.ContextVar["LocationList"] = contextvars.ContextVar( + _current: contextvars.ContextVar[LocationList] = contextvars.ContextVar( "ContextList._current" ) @@ -917,7 +967,7 @@ class HexLocations(LocationList): x_count: int, y_count: int, major_radius: bool = False, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] = (Align.CENTER, Align.CENTER), ): # pylint: disable=too-many-locals @@ -963,14 +1013,7 @@ class HexLocations(LocationList): min_corner = Vector(sorted_points[0][0].X, sorted_points[1][0].Y) # Calculate the amount to offset the array to align it - align_offset = [] - for i in range(2): - if self.align[i] == Align.MIN: - align_offset.append(0) - elif self.align[i] == Align.CENTER: - align_offset.append(-size[i] / 2) - elif self.align[i] == Align.MAX: - align_offset.append(-size[i]) + align_offset = to_align_offset((0, 0), size, align) # Align the points points = ShapeList( @@ -1020,7 +1063,7 @@ class PolarLocations(LocationList): if count < 1: raise ValueError(f"At least 1 elements required, requested {count}") if count == 1: - angle_step = 0 + angle_step = 0.0 else: angle_step = angular_range / (count - int(endpoint)) @@ -1058,15 +1101,15 @@ class Locations(LocationList): def __init__( self, - *pts: Union[ - VectorLike, - Vertex, - Location, - Face, - Plane, - Axis, - Iterable[VectorLike, Vertex, Location, Face, Plane, Axis], - ], + *pts: ( + VectorLike + | Vertex + | Location + | Face + | Plane + | Axis + | Iterable[VectorLike | Vertex | Location | Face | Plane | Axis] + ), ): local_locations = [] for point in flatten_sequence(*pts): @@ -1075,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): @@ -1148,7 +1191,7 @@ class GridLocations(LocationList): y_spacing: float, x_count: int, y_count: int, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] = (Align.CENTER, Align.CENTER), ): if x_count < 1 or y_count < 1: raise ValueError( @@ -1163,29 +1206,22 @@ class GridLocations(LocationList): size = [x_spacing * (x_count - 1), y_spacing * (y_count - 1)] self.size = Vector(*size) #: size of the grid - align_offset = [] - for i in range(2): - if self.align[i] == Align.MIN: - align_offset.append(0.0) - elif self.align[i] == Align.CENTER: - align_offset.append(-size[i] / 2) - elif self.align[i] == Align.MAX: - align_offset.append(-size[i]) + align_offset = to_align_offset((0, 0), size, align) - self.min = Vector(*align_offset) #: bottom left corner + self.min = align_offset #: bottom left corner self.max = self.min + self.size #: top right corner # Create the list of local locations - local_locations = [] - for i, j in product(range(x_count), range(y_count)): - local_locations.append( - Location( - Vector( - i * x_spacing + align_offset[0], - j * y_spacing + align_offset[1], - ) + local_locations = [ + Location( + align_offset + + Vector( + i * x_spacing, + j * y_spacing, ) ) + for i, j in product(range(x_count), range(y_count)) + ] self.local_locations = Locations._move_to_existing( local_locations @@ -1209,18 +1245,18 @@ class WorkplaneList: """ # Context variable used to link to WorkplaneList instance - _current: contextvars.ContextVar["WorkplaneList"] = contextvars.ContextVar( + _current: contextvars.ContextVar[WorkplaneList] = contextvars.ContextVar( "WorkplaneList._current" ) - def __init__(self, *workplanes: Union[Face, Plane, Location]): + def __init__(self, *workplanes: Face | Plane | Location): self._reset_tok = None self.workplanes = WorkplaneList._convert_to_planes(workplanes) self.locations_context = None self.plane_index = 0 @staticmethod - def _convert_to_planes(objs: Iterable[Union[Face, Plane, Location]]) -> list[Plane]: + def _convert_to_planes(objs: Iterable[Face | Plane | Location]) -> list[Plane]: """Translate objects to planes""" objs = flatten_sequence(*objs) planes = [] @@ -1271,8 +1307,16 @@ class WorkplaneList: """Return the instance of the current ContextList""" return cls._current.get(None) + @overload @classmethod - def localize(cls, *points: VectorLike) -> Union[list[Vector], Vector]: + def localize(cls, points: VectorLike) -> Vector: ... # type: ignore[overload-overlap] + + @overload + @classmethod + def localize(cls, *points: VectorLike) -> list[Vector]: ... + + @classmethod # type: ignore[misc] + def localize(cls, *points: VectorLike): """Localize a sequence of points to the active workplane (only used by BuildLine where there is only one active workplane) @@ -1286,7 +1330,11 @@ class WorkplaneList: points_per_workplane = [] workplane = WorkplaneList._get_context().workplanes[0] localized_pts = [ - workplane.from_local_coords(pt) if isinstance(pt, tuple) else pt + ( + cast(Vector, workplane.from_local_coords(Vector(pt))) + if isinstance(pt, tuple) + else Vector(pt) + ) for pt in points ] if len(localized_pts) == 1: @@ -1295,31 +1343,60 @@ class WorkplaneList: points_per_workplane.extend(localized_pts) if len(points_per_workplane) == 1: - result = points_per_workplane[0] - else: - result = points_per_workplane - return result + return points_per_workplane[0] + return points_per_workplane -P = ParamSpec("P") +# 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[Concatenate[Builder, P], T2], -) -> Callable[P, T2]: + func: Callable[[Builder, Select], T2], + # ) -> ContextComponentGetter[T2]: +) -> Callable[[Select], T2]: + """ + Wraps a Builder method to automatically provide the Builder context. + + This function creates a wrapper around the provided Builder method (`func`) that + automatically retrieves the current Builder context and passes it as the first + argument to the method. This allows the method to be called without explicitly + providing the Builder context. + + Args: + func (Callable[[Builder, Select], T2]): The Builder method to be wrapped. + - The method must take a `Builder` instance as its first argument and + a `Select` instance as its second argument. + + Returns: + ContextComponentGetter[T2]: A callable that takes only a `Select` argument and + internally retrieves the Builder context to call the original method. + + Raises: + RuntimeError: If no Builder context is available when the returned function + is called. + """ + @functools.wraps(func) - def getter(select: Select = Select.ALL): - context = Builder._get_context(func.__name__) - if not context: + def getter(select: Select = Select.ALL) -> T2: + # Retrieve the current Builder context based on the method name + 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" ) + # Call the original method with the retrieved context and provided select return func(context, select) return getter +# The following functions are used to get the shapes from the builder in context vertices = __gen_context_component_getter(Builder.vertices) edges = __gen_context_component_getter(Builder.edges) wires = __gen_context_component_getter(Builder.wires) diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index fe8ba0e..44d7c8b 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -27,7 +27,17 @@ license: """ from __future__ import annotations -from enum import Enum, auto + +from enum import Enum, auto, IntEnum, unique +from typing import TypeAlias, Union + +from OCP.GccEnt import ( + GccEnt_unqualified, + GccEnt_enclosing, + GccEnt_enclosed, + GccEnt_outside, + GccEnt_noqualifier, +) class Align(Enum): @@ -36,11 +46,23 @@ class Align(Enum): MIN = auto() CENTER = auto() MAX = auto() + NONE = None def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" +Align2DType: TypeAlias = Union[ + Union[Align, None], + tuple[Union[Align, None], Union[Align, None]], +] + +Align3DType: TypeAlias = Union[ + Union[Align, None], + tuple[Union[Align, None], Union[Align, None], Union[Align, None]], +] + + class ApproxOption(Enum): """DXF export spline approximation strategy""" @@ -73,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""" @@ -163,11 +207,12 @@ class Intrinsic(Enum): class Keep(Enum): """Split options""" - TOP = auto() + ALL = auto() BOTTOM = auto() + BOTH = auto() INSIDE = auto() OUTSIDE = auto() - BOTH = auto() + TOP = auto() def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" @@ -203,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}>" @@ -263,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""" @@ -327,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 2822c3d..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 @@ -69,24 +69,36 @@ class BuildLine(Builder): _shape = Edge # Type of shapes being constructed _sub_class = Curve # Class of line/_obj - @property - def _obj(self) -> Curve: - return self.line - - @_obj.setter - def _obj(self, value: Curve) -> None: - self.line = value - def __init__( self, - workplane: Union[Face, Plane, Location] = Plane.XY, + workplane: Face | Plane | Location = Plane.XY, mode: Mode = Mode.ADD, ): - self.line: Curve = None + self._line: Curve | None = None super().__init__(workplane, mode=mode) if len(self.workplanes) > 1: raise ValueError("BuildLine only accepts one workplane") + @property + def line(self) -> Curve | None: + """Get the current line""" + return self._line + + @line.setter + def line(self, value: Curve) -> None: + """Set the current line""" + self._line = value + + @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""" self._current.reset(self._reset_tok) @@ -126,6 +138,6 @@ class BuildLine(Builder): """solid() not implemented""" raise NotImplementedError("solid() doesn't apply to BuildLine") - def _add_to_pending(self, *objects: Union[Edge, Face], face_plane: Plane = None): + def _add_to_pending(self, *objects: Edge | Face, face_plane: Plane | None = None): """_add_to_pending not implemented""" raise NotImplementedError("_add_to_pending doesn't apply to BuildLine") diff --git a/src/build123d/build_part.py b/src/build123d/build_part.py index 11911c6..d37bdae 100644 --- a/src/build123d/build_part.py +++ b/src/build123d/build_part.py @@ -31,15 +31,13 @@ license: from __future__ import annotations -from typing import Union - from build123d.build_common import Builder, logger from build123d.build_enums import Mode 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 @@ -59,13 +57,38 @@ class BuildPart(Builder): _shape = Solid # Type of shapes being constructed _sub_class = Part # Class of part/_obj + def __init__( + self, + *workplanes: Face | Plane | Location, + mode: Mode = Mode.ADD, + ): + self.joints: dict[str, Joint] = {} + self._part: Part | None = None # Use a private attribute + self.pending_faces: list[Face] = [] + self.pending_face_planes: list[Plane] = [] + self.pending_planes: list[Plane] = [] + self.pending_edges: list[Edge] = [] + super().__init__(*workplanes, mode=mode) + @property - def _obj(self) -> Part: - return self.part + def part(self) -> Part | None: + """Get the current part""" + return self._part + + @part.setter + def part(self, value: Part) -> None: + """Set the current part""" + self._part = value + + @property + def _obj(self) -> Part | None: + """Alias _obj to part""" + return self._part @_obj.setter def _obj(self, value: Part) -> None: - self.part = value + """Set the current part""" + self._part = value @property def pending_edges_as_wire(self) -> Wire: @@ -73,24 +96,11 @@ class BuildPart(Builder): return Wire.combine(self.pending_edges)[0] @property - def location(self) -> Location: + def location(self) -> Location | None: """Builder's location""" return self.part.location if self.part is not None else Location() - def __init__( - self, - *workplanes: Union[Face, Plane, Location], - mode: Mode = Mode.ADD, - ): - self.joints: dict[str, Joint] = {} - self.part: Part = None - self.pending_faces: list[Face] = [] - self.pending_face_planes: list[Plane] = [] - self.pending_planes: list[Plane] = [] - self.pending_edges: list[Edge] = [] - super().__init__(*workplanes, mode=mode) - - def _add_to_pending(self, *objects: Union[Edge, Face], face_plane: Plane = None): + def _add_to_pending(self, *objects: Edge | Face, face_plane: Plane | None = None): """Add objects to BuildPart pending lists Args: @@ -104,7 +114,8 @@ class BuildPart(Builder): face_plane, ) self.pending_faces.append(face) - self.pending_face_planes.append(face_plane) + if face_plane is not None: + self.pending_face_planes.append(face_plane) new_edges = [o for o in objects if isinstance(o, Edge)] for edge in new_edges: diff --git a/src/build123d/build_sketch.py b/src/build123d/build_sketch.py index 8b4e053..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 @@ -63,14 +63,35 @@ class BuildSketch(Builder): _shape = Face # Type of shapes being constructed _sub_class = Sketch # Class of sketch/_obj + def __init__( + self, + *workplanes: Face | Plane | Location, + mode: Mode = Mode.ADD, + ): + self.mode = mode + self._sketch_local: Sketch | None = None + self.pending_edges: ShapeList[Edge] = ShapeList() + super().__init__(*workplanes, mode=mode) + @property - def _obj(self) -> Sketch: - """The builder's object""" - return self.sketch_local + def sketch_local(self) -> Sketch | None: + """Get the builder's object""" + return self._sketch_local + + @sketch_local.setter + def sketch_local(self, value: Sketch) -> None: + """Set the builder's object""" + self._sketch_local = value + + @property + def _obj(self) -> Sketch | None: + """Alias _obj to sketch""" + return self._sketch_local @_obj.setter def _obj(self, value: Sketch) -> None: - self.sketch_local = value + """Set the current sketch""" + self._sketch_local = value @property def sketch(self): @@ -85,16 +106,6 @@ class BuildSketch(Builder): global_objs.append(plane.from_local_coords(self._obj)) return Sketch(Compound(global_objs).wrapped) - def __init__( - self, - *workplanes: Union[Face, Plane, Location], - mode: Mode = Mode.ADD, - ): - self.mode = mode - self.sketch_local: Sketch = None - self.pending_edges: ShapeList[Edge] = ShapeList() - super().__init__(*workplanes, mode=mode) - def solids(self, *args): """solids() not implemented""" raise NotImplementedError("solids() doesn't apply to BuildSketch") @@ -103,12 +114,12 @@ class BuildSketch(Builder): """solid() not implemented""" raise NotImplementedError("solid() doesn't apply to BuildSketch") - def consolidate_edges(self) -> Union[Wire, list[Wire]]: + def consolidate_edges(self) -> Wire | list[Wire]: """Unify pending edges into one or more Wires""" wires = Wire.combine(self.pending_edges) return wires if len(wires) > 1 else wires[0] - def _add_to_pending(self, *objects: Edge, face_plane: Plane = None): + def _add_to_pending(self, *objects: Edge, face_plane: Plane | None = None): """Integrate a sequence of objects into existing builder object""" if face_plane: raise NotImplementedError("face_plane arg not supported for this method") diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index d7c1086..415d33e 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -29,7 +29,9 @@ license: from dataclasses import dataclass from datetime import date from math import copysign, floor, gcd, log2, pi -from typing import ClassVar, Iterable, Optional, Union +from typing import cast, ClassVar, TypeAlias + +from collections.abc import Iterable from build123d.build_common import IN, MM from build123d.build_enums import ( @@ -50,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, Edge, Sketch, Vertex, Wire +from build123d.topology import Compound, Curve, Edge, ShapeList, Sketch, Vertex, Wire class ArrowHead(BaseSketchObject): @@ -100,7 +102,7 @@ class Arrow(BaseSketchObject): Args: arrow_size (float): arrow head tip to tail length - shaft_path (Union[Edge, Wire]): line describing the shaft shape + shaft_path (Edge | Wire): line describing the shaft shape shaft_width (float): line width of shaft head_at_start (bool, optional): Defaults to True. head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED. @@ -112,7 +114,7 @@ class Arrow(BaseSketchObject): def __init__( self, arrow_size: float, - shaft_path: Union[Edge, Wire], + shaft_path: Edge | Wire, shaft_width: float, head_at_start: bool = True, head_type: HeadType = HeadType.CURVED, @@ -139,17 +141,15 @@ class Arrow(BaseSketchObject): shaft_pen = shaft_path.perpendicular_line(shaft_width, 0) shaft = sweep(shaft_pen, shaft_path, mode=Mode.PRIVATE) - arrow = arrow_head.fuse(shaft).clean() + arrow = cast(Compound, arrow_head.fuse(shaft)).clean() super().__init__(arrow, rotation=0, align=None, mode=mode) -PathDescriptor = Union[ - Wire, - Edge, - list[Union[Vector, Vertex, tuple[float, float, float]]], -] -PointLike = Union[Vector, Vertex, tuple[float, float, float]] +PointLike: TypeAlias = Vector | Vertex | tuple[float, float, float] +"""General type for points in 3D space""" +PathDescriptor: TypeAlias = Wire | Edge | list[PointLike] +"""General type for a path in 3D space""" @dataclass @@ -221,17 +221,17 @@ class Draft: def _number_with_units( self, number: float, - tolerance: Union[float, tuple[float, float]] = None, - display_units: Optional[bool] = None, + tolerance: float | tuple[float, float] | None = None, + display_units: bool | None = None, ) -> str: """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,29 +258,26 @@ 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 @staticmethod - def _process_path(path: PathDescriptor) -> Union[Edge, Wire]: + def _process_path(path: PathDescriptor) -> Edge | Wire: """Convert a PathDescriptor into a Edge/Wire""" 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: @@ -293,10 +290,10 @@ class Draft: def _label_to_str( self, - label: str, + label: str | None, line_wire: Wire, label_angle: bool, - tolerance: Optional[Union[float, tuple[float, float]]], + tolerance: float | tuple[float, float] | None, ) -> str: """Create the str to use as the label text""" line_length = line_wire.length @@ -317,7 +314,7 @@ class Draft: @staticmethod def _sketch_location( - path: Union[Edge, Wire], u_value: float, flip: bool = False + path: Edge | Wire, u_value: float, flip: bool = False ) -> Location: """Given a path on Plane.XY, determine the Location for object placement""" angle = path.tangent_angle_at(u_value) + int(flip) * 180 @@ -349,7 +346,7 @@ class DimensionLine(BaseSketchObject): argument is desired not an actual measurement. Defaults to None. arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement of the start and end arrows. Defaults to (True, True). - tolerance (Union[float, tuple[float, float]], optional): an optional tolerance + tolerance (float | tuple[float, float], optional): an optional tolerance value to add to the extracted length value. If a single tolerance value is provided it is shown as ± the provided value while a pair of values are shown as separate + and - values. Defaults to None. @@ -366,14 +363,14 @@ class DimensionLine(BaseSketchObject): def __init__( self, path: PathDescriptor, - draft: Draft = None, - sketch: Sketch = None, - label: str = None, + draft: Draft, + sketch: Sketch | None = None, + label: str | None = None, arrows: tuple[bool, bool] = (True, True), - tolerance: Union[float, tuple[float, float]] = None, + tolerance: float | tuple[float, float] | None = None, label_angle: bool = False, mode: Mode = Mode.ADD, - ) -> Sketch: + ): # pylint: disable=too-many-locals context = BuildSketch._get_context(self) @@ -439,31 +436,46 @@ class DimensionLine(BaseSketchObject): overage = shaft_length + draft.pad_around_text + label_length / 2 label_u_values = [0.5, -overage / path_length, 1 + overage / path_length] - # d_lines = Sketch(children=arrows[0]) d_lines = {} - # for arrow_pair in arrow_shapes: for u_value in label_u_values: - d_line = Sketch() - for add_arrow, arrow_shape in zip(arrows, arrow_shapes): - if add_arrow: - d_line += arrow_shape + select_arrow_shapes = [ + arrow_shape + for add_arrow, arrow_shape in zip(arrows, arrow_shapes) + if add_arrow + ] + d_line = Sketch(select_arrow_shapes) flip_label = path_obj.tangent_at(u_value).get_angle(Vector(1, 0, 0)) >= 180 loc = Draft._sketch_location(path_obj, u_value, flip_label) placed_label = label_shape.located(loc) - self_intersection = d_line.intersect(placed_label).area + self_intersection = cast( + Sketch | None, Sketch.intersect(d_line, placed_label) + ) + if self_intersection is None: + self_intersection_area = 0.0 + else: + 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 - common_area = 0.0 if sketch is None else d_line.intersect(sketch).area - common_area += self_intersection - score = (d_line.area - 10 * common_area) / bbox_size.X + if sketch is None: + common_area = 0.0 + else: + line_intersection = cast( + Sketch | None, Sketch.intersect(d_line, sketch) + ) + if line_intersection is None: + common_area = 0.0 + else: + common_area = sum(f.area for f in line_intersection.faces()) + common_area += self_intersection_area + score = (d_line.area - 10 * common_area) / bbox_size d_lines[d_line] = score # Sort by score to find the best option - d_lines = sorted(d_lines.items(), key=lambda x: x[1]) + sorted_d_lines = sorted(d_lines.items(), key=lambda x: x[1]) - super().__init__(obj=d_lines[-1][0], rotation=0, align=None, mode=mode) + super().__init__(obj=sorted_d_lines[-1][0], rotation=0, align=None, mode=mode) class ExtensionLine(BaseSketchObject): @@ -485,7 +497,7 @@ class ExtensionLine(BaseSketchObject): is desired not an actual measurement. Defaults to None. arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement of the start and end arrows. Defaults to (True, True). - tolerance (Union[float, tuple[float, float]], optional): an optional tolerance + tolerance (float | tuple[float, float], optional): an optional tolerance value to add to the extracted length value. If a single tolerance value is provided it is shown as ± the provided value while a pair of values are shown as separate + and - values. Defaults to None. @@ -503,12 +515,12 @@ class ExtensionLine(BaseSketchObject): border: PathDescriptor, offset: float, draft: Draft, - sketch: Sketch = None, - label: str = None, + sketch: Sketch | None = None, + label: str | None = None, arrows: tuple[bool, bool] = (True, True), - tolerance: Union[float, tuple[float, float]] = None, + tolerance: float | tuple[float, float] | None = None, label_angle: bool = False, - project_line: VectorLike = None, + project_line: VectorLike | None = None, mode: Mode = Mode.ADD, ): # pylint: disable=too-many-locals @@ -527,7 +539,7 @@ class ExtensionLine(BaseSketchObject): if offset == 0: raise ValueError("A dimension line should be used if offset is 0") dimension_path = object_to_measure.offset_2d( - distance=offset, side=side_lut[copysign(1, offset)], closed=False + distance=offset, side=side_lut[int(copysign(1, offset))], closed=False ) dimension_label_str = ( label @@ -620,12 +632,12 @@ class TechnicalDrawing(BaseSketchObject): def __init__( self, designed_by: str = "build123d", - design_date: Optional[date] = None, + design_date: date | None = None, page_size: PageSize = PageSize.A4, title: str = "Title", sub_title: str = "Sub Title", drawing_number: str = "B3D-1", - sheet_number: int = None, + sheet_number: int | None = None, drawing_scale: float = 1.0, nominal_text_size: float = 10.0, line_width: float = 0.5, @@ -687,22 +699,25 @@ class TechnicalDrawing(BaseSketchObject): 4: 3 / 12, 5: 5 / 12, } - for i, label in enumerate(["F", "E", "D", "C", "B", "A"]): + for i, grid_label in enumerate(["F", "E", "D", "C", "B", "A"]): for y_index in [-0.5, 0.5]: grid_labels += Pos( x_centers[i] * frame_width, y_index * (frame_height + 1.5 * nominal_text_size), - ) * Sketch(Compound.make_text(label, nominal_text_size).wrapped) + ) * Sketch(Compound.make_text(grid_label, nominal_text_size).wrapped) # 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) bf_pnt4 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (2 / 3) - box_frame_curve += Edge.make_line(bf_pnt3, (bf_pnt2.X, bf_pnt3.Y)) + box_frame_curve = Curve() + [ + box_frame_curve, + Edge.make_line(bf_pnt3, (bf_pnt2.X, bf_pnt3.Y)), + ] box_frame_curve += Edge.make_line(bf_pnt4, (bf_pnt2.X, bf_pnt4.Y)) bf_pnt5 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (1 / 3) bf_pnt6 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (2 / 3) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index b56914c..f5828bd 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -34,42 +34,45 @@ import math import xml.etree.ElementTree as ET from copy import copy from enum import Enum, auto -from os import PathLike, fsdecode, fspath -from pathlib import Path -from typing import Callable, Iterable, List, Optional, Tuple, Union +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 import ezdxf import svgpathtools as PT from ezdxf import zoom from ezdxf.colors import RGB, aci2rgb from ezdxf.math import Vec2 -from OCP.BRepLib import BRepLib # type: ignore -from OCP.BRepTools import BRepTools_WireExplorer # type: ignore -from OCP.Geom import Geom_BezierCurve # type: ignore -from OCP.GeomConvert import GeomConvert # type: ignore -from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve # type: ignore -from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ # type: ignore -from OCP.HLRAlgo import HLRAlgo_Projector # type: ignore -from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape # type: ignore -from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum # type: ignore -from OCP.TopExp import TopExp_Explorer # type: ignore +from OCP.BRepLib import BRepLib +from OCP.BRepTools import BRepTools_WireExplorer +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 +from OCP.HLRAlgo import HLRAlgo_Projector +from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape +from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum +from OCP.TopExp import TopExp_Explorer +from OCP.TopoDS import TopoDS from typing_extensions import Self -from build123d.build_enums import Unit -from build123d.geometry import TOLERANCE, Color +from build123d.build_enums import Unit, GeomType +from build123d.geometry import TOLERANCE, Color, Vector, VectorLike from build123d.topology import ( BoundBox, Compound, Edge, Wire, - GeomType, Shape, - Vector, - VectorLike, ) from build123d.build_common import UNITS_PER_METER -PathSegment = Union[PT.Line, PT.Arc, PT.QuadraticBezier, PT.CubicBezier] +PathSegment: TypeAlias = PT.Line | PT.Arc | PT.QuadraticBezier | PT.CubicBezier +"""A type alias for the various path segment types in the svgpathtools library.""" # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -82,11 +85,11 @@ class Drawing: self, shape: Shape, *, - look_at: VectorLike = None, + look_at: VectorLike | None = None, look_from: VectorLike = (1, -1, 1), look_up: VectorLike = (0, 0, 1), with_hidden: bool = True, - focus: Union[float, None] = None, + focus: float | None = None, ): # pylint: disable=too-many-locals hlr = HLRBRep_Algo() @@ -508,9 +511,9 @@ class ExportDXF(Export2D): self, version: str = ezdxf.DXF2013, unit: Unit = Unit.MM, - color: Optional[ColorIndex] = None, - line_weight: Optional[float] = None, - line_type: Optional[LineType] = None, + color: ColorIndex | None = None, + line_weight: float | None = None, + line_type: LineType | None = None, ): self._non_planar_point_count = 0 if unit not in self._UNITS_LOOKUP: @@ -540,9 +543,9 @@ class ExportDXF(Export2D): self, name: str, *, - color: Optional[ColorIndex] = None, - line_weight: Optional[float] = None, - line_type: Optional[LineType] = None, + color: ColorIndex | None = None, + line_weight: float | None = None, + line_type: LineType | None = None, ) -> Self: """add_layer @@ -562,7 +565,7 @@ class ExportDXF(Export2D): """ # ezdxf :doc:`line type `. - kwargs = {} + kwargs: dict[str, Any] = {} if line_type is not None: linetype = self._linetype(line_type) @@ -587,7 +590,7 @@ class ExportDXF(Export2D): # The linetype is not in the doc yet. # Add it from our available definitions. if linetype in Export2D.LINETYPE_DEFS: - desc, pattern = Export2D.LINETYPE_DEFS.get(linetype) + desc, pattern = Export2D.LINETYPE_DEFS.get(linetype) # type: ignore[misc] self._document.linetypes.add( name=linetype, pattern=[self._linetype_scale * v for v in pattern], @@ -599,13 +602,13 @@ class ExportDXF(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def add_shape(self, shape: Union[Shape, Iterable[Shape]], layer: str = "") -> Self: + def add_shape(self, shape: Shape | Iterable[Shape], layer: str = "") -> Self: """add_shape Adds a shape to the specified layer. Args: - shape (Union[Shape, Iterable[Shape]]): The shape or collection of shapes to be + shape (Shape | Iterable[Shape]): The shape or collection of shapes to be added. It can be a single Shape object or an iterable of Shape objects. layer (str, optional): The name of the layer where the shape will be added. If not specified, the default layer will be used. Defaults to "". @@ -635,25 +638,30 @@ class ExportDXF(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, file_name: Union[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 (Union[PathLike, str, bytes]): The file name (including path) where the DXF data will - be written. + 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 # extents of its entities. # 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") # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_point(self, pt: Union[gp_XYZ, gp_Pnt, gp_Vec, Vector]) -> Vec2: + def _convert_point(self, pt: gp_XYZ | gp_Pnt | gp_Vec | Vector) -> Vec2: """Create a Vec2 from a gp_Pnt or Vector. This method also checks for points z != 0.""" if isinstance(pt, (gp_XYZ, gp_Pnt, gp_Vec)): @@ -682,7 +690,7 @@ class ExportDXF(Export2D): def _convert_circle(self, edge: Edge, attribs: dict): """Converts a Circle object into a DXF circle entity.""" - curve = edge._geom_adaptor() + curve = edge.geom_adaptor() circle = curve.Circle() center = self._convert_point(circle.Location()) radius = circle.Radius() @@ -710,7 +718,7 @@ class ExportDXF(Export2D): def _convert_ellipse(self, edge: Edge, attribs: dict): """Converts an Ellipse object into a DXF ellipse entity.""" - geom = edge._geom_adaptor() + geom = edge.geom_adaptor() ellipse = geom.Ellipse() minor_radius = ellipse.MinorRadius() major_radius = ellipse.MajorRadius() @@ -743,20 +751,22 @@ class ExportDXF(Export2D): # This pulls the underlying Geom_BSplineCurve out of the Edge. # The adaptor also supplies a parameter range for the curve. - adaptor = edge._geom_adaptor() + adaptor = edge.geom_adaptor() curve = adaptor.Curve().Curve() u1 = adaptor.FirstParameter() u2 = adaptor.LastParameter() # 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 not edge or edge.location is None: + raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -828,17 +838,17 @@ class ExportSVG(Export2D): should fit the strokes of the shapes. Defaults to True. precision (int, optional): The number of decimal places used for rounding coordinates in the SVG. Defaults to 6. - fill_color (Union[ColorIndex, RGB, None], optional): The default fill color + fill_color (ColorIndex | RGB | None, optional): The default fill color for shapes. It can be specified as a ColorIndex, an RGB tuple, or None. Defaults to None. - line_color (Union[ColorIndex, RGB, None], optional): The default line color for + line_color (ColorIndex | RGB | None, optional): The default line color for shapes. It can be specified as a ColorIndex or an RGB tuple, or None. Defaults to Export2D.DEFAULT_COLOR_INDEX. line_weight (float, optional): The default line weight (stroke width) for shapes, in millimeters. Defaults to Export2D.DEFAULT_LINE_WEIGHT. line_type (LineType, optional): The default line type for shapes. It should be a LineType enum. Defaults to Export2D.DEFAULT_LINE_TYPE. - dot_length (Union[DotLength, float], optional): The width of rendered dots in a + dot_length (DotLength | float, optional): The width of rendered dots in a Can be either a DotLength enum or a float value in tenths of an inch. Defaults to DotLength.INKSCAPE_COMPAT. @@ -872,27 +882,72 @@ class ExportSVG(Export2D): def __init__( self, name: str, - fill_color: Union[ColorIndex, RGB, Color, None], - line_color: Union[ColorIndex, RGB, Color, None], + fill_color: ColorIndex | RGB | Color | None, + line_color: ColorIndex | RGB | Color | None, line_weight: float, line_type: LineType, ): def convert_color( - c: Union[ColorIndex, RGB, Color, None], - ) -> Union[Color, None]: - if isinstance(c, ColorIndex): - # The easydxf color indices BLACK and WHITE have the same - # value (7), and are both mapped to (255,255,255) by the - # aci2rgb() function. We prefer (0,0,0). - if c == ColorIndex.BLACK: - c = RGB(0, 0, 0) - else: - c = aci2rgb(c.value) - elif isinstance(c, tuple): - c = RGB(*c) - if isinstance(c, RGB): - c = Color(*c.to_floats(), 1) - return c + input_color: ColorIndex | RGB | Color | tuple | None, + ) -> Color | None: + """ + Convert various color representations into a `Color` object. + + This function takes an input color, which can be of type `ColorIndex`, `RGB`, + `Color`, `tuple`, or `None`, and converts it into a `Color` object. If the input + is `None`, the function returns `None`. It handles specific cases for `ColorIndex.BLACK` + and other `ColorIndex` values using the `aci2rgb` function. + + Args: + input_color (ColorIndex | RGB | Color | tuple | None): The input color to be converted. + - `ColorIndex`: A predefined color index from `easydxf`. Special handling for + `ColorIndex.BLACK` ensures it maps to `RGB(0, 0, 0)` instead of the default + `aci2rgb` mapping to `RGB(255, 255, 255)`. + - `RGB`: A direct representation of red, green, and blue components. + - `Color`: An existing `Color` object. + - `tuple`: A tuple of RGB values (e.g., `(255, 0, 0)` for red). + - `None`: Represents no color. + + Returns: + Color | None: The converted `Color` object or `None` if the input was `None`. + + Raises: + ValueError: If the input color type is unsupported. + + Notes: + - The `easydxf` color indices BLACK and WHITE have the same value (7), and both + are mapped to `(255, 255, 255)` by the `aci2rgb()` function. This implementation + overrides the default mapping to prefer `(0, 0, 0)` for `ColorIndex.BLACK`. + """ + final_color: Color | None + match input_color: + case ColorIndex.BLACK: + # Map BLACK explicitly to RGB(0, 0, 0) + final_color = Color(0.0, 0.0, 0.0, 1.0) + case ColorIndex() as color_index: + # Convert other ColorIndex values using aci2rgb + rgb_color = aci2rgb(color_index.value) + red, green, blue = rgb_color.to_floats() + final_color = Color(red, green, blue, 1.0) + case tuple() as color_tuple: + # Convert tuple directly to Color + rgb_color = RGB(*color_tuple) + red, green, blue = rgb_color.to_floats() + final_color = Color(red, green, blue, 1.0) + case RGB() as rgb: + # Convert RGB directly to Color + red, green, blue = rgb.to_floats() + final_color = Color(red, green, blue, 1.0) + case Color() as color: + # Already a Color + final_color = color + case None: + # If None, return None + final_color = None + case _: + raise ValueError(f"Unsupported input type: {type(input_color)}") + + return final_color self.name = name self.fill_color = convert_color(fill_color) @@ -910,11 +965,11 @@ class ExportSVG(Export2D): margin: float = 0, fit_to_stroke: bool = True, precision: int = 6, - fill_color: Union[ColorIndex, RGB, Color, None] = None, - line_color: Union[ColorIndex, RGB, Color, None] = Export2D.DEFAULT_COLOR_INDEX, + fill_color: ColorIndex | RGB | Color | None = None, + line_color: ColorIndex | RGB | Color | None = Export2D.DEFAULT_COLOR_INDEX, line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters line_type: LineType = Export2D.DEFAULT_LINE_TYPE, - dot_length: Union[DotLength, float] = DotLength.INKSCAPE_COMPAT, + dot_length: DotLength | float = DotLength.INKSCAPE_COMPAT, ): if unit not in ExportSVG._UNIT_STRING: raise ValueError( @@ -929,7 +984,7 @@ class ExportSVG(Export2D): self.dot_length = dot_length self._non_planar_point_count = 0 self._layers: dict[str, ExportSVG._Layer] = {} - self._bounds: BoundBox = None + self._bounds: BoundBox | None = None # Add the default layer. self.add_layer( @@ -946,8 +1001,8 @@ class ExportSVG(Export2D): self, name: str, *, - fill_color: Union[ColorIndex, RGB, Color, None] = None, - line_color: Union[ColorIndex, RGB, Color, None] = Export2D.DEFAULT_COLOR_INDEX, + fill_color: ColorIndex | RGB | Color | None = None, + line_color: ColorIndex | RGB | Color | None = Export2D.DEFAULT_COLOR_INDEX, line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters line_type: LineType = Export2D.DEFAULT_LINE_TYPE, ) -> Self: @@ -957,10 +1012,10 @@ class ExportSVG(Export2D): Args: name (str): The name of the layer. Must be unique among all layers. - fill_color (Union[ColorIndex, RGB, Color, None], optional): The fill color for shapes + fill_color (ColorIndex | RGB | Color | None, optional): The fill color for shapes on this layer. It can be specified as a ColorIndex, an RGB tuple, a Color, or None. Defaults to None. - line_color (Union[ColorIndex, RGB, Color, None], optional): The line color for shapes on + line_color (ColorIndex | RGB | Color | None, optional): The line color for shapes on this layer. It can be specified as a ColorIndex or an RGB tuple, a Color, or None. Defaults to Export2D.DEFAULT_COLOR_INDEX. line_weight (float, optional): The line weight (stroke width) for shapes on @@ -993,7 +1048,7 @@ class ExportSVG(Export2D): def add_shape( self, - shape: Union[Shape, Iterable[Shape]], + shape: Shape | Iterable[Shape], layer: str = "", reverse_wires: bool = False, ): @@ -1002,7 +1057,7 @@ class ExportSVG(Export2D): Adds a shape or a collection of shapes to the specified layer. Args: - shape (Union[Shape, Iterable[Shape]]): The shape or collection of shapes to be + shape (Shape | Iterable[Shape]): The shape or collection of shapes to be added. It can be a single Shape object or an iterable of Shape objects. layer (str, optional): The name of the layer where the shape(s) will be added. Defaults to "". @@ -1014,12 +1069,12 @@ class ExportSVG(Export2D): """ if layer not in self._layers: raise ValueError(f"Undefined layer: {layer}.") - layer = self._layers[layer] + _layer = self._layers[layer] if isinstance(shape, Shape): - self._add_single_shape(shape, layer, reverse_wires) + self._add_single_shape(shape, _layer, reverse_wires) else: for s in shape: - self._add_single_shape(s, layer, reverse_wires) + self._add_single_shape(s, _layer, reverse_wires) def _add_single_shape(self, shape: Shape, layer: _Layer, reverse_wires: bool): # pylint: disable=too-many-locals @@ -1063,7 +1118,7 @@ class ExportSVG(Export2D): ) while explorer.More(): topo_wire = explorer.Current() - loose_wires.append(Wire(topo_wire)) + loose_wires.append(Wire(TopoDS.Wire_s(topo_wire))) explorer.Next() # print(f"{len(loose_wires)} loose wires") for wire in loose_wires: @@ -1082,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] @@ -1099,13 +1154,16 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @staticmethod - def _wire_edges(wire: Wire, reverse: bool) -> List[Edge]: + def _wire_edges(wire: Wire, reverse: bool) -> list[Edge]: + # Note that BRepTools_WireExplorer can return edges in a different order + # than the standard edges() method. edges = [] explorer = BRepTools_WireExplorer(wire.wrapped) while explorer.More(): topo_edge = explorer.Current() edges.append(Edge(topo_edge)) explorer.Next() + # edges = wire.edges() if reverse: edges.reverse() return edges @@ -1137,7 +1195,7 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _path_point(self, pt: Union[gp_Pnt, Vector]) -> complex: + def _path_point(self, pt: gp_Pnt | Vector) -> complex: """Create a complex point from a gp_Pnt or Vector. We are using complex because that is what svgpathtools wants. This method also checks for points z != 0.""" @@ -1157,7 +1215,7 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _line_segment(self, edge: Edge, reverse: bool) -> PT.Line: - curve = edge._geom_adaptor() + curve = edge.geom_adaptor() fp = curve.FirstParameter() lp = curve.LastParameter() (u0, u1) = (lp, fp) if reverse else (fp, lp) @@ -1187,7 +1245,13 @@ class ExportSVG(Export2D): def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: # pylint: disable=too-many-locals - curve = edge._geom_adaptor() + if edge.length < 1e-6: + warn( + "Skipping arc that is too small to export safely (length < 1e-6).", + stacklevel=7, + ) + return [] + curve = edge.geom_adaptor() circle = curve.Circle() radius = circle.Radius() x_axis = circle.XAxis().Direction() @@ -1200,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)) @@ -1215,7 +1279,7 @@ class ExportSVG(Export2D): def _circle_element(self, edge: Edge) -> ET.Element: """Converts a Circle object into an SVG circle element.""" if edge.is_closed: - curve = edge._geom_adaptor() + curve = edge.geom_adaptor() circle = curve.Circle() radius = circle.Radius() center = circle.Location() @@ -1233,7 +1297,13 @@ class ExportSVG(Export2D): def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: # pylint: disable=too-many-locals - curve = edge._geom_adaptor() + if edge.length < 1e-6: + warn( + "Skipping ellipse that is too small to export safely (length < 1e-6).", + stacklevel=7, + ) + return [] + curve = edge.geom_adaptor() ellipse = curve.Ellipse() minor_radius = ellipse.MinorRadius() major_radius = ellipse.MajorRadius() @@ -1247,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)) @@ -1276,12 +1346,14 @@ class ExportSVG(Export2D): # This pulls the underlying Geom_BSplineCurve out of the Edge. # The adaptor also supplies a parameter range for the curve. - adaptor = edge._geom_adaptor() + adaptor = edge.geom_adaptor() spline = adaptor.Curve().Curve() u1 = adaptor.FirstParameter() u2 = adaptor.LastParameter() # Apply the shape location to the geometry. + if not edge or edge.location is None: + raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) # describe_bspline(spline) @@ -1290,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: @@ -1346,6 +1418,8 @@ class ExportSVG(Export2D): } def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + if not edge: + raise ValueError(f"Edge is empty {edge}.") edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED geom_type = edge.geom_type segments = self._SEGMENT_LOOKUP.get(geom_type, ExportSVG._other_segments) @@ -1390,10 +1464,12 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _group_for_layer(self, layer: _Layer, attribs: dict = None) -> ET.Element: - def _color_attribs(c: Color) -> Tuple[str, str]: - if c: - (r, g, b, a) = tuple(c) + def _group_for_layer( + self, layer: _Layer, attribs: dict | None = None + ) -> ET.Element: + def _color_attribs(color: Color | None) -> tuple[str, str | None]: + if color is not None: + (r, g, b, a) = tuple(color) (r, g, b, a) = (int(r * 255), int(g * 255), int(b * 255), round(a, 3)) rgb = f"rgb({r},{g},{b})" opacity = f"{a}" if a < 1 else None @@ -1402,9 +1478,9 @@ class ExportSVG(Export2D): if attribs is None: attribs = {} - (fill, fill_opacity) = _color_attribs(layer.fill_color) + fill, fill_opacity = _color_attribs(layer.fill_color) attribs["fill"] = fill - if fill_opacity: + if fill_opacity is not None: attribs["fill-opacity"] = fill_opacity (stroke, stroke_opacity) = _color_attribs(layer.line_color) attribs["stroke"] = stroke @@ -1428,16 +1504,18 @@ class ExportSVG(Export2D): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, path: Union[PathLike, str, bytes]): + def write(self, path: PathLike | str | bytes | BytesIO): """write Writes the SVG data to the specified file path. Args: - path (Union[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 + if bb is None: + raise ValueError("No shapes to export.") doc_margin = self.margin if self.fit_to_stroke: max_line_weight = max(l.line_weight for l in self._layers.values()) @@ -1478,4 +1556,9 @@ class ExportSVG(Export2D): xml = ET.ElementTree(svg) ET.indent(xml, " ") - xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=False) + + 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 f4e640e..52fe4e9 100644 --- a/src/build123d/exporters3d.py +++ b/src/build123d/exporters3d.py @@ -29,13 +29,14 @@ 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 +from OCP.APIHeaderSection import APIHeaderSection_MakeHeader from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.BRepTools import BRepTools from OCP.IFSelect import IFSelect_ReturnStatus @@ -46,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 +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 @@ -156,7 +161,7 @@ def _create_xde(to_export: Shape, unit: Unit = Unit.MM) -> TDocStd_Document: def export_brep( to_export: Shape, - file_path: Union[PathLike, str, bytes, BytesIO], + file_path: PathLike | str | bytes | BytesIO, ) -> bool: """Export this shape to a BREP file @@ -177,7 +182,7 @@ def export_brep( def export_gltf( to_export: Shape, - file_path: Union[PathLike, str, bytes], + file_path: PathLike | str | bytes, unit: Unit = Unit.MM, binary: bool = False, linear_deflection: float = 0.001, @@ -215,6 +220,8 @@ def export_gltf( # Map from OCCT's right-handed +Z up coordinate system to glTF's right-handed +Y # up coordinate system # https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units + if to_export.location is None: + raise ValueError("Shape must have a location to export to glTF") original_location = to_export.location to_export.location *= Location((0, 0, 0), (1, 0, 0), -90) @@ -237,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) @@ -255,10 +262,12 @@ def export_gltf( def export_step( to_export: Shape, - file_path: Union[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 @@ -268,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. @@ -288,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) @@ -296,16 +305,19 @@ def export_step( writer.SetLayerMode(True) writer.SetNameMode(True) - # - # APIHeaderSection doesn't seem to be supported by OCP - TBD - # - - # APIHeaderSection_MakeHeader makeHeader(writer.Writer().Model()) - # makeHeader.SetName(TCollection_HAsciiString(path)) - # makeHeader.SetAuthorValue (1, TCollection_HAsciiString("Volker")); - # makeHeader.SetOrganizationValue (1, TCollection_HAsciiString("myCompanyName")); - # makeHeader.SetOriginatingSystem(TCollection_HAsciiString("myApplicationName")); - # makeHeader.SetDescriptionValue(1, TCollection_HAsciiString("myApplication Model")); + 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")); + header.SetOriginatingSystem(TCollection_HAsciiString("build123d")) + # header.SetDescriptionValue(1, TCollection_HAsciiString("myApplication Model")); STEPCAFControl_Controller.Init_s() STEPControl_Controller.Init_s() @@ -314,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") @@ -323,7 +341,7 @@ def export_step( def export_stl( to_export: Shape, - file_path: Union[PathLike, str, bytes], + file_path: PathLike | str | bytes, tolerance: float = 1e-3, angular_tolerance: float = 0.1, ascii_format: bool = False, @@ -334,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 @@ -356,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 9f563e7..3e3807f 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -34,32 +34,26 @@ 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 copy +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, - Iterable, - List, - Optional, - Sequence, - Tuple, - Union, - overload, - TypeVar, -) - +import webcolors # type: ignore from OCP.Bnd import Bnd_Box, Bnd_OBB from OCP.BRep import BRep_Tool from OCP.BRepBndLib import BRepBndLib -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace +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, @@ -79,10 +73,14 @@ 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_Face, TopoDS_Shape, TopoDS_Vertex +from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Vertex -from build123d.build_enums import Align, 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 # Create a build123d logger to distinguish these logs from application logs. # If the user doesn't configure logging, all build123d logs will be discarded. @@ -90,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 @@ -134,7 +133,8 @@ class Vector: x (float): x component y (float): y component z (float): z component - vec (Union[Vector, Sequence(float), gp_Vec, gp_Pnt, gp_Dir, gp_XYZ]): vector representations + vec (Vector | Sequence(float) | gp_Vec | gp_Pnt | gp_Dir | gp_XYZ): vector + representations Note that if no z value is provided it's assumed to be zero. If no values are provided the returned Vector has the value of 0, 0, 0. @@ -144,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 @@ -165,7 +169,7 @@ class Vector: ... @overload - def __init__(self, v: Union[gp_Vec, gp_Pnt, gp_Dir, gp_XYZ]): # pragma: no cover + def __init__(self, v: gp_Vec | gp_Pnt | gp_Dir | gp_XYZ): # pragma: no cover ... @overload @@ -272,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 @@ -362,7 +372,7 @@ class Vector: """Unsigned angle between vectors""" return self.wrapped.Angle(vec.wrapped) * RAD2DEG - def get_signed_angle(self, vec: Vector, normal: Vector = None) -> float: + def get_signed_angle(self, vec: Vector, normal: Vector | None = None) -> float: """Signed Angle Between Vectors Return the signed angle in degrees between two vectors with the given normal @@ -429,7 +439,7 @@ class Vector: """Vector length operator abs()""" return self.length - def __and__(self: Plane, other: Union[Axis, Location, Plane, VectorLike, "Shape"]): + def __and__(self, other: Axis | Location | Plane | VectorLike | Shape): """intersect vector with other &""" return self.intersect(other) @@ -446,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(self.X) + hash(self.Y) + hash(self.Z) + 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""" @@ -480,14 +496,15 @@ class Vector: Vector: transformed vector """ if not is_direction: - # to gp_Pnt to obey build123d transformation convention (in OCP.vectors do not translate) + # to gp_Pnt to obey build123d transformation convention (in OCP.vectors do not + # translate) pnt = self.to_pnt() pnt_t = pnt.Transformed(affine_transform.wrapped.Trsf()) 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 @@ -506,44 +523,58 @@ class Vector: return Vector(self.wrapped.Rotated(axis.wrapped, pi * angle / 180)) @overload - def intersect(self, vector: VectorLike) -> Union[Vector, None]: + def intersect(self, vector: VectorLike) -> Vector | None: """Find intersection of vector and vector""" @overload - def intersect(self, location: Location) -> Union[Vector, None]: - """Find intersection of location and vector""" + def intersect(self, location: Location) -> Vector | None: + """Find intersection of vector and location""" @overload - def intersect(self, axis: Axis) -> Union[Vector, None]: - """Find intersection of axis and vector""" + def intersect(self, axis: Axis) -> Vector | None: + """Find intersection of vector and axis""" @overload - def intersect(self, plane: Plane) -> Union[Vector, None]: - """Find intersection of plane and vector""" + def intersect(self, plane: Plane) -> Vector | None: + """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: return axis.intersect(self) - elif plane is not None: + if plane is not None: return plane.intersect(self) - elif vector is not None and self == vector: + if vector is not None and self == vector: return vector - elif location is not None: + if location is not None: return location.intersect(self) - elif shape is not None: + if shape is not None: return shape.intersect(self) + return None -#:TypeVar("VectorLike"): Tuple of float or Vector defining a position in space -VectorLike = Union[ - Vector, tuple[float, float], tuple[float, float, float], Iterable[float] -] + +VectorLike: TypeAlias = ( + Vector | tuple[float, float] | tuple[float, float, float] | Sequence[float] +) +""" +VectorLike: Represents a position in space. + +- `Vector`: A vector object from `build123d`. +- `tuple[float, float]`: A 2D coordinate (x, y). +- `tuple[float, float, float]`: A 3D coordinate (x, y, z). +- `Sequence[float]`: A general sequence of floats (e.g., for higher dimensions). +""" class AxisMeta(type): @@ -552,17 +583,17 @@ class AxisMeta(type): @property def X(cls) -> Axis: """X Axis""" - return Axis((0, 0, 0), (1, 0, 0)) + return cls((0, 0, 0), (1, 0, 0)) @property def Y(cls) -> Axis: """Y Axis""" - return Axis((0, 0, 0), (0, 1, 0)) + return cls((0, 0, 0), (0, 1, 0)) @property def Z(cls) -> Axis: """Z Axis""" - return Axis((0, 0, 0), (0, 0, 1)) + return cls((0, 0, 0), (0, 0, 1)) class Axis(metaclass=AxisMeta): @@ -574,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 @@ -583,75 +615,111 @@ class Axis(metaclass=AxisMeta): _dim = 1 + @overload + def __init__(self, gp_ax1: gp_Ax1): + """Axis: point and direction""" + + @overload + 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): + """Axis: start of Edge""" + + 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: + 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: + raise ValueError(f"Unrecognized single argument: {arg}") + elif len(args) == 2: + origin, direction = args + + # Handle edge-based construction + if edge is not None: + if not (hasattr(edge, "wrapped") and isinstance(edge.wrapped, TopoDS_Edge)): + raise ValueError(f"Invalid edge argument: {edge}") + + 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) + 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 = gp_ax1 # type: ignore[annotation-unchecked] + + @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: """Return self as Location""" return Location(Plane(origin=self.position, z_dir=self.direction)) - @overload - def __init__(self, gp_ax1: gp_Ax1): # pragma: no cover - """Axis: point and direction""" - - @overload - def __init__(self, origin: VectorLike, direction: VectorLike): # pragma: no cover - """Axis: point and direction""" - - @overload - def __init__(self, edge: "Edge"): # pragma: no cover - """Axis: start of Edge""" - - def __init__(self, *args, **kwargs): - gp_ax1, origin, direction = (None,) * 3 - if len(args) == 1: - if type(args[0]).__name__ == "Edge": - origin = args[0].position_at(0) - direction = args[0].tangent_at(0) - elif isinstance(args[0], gp_Ax1): - gp_ax1 = args[0] - else: - origin = args[0] - if len(args) == 2: - origin = args[0] - direction = args[1] - - origin = kwargs.get("origin", origin) - direction = kwargs.get("direction", direction) - gp_ax1 = kwargs.get("gp_ax1", gp_ax1) - if "edge" in kwargs and type(kwargs["edge"]).__name__ == "Edge": - origin = kwargs["edge"].position_at(0) - direction = kwargs["edge"].tangent_at(0) - - unknown_args = ", ".join( - set(kwargs.keys()).difference(["gp_ax1", "origin", "direction", "edge"]) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - if gp_ax1 is not None: - self.wrapped = gp_ax1 - else: - try: - origin = Vector(origin) - direction = Vector(direction) - except TypeError as exc: - raise ValueError("Invalid Axis parameters") from exc - - self.wrapped = gp_Ax1( - Vector(origin).to_pnt(), - gp_Dir(*Vector(direction).normalized().to_tuple()), - ) - - 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 - def __copy__(self) -> Axis: """Return copy of self""" return Axis(self.position, self.direction) @@ -660,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"Axis: ({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): @@ -675,11 +755,21 @@ class Axis(metaclass=AxisMeta): def located(self, new_location: Location): """relocates self to a new location possibly changing position and direction""" - new_gp_ax1 = self.wrapped.Transformed(new_location.wrapped.Transformation()) + if self.wrapped is None: + raise ValueError("Can't located empty Axis") + top_location: TopLoc_Location = new_location.wrapped # type: ignore[has-type] + self_gp_ax1: gp_Ax1 = self.wrapped + new_gp_ax1: gp_Ax1 = self_gp_ax1.Transformed(top_location.Transformation()) return Axis(new_gp_ax1) 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( @@ -752,6 +842,45 @@ class Axis(metaclass=AxisMeta): """ return self.wrapped.IsParallel(other.wrapped, angular_tolerance * (pi / 180)) + def is_skew(self, other: Axis, tolerance: float = 1e-5) -> bool: + """are axes skew + + Returns True if this axis and another axis are skew, meaning they are neither + parallel nor coplanar. Two axes are skew if they do not lie in the same plane + 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). + + If either condition is false (i.e., the axes are parallel or coplanar), they are + not skew. + + Args: + other (Axis): axis to compare to + tolerance (float, optional): max deviation. Defaults to 1e-5. + + Returns: + bool: axes are skew + """ + if self.is_parallel(other, tolerance): + # If parallel, check if they are coincident + parallel_offset = (self.position - other.position).cross(self.direction) + # True if distinct, False if coincident + return parallel_offset.length > tolerance + + # Compute the determinant + coplanarity = (self.position - other.position).dot( + self.direction.cross(other.direction) + ) + + # If determinant is near zero, they are coplanar; otherwise, they are skew + return abs(coplanarity) > tolerance + def angle_between(self, other: Axis) -> float: """calculate angle between axes @@ -768,71 +897,70 @@ class Axis(metaclass=AxisMeta): def reverse(self) -> Axis: """Return a copy of self with the direction reversed""" - return Axis(self.wrapped.Reversed()) + return type(self)(self.wrapped.Reversed()) def __neg__(self) -> Axis: """Flip direction operator -""" return self.reverse() - def __and__(self: Plane, other: Union[Axis, Location, Plane, VectorLike, "Shape"]): + def __and__( + self, other: Axis | Location | Plane | VectorLike | Shape + ) -> Vector | Location | Axis | None: """intersect vector with other &""" return self.intersect(other) @overload - def intersect(self, vector: VectorLike) -> Union[Vector, None]: - """Find intersection of vector and axis""" + def intersect(self, vector: VectorLike) -> Vector | None: + """Find intersection of axis and vector""" @overload - def intersect(self, location: Location) -> Union[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) -> Union[Axis, None]: + def intersect(self, axis: Axis) -> Vector | Axis | None: """Find intersection of axis and axis""" @overload - def intersect(self, plane: Plane) -> Union[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: if self.is_coaxial(axis): return self - else: - # Extract points and directions to numpy arrays - p1 = np.array([*self.position]) - d1 = np.array([*self.direction]) - p2 = np.array([*axis.position]) - d2 = np.array([*axis.direction]) - # Compute the cross product of directions - cross_d1_d2 = np.cross(d1, d2) - cross_d1_d2_norm = np.linalg.norm(cross_d1_d2) + if self.is_skew(axis): + return None - if cross_d1_d2_norm < TOLERANCE: - # The directions are parallel - return None + # Extract points and directions to numpy arrays + p1 = np.array([*self.position]) + d1 = np.array([*self.direction]) + p2 = np.array([*axis.position]) + d2 = np.array([*axis.direction]) - # Solve the system of equations to find the intersection - system_of_equations = np.array([d1, -d2, cross_d1_d2]).T - origin_diff = p2 - p1 - try: - t1, t2, _ = np.linalg.solve(system_of_equations, origin_diff) - except np.linalg.LinAlgError: - return None # The lines do not intersect + # 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, _, _ = np.linalg.lstsq(system_of_equations, origin_diff, rcond=None)[0] - # Calculate the intersection point - intersection_point = p1 + t1 * d1 - return Vector(*intersection_point) + # Calculate the intersection point + intersection_point = p1 + t1 * d1 + return Vector(*intersection_point) - elif plane is not None: + if plane is not None: return plane.intersect(self) - elif vector is not None: + if vector is not None: # Create a vector from the origin to the point - vec_to_point: Vector = vector - self.position + vec_to_point = vector - self.position # Project the vector onto the direction of the axis projected_length = vec_to_point.dot(self.direction) @@ -842,34 +970,52 @@ class Axis(metaclass=AxisMeta): if vector == projected_vec: return vector - elif location is not None: + if location is not None: # 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 - elif shape is not None: + if shape is not None: return shape.intersect(self) + return None + class BoundBox: """A BoundingBox for a Shape""" def __init__(self, bounding_box: Bnd_Box) -> None: - self.wrapped: Bnd_Box = bounding_box - x_min, y_min, z_min, x_max, y_max, z_max = bounding_box.Get() + + if bounding_box.IsVoid(): + 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 self.min = Vector(x_min, y_min, z_min) #: location of minimum corner 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)""" + if self.wrapped is None: + return 0.0 return self.wrapped.SquareExtent() ** 0.5 def __repr__(self): @@ -885,8 +1031,8 @@ class BoundBox: def add( self, - obj: Union[tuple[float, float, float], Vector, BoundBox], - tol: float = None, + obj: tuple[float, float, float] | Vector | BoundBox, + tol: float | None = None, ) -> BoundBox: """Returns a modified (expanded) bounding box @@ -899,11 +1045,7 @@ class BoundBox: This bounding box is not changed. Args: - obj: Union[tuple[float: - float: - float]: - Vector: - BoundBox]: + obj: tuple[float, float, float] | Vector | BoundBox]: tol: float: (Default value = None) Returns: @@ -914,19 +1056,20 @@ class BoundBox: tmp = Bnd_Box() tmp.SetGap(tol) - tmp.Add(self.wrapped) + if self.wrapped is not None: + tmp.Add(self.wrapped) if isinstance(obj, tuple): tmp.Update(*obj) elif isinstance(obj, Vector): - tmp.Update(*obj.to_tuple()) - elif isinstance(obj, BoundBox): + tmp.Update(*obj) + elif isinstance(obj, BoundBox) and obj.wrapped is not None: tmp.Add(obj.wrapped) return BoundBox(tmp) @staticmethod - def find_outside_box_2d(bb1: BoundBox, bb2: BoundBox) -> Optional[BoundBox]: + def find_outside_box_2d(bb1: BoundBox, bb2: BoundBox) -> BoundBox | None: """Compares bounding boxes Compares bounding boxes. Returns none if neither is inside the other. @@ -963,12 +1106,11 @@ class BoundBox: return result @classmethod - def _from_topo_ds( + def from_topo_ds( cls, shape: TopoDS_Shape, - tolerance: float = None, + tolerance: float | None = None, optimal: bool = True, - oriented: bool = False, ) -> BoundBox: """Constructs a bounding box from a TopoDS_Shape @@ -984,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? @@ -1019,19 +1152,9 @@ class BoundBox: and second_box.max.Z < self.max.Z ) - def to_align_offset(self, align: Tuple[float, float]) -> Tuple[float, float]: + def to_align_offset(self, align: Align2DType | Align3DType) -> Vector: """Amount to move object to achieve the desired alignment""" - align_offset = [] - for i in range(2): - if align[i] == Align.MIN: - align_offset.append(-self.min.to_tuple()[i]) - elif align[i] == Align.CENTER: - align_offset.append( - -(self.min.to_tuple()[i] + self.max.to_tuple()[i]) / 2 - ) - elif align[i] == Align.MAX: - align_offset.append(-self.max.to_tuple()[i]) - return align_offset + return to_align_offset(self.min, self.max, align) class Color: @@ -1043,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 @@ -1069,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""" @@ -1165,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)) @@ -1185,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. @@ -1233,6 +1565,149 @@ class Location: Extrinsic.ZYZ: gp_EulerSequence.gp_Extrinsic_ZYZ, } + @overload + def __init__(self): + """Empty location with not rotation or translation with respect to the original location.""" + + @overload + def __init__(self, location: Location): + """Location with another given location.""" + + @overload + def __init__(self, translation: VectorLike, angle: float = 0): + """Location with translation with respect to the original location. + If angle != 0 then the location includes a rotation around z-axis by angle""" + + @overload + def __init__(self, translation: VectorLike, rotation: RotationLike | None = None): + """Location with translation with respect to the original location. + If rotation is not None then the location includes the rotation (see also Rotation class) + """ + + @overload + def __init__( + self, + translation: VectorLike, + rotation: RotationLike, + ordering: Extrinsic | Intrinsic, + ): + """Location with translation with respect to the original location. + If rotation is not None then the location includes the rotation (see also Rotation class) + ordering defaults to Intrinsic.XYZ, but can also be set to Extrinsic + """ + + @overload + def __init__(self, plane: Plane): + """Location corresponding to the location of the Plane.""" + + @overload + 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): + """Location wrapping the low-level TopLoc_Location object t""" + + @overload + 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): + """Location with translation t and rotation around direction by angle + with respect to the original location.""" + + def __init__( + self, *args, **kwargs + ): # pylint: disable=too-many-branches, too-many-locals, too-many-statements + + self.location_index = 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 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(f"Invalid positional arguments: {args}") + + # Construct transformation + trsf = gp_Trsf() + + if plane: + cs = gp_Ax3( + plane.origin.to_pnt(), + plane.z_dir.to_dir(), + plane.x_dir.to_dir(), + ) + trsf.SetTransformation(cs) + trsf.Invert() + + 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: """Extract Position component of self @@ -1241,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): @@ -1250,6 +1725,8 @@ class Location: Args: value (VectorLike): New position """ + if self.wrapped is None: + raise ValueError("Can't determine position of empty Location") trsf_position = gp_Trsf() trsf_position.SetTranslationPart(Vector(value).wrapped) trsf_orientation = gp_Trsf() @@ -1264,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): @@ -1306,150 +1783,6 @@ class Location: plane = Plane(self) return Axis(plane.origin, plane.z_dir) - @overload - def __init__(self): # pragma: no cover - """Empty location with not rotation or translation with respect to the original location.""" - - @overload - def __init__(self, location: Location): # pragma: no cover - """Location with another given location.""" - - @overload - def __init__(self, translation: VectorLike, angle: float = 0): # pragma: no cover - """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 - ): # 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) - """ - - @overload - def __init__( - self, - translation: VectorLike, - rotation: RotationLike, - ordering: Union[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 - """Location corresponding to the location of the Plane.""" - - @overload - def __init__(self, plane: Plane, plane_offset: VectorLike): # pragma: no cover - """Location corresponding to the angular location of the Plane with - translation plane_offset.""" - - @overload - def __init__(self, top_loc: TopLoc_Location): # pragma: no cover - """Location wrapping the low-level TopLoc_Location object t""" - - @overload - def __init__(self, gp_trsf: gp_Trsf): # pragma: no cover - """Location wrapping the low-level gp_Trsf object t""" - - @overload - def __init__( - self, translation: VectorLike, direction: VectorLike, angle: float - ): # pragma: no cover - """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() - - if len(args) == 0: - pass - - elif len(args) == 1: - translation = args[0] - - 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 - else: - raise TypeError("Unexpected parameters") - - 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) - - # 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") - - transform.SetTranslationPart(Vector(translation).wrapped) - self.wrapped = TopLoc_Location(transform) - def inverse(self) -> Location: """Inverted location""" return Location(self.wrapped.Inverted()) @@ -1462,21 +1795,62 @@ class Location: """Lib/copy.py deep copy""" return Location(self.wrapped.Transformation()) - T = TypeVar("T", bound=Union["Location", "Shape"]) + @overload + def __mul__(self, other: Shape) -> Shape: ... - def __mul__(self, other: T) -> T: + @overload + def __mul__(self, other: Location) -> Location: ... + + @overload + def __mul__(self, other: Iterable[Location]) -> list[Location]: ... + + def __mul__( + self, other: Shape | Location | Iterable[Location] + ) -> Shape | Location | list[Location]: """Combine locations""" - if hasattr(other, "wrapped") and not isinstance( - other.wrapped, TopLoc_Location - ): # Shape - result = other.moved(self) - elif isinstance(other, Iterable) and all( - isinstance(o, Location) for o in other - ): - result = [Location(self.wrapped * loc.wrapped) for loc in other] - else: - result = Location(self.wrapped * other.wrapped) - return result + if self.wrapped is None: + raise ValueError("Cannot move a shape at an empty location") + + # other is a Shape + if hasattr(other, "wrapped") and isinstance(other.wrapped, TopoDS_Shape): + # result = other.moved(self) + 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, + TopAbs_ShapeEnum.TopAbs_FACE: TopoDS.Face_s, + TopAbs_ShapeEnum.TopAbs_SHELL: TopoDS.Shell_s, + TopAbs_ShapeEnum.TopAbs_SOLID: TopoDS.Solid_s, + TopAbs_ShapeEnum.TopAbs_COMPOUND: TopoDS.Compound_s, + } + assert other.wrapped is not None + try: + f_downcast = downcast_lut[other.wrapped.ShapeType()] + except KeyError as exc: + raise ValueError(f"Unknown object type {other}") from exc + + result: Shape = copy_module.deepcopy(other, None) # type: ignore[arg-type] + result.wrapped = f_downcast(other.wrapped.Moved(self.wrapped)) + return result + + # other is a Location + if isinstance(other, Location): + if other.wrapped is None: + raise ValueError("Can't multiply by empty location") + return Location(self.wrapped * other.wrapped) + + # other is a list of Locations + if isinstance(other, Iterable): + others = list(other) + if not all(isinstance(o, Location) for o in others): + raise ValueError("other must be a list of Locations") + if any(o.wrapped is None for o in others): + raise ValueError("Can't multiple by empty Locations") + return [Location(self.wrapped * loc.wrapped) for loc in others] + + raise ValueError(f"Invalid input {other}") def __pow__(self, exponent: int) -> Location: return Location(self.wrapped.Powered(exponent)) @@ -1499,33 +1873,134 @@ 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 -""" return Location(-Plane(self)) - def __and__(self: Plane, other: Union[Axis, Location, Plane, VectorLike, "Shape"]): + def __and__( + self, other: Axis | Location | Plane | VectorLike | Shape + ) -> Vector | Location | None: """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() - rv_trans = (trans.X(), trans.Y(), trans.Z()) - rv_rot = [ + rv_trans: tuple[float, float, float] = (trans.X(), trans.Y(), trans.Z()) + rv_rot: tuple[float, float, float] = tuple( degrees(a) for a in rot.GetEulerAngles(gp_EulerSequence.gp_Intrinsic_XYZ) - ] + ) # type: ignore[assignment] - return rv_trans, tuple(rv_rot) + return rv_trans, rv_rot def __repr__(self): """To String @@ -1535,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): @@ -1547,44 +2022,54 @@ 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) -> Union[Vector, None]: - """Find intersection of vector and location""" + def intersect(self, vector: VectorLike) -> Vector | None: + """Find intersection of location and vector""" @overload - def intersect(self, location: Location) -> Union[Location, None]: + def intersect(self, location: Location) -> Vector | Location | None: """Find intersection of location and location""" @overload - def intersect(self, axis: Axis) -> Union[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) -> Union[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: return axis.intersect(self) - elif plane is not None: + if plane is not None: return plane.intersect(self) - elif vector is not None and self.position == vector: + if vector is not None and self.position == vector: return vector - elif 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 - elif shape is not None: + if shape is not None: return shape.intersect(self) + return None + class LocationEncoder(json.JSONEncoder): """Custom JSON Encoder for Location values @@ -1613,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()} @@ -1624,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 @@ -1636,7 +2332,8 @@ class Rotation(Location): X (float): rotation in degrees about X axis Y (float): rotation in degrees about Y axis Z (float): rotation in degrees about Z axis - optionally specify rotation ordering with Intrinsic or Extrinsic enums, defaults to Intrinsic.XYZ + optionally specify rotation ordering with Intrinsic or Extrinsic enums, + defaults to Intrinsic.XYZ """ @@ -1644,7 +2341,7 @@ class Rotation(Location): def __init__( self, rotation: RotationLike, - ordering: Union[Extrinsic, Intrinsic] == Intrinsic.XYZ, + ordering: Extrinsic | Intrinsic == Intrinsic.XYZ, # type: ignore[valid-type] ): """Subclass of Location used only for object rotation ordering is for order of rotations in Intrinsic or Extrinsic enums""" @@ -1655,7 +2352,7 @@ class Rotation(Location): X: float = 0, Y: float = 0, Z: float = 0, - ordering: Union[Extrinsic, Intrinsic] = Intrinsic.XYZ, + ordering: Extrinsic | Intrinsic = Intrinsic.XYZ, ): """Subclass of Location used only for object rotation ordering is for order of rotations in Intrinsic or Extrinsic enums""" @@ -1671,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)) @@ -1692,8 +2389,13 @@ class Rotation(Location): Rot = Rotation # Short form for Algebra users who like compact notation -#:TypeVar("RotationLike"): Three tuple of angles about x, y, z or Rotation -RotationLike = Union[tuple[float, float, float], Rotation] +RotationLike: TypeAlias = Rotation | tuple[float, float, float] +""" +RotationLike: Represents a rotation. + +- `Rotation`: A specialized `Location` with the orientation set. +- `tuple[float, float, float]`: Euler rotations about the X, Y, and Z axes. +""" class Pos(Location): @@ -1712,29 +2414,30 @@ class Pos(Location): """Position by X, Y, Z""" def __init__(self, *args, **kwargs): - position = [0, 0, 0] - # VectorLike - if len(args) == 1 and isinstance(args[0], (tuple, Vector)): - position = list(args[0]) - # Vertex - elif len(args) == 1 and isinstance(args[0], Iterable): - position = list(args[0]) - # Values - elif 1 <= len(args) <= 3 and all([isinstance(v, (float, int)) for v in args]): - position = list(args) + [0] * (3 - len(args)) + x, y, z, v = 0, 0, 0, None - unknown_args = ", ".join(set(kwargs.keys()).difference(["v", "X", "Y", "Z"])) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") + # Handle args + if args: + if all(isinstance(v, (float, int)) for v in args): + x, y, z = Vector(args) + elif len(args) == 1: + x, y, z = Vector(args[0]) + else: + raise TypeError(f"Invalid inputs to Pos {args}") - if "X" in kwargs: - position[0] = kwargs["X"] - if "Y" in kwargs: - position[1] = kwargs["Y"] - if "Z" in kwargs: - position[2] = kwargs["Z"] + # Handle kwargs + x = kwargs.pop("X", x) + y = kwargs.pop("Y", y) + z = kwargs.pop("Z", z) + v = kwargs.pop("v", Vector(x, y, z)) - super().__init__(tuple(position)) + # Handle unexpected kwargs + if kwargs: + raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + if v is not None: + x, y, z = v + super().__init__(Vector(x, y, z)) class Matrix: @@ -1759,28 +2462,45 @@ class Matrix: """ @overload - def __init__(self) -> None: # pragma: no cover + def __init__(self): # pragma: no cover ... @overload - def __init__(self, matrix: Union[gp_GTrsf, gp_Trsf]) -> None: # pragma: no cover + def __init__(self, trsf: gp_GTrsf | gp_Trsf): # pragma: no cover ... @overload - def __init__(self, matrix: Sequence[Sequence[float]]) -> None: # pragma: no cover + def __init__(self, matrix: Sequence[Sequence[float]]): # pragma: no cover ... - def __init__(self, matrix=None): - if matrix is None: - self.wrapped = gp_GTrsf() - elif isinstance(matrix, gp_GTrsf): - self.wrapped = matrix - elif isinstance(matrix, gp_Trsf): - self.wrapped = gp_GTrsf(matrix) - elif isinstance(matrix, (list, tuple)): + def __init__(self, *args, **kwargs): + default_matrix = None + default_trsf = gp_GTrsf() + + # Handle args + if args: + if isinstance(args[0], gp_GTrsf): + default_trsf = args[0] + elif isinstance(args[0], gp_Trsf): + default_trsf = gp_GTrsf(args[0]) + elif isinstance(args[0], Sequence): + default_matrix = args[0] + else: + raise TypeError(f"{args[0]} is of an unexpected type") + + # Handle kwargs + trsf = kwargs.pop("trsf", default_trsf) + matrix = kwargs.pop("matrix", default_matrix) + + # Handle unexpected kwargs + if kwargs: + raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + # Validate matrix + if matrix is not None: # Validate matrix size & 4x4 last row value valid_sizes = all( - (isinstance(row, (list, tuple)) and (len(row) == 4)) for row in matrix + (isinstance(row, Sequence) and (len(row) == 4)) for row in matrix ) and len(matrix) in (3, 4) if not valid_sizes: raise TypeError( @@ -1792,13 +2512,13 @@ class Matrix: ) # Assign values to matrix - self.wrapped = gp_GTrsf() for i, row in enumerate(matrix[:3]): for j, element in enumerate(row): - self.wrapped.SetValue(i + 1, j + 1, element) + if not isinstance(element, (int, float)): + raise TypeError("Only float or int are valid in the matrix") + trsf.SetValue(i + 1, j + 1, element) - else: - raise TypeError(f"Invalid param to matrix constructor: {matrix}") + self.wrapped = trsf #: the OCP transformation function def rotate(self, axis: Axis, angle: float): """General rotate about axis""" @@ -1976,10 +2696,10 @@ class Plane(metaclass=PlaneMeta): Args: gp_pln (gp_Pln): an OCCT plane object - origin (Union[tuple[float, float, float], Vector]): the origin in global coordinates - x_dir (Union[tuple[float, float, float], Vector], optional): an optional vector + origin (tuple[float, float, float] | Vector): the origin in global coordinates + x_dir (tuple[float, float, float] | Vector | None): an optional vector representing the X Direction. Defaults to None. - z_dir (Union[tuple[float, float, float], Vector], optional): the normal direction + z_dir (tuple[float, float, float] | Vector | None): the normal direction for the plane. Defaults to (0, 0, 1). Attributes: @@ -2014,87 +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: Optional[VectorLike] = 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, + 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() @@ -2102,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) @@ -2116,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()) @@ -2131,20 +2860,22 @@ 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: gp_Ax3 = None - self.reverse_transform: Matrix = None - self.forward_transform: Matrix = None + + self.local_coord_system = None #: gp_Ax3 | None + self.reverse_transform = None #: Matrix | None + self.forward_transform = None #: Matrix | None self.origin = self._origin # set origin to calculate transformations def offset(self, amount: float) -> Plane: @@ -2167,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 @@ -2179,32 +2910,38 @@ 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) - def __mul__( - self, other: Union[Location, "Shape"] - ) -> Union[Plane, List[Plane], "Shape"]: + def __mul__(self, other: Location | Shape) -> Plane | list[Plane] | Shape: if isinstance(other, Location): - result = Plane(self.location * other) - elif ( # LocationList + return Plane(self.location * other) + if ( # LocationList hasattr(other, "local_locations") and hasattr(other, "location_index") ) or ( # tuple of locations isinstance(other, (list, tuple)) and all([isinstance(o, Location) for o in other]) ): - result = [self * loc for loc in other] - elif hasattr(other, "wrapped") and not isinstance(other, Vector): # Shape - result = self.location * other + return [self * loc for loc in other] + if hasattr(other, "wrapped") and not isinstance(other, Vector): # Shape + return self.location * other - else: - raise TypeError( - "Planes can only be multiplied with Locations or Shapes to relocate them" - ) - return result + raise TypeError( + "Planes can only be multiplied with Locations or Shapes to relocate them" + ) - def __and__(self: Plane, other: Union[Axis, Location, Plane, VectorLike, "Shape"]): + def __and__(self: Plane, other: Axis | Location | Plane | VectorLike | Shape): """intersect plane with other &""" return self.intersect(other) @@ -2216,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: @@ -2239,14 +2976,14 @@ class Plane(metaclass=PlaneMeta): gp_Ax3(self._origin.to_pnt(), self.z_dir.to_dir(), self.x_dir.to_dir()) ) - def shift_origin(self, locator: Union[Axis, VectorLike, "Vertex"]) -> Plane: + def shift_origin(self, locator: Axis | VectorLike | Vertex) -> Plane: """shift plane origin Creates a new plane with the origin moved within the plane to the point of intersection of the axis or at the given Vertex. The plane's x_dir and z_dir are unchanged. Args: - locator (Union[Axis, VectorLike, Vertex]): Either Axis that intersects the new + locator (Axis | VectorLike | Vertex): Either Axis that intersects the new plane origin or Vertex within Plane. Raises: @@ -2258,8 +2995,11 @@ class Plane(metaclass=PlaneMeta): Plane: plane with new origin """ - if type(locator).__name__ == "Vertex": - new_origin = tuple(locator) + if hasattr(locator, "wrapped") and locator.wrapped is None: + raise ValueError("Can't shift origin to empty locator") + if hasattr(locator, "wrapped") and isinstance(locator.wrapped, TopoDS_Vertex): + geom_point = BRep_Tool.Pnt_s(locator.wrapped) + new_origin = Vector(geom_point.X(), geom_point.Y(), geom_point.Z()) if not self.contains(new_origin): raise ValueError(f"{locator} is not located within plane") elif isinstance(locator, (tuple, Vector)): @@ -2267,9 +3007,10 @@ class Plane(metaclass=PlaneMeta): if not self.contains(locator): raise ValueError(f"{locator} is not located within plane") elif isinstance(locator, Axis): - new_origin = self.intersect(locator) - if new_origin is None: + intersection = self.intersect(locator) + if not isinstance(intersection, Vector): raise ValueError(f"{locator} doesn't intersect the plane") + new_origin = intersection else: raise TypeError(f"Invalid locate type: {type(locator)}") return Plane(origin=new_origin, x_dir=self.x_dir, z_dir=self.z_dir) @@ -2277,21 +3018,21 @@ class Plane(metaclass=PlaneMeta): def rotated( self, rotation: VectorLike = (0, 0, 0), - ordering: Union[Extrinsic, Intrinsic] = None, + ordering: Extrinsic | Intrinsic | None = None, ) -> 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). - ordering (Union[Intrinsic, Extrinsic], optional): order of rotations in Intrinsic or Extrinsic rotation mode, defaults to Intrinsic.XYZ + 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 Returns: Plane: a copy of this plane rotated as requested. @@ -2323,7 +3064,7 @@ class Plane(metaclass=PlaneMeta): Returns: Plane: relocated plane """ - self_copy = copy.deepcopy(self) + self_copy = copy_module.deepcopy(self) self_copy.wrapped.Transform(loc.wrapped.Transformation()) return Plane(self_copy.wrapped) @@ -2341,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) @@ -2352,9 +3093,9 @@ class Plane(metaclass=PlaneMeta): inverse_t.SetTransformation(local_coord_system, global_coord_system) inverse.wrapped = gp_GTrsf(inverse_t) - self.local_coord_system: gp_Ax3 = local_coord_system - self.reverse_transform: Matrix = inverse - self.forward_transform: Matrix = forward + self.local_coord_system = local_coord_system #: gp_Ax3 + self.reverse_transform = inverse #: Matrix + self.forward_transform = forward #: Matrix @property def location(self) -> Location: @@ -2369,14 +3110,14 @@ class Plane(metaclass=PlaneMeta): return axis def _to_from_local_coords( - self, obj: Union[VectorLike, Any, BoundBox], to_from: bool = True + self, obj: VectorLike | Any | BoundBox, to_from: bool = True ): """_to_from_local_coords Reposition the object relative to this plane Args: - obj (Union[VectorLike, Shape, BoundBox]): an object to reposition. Note that + obj (VectorLike | Shape | BoundBox): an object to reposition. Note that type Any refers to all topological classes. to_from (bool, optional): direction of transformation. Defaults to True (to). @@ -2390,30 +3131,54 @@ class Plane(metaclass=PlaneMeta): transform_matrix = self.forward_transform if to_from else self.reverse_transform if isinstance(obj, (tuple, Vector)): - return_value = Vector(obj).transform(transform_matrix) - elif isinstance(obj, BoundBox): + return Vector(obj).transform(transform_matrix) + if isinstance(obj, BoundBox): global_bottom_left = Vector(obj.min.X, obj.min.Y, obj.min.Z) global_top_right = Vector(obj.max.X, obj.max.Y, obj.max.Z) 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_value = BoundBox(local_bbox) - elif hasattr(obj, "wrapped"): # Shapes - return_value = obj.transform_shape(transform_matrix) - else: - raise ValueError( - f"Unable to repositioned type {type(obj)} with respect to local coordinates" - ) - return return_value + 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: 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, + TopAbs_ShapeEnum.TopAbs_FACE: TopoDS.Face_s, + TopAbs_ShapeEnum.TopAbs_SHELL: TopoDS.Shell_s, + TopAbs_ShapeEnum.TopAbs_SOLID: TopoDS.Solid_s, + TopAbs_ShapeEnum.TopAbs_COMPOUND: TopoDS.Compound_s, + } + assert obj.wrapped is not None + try: + f_downcast = downcast_lut[obj.wrapped.ShapeType()] + except KeyError as exc: + raise ValueError(f"Unknown object type {obj}") from exc - def to_local_coords(self, obj: Union[VectorLike, Any, BoundBox]): + new_shape: Shape = copy_module.deepcopy(obj, None) # type: ignore[arg-type] + new_shape.wrapped = f_downcast( + BRepBuilderAPI_Transform( + obj.wrapped, transform_matrix.wrapped.Trsf() + ).Shape() + ) + return new_shape + raise ValueError( + f"Unable to repositioned type {type(obj)} with respect to local coordinates" + ) + + def to_local_coords(self, obj: VectorLike | Any | BoundBox): """Reposition the object relative to this plane Args: - obj: Union[VectorLike, Shape, BoundBox] an object to reposition. Note that + obj: VectorLike | Shape | BoundBox an object to reposition. Note that type Any refers to all topological classes. Returns: @@ -2422,11 +3187,11 @@ class Plane(metaclass=PlaneMeta): """ return self._to_from_local_coords(obj, True) - def from_local_coords(self, obj: Union[tuple, Vector, Any, BoundBox]): + def from_local_coords(self, obj: tuple | Vector | Any | BoundBox): """Reposition the object relative from this plane Args: - obj: Union[VectorLike, Shape, BoundBox] an object to reposition. Note that + obj: VectorLike | Shape | BoundBox an object to reposition. Note that type Any refers to all topological classes. Returns: @@ -2444,15 +3209,13 @@ class Plane(metaclass=PlaneMeta): ) return Location(transformation) - def contains( - self, obj: Union[VectorLike, Axis], tolerance: float = TOLERANCE - ) -> bool: + def contains(self, obj: VectorLike | Axis, tolerance: float = TOLERANCE) -> bool: """contains Is this point or Axis fully contained in this plane? Args: - obj (Union[VectorLike,Axis]): point or Axis to evaluate + obj (VectorLike | Axis): point or Axis to evaluate tolerance (float, optional): comparison tolerance. Defaults to TOLERANCE. Returns: @@ -2470,50 +3233,54 @@ class Plane(metaclass=PlaneMeta): return return_value @overload - def intersect(self, vector: VectorLike) -> Union[Vector, None]: - """Find intersection of vector and plane""" + def intersect(self, vector: VectorLike) -> Vector | None: + """Find intersection of plane and vector""" @overload - def intersect(self, location: Location) -> Union[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) -> Union[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) -> Union[Axis, None]: + def intersect(self, plane: Plane) -> Axis | Plane | None: """Find intersection of plane and plane""" @overload - def intersect(self, shape: "Shape") -> Union["Shape", None]: + def intersect(self, shape: Shape) -> Shape | None: """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) if axis is not None: if self.contains(axis): return axis + + geom_line = Geom_Line(axis.wrapped) + geom_plane = Geom_Plane(self.local_coord_system) + + intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) + + if ( + intersection_calculator.IsDone() + and intersection_calculator.NbPoints() == 1 + ): + # Get the intersection point + intersection_point = Vector(intersection_calculator.Point(1)) else: - geom_line = Geom_Line(axis.wrapped) - geom_plane = Geom_Plane(self.local_coord_system) + intersection_point = None - intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) + return intersection_point - if ( - intersection_calculator.IsDone() - and intersection_calculator.NbPoints() == 1 - ): - # Get the intersection point - intersection_point = Vector(intersection_calculator.Point(1)) - else: - intersection_point = None + if plane is not None: + if self.contains(plane.origin) and self.z_dir == plane.z_dir: + return self - return intersection_point - - elif plane is not None: surface1 = Geom_Plane(self.wrapped) surface2 = Geom_Plane(plane.wrapped) intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE) @@ -2524,13 +3291,65 @@ class Plane(metaclass=PlaneMeta): axis = intersection_line.Position() return Axis(axis) - elif vector is not None and self.contains(vector): + if vector is not None and self.contains(vector): return vector - elif location is not None: + 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 - elif shape is not None: + 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, + max_point: VectorLike, + align: Align2DType | Align3DType, + center: VectorLike | None = None, +) -> Vector: + """Amount to move object to achieve the desired alignment""" + align_offset = [] + + if center is None: + center = (Vector(min_point) + Vector(max_point)) / 2 + + if align is None or align is Align.NONE: + return Vector(0, 0, 0) + if align is Align.MIN: + return -Vector(min_point) + if align is Align.MAX: + return -Vector(max_point) + if align is Align.CENTER: + return -Vector(center) + + for alignment, min_coord, max_coord, center_coord in zip( + map(Align, align), + min_point, + max_point, + center, + ): + if alignment == Align.MIN: + align_offset.append(-min_coord) + elif alignment == Align.CENTER: + align_offset.append(-center_coord) + elif alignment == Align.MAX: + align_offset.append(-max_coord) + elif alignment == Align.NONE: + align_offset.append(0) + return Vector(*align_offset) diff --git a/src/build123d/importers.py b/src/build123d/importers.py index b9f5299..d53628a 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -31,13 +31,17 @@ license: import os from os import PathLike, fsdecode +import re import unicodedata from math import degrees from pathlib import Path -from typing import Optional, TextIO, Union +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 @@ -68,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, @@ -93,7 +98,7 @@ topods_lut = { } -def import_brep(file_name: Union[PathLike, str, bytes]) -> Shape: +def import_brep(file_name: PathLike | str | bytes) -> Shape: """Import shape from a BREP file Args: @@ -108,15 +113,16 @@ def import_brep(file_name: Union[PathLike, str, bytes]) -> Shape: shape = TopoDS_Shape() builder = BRep_Builder() - BRepTools.Read_s(shape, fsdecode(file_name), builder) + file_name_str = fsdecode(file_name) + BRepTools.Read_s(shape, file_name_str, builder) if shape.IsNull(): - raise ValueError(f"Could not import {file_name}") + raise ValueError(f"Could not import {file_name_str}") - return Shape.cast(shape) + return Compound.cast(shape) -def import_step(filename: Union[PathLike, str, bytes]) -> Compound: +def import_step(filename: PathLike | str | bytes) -> Compound: """import_step Extract shapes from a STEP file and return them as a Compound object. @@ -141,39 +147,44 @@ def import_step(filename: Union[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 + 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 - 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 - - def build_assembly(parent_tdf_label: Optional[TDF_Label] = None) -> list[Shape]: + def build_assembly(parent_tdf_label: TDF_Label | None = None) -> list[Shape]: """Recursively extract object into an assembly""" sub_tdf_labels = TDF_LabelSequence() if parent_tdf_label is None: @@ -197,7 +208,7 @@ def import_step(filename: Union[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))) @@ -207,6 +218,9 @@ def import_step(filename: Union[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()) @@ -219,7 +233,6 @@ def import_step(filename: Union[PathLike, str, bytes]) -> Compound: reader.Transfer(doc) root = Compound() - root.for_construction = None root.children = build_assembly() # Remove empty Compound wrapper if single free object if len(root.children) == 1: @@ -228,7 +241,7 @@ def import_step(filename: Union[PathLike, str, bytes]) -> Compound: return root -def import_stl(file_name: Union[PathLike, str, bytes]) -> Face: +def import_stl(file_name: PathLike | str | bytes) -> Face: """import_stl Extract shape from an STL file and return it as a Face reference object. @@ -255,7 +268,7 @@ def import_stl(file_name: Union[PathLike, str, bytes]) -> Face: def import_svg_as_buildline_code( - file_name: Union[PathLike, str, bytes], + file_name: PathLike | str | bytes, ) -> tuple[str, str]: """translate_to_buildline_code @@ -332,23 +345,25 @@ def import_svg_as_buildline_code( def import_svg( - svg_file: Union[str, Path, TextIO], + svg_file: str | Path | TextIO, *, flip_y: bool = True, + align: Align | tuple[Align, Align] | None = Align.MIN, ignore_visibility: bool = False, - label_by: str = "id", - is_inkscape_label: bool = False, -) -> ShapeList[Union[Wire, Face]]: + label_by: Literal["id", "class", "inkscape:label"] | str = "id", + is_inkscape_label: bool | None = None, # TODO remove for `1.0` release +) -> ShapeList[Wire | Face]: """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. Defaults to "id". - is_inkscape_label (bool, optional): flag to indicate that the attribute - is an Inkscape label like `inkscape:label` - label_by would be set to - `label` in this case. Defaults to False. + label_by (str, optional): XML attribute to use for imported shapes' `label` property. + Defaults to "id". + Use `inkscape:label` to read labels set from Inkscape's "Layers and Objects" panel. Raises: ValueError: unexpected shape type @@ -356,18 +371,29 @@ def import_svg( Returns: ShapeList[Union[Wire, Face]]: objects contained in svg """ + if is_inkscape_label is not None: # TODO remove for `1.0` release + msg = "`is_inkscape_label` parameter is deprecated" + if is_inkscape_label: + label_by = "inkscape:" + label_by + msg += f", use `label_by={label_by!r}` instead" + warnings.warn(msg, stacklevel=2) + shapes = [] - label_by = ( - "{http://www.inkscape.org/namespaces/inkscape}" + label_by - if is_inkscape_label - else label_by + 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): @@ -375,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/joints.py b/src/build123d/joints.py index bb9af78..361b205 100644 --- a/src/build123d/joints.py +++ b/src/build123d/joints.py @@ -29,7 +29,7 @@ license: from __future__ import annotations from math import inf -from typing import Optional, Union, overload +from typing import overload from build123d.build_common import validate_inputs from build123d.build_enums import Align @@ -45,6 +45,10 @@ from build123d.geometry import ( ) from build123d.topology import Compound, Edge, Joint, Solid +# pylint can't cope with the combination of explicit and implicit kwargs on +# connect_to and relative_to methods +# pylint: disable=arguments-differ + class RigidJoint(Joint): """RigidJoint @@ -64,6 +68,8 @@ class RigidJoint(Joint): @property def location(self) -> Location: """Location of joint""" + if self.parent.location is None or self.relative_location is None: + raise RuntimeError("Joints are invalid") return self.parent.location * self.relative_location @property @@ -75,40 +81,50 @@ class RigidJoint(Joint): def __init__( self, label: str, - to_part: Optional[Union[Solid, Compound]] = None, - joint_location: Union[Location, None] = None, + to_part: Solid | Compound | None = None, + joint_location: Location | None = None, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if to_part is None: if context is not None: - to_part = context + part_or_builder = context else: raise ValueError("Either specify to_part or place in BuildPart scope") + else: + part_or_builder = to_part if joint_location is None: joint_location = Location() - self.relative_location = to_part.location.inverse() * joint_location - to_part.joints[label] = self - super().__init__(label, to_part) + if part_or_builder.location is None: + raise ValueError("Part must have a location") + self.relative_location = part_or_builder.location.inverse() * joint_location + part_or_builder.joints[label] = self + super().__init__(label, part_or_builder) @overload - def connect_to(self, other: BallJoint, *, angles: RotationLike = None, **kwargs): + def connect_to( + self, other: BallJoint, *, angles: RotationLike | None = None, **kwargs + ): """Connect RigidJoint and BallJoint""" @overload def connect_to( - self, other: CylindricalJoint, *, position: float = None, angle: float = None + self, + other: CylindricalJoint, + *, + position: float | None = None, + angle: float | None = None, ): """Connect RigidJoint and CylindricalJoint""" @overload - def connect_to(self, other: LinearJoint, *, position: float = None): + def connect_to(self, other: LinearJoint, *, position: float | None = None): """Connect RigidJoint and LinearJoint""" @overload - def connect_to(self, other: RevoluteJoint, *, angle: float = None): + def connect_to(self, other: RevoluteJoint, *, angle: float | None = None): """Connect RigidJoint and RevoluteJoint""" @overload @@ -129,21 +145,25 @@ class RigidJoint(Joint): return super()._connect_to(other, **kwargs) @overload - def relative_to(self, other: BallJoint, *, angles: RotationLike = None): + def relative_to(self, other: BallJoint, *, angles: RotationLike | None = None): """RigidJoint relative to BallJoint""" @overload def relative_to( - self, other: CylindricalJoint, *, position: float = None, angle: float = None + self, + other: CylindricalJoint, + *, + position: float | None = None, + angle: float | None = None, ): """RigidJoint relative to CylindricalJoint""" @overload - def relative_to(self, other: LinearJoint, *, position: float = None): + def relative_to(self, other: LinearJoint, *, position: float | None = None): """RigidJoint relative to LinearJoint""" @overload - def relative_to(self, other: RevoluteJoint, *, angle: float = None): + def relative_to(self, other: RevoluteJoint, *, angle: float | None = None): """RigidJoint relative to RevoluteJoint""" @overload @@ -226,6 +246,8 @@ class RevoluteJoint(Joint): @property def location(self) -> Location: """Location of joint""" + if self.parent.location is None or self.relative_axis.location is None: + raise RuntimeError("Joints are invalid") return self.parent.location * self.relative_axis.location @property @@ -244,18 +266,20 @@ class RevoluteJoint(Joint): def __init__( self, label: str, - to_part: Union[Solid, Compound] = None, + to_part: Solid | Compound | None = None, axis: Axis = Axis.Z, - angle_reference: VectorLike = None, + angle_reference: VectorLike | None = None, angular_range: tuple[float, float] = (0, 360), ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if to_part is None: if context is not None: - to_part = context + part_or_builder = context else: raise ValueError("Either specify to_part or place in BuildPart scope") + else: + part_or_builder = to_part self.angular_range = angular_range if angle_reference: @@ -264,12 +288,14 @@ class RevoluteJoint(Joint): self.angle_reference = Vector(angle_reference) else: self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir - self._angle = None - self.relative_axis = axis.located(to_part.location.inverse()) - to_part.joints[label] = self - super().__init__(label, to_part) + self._angle: float | None = None + if part_or_builder.location is None: + raise ValueError("Part must have a location") + self.relative_axis = axis.located(part_or_builder.location.inverse()) + part_or_builder.joints[label] = self + super().__init__(label, part_or_builder) - def connect_to(self, other: RigidJoint, *, angle: float = None): + def connect_to(self, other: RigidJoint, *, angle: float | None = None): """Connect RevoluteJoint and RigidJoint Args: @@ -282,9 +308,7 @@ class RevoluteJoint(Joint): """ return super()._connect_to(other, angle=angle) - def relative_to( - self, other: RigidJoint, *, angle: float = None - ): # pylint: disable=arguments-differ + def relative_to(self, other: RigidJoint, *, angle: float | None = None): """Relative location of RevoluteJoint to RigidJoint Args: @@ -298,15 +322,20 @@ class RevoluteJoint(Joint): if not isinstance(other, RigidJoint): raise TypeError(f"other must of type RigidJoint not {type(other)}") - angle = self.angular_range[0] if angle is None else angle - if angle < self.angular_range[0] or angle > self.angular_range[1]: - raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") - self._angle = angle + angle_degrees = self.angular_range[0] if angle is None else angle + if ( + angle_degrees < self.angular_range[0] + or angle_degrees > self.angular_range[1] + ): + raise ValueError( + f"angle ({angle_degrees}) must in range of {self.angular_range}" + ) + self._angle = angle_degrees # Avoid strange rotations when angle is zero by using 360 instead - angle = 360.0 if angle == 0.0 else angle + angle_degrees = 360.0 if angle_degrees == 0.0 else angle_degrees return ( self.relative_axis.location - * Rotation(0, 0, angle) + * Rotation(0, 0, angle_degrees) * other.relative_location.inverse() ) @@ -335,6 +364,8 @@ class LinearJoint(Joint): @property def location(self) -> Location: """Location of joint""" + if self.parent.location is None or self.relative_axis.location is None: + raise RuntimeError("Joints are invalid") return self.parent.location * self.relative_axis.location @property @@ -353,34 +384,41 @@ class LinearJoint(Joint): def __init__( self, label: str, - to_part: Union[Solid, Compound] = None, + to_part: Solid | Compound | None = None, axis: Axis = Axis.Z, linear_range: tuple[float, float] = (0, inf), ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if to_part is None: if context is not None: - to_part = context + part_or_builder = context else: raise ValueError("Either specify to_part or place in BuildPart scope") - + else: + part_or_builder = to_part self.axis = axis self.linear_range = linear_range self.position = None - self.relative_axis = axis.located(to_part.location.inverse()) + if part_or_builder.location is None: + raise ValueError("Part must have a location") + self.relative_axis = axis.located(part_or_builder.location.inverse()) self.angle = None - to_part.joints[label]: dict[str, Joint] = self - super().__init__(label, to_part) + part_or_builder.joints[label] = self + super().__init__(label, part_or_builder) @overload def connect_to( - self, other: RevoluteJoint, *, position: float = None, angle: float = None + self, + other: RevoluteJoint, + *, + position: float | None = None, + angle: float | None = None, ): """Connect LinearJoint and RevoluteJoint""" @overload - def connect_to(self, other: RigidJoint, *, position: float = None): + def connect_to(self, other: RigidJoint, *, position: float | None = None): """Connect LinearJoint and RigidJoint""" def connect_to(self, other: Joint, **kwargs): @@ -399,18 +437,20 @@ class LinearJoint(Joint): return super()._connect_to(other, **kwargs) @overload - def relative_to( - self, other: RigidJoint, *, position: float = None - ): # pylint: disable=arguments-differ + def relative_to(self, other: RigidJoint, *, position: float | None = None): """Relative location of LinearJoint to RigidJoint""" @overload def relative_to( - self, other: RevoluteJoint, *, position: float = None, angle: float = None - ): # pylint: disable=arguments-differ + self, + other: RevoluteJoint, + *, + position: float | None = None, + angle: float | None = None, + ): """Relative location of LinearJoint to RevoluteJoint""" - def relative_to(self, other, **kwargs): # pylint: disable=arguments-differ + def relative_to(self, other, **kwargs): """Relative location of LinearJoint to RevoluteJoint or RigidJoint Args: @@ -443,7 +483,6 @@ class LinearJoint(Joint): self.position = position if isinstance(other, RevoluteJoint): - other: RevoluteJoint angle = other.angular_range[0] if angle is None else angle if not other.angular_range[0] <= angle <= other.angular_range[1]: raise ValueError( @@ -511,6 +550,8 @@ class CylindricalJoint(Joint): @property def location(self) -> Location: """Location of joint""" + if self.parent.location is None or self.relative_axis.location is None: + raise RuntimeError("Joints are invalid") return self.parent.location * self.relative_axis.location @property @@ -530,20 +571,21 @@ class CylindricalJoint(Joint): def __init__( self, label: str, - to_part: Union[Solid, Compound] = None, + to_part: Solid | Compound | None = None, axis: Axis = Axis.Z, - angle_reference: VectorLike = None, + angle_reference: VectorLike | None = None, linear_range: tuple[float, float] = (0, inf), angular_range: tuple[float, float] = (0, 360), ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if to_part is None: if context is not None: - to_part = context + part_or_builder = context else: raise ValueError("Either specify to_part or place in BuildPart scope") - + else: + part_or_builder = to_part self.axis = axis self.linear_position = None self.rotational_position = None @@ -555,14 +597,20 @@ class CylindricalJoint(Joint): self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir self.angular_range = angular_range self.linear_range = linear_range - self.relative_axis = axis.located(to_part.location.inverse()) - self.position = None - self.angle = None - to_part.joints[label]: dict[str, Joint] = self - super().__init__(label, to_part) + if part_or_builder.location is None: + raise ValueError("Part must have a location") + self.relative_axis = axis.located(part_or_builder.location.inverse()) + self.position: float | None = None + self.angle: float | None = None + part_or_builder.joints[label] = self + super().__init__(label, part_or_builder) def connect_to( - self, other: RigidJoint, *, position: float = None, angle: float = None + self, + other: RigidJoint, + *, + position: float | None = None, + angle: float | None = None, ): """Connect CylindricalJoint and RigidJoint" @@ -579,8 +627,12 @@ class CylindricalJoint(Joint): return super()._connect_to(other, position=position, angle=angle) def relative_to( - self, other: RigidJoint, *, position: float = None, angle: float = None - ): # pylint: disable=arguments-differ + self, + other: RigidJoint, + *, + position: float | None = None, + angle: float | None = None, + ): """Relative location of CylindricalJoint to RigidJoint Args: @@ -596,19 +648,19 @@ class CylindricalJoint(Joint): if not isinstance(other, RigidJoint): raise TypeError(f"other must of type RigidJoint not {type(other)}") - position = sum(self.linear_range) / 2 if position is None else position - if not self.linear_range[0] <= position <= self.linear_range[1]: + position_value = sum(self.linear_range) / 2 if position is None else position + if not self.linear_range[0] <= position_value <= self.linear_range[1]: raise ValueError( - f"position ({position}) must in range of {self.linear_range}" + f"position ({position_value}) must in range of {self.linear_range}" ) - self.position = position + self.position = position_value angle = sum(self.angular_range) / 2 if angle is None else angle if not self.angular_range[0] <= angle <= self.angular_range[1]: raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") self.angle = angle joint_relative_position = Location( - self.relative_axis.position + self.relative_axis.direction * position + self.relative_axis.position + self.relative_axis.direction * position_value ) joint_rotation = Location( Plane( @@ -650,6 +702,8 @@ class BallJoint(Joint): @property def location(self) -> Location: """Location of joint""" + if self.parent.location is None or self.relative_location is None: + raise RuntimeError("Joints are invalid") return self.parent.location * self.relative_location @property @@ -680,31 +734,34 @@ class BallJoint(Joint): def __init__( self, label: str, - to_part: Optional[Union[Solid, Compound]] = None, - joint_location: Optional[Location] = None, + to_part: Solid | Compound | None = None, + joint_location: Location | None = None, angular_range: tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ] = ((0, 360), (0, 360), (0, 360)), angle_reference: Plane = Plane.XY, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if to_part is None: if context is not None: - to_part = context + part_or_builder = context else: raise ValueError("Either specify to_part or place in BuildPart scope") - + else: + part_or_builder = to_part if joint_location is None: joint_location = Location() - self.relative_location = to_part.location.inverse() * joint_location - to_part.joints[label] = self + if part_or_builder.location is None: + raise ValueError("Part must have a location") + self.relative_location = part_or_builder.location.inverse() * joint_location + part_or_builder.joints[label] = self self.angular_range = angular_range self.angle_reference = angle_reference - super().__init__(label, to_part) + super().__init__(label, part_or_builder) - def connect_to(self, other: RigidJoint, *, angles: RotationLike = None): + def connect_to(self, other: RigidJoint, *, angles: RotationLike | None = None): """Connect BallJoint and RigidJoint Args: @@ -718,9 +775,7 @@ class BallJoint(Joint): """ return super()._connect_to(other, angles=angles) - def relative_to( - self, other: RigidJoint, *, angles: RotationLike = None - ): # pylint: disable=arguments-differ + def relative_to(self, other: RigidJoint, *, angles: RotationLike | None = None): """relative_to - BallJoint Return the relative location from this joint to the RigidJoint of another object @@ -738,12 +793,20 @@ class BallJoint(Joint): if not isinstance(other, RigidJoint): raise TypeError(f"other must of type RigidJoint not {type(other)}") - rotation = ( - Rotation(*[self.angular_range[i][0] for i in [0, 1, 2]]) - if angles is None - else Rotation(*angles) - ) * self.angle_reference.location + if isinstance(angles, Rotation): + angle_rotation = angles + elif isinstance(angles, tuple): + angle_rotation = Rotation(*angles) + elif angles is None: + angle_rotation = Rotation( + self.angular_range[0][0], + self.angular_range[1][0], + self.angular_range[2][0], + ) + else: + raise TypeError(f"angles is of an unknown type {type(angles)}") + rotation = angle_rotation * self.angle_reference.location for i, rotations in zip( [0, 1, 2], [rotation.orientation.X, rotation.orientation.Y, rotation.orientation.Z], diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py index 326a1c0..9e3600b 100644 --- a/src/build123d/jupyter_tools.py +++ b/src/build123d/jupyter_tools.py @@ -25,187 +25,22 @@ license: # pylint: disable=no-name-in-module from json import dumps -from typing import Any, Dict, List -from IPython.display import Javascript -from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter +import os +import uuid +from string import Template +from typing import Any +from IPython.display import HTML +from build123d.vtk_tools import to_vtkpoly_string DEFAULT_COLOR = [1, 0.8, 0, 1] -TEMPLATE_RENDER = """ - -function render(data, parent_element, ratio){{ - - // Initial setup - const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance(); - const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({{ background: [1, 1, 1 ] }}); - renderWindow.addRenderer(renderer); - - // iterate over all children children - for (var el of data){{ - var trans = el.position; - var rot = el.orientation; - var rgba = el.color; - var shape = el.shape; - - // load the inline data - var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance(); - const textEncoder = new TextEncoder(); - reader.parseAsArrayBuffer(textEncoder.encode(shape)); - - // setup actor,mapper and add - const mapper = vtk.Rendering.Core.vtkMapper.newInstance(); - mapper.setInputConnection(reader.getOutputPort()); - mapper.setResolveCoincidentTopologyToPolygonOffset(); - mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100); - - const actor = vtk.Rendering.Core.vtkActor.newInstance(); - actor.setMapper(mapper); - - // set color and position - actor.getProperty().setColor(rgba.slice(0,3)); - actor.getProperty().setOpacity(rgba[3]); - - actor.rotateZ(rot[2]*180/Math.PI); - actor.rotateY(rot[1]*180/Math.PI); - actor.rotateX(rot[0]*180/Math.PI); - - actor.setPosition(trans); - - renderer.addActor(actor); - - }}; - - renderer.resetCamera(); - - const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance(); - renderWindow.addView(openglRenderWindow); - - // Add output to the "parent element" - var container; - var dims; - - if(typeof(parent_element.appendChild) !== "undefined"){{ - container = document.createElement("div"); - parent_element.appendChild(container); - dims = parent_element.getBoundingClientRect(); - }}else{{ - container = parent_element.append("
").children("div:last-child").get(0); - dims = parent_element.get(0).getBoundingClientRect(); - }}; - - openglRenderWindow.setContainer(container); - - // handle size - if (ratio){{ - openglRenderWindow.setSize(dims.width, dims.width*ratio); - }}else{{ - openglRenderWindow.setSize(dims.width, dims.height); - }}; - - // Interaction setup - const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance(); - - const manips = {{ - rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(), - pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(), - zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(), - zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(), - roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(), - }}; - - manips.zoom1.setControl(true); - manips.zoom2.setScrollEnabled(true); - manips.roll.setShift(true); - manips.pan.setButton(2); - - for (var k in manips){{ - interact_style.addMouseManipulator(manips[k]); - }}; - - const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance(); - interactor.setView(openglRenderWindow); - interactor.initialize(); - interactor.bindEvents(container); - interactor.setInteractorStyle(interact_style); - - // Orientation marker - - const axes = vtk.Rendering.Core.vtkAnnotatedCubeActor.newInstance(); - axes.setXPlusFaceProperty({{text: '+X'}}); - axes.setXMinusFaceProperty({{text: '-X'}}); - axes.setYPlusFaceProperty({{text: '+Y'}}); - axes.setYMinusFaceProperty({{text: '-Y'}}); - axes.setZPlusFaceProperty({{text: '+Z'}}); - axes.setZMinusFaceProperty({{text: '-Z'}}); - - const orientationWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget.newInstance({{ - actor: axes, - interactor: interactor }}); - orientationWidget.setEnabled(true); - orientationWidget.setViewportCorner(vtk.Interaction.Widgets.vtkOrientationMarkerWidget.Corners.BOTTOM_LEFT); - orientationWidget.setViewportSize(0.2); - -}}; -""" - -TEMPLATE = ( - TEMPLATE_RENDER - + """ - -new Promise( - function(resolve, reject) - {{ - if (typeof(require) !== "undefined" ){{ - require.config({{ - "paths": {{"vtk": "https://unpkg.com/vtk"}}, - }}); - require(["vtk"], resolve, reject); - }} else if ( typeof(vtk) === "undefined" ){{ - var script = document.createElement("script"); - script.onload = resolve; - script.onerror = reject; - script.src = "https://unpkg.com/vtk.js"; - document.head.appendChild(script); - }} else {{ resolve() }}; - }} -).then(() => {{ - var parent_element = {element}; - var data = {data}; - render(data, parent_element, {ratio}); -}}); -""" -) +dir_path = os.path.dirname(os.path.realpath(__file__)) +with open(os.path.join(dir_path, "template_render.js"), encoding="utf-8") as f: + TEMPLATE_JS = f.read() -def to_vtkpoly_string( - shape: Any, tolerance: float = 1e-3, angular_tolerance: float = 0.1 -) -> str: - """to_vtkpoly_string - - Args: - shape (Shape): object to convert - tolerance (float, optional): Defaults to 1e-3. - angular_tolerance (float, optional): Defaults to 0.1. - - Raises: - ValueError: not a valid Shape - - Returns: - str: vtkpoly str - """ - if not hasattr(shape, "wrapped"): - raise ValueError(f"Type {type(shape)} is not supported") - - writer = vtkXMLPolyDataWriter() - writer.SetWriteToOutputString(True) - writer.SetInputData(shape.to_vtk_poly_data(tolerance, angular_tolerance, True)) - writer.Write() - - return writer.GetOutputString() - - -def display(shape: Any) -> Javascript: - """display +def shape_to_html(shape: Any) -> HTML: + """shape_to_html Args: shape (Shape): object to display @@ -214,9 +49,9 @@ def display(shape: Any) -> Javascript: ValueError: not a valid Shape Returns: - Javascript: code + HTML: html code """ - payload: List[Dict[str, Any]] = [] + payload: list[dict[str, Any]] = [] if not hasattr(shape, "wrapped"): # Is a "Shape" raise ValueError(f"Type {type(shape)} is not supported") @@ -229,6 +64,12 @@ def display(shape: Any) -> Javascript: "orientation": [0, 0, 0], } ) - code = TEMPLATE.format(data=dumps(payload), element="element", ratio=0.5) - return Javascript(code) + # 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 + ) + html = HTML(f"
") + + return html diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index 4380bd9..c268a2f 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -81,15 +81,18 @@ license: # pylint has trouble with the OCP imports # pylint: disable=no-name-in-module, import-error -import copy +import copy as copy_module import ctypes +from io import BytesIO import math import os import sys -import uuid import warnings from os import PathLike, fsdecode -from typing import Iterable, Union +from typing import Union +from uuid import UUID + +from collections.abc import Iterable import OCP.TopAbs as ta from OCP.BRep import BRep_Tool @@ -103,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: @@ -214,7 +225,7 @@ class Mesher: name space `build123d`, name equal to the base file name and the type as `python`""" caller_file = sys._getframe().f_back.f_code.co_filename - with open(caller_file, mode="r", encoding="utf-8") as code_file: + with open(caller_file, encoding="utf-8") as code_file: source_code = code_file.read() # read whole file to a string self.add_meta_data( @@ -240,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) @@ -293,6 +304,8 @@ class Mesher: ocp_mesh_vertices.append(pnt) # Store the triangles from the triangulated faces + if not facet: + continue facet_reversed = facet.wrapped.Orientation() == ta.TopAbs_REVERSED order = [1, 3, 2] if facet_reversed else [1, 2, 3] for tri in poly_triangulation.Triangles(): @@ -303,40 +316,47 @@ class Mesher: @staticmethod def _create_3mf_mesh( ocp_mesh_vertices: list[tuple[float, float, float]], - triangles: list[list[int, int, int]], + triangles: list[list[int]], ): # Round off the vertices to avoid vertices within tolerance being # considered as different vertices digits = -int(round(math.log(TOLERANCE, 10), 1)) - ocp_mesh_vertices = [ - (round(x, digits), round(y, digits), round(z, digits)) - for x, y, z in ocp_mesh_vertices + + # 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)) + if key not in vertex_to_idx: + 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() ] - """Create the data to create a 3mf mesh""" - # Create a lookup table of face vertex to shape vertex - unique_vertices = list(set(ocp_mesh_vertices)) - vert_table = { - i: unique_vertices.index(pnt) for i, pnt in enumerate(ocp_mesh_vertices) - } - # Create vertex list of 3MF positions - vertices_3mf = [] - for pnt in unique_vertices: - c_array = (ctypes.c_float * 3)(*pnt) - vertices_3mf.append(Lib3MF.Position(c_array)) - # mesh_3mf.AddVertex Should AddVertex be used to save memory? - - # Create triangle point list + # Pre-allocate triangles array and process in bulk + c_uint3 = ctypes.c_uint * 3 triangles_3mf = [] - for vertex_indices in triangles: - mapped_indices = [ - vert_table[i] for i in [vertex_indices[i] for i in range(3)] - ] - # Remove degenerate triangles - if len(set(mapped_indices)) != 3: - continue - c_array = (ctypes.c_uint * 3)(*mapped_indices) - triangles_3mf.append(Lib3MF.Triangle(c_array)) + + # Process triangles in bulk + for tri in triangles: + # Map indices directly without list comprehension + a, b, c = tri[0], tri[1], tri[2] + 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)) + ) return (vertices_3mf, triangles_3mf) @@ -354,12 +374,12 @@ class Mesher: def add_shape( self, - shape: Union[Shape, Iterable[Shape]], + shape: Shape | Iterable[Shape], linear_deflection: float = 0.001, angular_deflection: float = 0.1, mesh_type: MeshType = MeshType.MODEL, - part_number: str = None, - uuid_value: uuid = None, + part_number: str | None = None, + uuid_value: UUID | None = None, ): """add_shape @@ -392,7 +412,7 @@ class Mesher: # Mesh the shape ocp_mesh_vertices, triangles = Mesher._mesh_shape( - copy.deepcopy(b3d_shape), + copy_module.deepcopy(b3d_shape), linear_deflection, angular_deflection, ) @@ -454,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() @@ -467,23 +489,31 @@ 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 - def read(self, file_name: Union[PathLike, str, bytes]) -> list[Shape]: + def read(self, file_name: PathLike | str | bytes) -> list[Shape]: """read Args: @@ -505,7 +535,6 @@ class Mesher: # Extract 3MF meshes and translate to OCP meshes mesh_iterator: Lib3MF.MeshObjectIterator = self.model.GetMeshObjects() - self.meshes: list[Lib3MF.MeshObject] for _i in range(mesh_iterator.Count()): mesh_iterator.MoveNext() self.meshes.append(mesh_iterator.GetCurrentMeshObject()) @@ -527,7 +556,7 @@ class Mesher: return shapes - def write(self, file_name: Union[PathLike, str, bytes]): + def write(self, file_name: PathLike | str | bytes): """write Args: @@ -542,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 0247ba9..ad2a17a 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -28,26 +28,40 @@ license: from __future__ import annotations -import copy +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 Iterable, Union +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 = BuildLine._get_context(log=False) + context: BuildLine | None = BuildLine._get_context(log=False) if context is not None and isinstance(context, BuildLine): if isinstance(curve, Wire): @@ -60,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] @@ -69,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] @@ -88,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] @@ -106,30 +244,193 @@ class Bezier(BaseEdgeObject): def __init__( self, *cntl_pnts: VectorLike, - weights: list[float] = None, + weights: list[float] | None = None, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - cntl_pnts = flatten_sequence(*cntl_pnts) - polls = WorkplaneList.localize(*cntl_pnts) + cntl_pnt_list = flatten_sequence(*cntl_pnts) + polls = WorkplaneList.localize(*cntl_pnt_list) curve = Edge.make_bezier(*polls, weights=weights) 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] @@ -142,14 +443,16 @@ class CenterArc(BaseEdgeObject): arc_size: float, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) center_point = WorkplaneList.localize(center) if context is None: circle_workplane = Plane.XY else: - circle_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + circle_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) circle_workplane.origin = center_point arc_direction = ( AngularDirection.COUNTER_CLOCKWISE @@ -173,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 @@ -197,13 +500,16 @@ class DoubleTangentArc(BaseEdgeObject): self, pnt: VectorLike, tangent: VectorLike, - other: Union[Curve, Edge, Wire], + other: Curve | Edge | Wire, keep: Keep = Keep.TOP, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + 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: @@ -266,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] @@ -302,11 +610,11 @@ class EllipticalStartArc(BaseEdgeObject): sweep_flag: bool = True, plane: Plane = Plane.XY, mode: Mode = Mode.ADD, - ) -> Edge: + ): # Debugging incomplete raise RuntimeError("Implementation incomplete") - # context: BuildLine = BuildLine._get_context(self) + # context: BuildLine | None = BuildLine._get_context(self) # context.validate_inputs(self) # # Calculate the ellipse parameters based on the SVG implementation here: @@ -372,25 +680,27 @@ class EllipticalStartArc(BaseEdgeObject): # context._add_to_context(curve, mode=mode) # super().__init__(curve.wrapped) - # context: BuildLine = BuildLine._get_context(self) + # context: BuildLine | None = BuildLine._get_context(self) 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] @@ -406,14 +716,16 @@ class EllipticalCenterArc(BaseEdgeObject): angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) center_pnt = WorkplaneList.localize(center) if context is None: ellipse_workplane = Plane.XY else: - ellipse_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + ellipse_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) ellipse_workplane.origin = center_pnt curve = Edge.make_ellipse( x_radius=x_radius, @@ -432,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] @@ -458,7 +775,7 @@ class Helix(BaseEdgeObject): lefthand: bool = False, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) center_pnt = WorkplaneList.localize(center) @@ -469,55 +786,73 @@ 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] def __init__( self, - *pts: Union[VectorLike, Iterable[VectorLike]], - radius: float, + *pts: VectorLike | Iterable[VectorLike], + radius: float | Iterable[float], close: bool = False, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) + points = flatten_sequence(*pts) - pts = flatten_sequence(*pts) - - if len(pts) < 2: + if len(points) < 2: raise ValueError("FilletPolyline requires two or more pts") - if radius <= 0: - raise ValueError("radius must be positive") - lines_pts = WorkplaneList.localize(*pts) + 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() @@ -525,57 +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 = set( - 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 @@ -593,7 +979,7 @@ class JernArc(BaseEdgeObject): arc_size: float, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) start = WorkplaneList.localize(start) @@ -601,7 +987,9 @@ class JernArc(BaseEdgeObject): if context is None: jern_workplane = Plane.XY else: - jern_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + jern_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) jern_workplane.origin = start start_tangent = Vector(tangent).transform( jern_workplane.reverse_transform, is_direction=True @@ -615,7 +1003,7 @@ class JernArc(BaseEdgeObject): Axis(start, jern_workplane.z_dir), arc_size ) if abs(arc_size) >= 360: - circle_plane = copy.copy(jern_workplane) + circle_plane = copy_module.copy(jern_workplane) circle_plane.origin = self.center_point circle_plane.x_dir = self.start - circle_plane.origin arc = Edge.make_circle(radius, circle_plane) @@ -628,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 @@ -640,19 +1028,17 @@ class Line(BaseEdgeObject): _applies_to = [BuildLine._tag] - def __init__( - self, *pts: Union[VectorLike, Iterable[VectorLike]], mode: Mode = Mode.ADD - ): - pts = flatten_sequence(*pts) - if len(pts) != 2: + def __init__(self, *pts: VectorLike | Iterable[VectorLike], mode: Mode = Mode.ADD): + points = flatten_sequence(*pts) + if len(points) != 2: raise ValueError("Line requires two pts") - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - pts = WorkplaneList.localize(*pts) + points_localized = WorkplaneList.localize(*points) - lines_pts = [Vector(p) for p in pts] + lines_pts = [Vector(p) for p in points_localized] new_edge = Edge.make_line(lines_pts[0], lines_pts[1]) super().__init__(new_edge, mode=mode) @@ -661,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 """ @@ -677,10 +1063,10 @@ class IntersectingLine(BaseEdgeObject): self, start: VectorLike, direction: VectorLike, - other: Union[Curve, Edge, Wire], + other: Curve | Edge | Wire, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) start = WorkplaneList.localize(start) @@ -702,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 @@ -722,25 +1111,27 @@ class PolarLine(BaseEdgeObject): self, start: VectorLike, length: float, - angle: float = None, - direction: VectorLike = None, + angle: float | None = None, + direction: VectorLike | None = None, length_mode: LengthMode = LengthMode.DIAGONAL, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) start = WorkplaneList.localize(start) if context is None: polar_workplane = Plane.XY else: - polar_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + polar_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) - if direction: - direction = WorkplaneList.localize(direction) - angle = Vector(1, 0, 0).get_angle(direction) + if direction is not None: + direction_localized = WorkplaneList.localize(direction).normalized() + angle = Vector(1, 0, 0).get_angle(direction_localized) elif angle is not None: - direction = polar_workplane.x_dir.rotate( + direction_localized = polar_workplane.x_dir.rotate( Axis((0, 0, 0), polar_workplane.z_dir), angle, ) @@ -748,11 +1139,11 @@ class PolarLine(BaseEdgeObject): raise ValueError("Either angle or direction must be provided") if length_mode == LengthMode.DIAGONAL: - length_vector = direction * length + length_vector = direction_localized * length elif length_mode == LengthMode.HORIZONTAL: - length_vector = direction * (length / cos(radians(angle))) + length_vector = direction_localized * abs(length / cos(radians(angle))) elif length_mode == LengthMode.VERTICAL: - length_vector = direction * (length / sin(radians(angle))) + length_vector = direction_localized * abs(length / sin(radians(angle))) new_edge = Edge.make_line(start, start + length_vector) @@ -762,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 @@ -777,18 +1168,18 @@ class Polyline(BaseLineObject): def __init__( self, - *pts: Union[VectorLike, Iterable[VectorLike]], + *pts: VectorLike | Iterable[VectorLike], close: bool = False, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - pts = flatten_sequence(*pts) - if len(pts) < 2: + points = flatten_sequence(*pts) + if len(points) < 2: raise ValueError("Polyline requires two or more pts") - lines_pts = WorkplaneList.localize(*pts) + lines_pts = WorkplaneList.localize(*points) new_edges = [ Edge.make_line(lines_pts[i], lines_pts[i + 1]) @@ -803,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 @@ -827,7 +1218,7 @@ class RadiusArc(BaseEdgeObject): short_sagitta: bool = True, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) start, end = WorkplaneList.localize(start_point, end_point) @@ -849,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] @@ -873,7 +1266,7 @@ class SagittaArc(BaseEdgeObject): sagitta: float, mode: Mode = Mode.ADD, ): - context: BuildLine = BuildLine._get_context(self) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) start, end = WorkplaneList.localize(start_point, end_point) @@ -881,7 +1274,9 @@ class SagittaArc(BaseEdgeObject): if context is None: sagitta_workplane = Plane.XY else: - sagitta_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + sagitta_workplane = copy_module.copy( + WorkplaneList._get_context().workplanes[0] + ) sagitta_vector: Vector = (end - start).normalized() * abs(sagitta) sagitta_vector = sagitta_vector.rotate( Axis(sagitta_workplane.origin, sagitta_workplane.z_dir), @@ -891,38 +1286,41 @@ 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] def __init__( self, - *pts: Union[VectorLike, Iterable[VectorLike]], - tangents: Iterable[VectorLike] = None, - tangent_scalars: Iterable[float] = None, + *pts: VectorLike | Iterable[VectorLike], + tangents: Iterable[VectorLike] | None = None, + tangent_scalars: Iterable[float] | None = None, periodic: bool = False, mode: Mode = Mode.ADD, ): - pts = flatten_sequence(*pts) - context: BuildLine = BuildLine._get_context(self) + points = flatten_sequence(*pts) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - spline_pts = WorkplaneList.localize(*pts) + spline_pts = WorkplaneList.localize(*points) if tangents: spline_tangents = [ @@ -931,10 +1329,10 @@ class Spline(BaseEdgeObject): else: spline_tangents = None - if tangents and not tangent_scalars: - scalars = [1.0] * len(tangents) + if tangents is not None and tangent_scalars is None: + scalars = [1.0] * len(list(tangents)) else: - scalars = tangent_scalars + scalars = list(tangent_scalars) if tangent_scalars is not None else [] spline = Edge.make_spline( [p if isinstance(p, Vector) else Vector(*p) for p in spline_pts], @@ -955,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 @@ -972,18 +1370,18 @@ class TangentArc(BaseEdgeObject): def __init__( self, - *pts: Union[VectorLike, Iterable[VectorLike]], + *pts: VectorLike | Iterable[VectorLike], tangent: VectorLike, tangent_from_first: bool = True, mode: Mode = Mode.ADD, ): - pts = flatten_sequence(*pts) - context: BuildLine = BuildLine._get_context(self) + points = flatten_sequence(*pts) + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - if len(pts) != 2: + if len(points) != 2: raise ValueError("tangent_arc requires two points") - arc_pts = WorkplaneList.localize(*pts) + arc_pts = WorkplaneList.localize(*points) arc_tangent = WorkplaneList.localize(tangent).normalized() point_indices = (0, -1) if tangent_from_first else (-1, 0) @@ -997,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 @@ -1009,16 +1407,580 @@ class ThreePointArc(BaseEdgeObject): _applies_to = [BuildLine._tag] - def __init__( - self, *pts: Union[VectorLike, Iterable[VectorLike]], mode: Mode = Mode.ADD - ): - context: BuildLine = BuildLine._get_context(self) + def __init__(self, *pts: VectorLike | Iterable[VectorLike], mode: Mode = Mode.ADD): + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - pts = flatten_sequence(*pts) - if len(pts) != 3: + points = flatten_sequence(*pts) + if len(points) != 3: raise ValueError("ThreePointArc requires three points") - points = WorkplaneList.localize(*pts) - arc = Edge.make_three_point_arc(*points) + points_localized = WorkplaneList.localize(*points) + 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 365683a..7054d7a 100644 --- a/src/build123d/objects_part.py +++ b/src/build123d/objects_part.py @@ -30,12 +30,11 @@ from __future__ import annotations from math import radians, tan -from typing import Union from build123d.build_common import LocationList, validate_inputs from build123d.build_enums import Align, Mode from build123d.build_part import BuildPart -from build123d.geometry import Location, Plane, Rotation, RotationLike, Vector -from build123d.topology import Compound, Part, Solid, tuplify +from build123d.geometry import Location, Plane, Rotation, RotationLike +from build123d.topology import Compound, Part, ShapeList, Solid, tuplify class BasePartObject(Part): @@ -45,37 +44,28 @@ class BasePartObject(Part): Args: solid (Solid): object to create - rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], 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] def __init__( self, - part: Union[Part, Solid], + part: Part | Solid, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = None, + align: Align | tuple[Align, Align, Align] | None = None, mode: Mode = Mode.ADD, ): if align is not None: align = tuplify(align, 3) bbox = part.bounding_box() - align_offset = [] - for i in range(3): - if align[i] == Align.MIN: - align_offset.append(-bbox.min.to_tuple()[i]) - elif align[i] == Align.CENTER: - align_offset.append( - -(bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 - ) - elif align[i] == Align.MAX: - align_offset.append(-bbox.max.to_tuple()[i]) - part.move(Location(Vector(*align_offset))) + offset = bbox.to_align_offset(align) + part.move(Location(offset)) - context: BuildPart = BuildPart._get_context(self, log=False) + context: BuildPart | None = BuildPart._get_context(self, log=False) rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation self.rotation = rotate if context is None: @@ -113,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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -133,14 +123,14 @@ class Box(BasePartObject): width: float, height: float, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.length = length @@ -157,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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -179,14 +169,14 @@ class Cone(BasePartObject): height: float, arc_size: float = 360, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.bottom_radius = bottom_radius @@ -210,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] @@ -227,10 +217,10 @@ class CounterBoreHole(BasePartObject): radius: float, counter_bore_radius: float, counter_bore_depth: float, - depth: float = None, + depth: float | None = None, mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -244,7 +234,7 @@ class CounterBoreHole(BasePartObject): raise ValueError("No depth provided") self.mode = mode - solid = Solid.make_cylinder( + fused = Solid.make_cylinder( radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1)) ).fuse( Solid.make_cylinder( @@ -253,20 +243,25 @@ class CounterBoreHole(BasePartObject): Plane((0, 0, -counter_bore_depth)), ) ) + if isinstance(fused, ShapeList): + solid = Part(fused) + else: + solid = fused super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) 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] @@ -275,11 +270,11 @@ class CounterSinkHole(BasePartObject): self, radius: float, counter_sink_radius: float, - depth: float = None, + depth: float | None = None, counter_sink_angle: float = 82, # Common tip angle mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -294,7 +289,7 @@ class CounterSinkHole(BasePartObject): self.mode = mode cone_height = counter_sink_radius / tan(radians(counter_sink_angle / 2.0)) - solid = Solid.make_cylinder( + fused = Solid.make_cylinder( radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1)) ).fuse( Solid.make_cone( @@ -305,22 +300,27 @@ class CounterSinkHole(BasePartObject): ), Solid.make_cylinder(counter_sink_radius, self.hole_depth), ) + if isinstance(fused, ShapeList): + solid = Part(fused) + else: + solid = fused + super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) 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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -331,14 +331,14 @@ class Cylinder(BasePartObject): height: float, arc_size: float = 360, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -359,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] @@ -372,10 +372,10 @@ class Hole(BasePartObject): def __init__( self, radius: float, - depth: float = None, + depth: float | None = None, mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -406,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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -428,14 +428,14 @@ class Sphere(BasePartObject): arc_size2: float = 90, arc_size3: float = 360, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -458,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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -482,14 +482,14 @@ class Torus(BasePartObject): minor_end_angle: float = 360, major_angle: float = 360, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.major_radius = major_radius @@ -514,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 (Union[Align, tuple[Align, Align, Align]], 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] @@ -542,14 +543,14 @@ class Wedge(BasePartObject): xmax: float, zmax: float, rotation: RotationLike = (0, 0, 0), - align: Union[Align, tuple[Align, Align, Align]] = ( + align: Align | tuple[Align, Align, Align] = ( Align.CENTER, Align.CENTER, Align.CENTER, ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if any([value <= 0 for value in [xsize, ysize, zsize]]): diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py index 7958cec..f301c0e 100644 --- a/src/build123d/objects_sketch.py +++ b/src/build123d/objects_sketch.py @@ -31,21 +31,31 @@ from __future__ import annotations import trianglesolver from math import cos, degrees, pi, radians, sin, tan -from typing import Iterable, Union +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, Location, Rotation, Vector, VectorLike +from build123d.geometry import ( + Axis, + Location, + Rotation, + Vector, + VectorLike, + to_align_offset, + TOLERANCE, +) from build123d.topology import ( Compound, Edge, Face, ShapeList, Sketch, + Vertex, Wire, tuplify, - TOLERANCE, topo_explore_common_vertex, ) @@ -57,26 +67,26 @@ 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] def __init__( self, - obj: Union[Compound, Face], + obj: Compound | Face, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = None, + align: Align | tuple[Align, Align] | None = None, mode: Mode = Mode.ADD, ): if align is not None: align = tuplify(align, 2) - obj.move(Location(Vector(*obj.bounding_box().to_align_offset(align)))) + obj.move(Location(obj.bounding_box().to_align_offset(align))) - context: BuildSketch = BuildSketch._get_context(self, log=False) + context: BuildSketch | None = BuildSketch._get_context(self, log=False) if context is None: new_faces = obj.moved(Rotation(0, 0, rotation)).faces() @@ -86,11 +96,11 @@ class BaseSketchObject(Sketch): obj = obj.moved(Rotation(0, 0, rotation)) - new_faces = [ + new_faces = ShapeList( face.moved(location) for face in obj.faces() for location in LocationList._get_context().local_locations - ] + ) if isinstance(context, BuildSketch): context._add_to_context(*new_faces, mode=mode) @@ -100,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] @@ -114,10 +124,10 @@ class Circle(BaseSketchObject): def __init__( self, radius: float, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.radius = radius @@ -130,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] @@ -148,10 +158,10 @@ class Ellipse(BaseSketchObject): x_radius: float, y_radius: float, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.x_radius = x_radius @@ -165,39 +175,38 @@ 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] def __init__( self, - *pts: Union[VectorLike, Iterable[VectorLike]], + *pts: VectorLike | Iterable[VectorLike], rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) - pts = flatten_sequence(*pts) - self.pts = pts + flattened_pts = flatten_sequence(*pts) + 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) @@ -205,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] @@ -223,10 +232,10 @@ class Rectangle(BaseSketchObject): width: float, height: float, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.width = width @@ -238,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] @@ -260,10 +269,10 @@ class RectangleRounded(BaseSketchObject): height: float, radius: float, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if width <= 2 * radius or height <= 2 * radius: @@ -281,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] @@ -308,7 +316,7 @@ class RegularPolygon(BaseSketchObject): mode: Mode = Mode.ADD, ): # pylint: disable=too-many-locals - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if side_count < 3: @@ -344,69 +352,57 @@ class RegularPolygon(BaseSketchObject): mins = [pts_sorted[0][0].X, pts_sorted[1][0].Y] maxs = [pts_sorted[0][-1].X, pts_sorted[1][-1].Y] - if align is not None: - align = tuplify(align, 2) - align_offset = [] - for i in range(2): - if align[i] == Align.MIN: - align_offset.append(-mins[i]) - elif align[i] == Align.CENTER: - align_offset.append(0) - elif align[i] == Align.MAX: - align_offset.append(-maxs[i]) - else: - align_offset = [0, 0] - pts = [point + Vector(*align_offset) for point in pts] + align_offset = to_align_offset(mins, maxs, align, center=(0, 0)) + pts_ao = [point + align_offset for point in pts] - face = Face(Wire.make_polygon(pts)) + face = Face(Wire.make_polygon(pts_ao)) super().__init__(face, rotation=0, align=None, mode=mode) 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] def __init__( self, - arc: Union[Edge, Wire], + arc: Edge | Wire, height: float, rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.arc = arc 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] @@ -419,7 +415,7 @@ class SlotCenterPoint(BaseSketchObject): rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) center_v = Vector(center) @@ -429,6 +425,13 @@ class SlotCenterPoint(BaseSketchObject): self.slot_height = height half_line = point_v - center_v + + if half_line.length <= 0: + raise ValueError( + "Distance between center and point must be greater than 0 " + f"Got: distance = {half_line.length} (computed)" + ) + face = Face( Wire.combine( [ @@ -441,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] @@ -462,35 +464,44 @@ class SlotCenterToCenter(BaseSketchObject): rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + if center_separation < 0: + raise ValueError( + f"Requires center_separation > 0. Got: {center_separation=}" + ) + + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) 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] @@ -500,16 +511,21 @@ class SlotOverall(BaseSketchObject): width: float, height: float, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + if width < height: + raise ValueError( + f"Slot requires that width > height. Got: {width=}, {height=}" + ) + + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.width = width self.slot_height = height - if width != height: + if width > height: face = Face( Wire( [ @@ -519,28 +535,50 @@ class SlotOverall(BaseSketchObject): ).offset_2d(height / 2) ) else: - face = Circle(width / 2, mode=mode).face() + 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 @@ -551,15 +589,16 @@ class Text(BaseSketchObject): txt: str, font_size: float, font: str = "Arial", - font_path: str = None, + font_path: str | None = None, font_style: FontStyle = FontStyle.REGULAR, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), - path: Union[Edge, Wire] = None, + 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, + rotation: float = 0.0, mode: Mode = Mode.ADD, - ) -> Compound: - context = BuildSketch._get_context(self) + ): + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.txt = txt @@ -567,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 @@ -579,7 +619,8 @@ class Text(BaseSketchObject): font=font, font_path=font_path, font_style=font_style, - align=tuplify(align, 2), + text_align=text_align, + align=align, position_on_path=position_on_path, text_path=path, ) @@ -589,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 @@ -613,12 +654,12 @@ class Trapezoid(BaseSketchObject): width: float, height: float, left_side_angle: float, - right_side_angle: float = None, + right_side_angle: float | None = None, rotation: float = 0, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) right_side_angle = left_side_angle if not right_side_angle else right_side_angle @@ -670,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 @@ -695,17 +737,17 @@ class Triangle(BaseSketchObject): def __init__( self, *, - a: float = None, - b: float = None, - c: float = None, - A: float = None, - B: float = None, - C: float = None, - align: Union[None, Align, tuple[Align, Align]] = None, + a: float | None = None, + b: float | None = None, + c: float | None = None, + A: float | None = None, + B: float | None = None, + C: float | None = None, + align: Align | tuple[Align, Align] | None = None, rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if [v is None for v in [a, b, c]].count(True) == 3 or [ @@ -714,27 +756,29 @@ class Triangle(BaseSketchObject): raise ValueError("One length and two other values must be provided") A, B, C = (radians(angle) if angle is not None else None for angle in [A, B, C]) - a, b, c, A, B, C = trianglesolver.solve(a, b, c, A, B, C) - self.a = a #: length of side 'a' - self.b = b #: length of side 'b' - self.c = c #: length of side 'c' - self.A = degrees(A) #: interior angle 'A' in degrees - self.B = degrees(B) #: interior angle 'B' in degrees - self.C = degrees(C) #: interior angle 'C' in degrees + ar, br, cr, Ar, Br, Cr = trianglesolver.solve(a, b, c, A, B, C) + self.a = ar #: length of side 'a' + self.b = br #: length of side 'b' + self.c = cr #: length of side 'c' + self.A = degrees(Ar) #: interior angle 'A' in degrees + self.B = degrees(Br) #: interior angle 'B' in degrees + self.C = degrees(Cr) #: interior angle 'C' in degrees triangle = Face( Wire.make_polygon( - [Vector(0, 0), Vector(a, 0), Vector(c, 0).rotate(Axis.Z, self.B)] + [Vector(0, 0), Vector(ar, 0), Vector(cr, 0).rotate(Axis.Z, self.B)] ) ) - center_of_geometry = sum(Vector(v) for v in triangle.vertices()) / 3 + center_of_geometry = ( + sum((Vector(v) for v in triangle.vertices()), Vector(0, 0, 0)) / 3 + ) triangle.move(Location(-center_of_geometry)) alignment = None if align is None else tuplify(align, 2) super().__init__(obj=triangle, rotation=rotation, align=alignment, mode=mode) - self.edge_a = self.edges().filter_by(lambda e: abs(e.length - a) < TOLERANCE)[ + self.edge_a = self.edges().filter_by(lambda e: abs(e.length - ar) < TOLERANCE)[ 0 ] #: edge 'a' self.edge_b = self.edges().filter_by( - lambda e: abs(e.length - b) < TOLERANCE and e not in [self.edge_a] + lambda e: abs(e.length - br) < TOLERANCE and e not in [self.edge_a] )[ 0 ] #: edge 'b' @@ -746,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 3b809cd..2a2f007 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -27,10 +27,12 @@ license: """ -import copy +import copy as copy_module import logging from math import radians, tan -from typing import Union, Iterable +from typing import cast, TypeAlias + +from collections.abc import Iterable from build123d.build_common import ( Builder, @@ -76,13 +78,13 @@ from build123d.topology import ( logging.getLogger("build123d").addHandler(logging.NullHandler()) logger = logging.getLogger("build123d") -#:TypeVar("AddType"): Type of objects which can be added to a builder -AddType = Union[Edge, Wire, Face, Solid, Compound, Builder] +AddType: TypeAlias = Edge | Wire | Face | Solid | Compound | Builder +"""Type of objects which can be added to a builder""" def add( - objects: Union[AddType, Iterable[AddType]], - rotation: Union[float, RotationLike] = None, + objects: AddType | Iterable[AddType], + rotation: float | RotationLike | None = None, clean: bool = True, mode: Mode = Mode.ADD, ) -> Compound: @@ -99,23 +101,29 @@ def add( Edges and Wires are added to line. Args: - objects (Union[Edge, Wire, Face, Solid, Compound] or Iterable of): objects to add - rotation (Union[float, RotationLike], optional): rotation angle for sketch, + objects (Edge | Wire | Face | Solid | Compound or Iterable of): objects to add + rotation (float | RotationLike, optional): rotation angle for sketch, rotation about each axis for part. Defaults to None. clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ - context: Builder = Builder._get_context(None) + context: Builder | None = Builder._get_context(None) if context is None: raise RuntimeError("Add must have an active builder context") - object_iter = objects if isinstance(objects, Iterable) else [objects] + if isinstance(objects, Iterable) and not isinstance(objects, Compound): + object_list = list(objects) + else: + object_list = [objects] object_iter = [ - obj.unwrap(fully=False) if isinstance(obj, Compound) else obj - for obj in object_iter + ( + obj.unwrap(fully=False) + if isinstance(obj, Compound) + 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) ] - object_iter = [obj._obj if isinstance(obj, Builder) else obj for obj in object_iter] - validate_inputs(context, "add", object_iter) if isinstance(context, BuildPart): @@ -199,9 +207,9 @@ def add( def bounding_box( - objects: Union[Shape, Iterable[Shape]] = None, + objects: Shape | Iterable[Shape] | None = None, mode: Mode = Mode.PRIVATE, -) -> Union[Sketch, Part]: +) -> Sketch | Part: """Generic Operation: Add Bounding Box Applies to: BuildSketch and BuildPart @@ -212,7 +220,7 @@ def bounding_box( objects (Shape or Iterable of): objects to create bbox for mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: Builder = Builder._get_context("bounding_box") + context: Builder | None = Builder._get_context("bounding_box") if objects is None: if context is None or context is not None and context._obj is None: @@ -259,17 +267,17 @@ def bounding_box( return Part(Compound(new_objects).wrapped) -#:TypeVar("ChamferFilletType"): Type of objects which can be chamfered or filleted -ChamferFilletType = Union[Edge, Vertex] +ChamferFilletType: TypeAlias = Edge | Vertex +"""Type of objects which can be chamfered or filleted""" def chamfer( - objects: Union[ChamferFilletType, Iterable[ChamferFilletType]], + objects: ChamferFilletType | Iterable[ChamferFilletType], length: float, - length2: float = None, - angle: float = None, - reference: Union[Edge, Face] = None, -) -> Union[Sketch, Part]: + length2: float | None = None, + angle: float | None = None, + reference: Edge | Face | None = None, +) -> Sketch | Part: """Generic Operation: chamfer Applies to 2 and 3 dimensional objects. @@ -277,11 +285,11 @@ def chamfer( Chamfer the given sequence of edges or vertices. Args: - objects (Union[Edge,Vertex] or Iterable of): edges or vertices to chamfer + objects (Edge | Vertex or Iterable of): edges or vertices to chamfer length (float): chamfer size length2 (float, optional): asymmetric chamfer size. Defaults to None. angle (float, optional): chamfer angle in degrees. Defaults to None. - reference (Union[Edge,Face]): identifies the side where length is measured. Edge(s) must + reference (Edge | Face): identifies the side where length is measured. Edge(s) must be part of the face. Vertex/Vertices must be part of edge Raises: @@ -291,7 +299,7 @@ def chamfer( ValueError: Only one of length2 or angle should be provided ValueError: reference can only be used in conjunction with length2 or angle """ - context: Builder = Builder._get_context("chamfer") + context: Builder | None = Builder._get_context("chamfer") if length2 and angle: raise ValueError("Only one of length2 or angle should be provided") @@ -356,38 +364,39 @@ 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 if not target.is_closed: - object_list = 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, - ) - ), - object_list, + object_list = ShapeList( + filter( + lambda v: not ( + 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, + ) ) new_wire = target.chamfer_2d(length, length2, object_list, reference) if context is not None: context._add_to_context(new_wire, mode=Mode.REPLACE) return new_wire + raise ValueError("Invalid object dimension") + def fillet( - objects: Union[ChamferFilletType, Iterable[ChamferFilletType]], + objects: ChamferFilletType | Iterable[ChamferFilletType], radius: float, -) -> Union[Sketch, Part, Curve]: +) -> Sketch | Part | Curve: """Generic Operation: fillet Applies to 2 and 3 dimensional objects. @@ -396,7 +405,7 @@ def fillet( either end of an open line will be automatically skipped. Args: - objects (Union[Edge,Vertex] or Iterable of): edges or vertices to fillet + objects (Edge | Vertex or Iterable of): edges or vertices to fillet radius (float): fillet size - must be less than 1/2 local width Raises: @@ -405,7 +414,7 @@ def fillet( ValueError: objects must be Vertices ValueError: nothing to fillet """ - context: Builder = Builder._get_context("fillet") + context: Builder | None = Builder._get_context("fillet") if (objects is None and context is None) or ( objects is None and context is not None and context._obj is None ): @@ -455,43 +464,44 @@ 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 if not target.is_closed: - object_list = 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, - ) - ), - object_list, + object_list = ShapeList( + filter( + lambda v: not ( + 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, + ) ) new_wire = target.fillet_2d(radius, object_list) if context is not None: context._add_to_context(new_wire, mode=Mode.REPLACE) return new_wire + raise ValueError("Invalid object dimension") -#:TypeVar("MirrorType"): Type of objects which can be mirrored -MirrorType = Union[Edge, Wire, Face, Compound, Curve, Sketch, Part] + +MirrorType: TypeAlias = Edge | Wire | Face | Compound | Curve | Sketch | Part +"""Type of objects which can be mirrored""" def mirror( - objects: Union[MirrorType, Iterable[MirrorType]] = None, + objects: MirrorType | Iterable[MirrorType] | None = None, about: Plane = Plane.XZ, mode: Mode = Mode.ADD, -) -> Union[Curve, Sketch, Part, Compound]: +) -> Curve | Sketch | Part | Compound: """Generic Operation: mirror Applies to 1, 2, and 3 dimensional objects. @@ -499,15 +509,18 @@ def mirror( Mirror a sequence of objects over the given plane. Args: - objects (Union[Edge, Face,Compound] or Iterable of): objects to mirror + objects (Edge | Face | Compound or Iterable of): objects to mirror about (Plane, optional): reference plane. Defaults to "XZ". mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("mirror") - object_list = objects if isinstance(objects, Iterable) else [objects] + context: Builder | None = Builder._get_context("mirror") + if isinstance(objects, Iterable) and not isinstance(objects, Compound): + object_list = list(objects) + else: + object_list = [objects] if objects is None: if context is None or context is not None and context._obj is None: @@ -518,7 +531,7 @@ def mirror( validate_inputs(context, "mirror", object_list) - mirrored = [copy.deepcopy(o).mirror(about) for o in object_list] + mirrored = [copy_module.deepcopy(o).mirror(about) for o in object_list] if context is not None: context._add_to_context(*mirrored, mode=mode) @@ -533,20 +546,20 @@ def mirror( return mirrored_compound -#:TypeVar("OffsetType"): Type of objects which can be offset -OffsetType = Union[Edge, Face, Solid, Compound] +OffsetType: TypeAlias = Edge | Face | Solid | Compound +"""Type of objects which can be offset""" def offset( - objects: Union[OffsetType, Iterable[OffsetType]] = None, + objects: OffsetType | Iterable[OffsetType] | None = None, amount: float = 0, - openings: Union[Face, list[Face]] = None, + openings: Face | list[Face] | None = None, kind: Kind = Kind.ARC, side: Side = Side.BOTH, closed: bool = True, - min_edge_length: float = None, + min_edge_length: float | None = None, mode: Mode = Mode.REPLACE, -) -> Union[Curve, Sketch, Part, Compound]: +) -> Curve | Sketch | Part | Compound: """Generic Operation: offset Applies to 1, 2, and 3 dimensional objects. @@ -557,7 +570,7 @@ def offset( a hollow box with no lid. Args: - objects (Union[Edge, Face, Solid, Compound] or Iterable of): objects to offset + objects (Edge | Face | Solid | Compound or Iterable of): objects to offset amount (float): positive values external, negative internal openings (list[Face], optional), sequence of faces to open in part. Defaults to None. @@ -573,7 +586,7 @@ def offset( ValueError: missing objects ValueError: Invalid object type """ - context: Builder = Builder._get_context("offset") + context: Builder | None = Builder._get_context("offset") if objects is None: if context is None or context is not None and context._obj is None: @@ -622,15 +635,19 @@ def offset( pass # inner wires may go beyond the outer wire so subtract faces new_face = Face(outer_wire) - if inner_wires: - inner_faces = [Face(w) for w in inner_wires] - new_face = new_face.cut(*inner_faces) - if isinstance(new_face, Compound): - new_face = new_face.unwrap(fully=True) - if (new_face.normal_at() - face.normal_at()).length > 0.001: new_face = -new_face - new_faces.append(new_face) + if inner_wires: + inner_faces = [Face(w) for w in inner_wires] + subtraction = new_face.cut(*inner_faces) + if isinstance(subtraction, Compound): + new_faces.append(new_face.unwrap(fully=True)) + elif isinstance(subtraction, ShapeList): + new_faces.extend(subtraction) + else: + new_faces.append(subtraction) + else: + new_faces.append(new_face) if edges: if len(edges) == 1 and edges[0].geom_type == GeomType.LINE: new_wires = [ @@ -677,16 +694,16 @@ def offset( return offset_compound -#:TypeVar("ProjectType"): Type of objects which can be projected -ProjectType = Union[Edge, Face, Wire, Vector, Vertex] +ProjectType: TypeAlias = Edge | Face | Wire | Vector | Vertex +"""Type of objects which can be projected""" def project( - objects: Union[ProjectType, Iterable[ProjectType]] = None, - workplane: Plane = None, - target: Union[Solid, Compound, Part] = None, + objects: ProjectType | Iterable[ProjectType] | None = None, + workplane: Plane | None = None, + target: Solid | Compound | Part | None = None, mode: Mode = Mode.ADD, -) -> Union[Curve, Sketch, Compound, ShapeList[Vector]]: +) -> Curve | Sketch | Compound | ShapeList[Vector]: """Generic Operation: project Applies to 0, 1, and 2 dimensional objects. @@ -702,7 +719,7 @@ def project( BuildSketch and Edge/Wires into BuildLine. Args: - objects (Union[Edge, Face, Wire, VectorLike, Vertex] or Iterable of): + objects (Edge | Face | Wire | VectorLike | Vertex or Iterable of): objects or points to project workplane (Plane, optional): screen workplane mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -714,7 +731,7 @@ def project( ValueError: Edges, wires and points can only be projected in PRIVATE mode RuntimeError: BuildPart doesn't have a project operation """ - context: Builder = Builder._get_context("project") + context: Builder | None = Builder._get_context("project") if isinstance(objects, GroupBy): raise ValueError("project doesn't accept group_by, did you miss [n]?") @@ -735,13 +752,11 @@ 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 - point_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] - point_list = [Vector(pnt) for pnt in point_list] + vct_vrt_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] + point_list = [Vector(pnt) for pnt in vct_vrt_list] face_list = [o for o in object_list if isinstance(o, Face)] line_list = [o for o in object_list if isinstance(o, (Edge, Wire))] @@ -762,6 +777,7 @@ def project( raise ValueError( "Edges, wires and points can only be projected in PRIVATE mode" ) + working_plane = cast(Plane, workplane) # BuildLine and BuildSketch are from target to workplane while BuildPart is # from workplane to target so the projection direction needs to be flipped @@ -770,10 +786,13 @@ def project( if mode != Mode.PRIVATE and point_list: raise ValueError("Points can only be projected in PRIVATE mode") if target is None: - target = context._obj + target = context.part projection_flip = -1 else: - target = Face.make_rect(3 * object_size, 3 * object_size, plane=workplane) + target = Face.make_rect(3 * object_size, 3 * object_size, plane=working_plane) + + if target is None: + raise ValueError("A target object could not be determined") validate_inputs(context, "project") @@ -781,37 +800,39 @@ def project( obj: Shape for obj in face_list + line_list: obj_to_screen = (target.center() - obj.center()).normalized() - if workplane.from_local_coords(obj_to_screen).Z < 0: - projection_direction = -workplane.z_dir * projection_flip + if working_plane.from_local_coords(obj_to_screen).Z < 0: + projection_direction = -working_plane.z_dir * projection_flip else: - projection_direction = workplane.z_dir * projection_flip + projection_direction = working_plane.z_dir * projection_flip projection = obj.project_to_shape(target, projection_direction) if projection: if isinstance(context, BuildSketch): projected_shapes.extend( - [workplane.to_local_coords(p) for p in projection] + [working_plane.to_local_coords(p) for p in projection] ) elif isinstance(context, BuildLine): projected_shapes.extend(projection) else: # BuildPart - projected_shapes.append(projection[0]) + projected_shapes.extend(projection.faces()) - projected_points = [] + projected_points: ShapeList[Vector] = ShapeList() for pnt in point_list: - pnt_to_target = (workplane.origin - pnt).normalized() - if workplane.from_local_coords(pnt_to_target).Z < 0: - projection_axis = -Axis(pnt, workplane.z_dir * projection_flip) + pnt_to_target = (working_plane.origin - pnt).normalized() + if working_plane.from_local_coords(pnt_to_target).Z < 0: + projection_axis = -Axis(pnt, working_plane.z_dir * projection_flip) else: - projection_axis = Axis(pnt, workplane.z_dir * projection_flip) - projection = workplane.to_local_coords(workplane.intersect(projection_axis)) - if projection is not None: - projected_points.append(projection) + projection_axis = Axis(pnt, working_plane.z_dir * projection_flip) + intersection = working_plane.intersect(projection_axis) + if isinstance(intersection, Axis): + raise RuntimeError("working_plane and projection_axis are parallel") + if intersection is not None: + projected_points.append(working_plane.to_local_coords(intersection)) if context is not None: context._add_to_context(*projected_shapes, mode=mode) if projected_points: - result = ShapeList(projected_points) + result = projected_points else: result = Compound(projected_shapes) if all([obj._dim == 2 for obj in object_list]): @@ -823,10 +844,10 @@ def project( def scale( - objects: Union[Shape, Iterable[Shape]] = None, - by: Union[float, tuple[float, float, float]] = 1, + objects: Shape | Iterable[Shape] | None = None, + by: float | tuple[float, float, float] = 1, mode: Mode = Mode.REPLACE, -) -> Union[Curve, Sketch, Part, Compound]: +) -> Curve | Sketch | Part | Compound: """Generic Operation: scale Applies to 1, 2, and 3 dimensional objects. @@ -836,14 +857,14 @@ def scale( line, circle, etc. Args: - objects (Union[Edge, Face, Compound, Solid] or Iterable of): objects to scale - by (Union[float, tuple[float, float, float]]): scale factor + objects (Edge | Face | Compound | Solid or Iterable of): objects to scale + by (float | tuple[float, float, float]): scale factor mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("scale") + context: Builder | None = Builder._get_context("scale") if objects is None: if context is None or context is not None and context._obj is None: @@ -861,12 +882,12 @@ def scale( and len(by) == 3 and all(isinstance(s, (int, float)) for s in by) ): - factor = Vector(by) + by_vector = Vector(by) scale_matrix = Matrix( [ - [factor.X, 0.0, 0.0, 0.0], - [0.0, factor.Y, 0.0, 0.0], - [0.0, 0.0, factor.Z, 0.0], + [by_vector.X, 0.0, 0.0, 0.0], + [0.0, by_vector.Y, 0.0, 0.0], + [0.0, 0.0, by_vector.Z, 0.0], [0.0, 0.0, 0.0, 1.0], ] ) @@ -875,9 +896,12 @@ def scale( new_objects = [] for obj in object_list: + if obj is None: + continue current_location = obj.location + assert current_location is not None obj_at_origin = obj.located(Location(Vector())) - if isinstance(factor, float): + if isinstance(by, (int, float)): new_object = obj_at_origin.scale(factor).locate(current_location) else: new_object = obj_at_origin.transform_geometry(scale_matrix).locate( @@ -898,13 +922,13 @@ def scale( return scale_compound.unwrap(fully=False) -#:TypeVar("SplitType"): Type of objects which can be offset -SplitType = Union[Edge, Wire, Face, Solid] +SplitType: TypeAlias = Edge | Wire | Face | Solid +"""Type of objects which can be split""" def split( - objects: Union[SplitType, Iterable[SplitType]] = None, - bisect_by: Union[Plane, Face] = Plane.XZ, + objects: SplitType | Iterable[SplitType] | None = None, + bisect_by: Plane | Face | Shell = Plane.XZ, keep: Keep = Keep.TOP, mode: Mode = Mode.REPLACE, ): @@ -915,8 +939,8 @@ def split( Bisect object with plane and keep either top, bottom or both. Args: - objects (Union[Edge, Wire, Face, Solid] or Iterable of), objects to split - bisect_by (Union[Plane, Face], optional): plane to segment part. + objects (Edge | Wire | Face | Solid or Iterable of), objects to split + bisect_by (Plane | Face, optional): plane to segment part. Defaults to Plane.XZ. keep (Keep, optional): selector for which segment to keep. Defaults to Keep.TOP. mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. @@ -924,7 +948,7 @@ def split( Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("split") + context: Builder | None = Builder._get_context("split") if objects is None: if context is None or context is not None and context._obj is None: @@ -935,9 +959,18 @@ def split( validate_inputs(context, "split", object_list) - new_objects = [] + new_objects: list[SplitType] = [] for obj in object_list: - new_objects.append(obj.split(bisect_by, keep)) + bottom = None + if keep == Keep.BOTH: + top, bottom = obj.split(bisect_by, keep) + else: + top = obj.split(bisect_by, keep) + for subpart in [top, bottom]: + if isinstance(subpart, Iterable): + new_objects.extend(subpart) + elif subpart is not None: + new_objects.append(subpart) if context is not None: context._add_to_context(*new_objects, mode=mode) @@ -952,39 +985,39 @@ def split( return split_compound -#:TypeVar("SweepType"): Type of objects which can be swept -SweepType = Union[Compound, Edge, Wire, Face, Solid] +SweepType: TypeAlias = Compound | Edge | Wire | Face | Solid +"""Type of objects which can be swept""" def sweep( - sections: Union[SweepType, Iterable[SweepType]] = None, - path: Union[Curve, Edge, Wire, Iterable[Edge]] = None, + sections: SweepType | Iterable[SweepType] | None = None, + path: Curve | Edge | Wire | Iterable[Edge] | None = None, multisection: bool = False, is_frenet: bool = False, transition: Transition = Transition.TRANSFORMED, - normal: VectorLike = None, - binormal: Union[Edge, Wire] = None, + normal: VectorLike | None = None, + binormal: Edge | Wire | None = None, clean: bool = True, mode: Mode = Mode.ADD, -) -> Union[Part, Sketch]: +) -> Part | Sketch: """Generic Operation: sweep Sweep pending 1D or 2D objects along path. Args: - sections (Union[Compound, Edge, Wire, Face, Solid]): cross sections to sweep into object - path (Union[Curve, Edge, Wire], optional): path to follow. + sections (Compound | Edge | Wire | Face | Solid): cross sections to sweep into object + path (Curve | Edge | Wire, optional): path to follow. Defaults to context pending_edges. multisection (bool, optional): sweep multiple on path. Defaults to False. is_frenet (bool, optional): use frenet algorithm. Defaults to False. transition (Transition, optional): discontinuity handling option. Defaults to Transition.TRANSFORMED. normal (VectorLike, optional): fixed normal. Defaults to None. - binormal (Union[Edge, Wire], optional): guide rotation along path. Defaults to None. + binormal (Edge | Wire, optional): guide rotation along path. Defaults to None. clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combination. Defaults to Mode.ADD. """ - context: Builder = Builder._get_context("sweep") + context: Builder | None = Builder._get_context("sweep") section_list = ( [*sections] if isinstance(sections, (list, tuple, filter)) else [sections] @@ -994,7 +1027,11 @@ def sweep( validate_inputs(context, "sweep", section_list) if path is None: - if context is None or context is not None and not context.pending_edges: + if ( + context is None + or not isinstance(context, (BuildPart, BuildSketch)) + or not context.pending_edges + ): raise ValueError("path must be provided") path_wire = Wire(context.pending_edges) context.pending_edges = [] @@ -1019,8 +1056,8 @@ def sweep( else: raise ValueError("No sections provided") - edge_list = [] - face_list = [] + edge_list: list[Edge] = [] + face_list: list[Face] = [] for sec in section_list: if isinstance(sec, (Curve, Wire, Edge)): edge_list.extend(sec.edges()) @@ -1029,6 +1066,7 @@ def sweep( # sweep to create solids new_solids = [] + binormal_mode: Wire | Vector | None if face_list: if binormal is None and normal is not None: binormal_mode = Vector(normal) @@ -1055,7 +1093,7 @@ def sweep( ] # sweep to create faces - new_faces = [] + new_faces: list[Face] = [] if edge_list: for sec in section_list: swept = Shell.sweep(sec, path_wire, transition) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 09e6fc3..e3fe8dd 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -27,13 +27,16 @@ license: """ from __future__ import annotations -from typing import Union, Iterable -from build123d.build_enums import Mode, Until, Kind, Side +from typing import cast + +from collections.abc import Iterable +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, @@ -53,12 +56,65 @@ 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: Union[Face, Sketch] = None, - amount: float = None, - dir: VectorLike = None, # pylint: disable=redefined-builtin - until: Until = None, - target: Union[Compound, Solid] = None, + to_extrude: Face | Sketch | None = None, + amount: float | None = None, + dir: VectorLike | None = None, # pylint: disable=redefined-builtin + until: Until | None = None, + target: Compound | Solid | None = None, both: bool = False, taper: float = 0.0, clean: bool = True, @@ -87,7 +143,7 @@ def extrude( Part: extruded object """ # pylint: disable=too-many-locals, too-many-branches - context: BuildPart = BuildPart._get_context("extrude") + context: BuildPart | None = BuildPart._get_context("extrude") validate_inputs(context, "extrude", to_extrude) to_extrude_faces: list[Face] @@ -128,12 +184,6 @@ def extrude( if len(face_planes) != len(to_extrude_faces): raise ValueError("dir must be provided when extruding non-planar faces") - if until is not None: - if target is None and context is None: - raise ValueError("A target object must be provided") - if target is None: - target = context.part - logger.info( "%d face(s) to extrude on %d face plane(s)", len(to_extrude_faces), @@ -142,7 +192,7 @@ def extrude( for face, plane in zip(to_extrude_faces, face_planes): for direction in [1, -1] if both else [1]: - if amount: + if amount is not None: if taper == 0: new_solids.append( Solid.extrude( @@ -160,10 +210,21 @@ def extrude( ) else: + if until is None: + raise ValueError("Either amount or until must be provided") + if target is None: + if context is None: + raise ValueError("A target object must be provided") + target_object = context.part + else: + target_object = target + if target_object is None: + raise ValueError("No target object provided") + new_solids.append( Solid.extrude_until( - section=face, - target_object=target, + face, + target=target_object, direction=plane.z_dir * direction, until=until, ) @@ -173,7 +234,10 @@ def extrude( context._add_to_context(*new_solids, clean=clean, mode=mode) else: if len(new_solids) > 1: - new_solids = [new_solids.pop().fuse(*new_solids)] + fused_solids = new_solids.pop().fuse(*new_solids) + new_solids = ( + fused_solids if isinstance(fused_solids, list) else [fused_solids] + ) if clean: new_solids = [solid.clean() for solid in new_solids] @@ -181,7 +245,7 @@ def extrude( def loft( - sections: Union[Face, Sketch, Iterable[Union[Vertex, Face, Sketch]]] = None, + sections: Face | Sketch | Iterable[Vertex | Face | Sketch] | None = None, ruled: bool = False, clean: bool = True, mode: Mode = Mode.ADD, @@ -198,53 +262,82 @@ def loft( clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: BuildPart = BuildPart._get_context("loft") + + 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.append(s.face().outer_wire()) - 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) @@ -256,8 +349,8 @@ def loft( def make_brake_formed( thickness: float, - station_widths: Union[float, Iterable[float]], - line: Union[Edge, Wire, Curve] = None, + station_widths: float | Iterable[float], + line: Edge | Wire | Curve | None = None, side: Side = Side.LEFT, kind: Kind = Kind.ARC, clean: bool = True, @@ -293,7 +386,7 @@ def make_brake_formed( Part: sheet metal part """ # pylint: disable=too-many-locals, too-many-branches - context: BuildPart = BuildPart._get_context("make_brake_formed") + context: BuildPart | None = BuildPart._get_context("make_brake_formed") validate_inputs(context, "make_brake_formed") if line is not None: @@ -316,31 +409,35 @@ def make_brake_formed( raise ValueError("line not suitable - probably straight") from exc # Make edge pairs - station_edges = ShapeList() + station_edges: ShapeList[Edge] = ShapeList() line_vertices = line.vertices() + + if isinstance(station_widths, (float, int)): + station_widths_list = [station_widths] * len(line_vertices) + elif isinstance(station_widths, Iterable): + station_widths_list = list(station_widths) + else: + 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) - if isinstance(station_widths, (float, int)): - station_widths = [station_widths] * len(line_vertices) - if len(station_widths) != len(line_vertices): + if len(station_widths_list) != len(line_vertices): raise ValueError( f"widths must either be a single number or an iterable with " f"a length of the # vertices in line ({len(line_vertices)})" ) station_faces = [ Face.extrude(obj=e, direction=plane.z_dir * w) - for e, w in zip(station_edges, station_widths) + for e, w in zip(station_edges, station_widths_list) ] sweep_paths = line.edges().sort_by(line) - sections = [] + sections: list[Solid] = [] for i in range(len(station_faces) - 1): sections.append( Solid.sweep_multi( @@ -348,12 +445,13 @@ def make_brake_formed( ) ) if len(sections) > 1: - new_solid = sections.pop().fuse(*sections) + new_solid = cast(Part, Part.fuse(*sections)) else: new_solid = sections[0] 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() @@ -361,8 +459,8 @@ def make_brake_formed( def project_workplane( - origin: Union[VectorLike, Vertex], - x_dir: Union[VectorLike, Vertex], + origin: VectorLike | Vertex, + x_dir: VectorLike | Vertex, projection_dir: VectorLike, distance: float, ) -> Plane: @@ -386,7 +484,7 @@ def project_workplane( Returns: Plane: workplane aligned for projection """ - context: BuildPart = BuildPart._get_context("project_workplane") + context: BuildPart | None = BuildPart._get_context("project_workplane") if context is not None and not isinstance(context, BuildPart): raise RuntimeError( @@ -417,7 +515,7 @@ def project_workplane( def revolve( - profiles: Union[Face, Iterable[Face]] = None, + profiles: Face | Iterable[Face] | None = None, axis: Axis = Axis.Z, revolution_arc: float = 360.0, clean: bool = True, @@ -439,7 +537,7 @@ def revolve( Raises: ValueError: Invalid axis of revolution """ - context: BuildPart = BuildPart._get_context("revolve") + context: BuildPart | None = BuildPart._get_context("revolve") profile_list = flatten_sequence(profiles) @@ -447,22 +545,20 @@ 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): raise ValueError("No profiles provided") - profile_list = context.pending_faces + profile_faces = context.pending_faces context.pending_faces = [] context.pending_face_planes = [] else: - p_list = [] - for profile in profile_list: - p_list.extend(profile.faces()) - profile_list = p_list + profile_faces = profile_list.faces() - new_solids = [Solid.revolve(profile, angle, axis) for profile in profile_list] + new_solids = [Solid.revolve(profile, angle, axis) for profile in profile_faces] new_solid = Compound(new_solids) if context is not None: @@ -474,8 +570,8 @@ def revolve( def section( - obj: Part = None, - section_by: Union[Plane, Iterable[Plane]] = Plane.XZ, + obj: Part | None = None, + section_by: Plane | Iterable[Plane] = Plane.XZ, height: float = 0.0, clean: bool = True, mode: Mode = Mode.PRIVATE, @@ -492,13 +588,18 @@ def section( clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combination mode. Defaults to Mode.INTERSECT. """ - context: BuildPart = BuildPart._get_context("section") + context: BuildPart | None = BuildPart._get_context("section") validate_inputs(context, "section", None) - if context is not None and obj is None: - max_size = context.part.bounding_box(optimal=False).diagonal + if obj is not None: + to_section = obj + elif context is not None and context.part is not None: + to_section = context.part else: - max_size = obj.bounding_box(optimal=False).diagonal + raise ValueError("No object to section") + + 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 = ( @@ -523,7 +624,13 @@ def section( else: raise ValueError("obj must be provided") - new_objects = [obj.intersect(plane) for plane in planes] + new_objects: list[Face | Shell] = [] + for plane in planes: + intersection = to_section.intersect(plane) + if isinstance(intersection, ShapeList): + new_objects.extend(intersection) + elif intersection is not None: + new_objects.append(intersection) if context is not None: context._add_to_context( @@ -537,9 +644,9 @@ def section( def thicken( - to_thicken: Union[Face, Sketch] = None, - amount: float = None, - normal_override: VectorLike = None, + to_thicken: Face | Sketch | None = None, + amount: float | None = None, + normal_override: VectorLike | None = None, both: bool = False, clean: bool = True, mode: Mode = Mode.ADD, @@ -550,7 +657,7 @@ def thicken( Args: to_thicken (Union[Face, Sketch], optional): object to thicken. Defaults to None. - amount (float, optional): distance to extrude, sign controls direction. Defaults to None. + amount (float): distance to extrude, sign controls direction. normal_override (Vector, optional): The normal_override vector can be used to indicate which way is 'up', potentially flipping the face normal direction such that many faces with different normals all go in the same direction @@ -566,11 +673,14 @@ def thicken( Returns: Part: extruded object """ - context: BuildPart = BuildPart._get_context("thicken") + context: BuildPart | None = BuildPart._get_context("thicken") validate_inputs(context, "thicken", to_thicken) to_thicken_faces: list[Face] + if amount is None: + raise ValueError("An amount must be provided") + if to_thicken is None: if context is not None and context.pending_faces: # Get pending faces and face planes @@ -597,14 +707,16 @@ def thicken( ) for direction in [1, -1] if both else [1]: new_solids.append( - face.thicken(depth=amount, normal_override=face_normal * direction) + Solid.thicken( + face, depth=amount, normal_override=Vector(face_normal) * direction + ) ) if context is not None: context._add_to_context(*new_solids, clean=clean, mode=mode) else: if len(new_solids) > 1: - new_solids = [new_solids.pop().fuse(*new_solids)] + new_solids = [cast(Part, Part.fuse(*new_solids))] if clean: new_solids = [solid.clean() for solid in new_solids] diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index e5b2d6b..be5380c 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -28,24 +28,25 @@ license: """ from __future__ import annotations -from typing import Iterable, Union -from build123d.build_enums import Mode, SortBy + +from collections.abc import Iterable +from scipy.spatial import Voronoi +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, - TOLERANCE, ) -from build123d.geometry import Vector +from build123d.geometry import Plane, Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs from build123d.build_sketch import BuildSketch -from scipy.spatial import Voronoi def full_round( @@ -72,16 +73,18 @@ 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 = BuildSketch._get_context("full_round") + context: BuildSketch | None = BuildSketch._get_context("full_round") if not isinstance(edge, Edge): 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 @@ -107,94 +110,92 @@ def full_round( # Refine the largest empty circle center estimate by averaging the best # three candidates. The minimum distance between the edges and this # center is the circle radius. - best_three = [(float("inf"), None), (float("inf"), None), (float("inf"), None)] - + best_three: list[tuple[float, int]] = [ + (float("inf"), int()), + (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 = sum(voronoi_vertices[i] for i in best_indices) / 3 + # 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 connected_edges_end_points = [ 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 - other_edges = edge.topo_parent.edges() - topo_explore_connected_edges(edge) - [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( - edges: Union[Edge, Iterable[Edge]] = None, mode: Mode = Mode.ADD + edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD ) -> Sketch: """Sketch Operation: make_face @@ -205,7 +206,7 @@ def make_face( sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: BuildSketch = BuildSketch._get_context("make_face") + context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: outer_edges = flatten_sequence(edges) @@ -229,7 +230,7 @@ def make_face( def make_hull( - edges: Union[Edge, Iterable[Edge]] = None, mode: Mode = Mode.ADD + edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD ) -> Sketch: """Sketch Operation: make_hull @@ -240,7 +241,7 @@ def make_hull( sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: BuildSketch = BuildSketch._get_context("make_hull") + context: BuildSketch | None = BuildSketch._get_context("make_hull") if edges is not None: hull_edges = flatten_sequence(edges) @@ -267,7 +268,7 @@ def make_hull( def trace( - lines: Union[Curve, Edge, Wire, Iterable[Union[Curve, Edge, Wire]]] = None, + lines: Curve | Edge | Wire | Iterable[Curve | Edge | Wire] | None = None, line_width: float = 1, mode: Mode = Mode.ADD, ) -> Sketch: @@ -276,7 +277,7 @@ def trace( Convert edges, wires or pending edges into faces by sweeping a perpendicular line along them. Args: - lines (Union[Curve, Edge, Wire, Iterable[Union[Curve, Edge, Wire]]], optional): lines to + lines (Curve | Edge | Wire | Iterable[Curve | Edge | Wire]], optional): lines to trace. Defaults to sketch pending edges. line_width (float, optional): Defaults to 1. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -287,7 +288,7 @@ def trace( Returns: Sketch: Traced lines """ - context: BuildSketch = BuildSketch._get_context("trace") + context: BuildSketch | None = BuildSketch._get_context("trace") if lines is not None: trace_lines = flatten_sequence(lines) @@ -297,13 +298,24 @@ def trace( else: raise ValueError("No objects to trace") - new_faces = [] - for edge in trace_edges: - trace_pen = edge.perpendicular_line(line_width, 0) - new_faces.extend(Face.sweep(trace_pen, edge).faces()) + # Group the edges into wires to allow for nice transitions + trace_wires = Wire.combine(trace_edges) + + new_faces: list[Face] = [] + 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() + # pylint: disable=no-value-for-parameter combined_faces = Face.fuse(*new_faces) if len(new_faces) > 1 else new_faces[0] - return Sketch(combined_faces.wrapped) + result = ( + Sketch(combined_faces) + if isinstance(combined_faces, list) + else Sketch(combined_faces.wrapped) + ) + return result diff --git a/src/build123d/pack.py b/src/build123d/pack.py index 88ca580..28d5491 100644 --- a/src/build123d/pack.py +++ b/src/build123d/pack.py @@ -12,7 +12,9 @@ desc: from __future__ import annotations from dataclasses import dataclass -from typing import Callable, Collection, Optional, cast +from typing import Optional, cast + +from collections.abc import Callable, Collection from build123d import Location, Shape, Pos @@ -37,8 +39,8 @@ def _pack2d( y: float = 0 w: float = 0 h: float = 0 - down: Optional["_Node"] = None - right: Optional["_Node"] = None + down: _Node | None = None + right: _Node | None = None def find_node(start, w, h): if start.used: diff --git a/src/build123d/persistence.py b/src/build123d/persistence.py index 3876289..a60bdb3 100644 --- a/src/build123d/persistence.py +++ b/src/build123d/persistence.py @@ -51,7 +51,7 @@ from OCP.TopoDS import ( from build123d.topology import downcast -def serialize_shape(shape: TopoDS_Shape) -> bytes: +def serialize_shape(shape: TopoDS_Shape) -> bytes | None: """ Serialize a OCP shape, this method can be used to provide a custom serialization algo for pickle """ @@ -77,7 +77,7 @@ def deserialize_shape(buffer: bytes) -> TopoDS_Shape: return downcast(shape) -def serialize_location(location: TopLoc_Location) -> bytes: +def serialize_location(location: TopLoc_Location) -> bytes | None: """ Serialize a OCP location, this method can be used to provide a custom serialization algo for pickle diff --git a/src/build123d/template_render.js b/src/build123d/template_render.js new file mode 100644 index 0000000..afc7bd3 --- /dev/null +++ b/src/build123d/template_render.js @@ -0,0 +1,130 @@ +function render(data, div_id, ratio){ + + // Initial setup + const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance(); + const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({ background: [1, 1, 1 ] }); + renderWindow.addRenderer(renderer); + + // iterate over all children children + for (var el of data){ + var trans = el.position; + var rot = el.orientation; + var rgba = el.color; + var shape = el.shape; + + // load the inline data + var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance(); + const textEncoder = new TextEncoder(); + reader.parseAsArrayBuffer(textEncoder.encode(shape)); + + // setup actor,mapper and add + const mapper = vtk.Rendering.Core.vtkMapper.newInstance(); + mapper.setInputConnection(reader.getOutputPort()); + mapper.setResolveCoincidentTopologyToPolygonOffset(); + mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100); + + const actor = vtk.Rendering.Core.vtkActor.newInstance(); + actor.setMapper(mapper); + + // set color and position + actor.getProperty().setColor(rgba.slice(0,3)); + actor.getProperty().setOpacity(rgba[3]); + + actor.rotateZ(rot[2]*180/Math.PI); + actor.rotateY(rot[1]*180/Math.PI); + actor.rotateX(rot[0]*180/Math.PI); + + actor.setPosition(trans); + + renderer.addActor(actor); + + }; + + renderer.resetCamera(); + + const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance(); + renderWindow.addView(openglRenderWindow); + + // Get the div container + const container = document.getElementById(div_id); + const dims = container.parentElement.getBoundingClientRect(); + + openglRenderWindow.setContainer(container); + + // handle size + if (ratio){ + openglRenderWindow.setSize(dims.width, dims.width*ratio); + }else{ + openglRenderWindow.setSize(dims.width, dims.height); + }; + + // Interaction setup + const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance(); + + const manips = { + rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(), + pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(), + zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(), + zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(), + roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(), + }; + + manips.zoom1.setControl(true); + manips.zoom2.setScrollEnabled(true); + manips.roll.setShift(true); + manips.pan.setButton(2); + + for (var k in manips){ + interact_style.addMouseManipulator(manips[k]); + }; + + const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance(); + interactor.setView(openglRenderWindow); + interactor.initialize(); + interactor.bindEvents(container); + interactor.setInteractorStyle(interact_style); + + // Orientation marker + + const axes = vtk.Rendering.Core.vtkAnnotatedCubeActor.newInstance(); + axes.setXPlusFaceProperty({text: '+X'}); + axes.setXMinusFaceProperty({text: '-X'}); + axes.setYPlusFaceProperty({text: '+Y'}); + axes.setYMinusFaceProperty({text: '-Y'}); + axes.setZPlusFaceProperty({text: '+Z'}); + axes.setZMinusFaceProperty({text: '-Z'}); + + const orientationWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget.newInstance({ + actor: axes, + interactor: interactor }); + orientationWidget.setEnabled(true); + orientationWidget.setViewportCorner(vtk.Interaction.Widgets.vtkOrientationMarkerWidget.Corners.BOTTOM_LEFT); + orientationWidget.setViewportSize(0.2); + +}; + + +new Promise( + function(resolve, reject) + { + if (typeof(require) !== "undefined" ){ + require.config({ + "paths": {"vtk": "https://unpkg.com/vtk"}, + }); + require(["vtk"], resolve, reject); + } else if ( typeof(vtk) === "undefined" ){ + var script = document.createElement("script"); + script.onload = resolve; + script.onerror = reject; + script.src = "https://unpkg.com/vtk.js"; + document.head.appendChild(script); + } else { resolve() }; + } +).then(() => { + // data, div_id and ratio are templated by python + const div_id = "$div_id"; + const data = $data; + const ratio = $ratio; + + render(data, div_id, ratio); +}); diff --git a/src/build123d/topology.py b/src/build123d/topology.py deleted file mode 100644 index 955b2cd..0000000 --- a/src/build123d/topology.py +++ /dev/null @@ -1,9246 +0,0 @@ -""" -build123d topology - -name: topology.py -by: Gumyr -date: Oct 14, 2022 - -desc: - This python module is a CAD library based on OpenCascade containing - the base Shape class and all of its derived classes. - -license: - - Copyright 2022 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 - -# pylint has trouble with the OCP imports -# pylint: disable=no-name-in-module, import-error -# pylint: disable=too-many-lines -# 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 copy -import itertools -import os -import platform -import sys -import warnings -from abc import ABC, ABCMeta, abstractmethod -from io import BytesIO -from itertools import combinations -from math import radians, inf, pi, sin, cos, tan, copysign, ceil, floor, isclose -from typing import ( - Any, - Callable, - Dict, - Generic, - Iterable, - Iterator, - Optional, - Protocol, - Tuple, - Type, - TypeVar, - Union, - overload, -) -from typing import cast as tcast -from typing_extensions import Self, Literal, deprecated - -from anytree import NodeMixin, PreOrderIter, RenderTree -from IPython.lib.pretty import pretty -from numpy import ndarray -from scipy.optimize import minimize -from scipy.spatial import ConvexHull -from vtkmodules.vtkCommonDataModel import vtkPolyData -from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter - -import OCP.GeomAbs as ga # Geometry type enum -import OCP.TopAbs as ta # Topology type enum -from OCP.Aspect import Aspect_TOL_SOLID -from OCP.BOPAlgo import BOPAlgo_GlueEnum - -from OCP.BRep import BRep_Tool -from OCP.BRepAdaptor import ( - BRepAdaptor_CompCurve, - BRepAdaptor_Curve, - BRepAdaptor_Surface, -) -from OCP.BRepAlgo import BRepAlgo -from OCP.BRepAlgoAPI import ( - BRepAlgoAPI_BooleanOperation, - BRepAlgoAPI_Common, - BRepAlgoAPI_Cut, - BRepAlgoAPI_Fuse, - BRepAlgoAPI_Section, - BRepAlgoAPI_Splitter, -) -from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_Copy, - BRepBuilderAPI_DisconnectedWire, - BRepBuilderAPI_EmptyWire, - BRepBuilderAPI_GTransform, - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakePolygon, - BRepBuilderAPI_MakeShell, - BRepBuilderAPI_MakeSolid, - BRepBuilderAPI_MakeVertex, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_NonManifoldWire, - BRepBuilderAPI_RightCorner, - BRepBuilderAPI_RoundCorner, - BRepBuilderAPI_Sewing, - BRepBuilderAPI_Transform, - BRepBuilderAPI_Transformed, -) -from OCP.BRepCheck import BRepCheck_Analyzer -from OCP.BRepClass3d import BRepClass3d_SolidClassifier -from OCP.BRepExtrema import BRepExtrema_DistShapeShape -from OCP.BRepFeat import BRepFeat_MakeDPrism, BRepFeat_SplitShape -from OCP.BRepFill import BRepFill -from OCP.BRepFilletAPI import ( - BRepFilletAPI_MakeChamfer, - BRepFilletAPI_MakeFillet, - BRepFilletAPI_MakeFillet2d, -) -from OCP.BRepGProp import BRepGProp, BRepGProp_Face # used for mass calculation -from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter -from OCP.BRepLib import BRepLib, BRepLib_FindSurface -from OCP.BRepMesh import BRepMesh_IncrementalMesh -from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin -from OCP.BRepOffsetAPI import ( - BRepOffsetAPI_MakeFilling, - BRepOffsetAPI_MakeOffset, - BRepOffsetAPI_MakePipeShell, - BRepOffsetAPI_MakeThickSolid, - BRepOffsetAPI_ThruSections, -) -from OCP.BRepPrimAPI import ( - BRepPrimAPI_MakeBox, - BRepPrimAPI_MakeCone, - BRepPrimAPI_MakeCylinder, - BRepPrimAPI_MakePrism, - BRepPrimAPI_MakeRevol, - BRepPrimAPI_MakeSphere, - BRepPrimAPI_MakeTorus, - BRepPrimAPI_MakeWedge, -) -from OCP.BRepProj import BRepProj_Projection -from OCP.BRepTools import BRepTools -from OCP.Font import ( - Font_FA_Bold, - Font_FA_Italic, - Font_FA_Regular, - Font_FontMgr, - Font_SystemFont, -) -from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse # geometry construction -from OCP.gce import gce_MakeLin -from OCP.GCPnts import GCPnts_AbscissaPoint -from OCP.Geom import ( - Geom_BezierCurve, - Geom_BezierSurface, - Geom_ConicalSurface, - Geom_CylindricalSurface, - Geom_Plane, - Geom_Surface, - Geom_TrimmedCurve, - Geom_Line, -) -from OCP.GeomAdaptor import GeomAdaptor_Curve -from OCP.Geom2d import Geom2d_Curve, Geom2d_Line, Geom2d_TrimmedCurve -from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve -from OCP.GeomAbs import GeomAbs_C0, GeomAbs_Intersection, GeomAbs_JoinType -from OCP.GeomAPI import ( - GeomAPI_Interpolate, - GeomAPI_PointsToBSpline, - GeomAPI_PointsToBSplineSurface, - GeomAPI_ProjectPointOnSurf, - GeomAPI_ProjectPointOnCurve, -) -from OCP.GeomFill import ( - GeomFill_CorrectedFrenet, - GeomFill_Frenet, - GeomFill_TrihedronLaw, -) -from OCP.GeomLib import GeomLib_IsPlanarSurface -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, -) - -# properties used to store mass calculation result -from OCP.GProp import GProp_GProps -from OCP.HLRAlgo import HLRAlgo_Projector -from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape -from OCP.IFSelect import IFSelect_ReturnStatus -from OCP.Interface import Interface_Static -from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher -from OCP.IVtkVTK import IVtkVTK_ShapeData -from OCP.LocOpe import LocOpe_DPrism -from OCP.NCollection import NCollection_Utf8String -from OCP.Precision import Precision -from OCP.Prs3d import Prs3d_IsoAspect -from OCP.Quantity import Quantity_Color -from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds, ShapeAnalysis_Curve -from OCP.ShapeCustom import ShapeCustom, ShapeCustom_RestrictionParameters -from OCP.ShapeFix import ( - ShapeFix_Face, - ShapeFix_Shape, - ShapeFix_Solid, - ShapeFix_Wireframe, -) -from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain - -# for catching exceptions -from OCP.Standard import ( - Standard_Failure, - Standard_NoSuchObject, - Standard_ConstructionError, -) -from OCP.StdFail import StdFail_NotDone -from OCP.StdPrs import StdPrs_BRepFont -from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder -from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer -from OCP.StlAPI import StlAPI_Writer - -# Array of vectors (used for B-spline interpolation): -# Array of points (used for B-spline construction): -from OCP.TColgp import ( - TColgp_Array1OfPnt, - TColgp_Array1OfVec, - TColgp_HArray1OfPnt, - TColgp_HArray2OfPnt, -) -from OCP.TCollection import TCollection_AsciiString - -# Array of floats (used for B-spline interpolation): -# Array of booleans (used for B-spline interpolation): -from OCP.TColStd import ( - TColStd_Array1OfReal, - TColStd_HArray1OfBoolean, - TColStd_HArray1OfReal, - TColStd_HArray2OfReal, -) -from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum -from OCP.TopExp import TopExp, TopExp_Explorer # Topology explorer -from OCP.TopLoc import TopLoc_Location -from OCP.TopoDS import ( - TopoDS, - TopoDS_Builder, - TopoDS_Compound, - TopoDS_Face, - TopoDS_Iterator, - TopoDS_Shape, - TopoDS_Shell, - TopoDS_Solid, - TopoDS_Vertex, - TopoDS_Edge, - TopoDS_Wire, -) -from OCP.TopTools import ( - TopTools_HSequenceOfShape, - TopTools_IndexedDataMapOfShapeListOfShape, - TopTools_ListOfShape, - TopTools_SequenceOfShape, -) -from build123d.build_enums import ( - Align, - AngularDirection, - CenterOf, - FontStyle, - FrameMethod, - GeomType, - Keep, - Kind, - PositionMode, - Side, - SortBy, - Transition, - Until, -) -from build123d.geometry import ( - DEG2RAD, - TOLERANCE, - Axis, - BoundBox, - Color, - Location, - Matrix, - Plane, - Vector, - VectorLike, - logger, -) - - -HASH_CODE_MAX = 2147483647 # max 32bit signed int, required by OCC.Core.HashCode - -shape_LUT = { - ta.TopAbs_VERTEX: "Vertex", - ta.TopAbs_EDGE: "Edge", - ta.TopAbs_WIRE: "Wire", - ta.TopAbs_FACE: "Face", - ta.TopAbs_SHELL: "Shell", - ta.TopAbs_SOLID: "Solid", - ta.TopAbs_COMPOUND: "Compound", - ta.TopAbs_COMPSOLID: "CompSolid", -} - -shape_properties_LUT = { - ta.TopAbs_VERTEX: None, - ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, - ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, - ta.TopAbs_FACE: BRepGProp.SurfaceProperties_s, - ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s, - ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s, - ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s, - ta.TopAbs_COMPSOLID: BRepGProp.VolumeProperties_s, -} - -inverse_shape_LUT = {v: k for k, v in shape_LUT.items()} - -downcast_LUT = { - ta.TopAbs_VERTEX: TopoDS.Vertex_s, - ta.TopAbs_EDGE: TopoDS.Edge_s, - ta.TopAbs_WIRE: TopoDS.Wire_s, - ta.TopAbs_FACE: TopoDS.Face_s, - ta.TopAbs_SHELL: TopoDS.Shell_s, - ta.TopAbs_SOLID: TopoDS.Solid_s, - ta.TopAbs_COMPOUND: TopoDS.Compound_s, - ta.TopAbs_COMPSOLID: TopoDS.CompSolid_s, -} - -geom_LUT_FACE: Dict[ga.GeomAbs_SurfaceType, GeomType] = { - ga.GeomAbs_Plane: GeomType.PLANE, - ga.GeomAbs_Cylinder: GeomType.CYLINDER, - ga.GeomAbs_Cone: GeomType.CONE, - ga.GeomAbs_Sphere: GeomType.SPHERE, - ga.GeomAbs_Torus: GeomType.TORUS, - ga.GeomAbs_BezierSurface: GeomType.BEZIER, - ga.GeomAbs_BSplineSurface: GeomType.BSPLINE, - ga.GeomAbs_SurfaceOfRevolution: GeomType.REVOLUTION, - ga.GeomAbs_SurfaceOfExtrusion: GeomType.EXTRUSION, - ga.GeomAbs_OffsetSurface: GeomType.OFFSET, - ga.GeomAbs_OtherSurface: GeomType.OTHER, -} - -geom_LUT_EDGE: Dict[ga.GeomAbs_CurveType, GeomType] = { - ga.GeomAbs_Line: GeomType.LINE, - ga.GeomAbs_Circle: GeomType.CIRCLE, - ga.GeomAbs_Ellipse: GeomType.ELLIPSE, - ga.GeomAbs_Hyperbola: GeomType.HYPERBOLA, - ga.GeomAbs_Parabola: GeomType.PARABOLA, - ga.GeomAbs_BezierCurve: GeomType.BEZIER, - ga.GeomAbs_BSplineCurve: GeomType.BSPLINE, - ga.GeomAbs_OffsetCurve: GeomType.OFFSET, - ga.GeomAbs_OtherCurve: GeomType.OTHER, -} - -Shapes = Literal["Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"] - -TrimmingTool = Union[Plane, "Shell", "Face"] - - -def tuplify(obj: Any, dim: int) -> tuple: - """Create a size tuple""" - if obj is None: - result = None - elif isinstance(obj, (tuple, list)): - result = tuple(obj) - else: - result = tuple([obj] * dim) - return result - - -class Mixin1D: - """Methods to add to the Edge and Wire classes""" - - 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() - - return Vector(curve.Value(umin)) - - def end_point(self) -> Vector: - """The end point of this edge. - - Note that circles may have identical start and end points. - """ - curve = self._geom_adaptor() - umax = curve.LastParameter() - - return Vector(curve.Value(umax)) - - def param_at(self, distance: float) -> float: - """Parameter along a curve - - Compute parameter value at the specified normalized distance. - - Args: - d (float): normalized distance (0.0 >= d >= 1.0) - - Returns: - float: parameter value - """ - curve = self._geom_adaptor() - - length = GCPnts_AbscissaPoint.Length_s(curve) - return GCPnts_AbscissaPoint( - curve, length * distance, curve.FirstParameter() - ).Parameter() - - def tangent_at( - self, - position: Union[float, VectorLike] = 0.5, - position_mode: PositionMode = PositionMode.PARAMETER, - ) -> Vector: - """tangent_at - - Find the tangent at a given position on the 1D shape where the position - is either a float (or int) parameter or a point that lies on the shape. - - Args: - position (Union[float, VectorLike]): distance, parameter value, or - point on shape. Defaults to 0.5. - 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: - raise ValueError("position must be a float or a point") - # 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() - - tmp = gp_Pnt() - res = gp_Vec() - curve.D1(parameter, tmp, res) - - return Vector(gp_Dir(res)) - - def tangent_angle_at( - self, - location_param: float = 0.5, - position_mode: PositionMode = PositionMode.PARAMETER, - plane: Plane = Plane.XY, - ) -> float: - """tangent_angle_at - - Compute the tangent angle at the specified location - - Args: - location_param (float, optional): distance or parameter value. Defaults to 0.5. - position_mode (PositionMode, optional): position calculation mode. - Defaults to PositionMode.PARAMETER. - plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY. - - Returns: - float: angle in degrees between 0 and 360 - """ - tan_vector = self.tangent_at(location_param, position_mode) - angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0 - return angle - - def normal(self) -> Vector: - """Calculate the normal Vector. Only possible for planar curves. - - :return: normal vector - - Args: - - Returns: - - """ - - curve = self._geom_adaptor() - gtype = self.geom_type - - if gtype == GeomType.CIRCLE: - circ = curve.Circle() - return_value = Vector(circ.Axis().Direction()) - elif gtype == GeomType.ELLIPSE: - ell = curve.Ellipse() - return_value = Vector(ell.Axis().Direction()) - else: - find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True) - surf = find_surface.Surface() - - if isinstance(surf, Geom_Plane): - pln = surf.Pln() - return_value = Vector(pln.Axis().Direction()) - else: - raise ValueError("Normal not defined") - - return return_value - - def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector: - """Center of object - - Return the center based on center_of - - Args: - center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY. - - Returns: - Vector: center - """ - 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: - middle = self.bounding_box().center() - return middle - - def common_plane(self, *lines: Union[Edge, Wire]) -> Union[None, Plane]: - """common_plane - - Find the plane containing all the edges/wires (including self). If there - is no common plane return None. If the edges are coaxial, select one - of the infinite number of valid planes. - - Args: - lines (sequence of Union[Edge,Wire]): edges in common with self - - Returns: - Union[None, Plane]: Either the common plane or None - """ - # pylint: disable=too-many-locals - # Note: BRepLib_FindSurface is not helpful as it requires the - # Edges to form a surface perimeter. - points: list[Vector] = [] - all_lines: list[Edge, Wire] = [ - line for line in [self, *lines] if line is not None - ] - if any([not isinstance(line, (Edge, Wire)) for line in all_lines]): - raise ValueError("Only Edges or Wires are valid") - - result = None - # Are they all co-axial - if so, select one of the infinite planes - all_edges: list[Edge] = [e for l in all_lines for e in l.edges()] - if all([e.geom_type == GeomType.LINE for e in all_edges]): - as_axis = [Axis(e @ 0, e % 0) for e in all_edges] - 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 - c_plane = Plane(origin, z_dir=z_dir) - result = c_plane.shift_origin((0, 0)) - - if result is None: # not coaxial - # Shorten any infinite lines (from converted Axis) - normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines)) - infinite_lines = filter(lambda line: line.length > 1e50, all_lines) - # shortened_lines = [ - # l.trim(0.4999999999, 0.5000000001) for l in infinite_lines - # ] - shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines] - all_lines = normal_lines + shortened_lines - - for line in all_lines: - num_points = 2 if line.geom_type == GeomType.LINE else 8 - points.extend( - [line.position_at(i / (num_points - 1)) for i in range(num_points)] - ) - points = list(set(points)) # unique points - extreme_areas = {} - for subset in combinations(points, 3): - area = Face(Wire.make_polygon(subset, close=True)).area - extreme_areas[area] = subset - # The points that create the largest area make the most accurate plane - extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]] - - # Create a plane from these points - x_dir = (extremes[1] - extremes[0]).normalized() - z_dir = (extremes[2] - extremes[0]).cross(x_dir) - try: - c_plane = Plane(origin=(sum(extremes) / 3), z_dir=z_dir) - c_plane = c_plane.shift_origin((0, 0)) - except ValueError: - # There is no valid common plane - result = None - else: - # Are all of the points on the common plane - common = all([c_plane.contains(p) for p in points]) - result = c_plane if common else None - - return result - - @property - def length(self) -> float: - """Edge or Wire length""" - return GCPnts_AbscissaPoint.Length_s(self._geom_adaptor()) - - @property - def radius(self) -> float: - """Calculate the radius. - - Note that when applied to a Wire, the radius is simply the radius of the first edge. - - Args: - - Returns: - radius - - Raises: - ValueError: if kernel can not reduce the shape to a circular edge - - """ - geom = self._geom_adaptor() - try: - circ = geom.Circle() - except (Standard_NoSuchObject, Standard_Failure) as err: - raise ValueError("Shape could not be reduced to a circle") from err - return circ.Radius() - - @property - def is_forward(self) -> bool: - """Does the Edge/Wire loop forward or reverse""" - return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD - - @property - def is_closed(self) -> bool: - """Are the start and end points equal?""" - return BRep_Tool.IsClosed_s(self.wrapped) - - @property - def volume(self) -> float: - """volume - the volume of this Edge or Wire, which is always zero""" - return 0.0 - - def position_at( - self, distance: float, position_mode: PositionMode = PositionMode.PARAMETER - ) -> Vector: - """Position At - - Generate a position along the underlying curve. - - Args: - distance (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() - - if position_mode == PositionMode.PARAMETER: - param = self.param_at(distance) - else: - param = self.param_at(distance / self.length) - - return Vector(curve.Value(param)) - - def positions( - self, - distances: Iterable[float], - position_mode: PositionMode = PositionMode.PARAMETER, - ) -> 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. - - Returns: - list[Vector]: positions along curve - """ - return [self.position_at(d, position_mode) for d in distances] - - def location_at( - self, - distance: float, - position_mode: PositionMode = PositionMode.PARAMETER, - frame_method: FrameMethod = FrameMethod.FRENET, - planar: bool = False, - ) -> Location: - """Locations along curve - - Generate a location along the underlying curve. - - Args: - distance (float): distance or parameter value - position_mode (PositionMode, optional): position calculation mode. - Defaults to PositionMode.PARAMETER. - frame_method (FrameMethod, optional): moving frame calculation method. - Defaults to FrameMethod.FRENET. - planar (bool, optional): planar mode. Defaults to False. - - Returns: - Location: A Location object representing local coordinate system - at the specified distance. - """ - curve = self._geom_adaptor() - - if position_mode == PositionMode.PARAMETER: - param = self.param_at(distance) - else: - param = self.param_at(distance / self.length) - - law: GeomFill_TrihedronLaw - if frame_method == FrameMethod.FRENET: - law = GeomFill_Frenet() - else: - law = GeomFill_CorrectedFrenet() - - law.SetCurve(curve) - - tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec() - - law.D0(param, tangent, normal, binormal) - pnt = curve.Value(param) - - transformation = gp_Trsf() - if planar: - transformation.SetTransformation( - gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3() - ) - else: - transformation.SetTransformation( - gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3() - ) - - return Location(TopLoc_Location(transformation)) - - def locations( - self, - distances: Iterable[float], - position_mode: PositionMode = PositionMode.PARAMETER, - frame_method: FrameMethod = FrameMethod.FRENET, - planar: bool = False, - ) -> list[Location]: - """Locations along curve - - Generate location along the curve - - Args: - distances (Iterable[float]): distance or parameter values - position_mode (PositionMode, optional): position calculation mode. - Defaults to PositionMode.PARAMETER. - frame_method (FrameMethod, optional): moving frame calculation method. - Defaults to FrameMethod.FRENET. - planar (bool, optional): planar mode. Defaults to False. - - 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 - ] - - def __matmul__(self: Union[Edge, Wire], position: float) -> Vector: - """Position on wire operator @""" - return self.position_at(position) - - def __mod__(self: Union[Edge, Wire], position: float) -> Vector: - """Tangent on wire operator %""" - return self.tangent_at(position) - - def __xor__(self: Union[Edge, Wire], position: float) -> Location: - """Location on wire operator ^""" - return self.location_at(position) - - def offset_2d( - self, - distance: float, - kind: Kind = Kind.ARC, - side: Side = Side.BOTH, - closed: bool = True, - ) -> Union[Edge, Wire]: - """2d Offset - - Offsets a planar edge/wire - - Args: - distance (float): distance from edge/wire to offset - kind (Kind, optional): offset corner transition. Defaults to Kind.ARC. - side (Side, optional): side to place offset. Defaults to Side.BOTH. - closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT - offset. Defaults to True. - Raises: - RuntimeError: Multiple Wires generated - RuntimeError: Unexpected result type - - Returns: - Wire: offset wire - """ - # pylint: disable=too-many-branches, too-many-locals, too-many-statements - kind_dict = { - Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, - Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, - Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, - } - line = self if isinstance(self, Wire) else Wire([self]) - - # Avoiding a bug when the wire contains a single Edge - if len(line.edges()) == 1: - edge = line.edges()[0] - edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)] - topods_wire = Wire(edges).wrapped - else: - topods_wire = line.wrapped - - offset_builder = BRepOffsetAPI_MakeOffset() - offset_builder.Init(kind_dict[kind]) - # offset_builder.SetApprox(True) - offset_builder.AddWire(topods_wire) - offset_builder.Perform(distance) - - obj = downcast(offset_builder.Shape()) - if isinstance(obj, TopoDS_Compound): - offset_wire = None - for i, shape in enumerate(Compound(obj)): - offset_wire = Wire(shape.wrapped) - if i >= 1: - raise RuntimeError("Multiple Wires generated") - if offset_wire is None: - raise RuntimeError("No offset generated") - elif isinstance(obj, TopoDS_Wire): - offset_wire = Wire(obj) - else: - raise RuntimeError("Unexpected result type") - - if side != Side.BOTH: - # Find and remove the end arcs - offset_edges = offset_wire.edges() - edges_to_keep = [[], [], []] - 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]] - centers = [w.position_at(0.5) for w in wires] - angles = [ - line.tangent_at(0).get_signed_angle(c - line.position_at(0)) - for c in centers - ] - if side == Side.LEFT: - offset_wire = wires[int(angles[0] > angles[1])] - else: - offset_wire = wires[int(angles[0] <= angles[1])] - - if closed: - self0 = line.position_at(0) - self1 = line.position_at(1) - end0 = offset_wire.position_at(0) - end1 = offset_wire.position_at(1) - if (self0 - end0).length - abs(distance) <= TOLERANCE: - edge0 = Edge.make_line(self0, end0) - edge1 = Edge.make_line(self1, end1) - else: - edge0 = Edge.make_line(self0, end1) - edge1 = Edge.make_line(self1, end0) - offset_wire = Wire(line.edges() + offset_wire.edges() + [edge0, edge1]) - - offset_edges = offset_wire.edges() - return offset_edges[0] if len(offset_edges) == 1 else offset_wire - - def perpendicular_line( - self, length: float, u_value: float, plane: Plane = Plane.XY - ) -> Edge: - """perpendicular_line - - Create a line on the given plane perpendicular to and centered on beginning of self - - Args: - length (float): line length - u_value (float): position along line between 0.0 and 1.0 - plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY. - - Returns: - Edge: perpendicular line - """ - start = self.position_at(u_value) - local_plane = Plane( - origin=start, x_dir=self.tangent_at(u_value), z_dir=plane.z_dir - ) - line = Edge.make_line( - start + local_plane.y_dir * length / 2, - start - local_plane.y_dir * length / 2, - ) - return line - - def project( - self, face: Face, direction: VectorLike, closest: bool = True - ) -> Union[Mixin1D, list[Mixin1D]]: - """Project onto a face along the specified direction - - Args: - face: Face: - direction: VectorLike: - closest: bool: (Default value = True) - - Returns: - - """ - - bldr = BRepProj_Projection( - self.wrapped, face.wrapped, Vector(direction).to_dir() - ) - shapes = Compound(bldr.Shape()) - - # select the closest projection if requested - return_value: Union[Mixin1D, list[Mixin1D]] - - if closest: - dist_calc = BRepExtrema_DistShapeShape() - dist_calc.LoadS1(self.wrapped) - - min_dist = inf - - for shape in shapes: - dist_calc.LoadS2(shape.wrapped) - dist_calc.Perform() - dist = dist_calc.Value() - - if dist < min_dist: - min_dist = dist - return_value = tcast(Mixin1D, shape) - - else: - return_value = [tcast(Mixin1D, shape) for shape in shapes] - - return return_value - - -class Mixin3D: - """Additional methods to add to 3D Shape classes""" - - def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self: - """Fillet - - Fillets the specified edges of this solid. - - Args: - radius (float): float > 0, the radius of the fillet - edge_list (Iterable[Edge]): a list of Edge objects, which must belong to this solid - - Returns: - Any: Filleted solid - """ - native_edges = [e.wrapped for e in edge_list] - - fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped) - - for native_edge in native_edges: - fillet_builder.Add(radius, native_edge) - - try: - new_shape = self.__class__(fillet_builder.Shape()) - if not new_shape.is_valid(): - raise Standard_Failure - except (StdFail_NotDone, Standard_Failure) as err: - raise ValueError( - f"Failed creating a fillet with radius of {radius}, try a smaller value" - f" or use max_fillet() to find the largest valid fillet radius" - ) from err - - return new_shape - - def max_fillet( - self, - edge_list: Iterable[Edge], - tolerance=0.1, - max_iterations: int = 10, - ) -> float: - """Find Maximum Fillet Size - - Find the largest fillet radius for the given Shape and edges with a - recursive binary search. - - Example: - - max_fillet_radius = my_shape.max_fillet(shape_edges) - max_fillet_radius = my_shape.max_fillet(shape_edges, tolerance=0.5, max_iterations=8) - - - Args: - edge_list (Iterable[Edge]): a sequence of Edge objects, which must belong to this solid - tolerance (float, optional): maximum error from actual value. Defaults to 0.1. - max_iterations (int, optional): maximum number of recursive iterations. Defaults to 10. - - Raises: - RuntimeError: failed to find the max value - ValueError: the provided Shape is invalid - - Returns: - float: maximum fillet radius - """ - - def __max_fillet(window_min: float, window_max: float, current_iteration: int): - window_mid = (window_min + window_max) / 2 - - if current_iteration == max_iterations: - raise RuntimeError( - f"Failed to find the max value within {tolerance} in {max_iterations}" - ) - - fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped) - - for native_edge in native_edges: - fillet_builder.Add(window_mid, native_edge) - - # 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: - return __max_fillet(window_min, window_mid, current_iteration + 1) - - # These numbers work, are they close enough? - if not try larger window - if window_mid - window_min <= tolerance: - return_value = window_mid - else: - return_value = __max_fillet( - window_mid, window_max, current_iteration + 1 - ) - return return_value - - 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 - - max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0) - - return max_radius - - def chamfer( - self, - length: float, - length2: Optional[float], - edge_list: Iterable[Edge], - face: Face = None, - ) -> Self: - """Chamfer - - Chamfers the specified edges of this solid. - - Args: - length (float): length > 0, the length (length) of the chamfer - length2 (Optional[float]): length2 > 0, optional parameter for asymmetrical - chamfer. Should be `None` if not required. - edge_list (Iterable[Edge]): a list of Edge objects, which must belong to - this solid - face (Face): identifies the side where length is measured. The edge(s) must be - part of the face - - Returns: - Self: Chamfered solid - """ - if face: - if any((edge for edge in edge_list if edge not in face.edges())): - raise ValueError("Some edges are not part of the face") - - native_edges = [e.wrapped for e in edge_list] - - # make a edge --> faces mapping - edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() - TopExp.MapShapesAndAncestors_s( - self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map - ) - - # note: we prefer 'length' word to 'radius' as opposed to FreeCAD's API - chamfer_builder = BRepFilletAPI_MakeChamfer(self.wrapped) - - if length2: - distance1 = length - distance2 = length2 - else: - distance1 = length - distance2 = length - - for native_edge in native_edges: - if face: - topo_face = face.wrapped - else: - topo_face = edge_face_map.FindFromKey(native_edge).First() - - chamfer_builder.Add( - distance1, distance2, native_edge, TopoDS.Face_s(topo_face) - ) # NB: edge_face_map return a generic TopoDS_Shape - - try: - new_shape = self.__class__(chamfer_builder.Shape()) - if not new_shape.is_valid(): - raise Standard_Failure - except (StdFail_NotDone, Standard_Failure) as err: - raise ValueError( - "Failed creating a chamfer, try a smaller length value(s)" - ) from err - - return new_shape - - def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: - """Return center of object - - Find center of object - - Args: - center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS. - - Raises: - ValueError: Center of GEOMETRY is not supported for this object - NotImplementedError: Unable to calculate center of mass of this object - - Returns: - Vector: center - """ - if center_of == CenterOf.GEOMETRY: - raise ValueError("Center of GEOMETRY is not supported for this object") - if center_of == CenterOf.MASS: - properties = GProp_GProps() - calc_function = shape_properties_LUT[shapetype(self.wrapped)] - if calc_function: - calc_function(self.wrapped, properties) - middle = Vector(properties.CentreOfMass()) - else: - raise NotImplementedError - elif center_of == CenterOf.BOUNDING_BOX: - middle = self.bounding_box().center() - return middle - - def hollow( - self, - faces: Optional[Iterable[Face]], - thickness: float, - tolerance: float = 0.0001, - kind: Kind = Kind.ARC, - ) -> Solid: - """Hollow - - Return the outer shelled solid of self. - - Args: - faces (Optional[Iterable[Face]]): faces to be removed, - which must be part of the solid. Can be an empty list. - thickness (float): shell thickness - positive shells outwards, negative - shells inwards. - tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001. - kind (Kind, optional): intersection type. Defaults to Kind.ARC. - - Raises: - ValueError: Kind.TANGENT not supported - - Returns: - Solid: A hollow solid. - """ - if kind == Kind.TANGENT: - raise ValueError("Kind.TANGENT not supported") - - kind_dict = { - Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, - Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, - } - - occ_faces_list = TopTools_ListOfShape() - for face in faces: - occ_faces_list.Append(face.wrapped) - - shell_builder = BRepOffsetAPI_MakeThickSolid() - shell_builder.MakeThickSolidByJoin( - self.wrapped, - occ_faces_list, - thickness, - tolerance, - Intersection=True, - Join=kind_dict[kind], - ) - shell_builder.Build() - - if faces: - return_value = self.__class__(shell_builder.Shape()) - - else: # if no faces provided a watertight solid will be constructed - shell1 = self.__class__(shell_builder.Shape()).shells()[0].wrapped - shell2 = self.shells()[0].wrapped - - # s1 can be outer or inner shell depending on the thickness sign - if thickness > 0: - sol = BRepBuilderAPI_MakeSolid(shell1, shell2) - else: - sol = BRepBuilderAPI_MakeSolid(shell2, shell1) - - # fix needed for the orientations - return_value = self.__class__(sol.Shape()).fix() - - return return_value - - def offset_3d( - self, - openings: Optional[Iterable[Face]], - thickness: float, - tolerance: float = 0.0001, - kind: Kind = Kind.ARC, - ) -> Solid: - """Shell - - Make an offset solid of self. - - Args: - openings (Optional[Iterable[Face]]): faces to be removed, - which must be part of the solid. Can be an empty list. - thickness (float): offset amount - positive offset outwards, negative inwards - tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001. - kind (Kind, optional): intersection type. Defaults to Kind.ARC. - - Raises: - ValueError: Kind.TANGENT not supported - - Returns: - Solid: A shelled solid. - """ - if kind == Kind.TANGENT: - raise ValueError("Kind.TANGENT not supported") - - kind_dict = { - Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, - Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, - Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, - } - - occ_faces_list = TopTools_ListOfShape() - for face in openings: - occ_faces_list.Append(face.wrapped) - - offset_builder = BRepOffsetAPI_MakeThickSolid() - offset_builder.MakeThickSolidByJoin( - self.wrapped, - occ_faces_list, - thickness, - tolerance, - Intersection=True, - RemoveIntEdges=True, - Join=kind_dict[kind], - ) - offset_builder.Build() - - try: - offset_occt_solid = offset_builder.Shape() - except (StdFail_NotDone, Standard_Failure) as err: - raise RuntimeError( - "offset Error, an alternative kind may resolve this error" - ) from err - - offset_solid = self.__class__(offset_occt_solid) - - # The Solid can be inverted, if so reverse - if offset_solid.volume < 0: - offset_solid.wrapped.Reverse() - - return offset_solid - - 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. - - Args: - point: tuple or Vector representing 3D point to be tested - tolerance: tolerance for inside determination, default=1.0e-6 - point: VectorLike: - tolerance: float: (Default value = 1.0e-6) - - Returns: - bool indicating whether or not point is within solid - - """ - solid_classifier = BRepClass3d_SolidClassifier(self.wrapped) - solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance) - - return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace() - - def dprism( - self, - basis: Optional[Face], - bounds: list[Union[Face, Wire]], - depth: float = None, - taper: float = 0, - up_to_face: Face = None, - thru_all: bool = True, - additive: bool = True, - ) -> Solid: - """dprism - - Make a prismatic feature (additive or subtractive) - - Args: - basis (Optional[Face]): face to perform the operation on - bounds (list[Union[Face,Wire]]): list of profiles - depth (float, optional): depth of the cut or extrusion. Defaults to None. - taper (float, optional): in degrees. Defaults to 0. - up_to_face (Face, optional): a face to extrude until. Defaults to None. - thru_all (bool, optional): cut thru_all. Defaults to True. - additive (bool, optional): Defaults to True. - - Returns: - Solid: prismatic feature - """ - if isinstance(bounds[0], Wire): - sorted_profiles = sort_wires_by_build_order(bounds) - faces = [Face(p[0], p[1:]) for p in sorted_profiles] - else: - faces = bounds - - shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped - for face in faces: - feat = BRepFeat_MakeDPrism( - shape, - face.wrapped, - basis.wrapped if basis else TopoDS_Face(), - taper * DEG2RAD, - additive, - False, - ) - - if up_to_face is not None: - feat.Perform(up_to_face.wrapped) - elif thru_all or depth is None: - feat.PerformThruAll() - else: - feat.Perform(depth) - - shape = feat.Shape() - - return self.__class__(shape) - - -class Shape(NodeMixin): - """Shape - - Base class for all CAD objects such as Edge, Face, Solid, etc. - - Args: - obj (TopoDS_Shape, optional): OCCT object. Defaults to None. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - - Attributes: - wrapped (TopoDS_Shape): the OCP object - label (str): user assigned label - color (Color): object color - joints (dict[str:Joint]): dictionary of joints bound to this object (Solid only) - children (Shape): list of assembly children of this object (Compound only) - topo_parent (Shape): assembly parent of this object - - """ - - # pylint: disable=too-many-instance-attributes, too-many-public-methods - - _dim = None - - def __init__( - self, - obj: TopoDS_Shape = None, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - self.wrapped = downcast(obj) if obj is not None else None - self.for_construction = False - self.label = label - self._color = color - - # parent must be set following children as post install accesses children - self.parent = parent - - # Extracted objects like Vertices and Edges may need to know where they came from - self.topo_parent: Shape = None - - @property - def location(self) -> Location: - """Get this Shape's Location""" - return Location(self.wrapped.Location()) - - @location.setter - def location(self, value: Location): - """Set Shape's Location to value""" - self.wrapped.Location(value.wrapped) - - @property - def position(self) -> Vector: - """Get the position component of this Shape's Location""" - return self.location.position - - @position.setter - def position(self, value: VectorLike): - """Set the position component of this Shape's Location to value""" - loc = self.location - loc.position = value - self.location = loc - - @property - def orientation(self) -> Vector: - """Get the orientation component of this Shape's Location""" - return self.location.orientation - - @orientation.setter - def orientation(self, rotations: VectorLike): - """Set the orientation component of this Shape's Location to rotations""" - loc = self.location - loc.orientation = rotations - self.location = loc - - @property - def color(self) -> Union[None, Color]: - """Get the shape's color. If it's None, get the color of the nearest - ancestor, assign it to this Shape and return this value.""" - # Find the correct color for this node - if self._color is None: - # Find parent color - current_node = self - while current_node is not None: - parent_color = current_node._color - if parent_color is not None: - break - current_node = current_node.parent - node_color = parent_color - else: - node_color = self._color - self._color = node_color # Set the node's color for next time - return node_color - - @color.setter - def color(self, value): - """Set the shape's color""" - self._color = value - - def copy_attributes_to(self, target: Shape, exceptions: Iterable[str] = None): - """Copy common object attributes to target - - Note that preset attributes of target will not be overridden. - - Args: - target (Shape): object to gain attributes - exceptions (Iterable[str], optional): attributes not to copy - - Raises: - ValueError: invalid attribute - """ - # Find common attributes and eliminate exceptions - attrs1 = set(self.__dict__.keys()) - attrs2 = set(target.__dict__.keys()) - common_attrs = attrs1 & attrs2 - if exceptions is not None: - common_attrs -= set(exceptions) - - for attr in common_attrs: - # Copy the attribute only if the target's attribute not set - if not getattr(target, attr): - setattr(target, attr, getattr(self, attr)) - # Attach joints to the new part - if attr == "joints": - joint: Joint - for joint in target.joints.values(): - joint.parent = target - - @property - def is_manifold(self) -> bool: - """is_manifold - - Check if each edge in the given Shape has exactly two faces associated with it - (skipping degenerate edges). If so, the shape is manifold. - - Returns: - bool: is the shape manifold or water tight - """ - if isinstance(self, Compound): - # pylint: disable=not-an-iterable - return all(sub_shape.is_manifold for sub_shape in self) - - result = True - # Create an empty indexed data map to store the edges and their corresponding faces. - shape_map = TopTools_IndexedDataMapOfShapeListOfShape() - - # Fill the map with edges and their associated faces in the given shape. Each edge in - # the map is associated with a list of faces that share that edge. - TopExp.MapShapesAndAncestors_s( - self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, shape_map - ) - - # Iterate over the edges in the map and checks if each edge is non-degenerate and has - # exactly two faces associated with it. - for i in range(shape_map.Extent()): - # Access each edge in the map sequentially - edge = downcast(shape_map.FindKey(i + 1)) - - vertex0 = TopoDS_Vertex() - vertex1 = TopoDS_Vertex() - - # Extract the two vertices of the current edge and stores them in vertex0/1. - TopExp.Vertices_s(edge, vertex0, vertex1) - - # Check if both vertices are null and if they are the same vertex. If so, the - # edge is considered degenerate (i.e., has zero length), and it is skipped. - if vertex0.IsNull() and vertex1.IsNull() and vertex0.IsSame(vertex1): - continue - - # Check if the current edge has exactly two faces associated with it. If not, - # it means the edge is not shared by exactly two faces, indicating that the - # shape is not manifold. - if shape_map.FindFromIndex(i + 1).Extent() != 2: - result = False - break - - return result - - class _DisplayNode(NodeMixin): - """Used to create anytree structures from TopoDS_Shapes""" - - def __init__( - self, - label: str = "", - address: int = None, - position: Union[Vector, Location] = None, - parent: Shape._DisplayNode = None, - ): - self.label = label - self.address = address - self.position = position - self.parent = parent - self.children = [] - - _ordered_shapes = [ - TopAbs_ShapeEnum.TopAbs_COMPOUND, - TopAbs_ShapeEnum.TopAbs_SOLID, - TopAbs_ShapeEnum.TopAbs_SHELL, - TopAbs_ShapeEnum.TopAbs_FACE, - TopAbs_ShapeEnum.TopAbs_WIRE, - TopAbs_ShapeEnum.TopAbs_EDGE, - TopAbs_ShapeEnum.TopAbs_VERTEX, - ] - - @staticmethod - def _build_tree( - shape: TopoDS_Shape, - tree: list[_DisplayNode], - parent: _DisplayNode = None, - limit: TopAbs_ShapeEnum = TopAbs_ShapeEnum.TopAbs_VERTEX, - show_center: bool = True, - ) -> list[_DisplayNode]: - """Create an anytree copy of the TopoDS_Shape structure""" - - obj_type = shape_LUT[shape.ShapeType()] - if show_center: - loc = Shape(shape).bounding_box().center() - else: - loc = Location(shape.Location()) - tree.append(Shape._DisplayNode(obj_type, id(shape), loc, parent)) - iterator = TopoDS_Iterator() - iterator.Initialize(shape) - parent_node = tree[-1] - while iterator.More(): - child = iterator.Value() - if Shape._ordered_shapes.index( - child.ShapeType() - ) <= Shape._ordered_shapes.index(limit): - Shape._build_tree(child, tree, parent_node, limit) - iterator.Next() - return tree - - @staticmethod - def _show_tree(root_node, show_center: bool) -> str: - """Display an assembly or TopoDS_Shape anytree structure""" - - # Calculate the size of the tree labels - size_tuples = [(node.height, len(node.label)) for node in root_node.descendants] - size_tuples.append((root_node.height, len(root_node.label))) - # pylint: disable=cell-var-from-loop - size_tuples_per_level = [ - list(filter(lambda ll: ll[0] == l, size_tuples)) - for l in range(root_node.height + 1) - ] - max_sizes_per_level = [ - max(4, max(l[1] for l in level)) for level in size_tuples_per_level - ] - level_sizes_per_level = [ - l + i * 4 for i, l in enumerate(reversed(max_sizes_per_level)) - ] - tree_label_width = max(level_sizes_per_level) + 1 - - # Build the tree line by line - result = "" - for pre, _fill, node in RenderTree(root_node): - treestr = f"{pre}{node.label}".ljust(tree_label_width) - if hasattr(root_node, "address"): - address = node.address - name = "" - loc = ( - "Center" + str(node.position.to_tuple()) - if show_center - else "Position" + str(node.position.to_tuple()) - ) - else: - address = id(node) - name = node.__class__.__name__.ljust(9) - loc = ( - "Center" + str(node.center().to_tuple()) - if show_center - else "Location" + repr(node.location) - ) - result += f"{treestr}{name}at {address:#x}, {loc}\n" - return result - - def show_topology( - self, - limit_class: Literal[ - "Compound", "Edge", "Face", "Shell", "Solid", "Vertex", "Wire" - ] = "Vertex", - show_center: bool = None, - ) -> str: - """Display internal topology - - Display the internal structure of a Compound 'assembly' or Shape. Example: - - .. code:: - - >>> c1.show_topology() - - c1 is the root Compound at 0x7f4a4cafafa0, Location(...)) - ├── Solid at 0x7f4a4cafafd0, Location(...)) - ├── c2 is 1st compound Compound at 0x7f4a4cafaee0, Location(...)) - │ ├── Solid at 0x7f4a4cafad00, Location(...)) - │ └── Solid at 0x7f4a11a52790, Location(...)) - └── c3 is 2nd Compound at 0x7f4a4cafad60, Location(...)) - ├── Solid at 0x7f4a11a52700, Location(...)) - └── Solid at 0x7f4a11a58550, Location(...)) - - Args: - limit_class: type of displayed leaf node. Defaults to 'Vertex'. - show_center (bool, optional): If None, shows the Location of Compound 'assemblies' - and the bounding box center of Shapes. True or False forces the display. - Defaults to None. - - Returns: - str: tree representation of internal structure - """ - - if isinstance(self, Compound) and self.children: - show_center = False if show_center is None else show_center - result = Shape._show_tree(self, show_center) - else: - tree = Shape._build_tree( - self.wrapped, tree=[], limit=inverse_shape_LUT[limit_class] - ) - show_center = True if show_center is None else show_center - result = Shape._show_tree(tree[0], show_center) - return result - - def __add__(self, other: Union[list[Shape], Shape]) -> Self: - """fuse shape to self operator +""" - # Convert `other` to list of base objects and filter out None values - summands = [ - shape - for o in (other if isinstance(other, (list, tuple)) else [other]) - if o is not None - for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o]) - ] - # If there is nothing to add return the original object - if not summands: - return self - - # Check that all dimensions are the same - addend_dim = self._dim - if addend_dim is None: - raise ValueError("Dimensions of objects to add to are inconsistent") - - 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 len(summands) == 1: - sum_shape = summands[0] - else: - sum_shape = summands[0].fuse(*summands[1:]) - else: - sum_shape = self.fuse(*summands) - - # Simplify Compounds if possible - sum_shape = ( - sum_shape.unwrap(fully=True) - if isinstance(sum_shape, Compound) - else sum_shape - ) - - if SkipClean.clean: - sum_shape = sum_shape.clean() - - # To allow the @, % and ^ operators to work 1D objects must be type Curve - if addend_dim == 1: - sum_shape = Curve(Compound(sum_shape.edges()).wrapped) - - return sum_shape - - def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self: - """cut shape from self operator -""" - - 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 - subtrahends = [ - shape - for o in (other if isinstance(other, (list, tuple)) else [other]) - if o is not None - for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o]) - ] - # If there is nothing to subtract return the original object - if not subtrahends: - return self - - # Check that all dimensions are the same - minuend_dim = self._dim - if minuend_dim is None: - raise ValueError("Dimensions of objects to subtract from are inconsistent") - - # Check that the operation is valid - subtrahend_dims = [s._dim for s in subtrahends] - if any(d < minuend_dim for d in subtrahend_dims): - raise ValueError( - f"Only shapes with equal or greater dimension can be subtracted: " - f"not {type(self).__name__} ({minuend_dim}D) and " - f"{type(other).__name__} ({min(subtrahend_dims)}D)" - ) - - # Do the actual cut operation - difference = self.cut(*subtrahends) - - # Simplify Compounds if possible - difference = ( - difference.unwrap(fully=True) - if isinstance(difference, Compound) - else difference - ) - # To allow the @, % and ^ operators to work 1D objects must be type Curve - if minuend_dim == 1: - difference = Curve(Compound(difference.edges()).wrapped) - - return difference - - def __and__(self, other: Shape) -> Self: - """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): - raise ValueError("Cannot intersect shape with empty compound") - new_shape = self.intersect(*others) - - if new_shape.wrapped is not None and SkipClean.clean: - new_shape = new_shape.clean() - - # Simplify Compounds if possible - new_shape = ( - new_shape.unwrap(fully=True) - if isinstance(new_shape, Compound) - else new_shape - ) - - # To allow the @, % and ^ operators to work 1D objects must be type Curve - if self._dim == 1: - new_shape = Curve(Compound(new_shape.edges()).wrapped) - - return new_shape - - def __rmul__(self, other): - """right multiply for positioning operator *""" - if not ( - isinstance(other, (list, tuple)) - and all([isinstance(o, (Location, Plane)) for o in other]) - ): - raise ValueError( - "shapes can only be multiplied list of locations or planes" - ) - return [loc * self for loc in other] - - def center(self) -> Vector: - """All of the derived classes from Shape need a center method""" - raise NotImplementedError - - def clean(self) -> Self: - """clean - - Remove internal edges - - Returns: - Shape: Original object with extraneous internal edges removed - """ - upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True) - upgrader.AllowInternalEdges(False) - # upgrader.SetAngularTolerance(1e-5) - try: - upgrader.Build() - self.wrapped = downcast(upgrader.Shape()) - except Exception: - warnings.warn( - f"Unable to clean {self}", - stacklevel=2, - ) - return self - - def fix(self) -> Self: - """fix - try to fix shape if not valid""" - if not self.is_valid(): - shape_copy: Shape = copy.deepcopy(self, None) - shape_copy.wrapped = fix(self.wrapped) - - return shape_copy - - return self - - @classmethod - def cast(cls, obj: TopoDS_Shape, for_construction: bool = False) -> Self: - "Returns the right type of wrapper, given a OCCT object" - - new_shape = None - - # define the shape lookup table for casting - constructor__lut = { - ta.TopAbs_VERTEX: Vertex, - ta.TopAbs_EDGE: Edge, - ta.TopAbs_WIRE: Wire, - ta.TopAbs_FACE: Face, - ta.TopAbs_SHELL: Shell, - ta.TopAbs_SOLID: Solid, - ta.TopAbs_COMPOUND: Compound, - } - - shape_type = shapetype(obj) - # NB downcast is needed to handle TopoDS_Shape types - new_shape = constructor__lut[shape_type](downcast(obj)) - new_shape.for_construction = for_construction - - return new_shape - - @deprecated("Use the `export_stl` function instead") - def export_stl( - self, - file_name: str, - tolerance: float = 1e-3, - angular_tolerance: float = 0.1, - ascii_format: bool = False, - ) -> bool: - """Export STL - - Exports a shape to a specified STL file. - - Args: - file_name (str): 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 - result in meshes with a level of detail that is too low. The default is a good - starting point for a range of cases. Defaults to 1e-3. - angular_tolerance (float, optional): Angular deflection setting which limits the angle - between subsequent segments in a polyline. Defaults to 0.1. - ascii_format (bool, optional): Export the file as ASCII (True) or binary (False) - STL format. Defaults to False (binary). - - Returns: - bool: Success - """ - mesh = BRepMesh_IncrementalMesh( - self.wrapped, tolerance, True, angular_tolerance, True - ) - mesh.Perform() - - writer = StlAPI_Writer() - - if ascii_format: - writer.ASCIIMode = True - else: - writer.ASCIIMode = False - - return writer.Write(self.wrapped, file_name) - - @deprecated("Use the `export_step` function instead") - def export_step(self, file_name: str, **kwargs) -> IFSelect_ReturnStatus: - """Export this shape to a STEP file. - - kwargs is used to provide optional keyword arguments to configure the exporter. - - Args: - file_name (str): Path and filename for writing. - kwargs: used to provide optional keyword arguments to configure the exporter. - - Returns: - IFSelect_ReturnStatus: OCCT return status - """ - # Handle the extra settings for the STEP export - pcurves = 1 - if "write_pcurves" in kwargs and not kwargs["write_pcurves"]: - pcurves = 0 - precision_mode = kwargs["precision_mode"] if "precision_mode" in kwargs else 0 - - writer = STEPControl_Writer() - Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves) - Interface_Static.SetIVal_s("write.precision.mode", precision_mode) - writer.Transfer(self.wrapped, STEPControl_AsIs) - - return writer.Write(file_name) - - @deprecated("Use the `export_brep` function instead") - def export_brep(self, file: Union[str, BytesIO]) -> bool: - """Export this shape to a BREP file - - Args: - file: Union[str, BytesIO]: - - Returns: - - """ - - return_value = BRepTools.Write_s(self.wrapped, file) - - return True if return_value is None else return_value - - @property - def geom_type(self) -> GeomType: - """Gets the underlying geometry type. - - Args: - - Returns: - - """ - - shape: TopAbs_ShapeEnum = shapetype(self.wrapped) - - if shape == ta.TopAbs_EDGE: - geom = geom_LUT_EDGE[BRepAdaptor_Curve(self.wrapped).GetType()] - elif shape == ta.TopAbs_FACE: - geom = geom_LUT_FACE[BRepAdaptor_Surface(self.wrapped).GetType()] - else: - geom = GeomType.OTHER - - return geom - - def hash_code(self) -> int: - """Returns a hashed value denoting this shape. It is computed from the - TShape and the Location. The Orientation is not used. - - Args: - - Returns: - - """ - return self.wrapped.HashCode(HASH_CODE_MAX) - - 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.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 - :py:meth:`is_equal` - - Args: - other: Shape: - - Returns: - - """ - return self.wrapped.IsSame(other.wrapped) - - def is_equal(self, other: Shape) -> bool: - """Returns True if two shapes are equal, i.e. if they share the same - TShape with the same Locations and Orientations. Also see - :py:meth:`is_same`. - - Args: - other: Shape: - - Returns: - - """ - return self.wrapped.IsEqual(other.wrapped) - - def __eq__(self, other) -> bool: - """Are shapes same operator ==""" - return self.is_same(other) if isinstance(other, Shape) else NotImplemented - - 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: - - """ - chk = BRepCheck_Analyzer(self.wrapped) - chk.SetParallel(True) - return chk.IsValid() - - def bounding_box(self, tolerance: float = None, optimal: bool = True) -> BoundBox: - """Create a bounding box for this Shape. - - Args: - tolerance (float, optional): Defaults to None. - - Returns: - BoundBox: A box sized to contain this Shape - """ - return BoundBox._from_topo_ds( - self.wrapped, tolerance=tolerance, optimal=optimal - ) - - def mirror(self, mirror_plane: Plane = None) -> Self: - """ - Applies a mirror transform to this Shape. Does not duplicate objects - about the plane. - - Args: - mirror_plane (Plane): The plane to mirror about. Defaults to Plane.XY - Returns: - The mirrored shape - """ - if not mirror_plane: - mirror_plane = Plane.XY - - transformation = gp_Trsf() - transformation.SetMirror( - gp_Ax2(mirror_plane.origin.to_pnt(), mirror_plane.z_dir.to_dir()) - ) - - return self._apply_transform(transformation) - - @staticmethod - def combined_center( - objects: Iterable[Shape], center_of: CenterOf = CenterOf.MASS - ) -> Vector: - """combined center - - Calculates the center of a multiple objects. - - Args: - objects (Iterable[Shape]): list of objects - center_of (CenterOf, optional): centering option. Defaults to CenterOf.MASS. - - Raises: - ValueError: CenterOf.GEOMETRY not implemented - - Returns: - Vector: center of multiple objects - """ - if center_of == CenterOf.MASS: - total_mass = sum(Shape.compute_mass(o) for o in objects) - weighted_centers = [ - o.center(CenterOf.MASS).multiply(Shape.compute_mass(o)) for o in objects - ] - - sum_wc = weighted_centers[0] - for weighted_center in weighted_centers[1:]: - sum_wc = sum_wc.add(weighted_center) - middle = Vector(sum_wc.multiply(1.0 / total_mass)) - elif center_of == CenterOf.BOUNDING_BOX: - total_mass = len(objects) - - weighted_centers = [] - for obj in objects: - weighted_centers.append(obj.bounding_box().center()) - - sum_wc = weighted_centers[0] - for weighted_center in weighted_centers[1:]: - sum_wc = sum_wc.add(weighted_center) - - middle = Vector(sum_wc.multiply(1.0 / total_mass)) - else: - raise ValueError("CenterOf.GEOMETRY not implemented") - - return middle - - @staticmethod - def compute_mass(obj: Shape) -> float: - """Calculates the 'mass' of an object. - - Args: - obj: Compute the mass of this object - obj: Shape: - - Returns: - - """ - properties = GProp_GProps() - calc_function = shape_properties_LUT[shapetype(obj.wrapped)] - - if not calc_function: - raise NotImplementedError - - calc_function(obj.wrapped, properties) - return properties.Mass() - - def shape_type(self) -> Shapes: - """Return the shape type string for this class""" - return tcast(Shapes, shape_LUT[shapetype(self.wrapped)]) - - def _entities(self, topo_type: Shapes) -> list[TopoDS_Shape]: - out = {} # using dict to prevent duplicates - - explorer = TopExp_Explorer(self.wrapped, inverse_shape_LUT[topo_type]) - - while explorer.More(): - item = explorer.Current() - out[item.HashCode(HASH_CODE_MAX)] = ( - item # needed to avoid pseudo-duplicate entities - ) - explorer.Next() - - return list(out.values()) - - def _entities_from( - self, child_type: Shapes, parent_type: Shapes - ) -> Dict[Shape, list[Shape]]: - """This function is very slow on M1 macs and is currently unused""" - res = TopTools_IndexedDataMapOfShapeListOfShape() - - TopExp.MapShapesAndAncestors_s( - self.wrapped, - inverse_shape_LUT[child_type], - inverse_shape_LUT[parent_type], - res, - ) - - out: Dict[Shape, list[Shape]] = {} - for i in range(1, res.Extent() + 1): - out[Shape.cast(res.FindKey(i))] = [ - Shape.cast(el) for el in res.FindFromIndex(i) - ] - - return out - - def vertices(self) -> ShapeList[Vertex]: - """vertices - all the vertices in this Shape""" - vertex_list = ShapeList( - [Vertex(downcast(i)) for i in self._entities(Vertex.__name__)] - ) - for vertex in vertex_list: - vertex.topo_parent = self - return vertex_list - - def vertex(self) -> Vertex: - """Return the Vertex""" - vertices = self.vertices() - vertex_count = len(vertices) - if vertex_count != 1: - warnings.warn( - f"Found {vertex_count} vertices, returning first", - stacklevel=2, - ) - return vertices[0] - - def edges(self) -> ShapeList[Edge]: - """edges - all the edges in this Shape""" - edge_list = ShapeList( - [ - Edge(i) - for i in self._entities(Edge.__name__) - if not BRep_Tool.Degenerated_s(TopoDS.Edge_s(i)) - ] - ) - for edge in edge_list: - edge.topo_parent = self - return edge_list - - def edge(self) -> Edge: - """Return the Edge""" - edges = self.edges() - edge_count = len(edges) - if edge_count != 1: - warnings.warn( - f"Found {edge_count} edges, returning first", - stacklevel=2, - ) - return edges[0] - - def compounds(self) -> ShapeList[Compound]: - """compounds - all the compounds in this Shape""" - if isinstance(self, Compound): - # pylint: disable=not-an-iterable - sub_compounds = [c for c in self if isinstance(c, Compound)] - sub_compounds.append(self) - else: - sub_compounds = [] - return ShapeList(sub_compounds) - - def compound(self) -> Compound: - """Return the Compound""" - compounds = self.compounds() - compound_count = len(compounds) - if compound_count != 1: - warnings.warn( - f"Found {compound_count} compounds, returning first", - stacklevel=2, - ) - return compounds[0] - - def wires(self) -> ShapeList[Wire]: - """wires - all the wires in this Shape""" - return ShapeList([Wire(i) for i in self._entities(Wire.__name__)]) - - def wire(self) -> Wire: - """Return the Wire""" - wires = self.wires() - wire_count = len(wires) - if wire_count != 1: - warnings.warn( - f"Found {wire_count} wires, returning first", - stacklevel=2, - ) - return wires[0] - - def faces(self) -> ShapeList[Face]: - """faces - all the faces in this Shape""" - face_list = ShapeList([Face(i) for i in self._entities(Face.__name__)]) - for face in face_list: - face.topo_parent = self - return face_list - - def face(self) -> Face: - """Return the Face""" - faces = self.faces() - face_count = len(faces) - if face_count != 1: - msg = f"Found {face_count} faces, returning first" - warnings.warn(msg, stacklevel=2) - return faces[0] - - def shells(self) -> ShapeList[Shell]: - """shells - all the shells in this Shape""" - return ShapeList([Shell(i) for i in self._entities(Shell.__name__)]) - - def shell(self) -> Shell: - """Return the Shell""" - shells = self.shells() - shell_count = len(shells) - if shell_count != 1: - warnings.warn( - f"Found {shell_count} shells, returning first", - stacklevel=2, - ) - return shells[0] - - def solids(self) -> ShapeList[Solid]: - """solids - all the solids in this Shape""" - return ShapeList([Solid(i) for i in self._entities(Solid.__name__)]) - - def solid(self) -> Solid: - """Return the Solid""" - solids = self.solids() - solid_count = len(solids) - if solid_count != 1: - warnings.warn( - f"Found {solid_count} solids, returning first", - stacklevel=2, - ) - return solids[0] - - @property - def area(self) -> float: - """area -the surface area of all faces in this Shape""" - properties = GProp_GProps() - BRepGProp.SurfaceProperties_s(self.wrapped, properties) - - return properties.Mass() - - def _apply_transform(self, transformation: gp_Trsf) -> Self: - """Private Apply Transform - - Apply the provided transformation matrix to a copy of Shape - - Args: - transformation (gp_Trsf): transformation matrix - - Returns: - Shape: copy of transformed Shape - """ - shape_copy: Shape = copy.deepcopy(self, None) - transformed_shape = BRepBuilderAPI_Transform( - shape_copy.wrapped, transformation, True - ).Shape() - shape_copy.wrapped = downcast(transformed_shape) - return shape_copy - - def rotate(self, axis: Axis, angle: float) -> Self: - """rotate a copy - - Rotates a shape around an axis. - - Args: - axis (Axis): rotation Axis - angle (float): angle to rotate, in degrees - - Returns: - a copy of the shape, rotated - """ - transformation = gp_Trsf() - transformation.SetRotation(axis.wrapped, angle * DEG2RAD) - - return self._apply_transform(transformation) - - def translate(self, vector: VectorLike) -> Self: - """Translates this shape through a transformation. - - Args: - vector: VectorLike: - - Returns: - - """ - - transformation = gp_Trsf() - transformation.SetTranslation(Vector(vector).wrapped) - - return self._apply_transform(transformation) - - def scale(self, factor: float) -> Self: - """Scales this shape through a transformation. - - Args: - factor: float: - - Returns: - - """ - - transformation = gp_Trsf() - transformation.SetScale(gp_Pnt(), factor) - - return self._apply_transform(transformation) - - def __deepcopy__(self, memo) -> Self: - """Return deepcopy of self""" - # The wrapped object is a OCCT TopoDS_Shape which can't be pickled or copied - # with the standard python copy/deepcopy, so create a deepcopy 'memo' with this - # value already copied which causes deepcopy to skip it. - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - 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 == "joints": - for joint in result.joints.values(): - joint.parent = result - return result - - def __copy__(self) -> Self: - """Return shallow copy or reference of self - - Create an copy of this Shape that shares the underlying TopoDS_TShape. - - Used when there is a need for many objects with the same CAD structure but at - different Locations, etc. - for examples fasteners in a larger assembly. By - sharing the TopoDS_TShape, the memory size of such assemblies can be greatly reduced. - - Changes to the CAD structure of the base object will be reflected in all instances. - """ - reference = copy.deepcopy(self) - reference.wrapped.TShape(self.wrapped.TShape()) - return reference - - def copy(self) -> Self: - """Here for backwards compatibility with cq-editor""" - warnings.warn( - "copy() will be deprecated - use copy.copy() or copy.deepcopy() instead", - DeprecationWarning, - stacklevel=2, - ) - return copy.deepcopy(self, None) - - def transform_shape(self, t_matrix: Matrix) -> Self: - """Apply affine transform without changing type - - Transforms a copy of this Shape by the provided 3D affine transformation matrix. - Note that not all transformation are supported - primarily designed for translation - and rotation. See :transform_geometry: for more comprehensive transformations. - - Args: - t_matrix (Matrix): affine transformation matrix - - Returns: - Shape: copy of transformed shape with all objects keeping their type - """ - if isinstance(self, Vertex): - new_shape = Vertex(*t_matrix.multiply(Vector(self))) - else: - transformed = Shape.cast( - BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape() - ) - new_shape = copy.deepcopy(self, None) - new_shape.wrapped = transformed.wrapped - - return new_shape - - def transform_geometry(self, t_matrix: Matrix) -> Self: - """Apply affine transform - - WARNING: transform_geometry will sometimes convert lines and circles to - splines, but it also has the ability to handle skew and stretching - transformations. - - If your transformation is only translation and rotation, it is safer to - use :py:meth:`transform_shape`, which doesn't change the underlying type - of the geometry, but cannot handle skew transformations. - - Args: - t_matrix (Matrix): affine transformation matrix - - Returns: - Shape: a copy of the object, but with geometry transformed - """ - transformed = Shape.cast( - BRepBuilderAPI_GTransform(self.wrapped, t_matrix.wrapped, True).Shape() - ) - new_shape = copy.deepcopy(self, None) - new_shape.wrapped = transformed.wrapped - - return new_shape - - def locate(self, loc: Location) -> Self: - """Apply a location in absolute sense to self - - Args: - loc: Location: - - Returns: - - """ - - self.wrapped.Location(loc.wrapped) - - return self - - def located(self, loc: Location) -> Self: - """located - - Apply a location in absolute sense to a copy of self - - Args: - loc (Location): new absolute location - - Returns: - Shape: copy of Shape at location - """ - shape_copy: Shape = copy.deepcopy(self, None) - shape_copy.wrapped.Location(loc.wrapped) - return shape_copy - - def move(self, loc: Location) -> Self: - """Apply a location in relative sense (i.e. update current location) to self - - Args: - loc: Location: - - Returns: - - """ - - self.wrapped.Move(loc.wrapped) - - return self - - def moved(self, loc: Location) -> Self: - """moved - - Apply a location in relative sense (i.e. update current location) to a copy of self - - Args: - loc (Location): new location relative to current location - - Returns: - Shape: copy of Shape moved to relative location - """ - shape_copy: Shape = copy.deepcopy(self, None) - shape_copy.wrapped = downcast(shape_copy.wrapped.Moved(loc.wrapped)) - return shape_copy - - def relocate(self, loc: Location): - """Change the location of self while keeping it geometrically similar - - Args: - loc (Location): new location to set for self - """ - if self.location != loc: - old_ax = gp_Ax3() - old_ax.Transform(self.location.wrapped.Transformation()) - - new_ax = gp_Ax3() - new_ax.Transform(loc.wrapped.Transformation()) - - trsf = gp_Trsf() - trsf.SetDisplacement(new_ax, old_ax) - builder = BRepBuilderAPI_Transform(self.wrapped, trsf, True, True) - - self.wrapped = downcast(builder.Shape()) - self.wrapped.Location(loc.wrapped) - - def distance_to_with_closest_points( - self, other: Union[Shape, VectorLike] - ) -> tuple[float, Vector, Vector]: - """Minimal distance between two shapes and the points on each shape""" - other = other if isinstance(other, Shape) else Vertex(other) - dist_calc = BRepExtrema_DistShapeShape() - dist_calc.LoadS1(self.wrapped) - dist_calc.LoadS2(other.wrapped) - dist_calc.Perform() - return ( - dist_calc.Value(), - Vector(dist_calc.PointOnShape1(1)), - Vector(dist_calc.PointOnShape2(1)), - ) - - def distance_to(self, other: Union[Shape, VectorLike]) -> float: - """Minimal distance between two shapes""" - return self.distance_to_with_closest_points(other)[0] - - def closest_points(self, other: Union[Shape, VectorLike]) -> tuple[Vector, Vector]: - """Points on two shapes where the distance between them is minimal""" - return tuple(self.distance_to_with_closest_points(other)[1:]) - - def __hash__(self) -> int: - """Return has code""" - return self.hash_code() - - def _bool_op( - self, - args: Iterable[Shape], - tools: Iterable[Shape], - operation: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter], - ) -> Self: - """Generic boolean operation - - Args: - args: Iterable[Shape]: - tools: Iterable[Shape]: - operation: Union[BRepAlgoAPI_BooleanOperation: - BRepAlgoAPI_Splitter]: - - Returns: - - """ - - arg = TopTools_ListOfShape() - for obj in args: - arg.Append(obj.wrapped) - - tool = TopTools_ListOfShape() - for obj in tools: - tool.Append(obj.wrapped) - - operation.SetArguments(arg) - operation.SetTools(tool) - - operation.SetRunParallel(True) - operation.Build() - - result = Shape.cast(operation.Shape()) - - base = args[0] if isinstance(args, tuple) else args - base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) - - return result - - def cut(self, *to_cut: Shape) -> Self: - """Remove the positional arguments from this Shape. - - Args: - *to_cut: Shape: - - Returns: - - """ - - cut_op = BRepAlgoAPI_Cut() - - return self._bool_op((self,), to_cut, cut_op) - - def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Self: - """fuse - - Fuse a sequence of shapes into a single shape. - - Args: - to_fuse (sequence Shape): shapes to fuse - glue (bool, optional): performance improvement for some shapes. Defaults to False. - tol (float, optional): tolerance. Defaults to None. - - Returns: - Shape: fused shape - """ - - fuse_op = BRepAlgoAPI_Fuse() - if glue: - fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift) - if tol: - fuse_op.SetFuzzyValue(tol) - - return_value = self._bool_op((self,), to_fuse, fuse_op) - - return return_value - - def _intersect_with_axis(self, *axes: Axis) -> Shape: - lines = [Edge(a) for a in axes] - return self.intersect(*lines) - - def _intersect_with_plane(self, *planes: Plane) -> Shape: - surfaces = [Face.make_plane(p) for p in planes] - return self.intersect(*surfaces) - - def intersect(self, *to_intersect: Union[Shape, Axis, Plane]) -> Shape: - """Intersection of the arguments and this shape - - Args: - to_intersect (sequence of Union[Shape, Axis, Plane]): Shape(s) to - intersect with - - Returns: - Shape: Resulting object may be of a different class than self - """ - - # Convert any geometry objects into their respective topology objects - objs = [] - for obj in to_intersect: - if isinstance(obj, Vector): - objs.append(Vertex(obj)) - elif isinstance(obj, Axis): - objs.append(Edge(obj)) - elif isinstance(obj, Plane): - objs.append(Face.make_plane(obj)) - elif isinstance(obj, Location): - objs.append(Vertex(obj.position)) - else: - objs.append(obj) - - # Find the shape intersections - intersect_op = BRepAlgoAPI_Common() - shape_intersections = self._bool_op((self,), objs, intersect_op) - - return shape_intersections - - def _ocp_section( - self: Shape, other: Union[Vertex, Edge, Wire, Face] - ) -> tuple[list[Vertex], list[Edge]]: - """_ocp_section - - Create a BRepAlgoAPI_Section object - - The algorithm is to build a Section operation between arguments and tools. - The result of Section operation consists of vertices and edges. The result - of Section operation contains: - - new vertices that are subjects of V/V, E/E, E/F, F/F interferences - - vertices that are subjects of V/E, V/F interferences - - new edges that are subjects of F/F interferences - - edges that are Common Blocks - - - Args: - other (Union[Vertex, Edge, Wire, Face]): shape to section with - - Returns: - tuple[list[Vertex], list[Edge]]: section results - """ - 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.Build() - - # Get the resulting shapes from the intersection - intersectionShape = section.Shape() - - vertices = [] - # Iterate through the intersection shape to find intersection points/edges - explorer = TopExp_Explorer(intersectionShape, TopAbs_ShapeEnum.TopAbs_VERTEX) - while explorer.More(): - vertices.append(Vertex(downcast(explorer.Current()))) - explorer.Next() - edges = [] - explorer = TopExp_Explorer(intersectionShape, TopAbs_ShapeEnum.TopAbs_EDGE) - while explorer.More(): - edges.append(Edge(downcast(explorer.Current()))) - explorer.Next() - - return (vertices, edges) - - def faces_intersected_by_axis( - self, - axis: Axis, - tol: float = 1e-4, - ) -> ShapeList[Face]: - """Line Intersection - - Computes the intersections between the provided axis and the faces of this Shape - - Args: - axis (Axis): Axis on which the intersection line rests - tol (float, optional): Intersection tolerance. Defaults to 1e-4. - - Returns: - list[Face]: A list of intersected faces sorted by distance from axis.position - """ - line = gce_MakeLin(axis.wrapped).Value() - shape = self.wrapped - - intersect_maker = BRepIntCurveSurface_Inter() - intersect_maker.Init(shape, line, tol) - - faces_dist = [] # using a list instead of a dictionary to be able to sort it - while intersect_maker.More(): - inter_pt = intersect_maker.Pnt() - - distance = axis.position.to_pnt().SquareDistance(inter_pt) - - faces_dist.append( - ( - intersect_maker.Face(), - abs(distance), - ) - ) # will sort all intersected faces by distance whatever the direction is - - intersect_maker.Next() - - faces_dist.sort(key=lambda x: x[1]) - faces = [face[0] for face in faces_dist] - - return ShapeList([Face(face) for face in faces]) - - def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP) -> Self: - """split - - Split this shape by the provided plane or face. - - Args: - surface (Union[Plane,Face]): surface to segment shape - keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. - - Returns: - Shape: result of split - """ - shape_list = TopTools_ListOfShape() - shape_list.Append(self.wrapped) - - # Define the splitting tool - trim_tool = ( - Face.make_plane(tool).wrapped 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() - - result = Compound(downcast(splitter.Shape())).unwrap(fully=False) - if keep != Keep.BOTH: - if not isinstance(tool, Plane): - # Create solids from the surfaces for sorting - surface_up = tool.thicken(0.1) - tops, bottoms = [], [] - for part in result: - if isinstance(tool, Plane): - is_up = tool.to_local_coords(part).center().Z >= 0 - else: - is_up = surface_up.intersect(part).volume >= TOLERANCE - (tops if is_up else bottoms).append(part) - result = Compound(tops) if keep == Keep.TOP else Compound(bottoms) - - return result.unwrap(fully=True) - - @overload - def split_by_perimeter( - self, perimeter: Union[Edge, Wire], keep: Literal[Keep.INSIDE, Keep.OUTSIDE] - ) -> Union[Optional[Shell], Optional[Face]]: ... - - @overload - def split_by_perimeter( - self, perimeter: Union[Edge, Wire], keep: Literal[Keep.BOTH] - ) -> tuple[ - Union[Optional[Shell], Optional[Face]], - Union[Optional[Shell], Optional[Face]], - ]: ... - @overload - def split_by_perimeter( - self, perimeter: Union[Edge, Wire] - ) -> Union[Optional[Shell], Optional[Face]]: ... - def split_by_perimeter( - self, perimeter: Union[Edge, Wire], keep: Keep = Keep.INSIDE - ): - """split_by_perimeter - - Divide the faces of this object into those within the perimeter - and those outside the perimeter. - - Note: this method may fail if the perimeter intersects shape edges. - - Args: - perimeter (Union[Edge,Wire]): closed perimeter - keep (Keep, optional): which object(s) to return. Defaults to Keep.INSIDE. - - Raises: - ValueError: perimeter must be closed - ValueError: keep must be one of Keep.INSIDE|OUTSIDE|BOTH - - Returns: - Union[Optional[Shell], Optional[Face], - Tuple[Optional[Shell], Optional[Face]]]: The result of the split operation. - - - **Keep.INSIDE**: Returns the inside part as a `Shell` or `Face`, or `None` - if no inside part is found. - - **Keep.OUTSIDE**: Returns the outside part as a `Shell` or `Face`, or `None` - if no outside part is found. - - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is - either a `Shell`, `Face`, or `None` if no corresponding part is found. - - """ - - def get(los: TopTools_ListOfShape, shape_cls) -> list: - shapes = [] - for _ in range(los.Size()): - shapes.append(shape_cls(los.First())) - los.RemoveFirst() - return shapes - - if keep not in {Keep.INSIDE, Keep.OUTSIDE, Keep.BOTH}: - raise ValueError( - "keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH" - ) - - # Process the perimeter - if not perimeter.is_closed: - raise ValueError("perimeter must be a closed Wire or Edge") - perimeter_edges = TopTools_SequenceOfShape() - for perimeter_edge in perimeter.edges(): - perimeter_edges.Append(perimeter_edge.wrapped) - - # Split the faces by the perimeter edges - lefts: list[Face] = [] - rights: list[Face] = [] - for target_face in self.faces(): - constructor = BRepFeat_SplitShape(target_face.wrapped) - constructor.Add(perimeter_edges) - constructor.Build() - lefts.extend(get(constructor.Left(), Face)) - rights.extend(get(constructor.Right(), Face)) - - left = None if not lefts else lefts[0] if len(lefts) == 1 else Shell(lefts) - right = None if not rights else rights[0] if len(rights) == 1 else Shell(rights) - - # Is left or right the inside? - perimeter_length = perimeter.length - left_perimeter_length = ( - sum(e.length for e in left.edges()) if left is not None else 0 - ) - right_perimeter_length = ( - sum(e.length for e in right.edges()) if right is not None else 0 - ) - left_inside = abs(perimeter_length - left_perimeter_length) < abs( - perimeter_length - right_perimeter_length - ) - if keep == Keep.BOTH: - return (left, right) if left_inside else (right, left) - elif keep == Keep.INSIDE: - return left if left_inside else right - else: # keep == Keep.OUTSIDE: - return right if left_inside else left - - def distance(self, other: Shape) -> float: - """Minimal distance between two shapes - - Args: - other: Shape: - - Returns: - - """ - - return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value() - - def distances(self, *others: Shape) -> Iterator[float]: - """Minimal distances to between self and other shapes - - Args: - *others: Shape: - - Returns: - - """ - - dist_calc = BRepExtrema_DistShapeShape() - dist_calc.LoadS1(self.wrapped) - - for other_shape in others: - dist_calc.LoadS2(other_shape.wrapped) - dist_calc.Perform() - - yield dist_calc.Value() - - def mesh(self, tolerance: float, angular_tolerance: float = 0.1): - """Generate triangulation if none exists. - - Args: - tolerance: float: - angular_tolerance: float: (Default value = 0.1) - - Returns: - - """ - - if not BRepTools.Triangulation_s(self.wrapped, tolerance): - BRepMesh_IncrementalMesh( - self.wrapped, tolerance, True, angular_tolerance, True - ) - - def tessellate( - self, tolerance: float, angular_tolerance: float = 0.1 - ) -> Tuple[list[Vector], list[Tuple[int, int, int]]]: - """General triangulated approximation""" - self.mesh(tolerance, angular_tolerance) - - vertices: list[Vector] = [] - triangles: list[Tuple[int, int, int]] = [] - offset = 0 - - for face in self.faces(): - loc = TopLoc_Location() - poly = BRep_Tool.Triangulation_s(face.wrapped, loc) - trsf = loc.Transformation() - reverse = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED - - # add vertices - vertices += [ - Vector(v.X(), v.Y(), v.Z()) - for v in ( - poly.Node(i).Transformed(trsf) for i in range(1, poly.NbNodes() + 1) - ) - ] - # add triangles - triangles += [ - ( - ( - t.Value(1) + offset - 1, - t.Value(3) + offset - 1, - t.Value(2) + offset - 1, - ) - if reverse - else ( - t.Value(1) + offset - 1, - t.Value(2) + offset - 1, - t.Value(3) + offset - 1, - ) - ) - for t in poly.Triangles() - ] - - offset += poly.NbNodes() - - return vertices, triangles - - def to_splines( - self, degree: int = 3, tolerance: float = 1e-3, nurbs: bool = False - ) -> T: - """to_splines - - Approximate shape with b-splines of the specified degree. - - Args: - degree (int, optional): Maximum degree. Defaults to 3. - tolerance (float, optional): Approximation tolerance. Defaults to 1e-3. - nurbs (bool, optional): Use rational splines. Defaults to False. - - Returns: - T: _description_ - """ - params = ShapeCustom_RestrictionParameters() - - result = ShapeCustom.BSplineRestriction_s( - self.wrapped, - tolerance, # 3D tolerance - tolerance, # 2D tolerance - degree, - 1, # dummy value, degree is leading - ga.GeomAbs_C0, - ga.GeomAbs_C0, - True, # set degree to be leading - not nurbs, - params, - ) - - return self.__class__(result) - - def to_vtk_poly_data( - self, - tolerance: float = None, - angular_tolerance: float = None, - normals: bool = False, - ) -> vtkPolyData: - """Convert shape to vtkPolyData - - Args: - tolerance: float: - angular_tolerance: float: (Default value = 0.1) - normals: bool: (Default value = True) - - Returns: data object in VTK consisting of points, vertices, lines, and polygons - """ - vtk_shape = IVtkOCC_Shape(self.wrapped) - shape_data = IVtkVTK_ShapeData() - shape_mesher = IVtkOCC_ShapeMesher() - - drawer = vtk_shape.Attributes() - drawer.SetUIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0)) - drawer.SetVIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0)) - - if tolerance: - drawer.SetDeviationCoefficient(tolerance) - - if angular_tolerance: - drawer.SetDeviationAngle(angular_tolerance) - - shape_mesher.Build(vtk_shape, shape_data) - - vtk_poly_data = shape_data.getVtkPolyData() - - # convert to triangles and split edges - t_filter = vtkTriangleFilter() - t_filter.SetInputData(vtk_poly_data) - t_filter.Update() - - return_value = t_filter.GetOutput() - - # compute normals - if normals: - n_filter = vtkPolyDataNormals() - n_filter.SetComputePointNormals(True) - n_filter.SetComputeCellNormals(True) - n_filter.SetFeatureAngle(360) - n_filter.SetInputData(return_value) - n_filter.Update() - - return_value = n_filter.GetOutput() - - return return_value - - 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 - """ - return self.__class__(BRepAlgo.ConvertFace_s(self.wrapped, tolerance)) - - def _repr_javascript_(self): - """Jupyter 3D representation support""" - - from .jupyter_tools import display - - return display(self)._repr_javascript_() - - def transformed( - self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0) - ) -> Self: - """Transform Shape - - Rotate and translate the Shape by the three angles (in degrees) and offset. - - Args: - rotate (VectorLike, optional): 3-tuple of angles to rotate, in degrees. - Defaults to (0, 0, 0). - offset (VectorLike, optional): 3-tuple to offset. Defaults to (0, 0, 0). - - Returns: - Shape: transformed object - - """ - # Convert to a Vector of radians - rotate_vector = Vector(rotate).multiply(DEG2RAD) - # Compute rotation matrix. - t_rx = gp_Trsf() - t_rx.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), rotate_vector.X) - t_ry = gp_Trsf() - t_ry.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), rotate_vector.Y) - t_rz = gp_Trsf() - t_rz.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), rotate_vector.Z) - t_o = gp_Trsf() - t_o.SetTranslation(Vector(offset).wrapped) - return self._apply_transform(t_o * t_rx * t_ry * t_rz) - - def find_intersection_points(self, axis: Axis) -> list[tuple[Vector, Vector]]: - """Find point and normal at intersection - - Return both the point(s) and normal(s) of the intersection of the axis and the shape - - Args: - axis (Axis): axis defining the intersection line - - Returns: - list[tuple[Vector, Vector]]: Point and normal of intersection - """ - oc_shape = self.wrapped - - intersection_line = gce_MakeLin(axis.wrapped).Value() - intersect_maker = BRepIntCurveSurface_Inter() - intersect_maker.Init(oc_shape, intersection_line, 0.0001) - - intersections = [] - while intersect_maker.More(): - inter_pt = intersect_maker.Pnt() - # Calculate distance along axis - distance = axis.to_plane().to_local_coords(Vector(inter_pt)).Z - intersections.append( - ( - Face(intersect_maker.Face()), - Vector(inter_pt), - distance, - ) - ) - intersect_maker.Next() - - intersections.sort(key=lambda x: x[2]) - intersecting_faces = [i[0] for i in intersections] - intersecting_points = [i[1] for i in intersections] - intersecting_normals = [ - f.normal_at(intersecting_points[i]).normalized() - for i, f in enumerate(intersecting_faces) - ] - result = [] - for pnt, normal in zip(intersecting_points, intersecting_normals): - result.append((pnt, normal)) - - return result - - @deprecated("Use find_intersection_points instead") - def find_intersection(self, axis: Axis) -> list[tuple[Vector, Vector]]: - return self.find_intersection_points(axis) - - def project_faces( - self, - faces: Union[list[Face], Compound], - path: Union[Wire, Edge], - start: float = 0, - ) -> Compound: - """Projected Faces following the given path on Shape - - Project by positioning each face of to the shape along the path and - projecting onto the surface. - - Note that projection may result in distortion depending on - the shape at a position along the path. - - .. image:: projectText.png - - Args: - faces (Union[list[Face], Compound]): faces to project - path: Path on the Shape to follow - start: Relative location on path to start the faces. Defaults to 0. - - Returns: - The projected faces - - """ - # pylint: disable=too-many-locals - path_length = path.length - # The derived classes of Shape implement center - shape_center = self.center() # pylint: disable=no-member - - if isinstance(faces, Compound): - faces = faces.faces() - - first_face_min_x = faces[0].bounding_box().min.X - - logger.debug("projecting %d face(s)", len(faces)) - - # Position each face normal to the surface along the path and project to the surface - projected_faces = [] - for face in faces: - bbox = face.bounding_box() - face_center_x = (bbox.min.X + bbox.max.X) / 2 - relative_position_on_wire = ( - start + (face_center_x - first_face_min_x) / path_length - ) - path_position = path.position_at(relative_position_on_wire) - path_tangent = path.tangent_at(relative_position_on_wire) - projection_axis = Axis(path_position, shape_center - path_position) - (surface_point, surface_normal) = self.find_intersection_points( - projection_axis - )[0] - surface_normal_plane = Plane( - origin=surface_point, x_dir=path_tangent, z_dir=surface_normal - ) - projection_face: Face = surface_normal_plane.from_local_coords( - face.moved(Location((-face_center_x, 0, 0))) - ) - - logger.debug("projecting face at %0.2f", relative_position_on_wire) - projected_faces.append( - projection_face.project_to_shape(self, surface_normal * -1)[0] - ) - - logger.debug("finished projecting '%d' faces", len(faces)) - - return Compound(projected_faces) - - def _extrude( - self, direction: VectorLike - ) -> Union[Edge, Face, Shell, Solid, Compound]: - """_extrude - - Extrude self in the provided direction. - - Args: - direction (VectorLike): direction and magnitude of extrusion - - Raises: - ValueError: Unsupported class - RuntimeError: Generated invalid result - - Returns: - Union[Edge, Face, Shell, Solid, Compound]: extruded shape - """ - direction = Vector(direction) - - if not isinstance(self, (Vertex, Edge, Wire, Face, Shell)): - raise ValueError(f"extrude not supported for {type(self)}") - - prism_builder = BRepPrimAPI_MakePrism(self.wrapped, direction.wrapped) - new_shape = downcast(prism_builder.Shape()) - shape_type = new_shape.ShapeType() - - if shape_type == TopAbs_ShapeEnum.TopAbs_EDGE: - result = Edge(new_shape) - elif shape_type == TopAbs_ShapeEnum.TopAbs_FACE: - result = Face(new_shape) - elif shape_type == TopAbs_ShapeEnum.TopAbs_SHELL: - result = Shell(new_shape) - elif shape_type == TopAbs_ShapeEnum.TopAbs_SOLID: - result = Solid(new_shape) - elif shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID: - solids = [] - explorer = TopExp_Explorer(new_shape, TopAbs_ShapeEnum.TopAbs_SOLID) - while explorer.More(): - topods_solid = downcast(explorer.Current()) - solids.append(Solid(topods_solid)) - explorer.Next() - result = Compound(solids) - else: - raise RuntimeError("extrude produced an unexpected result") - return result - - @classmethod - def extrude( - cls, obj: Union[Vertex, Edge, Wire, Face, Shell], direction: VectorLike - ) -> Self: - """extrude - - Extrude a Shape in the provided direction. - * Vertices generate Edges - * Edges generate Faces - * Wires generate Shells - * Faces generate Solids - * Shells generate Compounds - - Args: - direction (VectorLike): direction and magnitude of extrusion - - Raises: - ValueError: Unsupported class - RuntimeError: Generated invalid result - - Returns: - Union[Edge, Face, Shell, Solid, Compound]: extruded shape - """ - return obj._extrude(direction) - - def project_to_viewport( - self, - viewport_origin: VectorLike, - viewport_up: VectorLike = (0, 0, 1), - look_at: VectorLike = 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). - - Returns: - tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges - """ - - def extract_edges(compound): - edges = [] # List to store the extracted edges - - # Create a TopExp_Explorer to traverse the sub-shapes of the compound - explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE) - - # Loop through the sub-shapes and extract edges - while explorer.More(): - edge = downcast(explorer.Current()) - edges.append(edge) - explorer.Next() - - return edges - - # Setup the projector - hidden_line_removal = HLRBRep_Algo() - hidden_line_removal.Add(self.wrapped) - - viewport_origin = Vector(viewport_origin) - look_at = Vector(look_at) if look_at else self.center() - projection_dir: Vector = (viewport_origin - look_at).normalized() - viewport_up = Vector(viewport_up).normalized() - camera_coordinate_system = gp_Ax2() - camera_coordinate_system.SetAxis( - gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir()) - ) - camera_coordinate_system.SetYDirection(viewport_up.to_dir()) - projector = HLRAlgo_Projector(camera_coordinate_system) - - hidden_line_removal.Projector(projector) - hidden_line_removal.Update() - hidden_line_removal.Hide() - - hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal) - - # Create the visible edges - visible_edges = [] - for edges in [ - hlr_shapes.VCompound(), - hlr_shapes.Rg1LineVCompound(), - hlr_shapes.OutLineVCompound(), - ]: - if not edges.IsNull(): - visible_edges.extend(extract_edges(downcast(edges))) - - # Create the hidden edges - hidden_edges = [] - for edges in [ - hlr_shapes.HCompound(), - hlr_shapes.OutLineHCompound(), - hlr_shapes.Rg1LineHCompound(), - ]: - if not edges.IsNull(): - hidden_edges.extend(extract_edges(downcast(edges))) - - # Fix the underlying geometry - otherwise we will get segfaults - for edge in visible_edges: - BRepLib.BuildCurves3d_s(edge, TOLERANCE) - for edge in hidden_edges: - BRepLib.BuildCurves3d_s(edge, TOLERANCE) - - # convert to native shape objects - # visible_edges = ShapeList(map(Shape, visible_edges)) - # hidden_edges = ShapeList(map(Shape, hidden_edges)) - visible_edges = ShapeList(map(Edge, visible_edges)) - hidden_edges = ShapeList(map(Edge, hidden_edges)) - - return (visible_edges, hidden_edges) - - -class Comparable(metaclass=ABCMeta): - """Abstract base class that requires comparison methods""" - - @abstractmethod - def __lt__(self, other: Any) -> bool: ... - - @abstractmethod - def __eq__(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) - - -class ShapePredicate(Protocol): - """Predicate for shape filters""" - - def __call__(self, shape: Shape) -> bool: ... - - -class ShapeList(list[T]): - """Subclass of list with custom filter and sort methods appropriate to CAD""" - - # pylint: disable=too-many-public-methods - - @property - def first(self) -> T: - """First element in the ShapeList""" - return self[0] - - @property - def last(self) -> T: - """Last element in the ShapeList""" - return self[-1] - - def filter_by( - self, - filter_by: Union[ShapePredicate, Axis, Plane, GeomType], - reverse: bool = False, - tolerance: float = 1e-5, - ) -> ShapeList[T]: - """filter by Axis, Plane, or GeomType - - Either: - - filter objects of type planar Face or linear Edge by their normal or tangent - (respectively) and sort the results by the given axis, or - - filter the objects by the provided type. Note that not all types apply to all - objects. - - Args: - filter_by (Union[Axis,Plane,GeomType]): axis, plane, or geom type to filter - and possibly sort by. Filtering by a plane returns faces/edges parallel - to that plane. - reverse (bool, optional): invert the geom type filter. Defaults to False. - tolerance (float, optional): maximum deviation from axis. Defaults to 1e-5. - - Raises: - ValueError: Invalid filter_by type - - Returns: - ShapeList: filtered list of objects - """ - - # could be moved out maybe? - def axis_parallel_predicate(axis: Axis, tolerance: float): - def pred(shape: Shape): - if isinstance(shape, Face) and shape.is_planar: - shape_axis = Axis(shape.center(), shape.normal_at(None)) - elif isinstance(shape, Edge) and shape.geom_type == GeomType.LINE: - shape_axis = Axis(shape.position_at(0), shape.tangent_at(0)) - else: - return False - return axis.is_parallel(shape_axis, tolerance) - - return pred - - 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 isinstance(shape, Face) and shape.is_planar: - shape_axis = Axis(shape.center(), shape.normal_at(None)) - return plane_axis.is_parallel(shape_axis, tolerance) - if isinstance(shape, Wire): - return all(pred(e) for e in shape.edges()) - if isinstance(shape, Edge): - for curve in shape.wrapped.TShape().Curves(): - if curve.IsCurve3D(): - return ShapeAnalysis_Curve.IsPlanar_s( - curve.Curve3D(), plane_xyz, tolerance - ) - return False - return False - - return pred - - # convert input to callable predicate - if callable(filter_by): - predicate = filter_by - elif isinstance(filter_by, Axis): - predicate = axis_parallel_predicate(filter_by, tolerance=tolerance) - elif isinstance(filter_by, Plane): - predicate = plane_parallel_predicate(filter_by, tolerance=tolerance) - elif isinstance(filter_by, GeomType): - - def predicate(obj): - return obj.geom_type == filter_by - - else: - raise ValueError(f"Unsupported filter_by predicate: {filter_by}") - - # final predicate is negated if `reverse=True` - if reverse: - - def actual_predicate(shape): - return not predicate(shape) - - else: - actual_predicate = predicate - - return ShapeList(filter(actual_predicate, self)) - - def filter_by_position( - self, - axis: Axis, - minimum: float, - maximum: float, - inclusive: tuple[bool, bool] = (True, True), - ) -> ShapeList[T]: - """filter by position - - Filter and sort objects by the position of their centers along given axis. - min and max values can be inclusive or exclusive depending on the inclusive tuple. - - Args: - axis (Axis): axis to sort by - minimum (float): minimum value - maximum (float): maximum value - inclusive (tuple[bool, bool], optional): include min,max values. - Defaults to (True, True). - - Returns: - ShapeList: filtered object list - """ - if inclusive == (True, True): - objects = filter( - lambda o: minimum - <= axis.to_plane().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 - < maximum, - self, - ) - elif inclusive == (False, True): - objects = filter( - lambda o: minimum - < axis.to_plane().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, - self, - ) - - return ShapeList(objects).sort_by(axis) - - def group_by( - self, - group_by: Union[Callable[[Shape], K], Axis, Edge, Wire, SortBy] = Axis.Z, - reverse=False, - tol_digits=6, - ) -> GroupBy[T, K]: - """group by - - Group objects by provided criteria and then sort the groups according to the criteria. - Note that not all group_by criteria apply to all objects. - - Args: - group_by (SortBy, optional): group and sort criteria. Defaults to Axis.Z. - reverse (bool, optional): flip order of sort. Defaults to False. - tol_digits (int, optional): Tolerance for building the group keys by - round(key, tol_digits) - - Returns: - GroupBy[K, ShapeList]: sorted list of ShapeLists - """ - - if isinstance(group_by, Axis): - axis_as_location = group_by.location.inverse() - - def key_f(obj): - return round( - (axis_as_location * Location(obj.center())).position.Z, - tol_digits, - ) - - elif isinstance(group_by, (Edge, Wire)): - - def key_f(obj): - return round( - group_by.param_at_point(obj.center()), - tol_digits, - ) - - elif isinstance(group_by, SortBy): - if group_by == SortBy.LENGTH: - - def key_f(obj): - return round(obj.length, tol_digits) - - elif group_by == SortBy.RADIUS: - - def key_f(obj): - return round(obj.radius, tol_digits) - - elif group_by == SortBy.DISTANCE: - - def key_f(obj): - return round(obj.center().length, tol_digits) - - elif group_by == SortBy.AREA: - - def key_f(obj): - return round(obj.area, tol_digits) - - elif group_by == SortBy.VOLUME: - - def key_f(obj): - return round(obj.volume, tol_digits) - - elif callable(group_by): - key_f = group_by - - else: - raise ValueError(f"Unsupported group_by function: {group_by}") - - return GroupBy(key_f, self, reverse=reverse) - - def sort_by( - self, sort_by: Union[Axis, Edge, Wire, SortBy] = Axis.Z, reverse: bool = False - ) -> ShapeList[T]: - """sort by - - Sort objects by provided criteria. Note that not all sort_by criteria apply to all - objects. - - Args: - sort_by (SortBy, optional): sort criteria. Defaults to SortBy.Z. - reverse (bool, optional): flip order of sort. Defaults to False. - - Returns: - ShapeList: sorted list of objects - """ - if isinstance(sort_by, Axis): - axis_as_location = sort_by.location.inverse() - objects = sorted( - self, - key=lambda o: (axis_as_location * Location(o.center())).position.Z, - reverse=reverse, - ) - elif isinstance(sort_by, (Edge, Wire)): - - def u_of_closest_center(obj) -> float: - """u-value of closest point between object center and sort_by""" - 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 - ) - - elif isinstance(sort_by, SortBy): - if sort_by == SortBy.LENGTH: - objects = sorted( - self, - key=lambda obj: obj.length, - reverse=reverse, - ) - elif sort_by == SortBy.RADIUS: - objects = sorted( - self, - key=lambda obj: obj.radius, - reverse=reverse, - ) - elif sort_by == SortBy.DISTANCE: - objects = sorted( - self, - key=lambda obj: obj.center().length, - reverse=reverse, - ) - elif sort_by == SortBy.AREA: - objects = sorted( - self, - key=lambda obj: obj.area, - reverse=reverse, - ) - elif sort_by == SortBy.VOLUME: - objects = sorted( - self, - key=lambda obj: obj.volume, - reverse=reverse, - ) - - return ShapeList(objects) - - def sort_by_distance( - self, other: Union[Shape, VectorLike], reverse: bool = False - ) -> ShapeList[T]: - """Sort by distance - - Sort by minimal distance between objects and other - - Args: - other (Union[Shape,VectorLike]): reference object - reverse (bool, optional): flip order of sort. Defaults to False. - - Returns: - ShapeList: Sorted shapes - """ - other = other if isinstance(other, Shape) else Vertex(other) - distances = sorted( - [(other.distance_to(obj), obj) for obj in self], - key=lambda obj: obj[0], - reverse=reverse, - ) - return ShapeList([obj[1] for obj in distances]) - - def vertices(self) -> ShapeList[Vertex]: - """vertices - all the vertices in this ShapeList""" - return ShapeList([v for shape in self for v in shape.vertices()]) - - def vertex(self) -> Vertex: - """Return the Vertex""" - vertices = self.vertices() - vertex_count = len(vertices) - if vertex_count != 1: - warnings.warn( - f"Found {vertex_count} vertices, returning first", - stacklevel=2, - ) - return vertices[0] - - def edges(self) -> ShapeList[Edge]: - """edges - all the edges in this ShapeList""" - return ShapeList([e for shape in self for e in shape.edges()]) - - def edge(self) -> Edge: - """Return the Edge""" - edges = self.edges() - edge_count = len(edges) - if edge_count != 1: - warnings.warn( - f"Found {edge_count} edges, returning first", - stacklevel=2, - ) - return edges[0] - - def wires(self) -> ShapeList[Wire]: - """wires - all the wires in this ShapeList""" - return ShapeList([w for shape in self for w in shape.wires()]) - - def wire(self) -> Wire: - """Return the Wire""" - wires = self.wires() - wire_count = len(wires) - if wire_count != 1: - warnings.warn( - f"Found {wire_count} wires, returning first", - stacklevel=2, - ) - return wires[0] - - def faces(self) -> ShapeList[Face]: - """faces - all the faces in this ShapeList""" - return ShapeList([f for shape in self for f in shape.faces()]) - - def face(self) -> Face: - """Return the Face""" - faces = self.faces() - face_count = len(faces) - if face_count != 1: - msg = f"Found {face_count} faces, returning first" - warnings.warn(msg, stacklevel=2) - return faces[0] - - def shells(self) -> ShapeList[Shell]: - """shells - all the shells in this ShapeList""" - return ShapeList([s for shape in self for s in shape.shells()]) - - def shell(self) -> Shell: - """Return the Shell""" - shells = self.shells() - shell_count = len(shells) - if shell_count != 1: - warnings.warn( - f"Found {shell_count} shells, returning first", - stacklevel=2, - ) - return shells[0] - - def solids(self) -> ShapeList[Solid]: - """solids - all the solids in this ShapeList""" - return ShapeList([s for shape in self for s in shape.solids()]) - - def solid(self) -> Solid: - """Return the Solid""" - solids = self.solids() - solid_count = len(solids) - if solid_count != 1: - warnings.warn( - f"Found {solid_count} solids, returning first", - stacklevel=2, - ) - return solids[0] - - def compounds(self) -> ShapeList[Compound]: - """compounds - all the compounds in this ShapeList""" - return ShapeList([c for shape in self for c in shape.compounds()]) - - def compound(self) -> Compound: - """Return the Compound""" - compounds = self.compounds() - compound_count = len(compounds) - if compound_count != 1: - warnings.warn( - f"Found {compound_count} compounds, returning first", - stacklevel=2, - ) - return compounds[0] - - def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z): - """Sort operator >""" - return self.sort_by(sort_by) - - def __lt__(self, sort_by: Union[Axis, SortBy] = Axis.Z): - """Reverse sort operator <""" - return self.sort_by(sort_by, reverse=True) - - def __rshift__(self, group_by: Union[Axis, SortBy] = Axis.Z): - """Group and select largest group operator >>""" - return self.group_by(group_by)[-1] - - def __lshift__(self, group_by: Union[Axis, SortBy] = Axis.Z): - """Group and select smallest group operator <<""" - return self.group_by(group_by)[0] - - def __or__(self, filter_by: Union[Axis, GeomType] = Axis.Z): - """Filter by axis or geomtype operator |""" - return self.filter_by(filter_by) - - def __eq__(self, other: object): - """ShapeLists equality operator ==""" - return ( - set(self) == set(other) if isinstance(other, ShapeList) else NotImplemented - ) - - # Normally implementing __eq__ is enough, but ShapeList subclasses list, - # which already implements __ne__, so we need to override it, too - def __ne__(self, other: ShapeList): - """ShapeLists inequality operator !=""" - return ( - set(self) != set(other) if isinstance(other, ShapeList) else NotImplemented - ) - - def __add__(self, other: ShapeList): - """Combine two ShapeLists together operator +""" - return ShapeList(list(self) + list(other)) - - def __sub__(self, other: ShapeList) -> ShapeList: - """Differences between two ShapeLists operator -""" - # hash_other = [hash(o) for o in other] - # hash_set = {hash(o): o for o in self if hash(o) not in hash_other} - # return ShapeList(hash_set.values()) - return ShapeList(set(self) - set(other)) - - def __and__(self, other: ShapeList): - """Intersect two ShapeLists operator &""" - return ShapeList(set(self) & set(other)) - - @overload - def __getitem__(self, key: int) -> T: ... - - @overload - def __getitem__(self, key: slice) -> ShapeList[T]: ... - - def __getitem__(self, key: Union[int, slice]) -> Union[T, ShapeList[T]]: - """Return slices of ShapeList as ShapeList""" - if isinstance(key, slice): - return_value = ShapeList(list(self).__getitem__(key)) - else: - return_value = list(self).__getitem__(key) - return return_value - - -class GroupBy(Generic[T, K]): - """Result of a Shape.groupby operation. Groups can be accessed by index or key""" - - def __init__( - self, - key_f: Callable[[T], K], - shapelist: Iterable[T], - *, - reverse: bool = False, - ): - # can't be a dict because K may not be hashable - self.key_to_group_index: list[tuple[K, int]] = [] - self.groups: list[ShapeList[T]] = [] - self.key_f = key_f - - for i, (key, shapegroup) in enumerate( - itertools.groupby(sorted(shapelist, key=key_f, reverse=reverse), key=key_f) - ): - self.groups.append(ShapeList(shapegroup)) - self.key_to_group_index.append((key, i)) - - def __iter__(self): - return iter(self.groups) - - def __len__(self): - return len(self.groups) - - def __getitem__(self, key: int): - return self.groups[key] - - def __str__(self): - return pretty(self) - - def __repr__(self): - return repr(ShapeList(self)) - - def _repr_pretty_(self, p, cycle=False): - if cycle: - p.text("(...)") - else: - with p.group(1, "[", "]"): - for idx, item in enumerate(self): - if idx: - p.text(",") - p.breakable() - p.pretty(item) - - def group(self, key: K): - """Select group by key""" - for k, i in self.key_to_group_index: - if key == k: - return self.groups[i] - raise KeyError(key) - - def group_for(self, shape: T): - """Select group by shape""" - return self.group(self.key_f(shape)) - - -class Compound(Mixin3D, Shape): - """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 - hierarchical arrangement facilitates the construction of complex models by - combining simpler shapes. Compound plays a pivotal role in managing the - composition and structure of intricate 3D models in computer-aided design - (CAD) applications, allowing engineers and designers to work with assemblies - of shapes as unified entities for efficient modeling and analysis.""" - - _dim = None - - @property - def _dim(self) -> Union[int, None]: - """The dimension of the shapes within the Compound - None if inconsistent""" - sub_dims = {s._dim for s in self.first_level_shapes()} - return sub_dims.pop() if len(sub_dims) == 1 else None - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - material: str = "", - joints: dict[str, Joint] = None, - parent: Compound = None, - children: Iterable[Shape] = None, - ): - """Build a Compound from an OCCT TopoDS_Shape/TopoDS_Compound - - Args: - obj (TopoDS_Shape, optional): OCCT Compound. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - material (str, optional): tag for external tools. Defaults to ''. - joints (dict[str, Joint], optional): names joints. Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - children (Iterable[Shape], optional): assembly children. Defaults to None. - """ - - @overload - def __init__( - self, - shapes: Iterable[Shape], - label: str = "", - color: Color = None, - material: str = "", - joints: dict[str, Joint] = None, - parent: Compound = None, - children: Iterable[Shape] = None, - ): - """Build a Compound from Shapes - - Args: - shapes (Iterable[Shape]): shapes within the compound - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - material (str, optional): tag for external tools. Defaults to ''. - joints (dict[str, Joint], optional): names joints. Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - children (Iterable[Shape], optional): assembly children. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - shapes, obj, label, color, material, joints, parent, children = (None,) * 8 - - if args: - l_a = len(args) - if isinstance(args[0], TopoDS_Shape): - obj, label, color, material, joints, parent, children = args[:7] + ( - None, - ) * (7 - l_a) - elif isinstance(args[0], Iterable): - shapes, label, color, material, joints, parent, children = args[:7] + ( - None, - ) * (7 - l_a) - - unknown_args = ", ".join( - set(kwargs.keys()).difference( - [ - "shapes", - "obj", - "label", - "material", - "color", - "joints", - "parent", - "children", - ] - ) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - shapes = kwargs.get("shapes", shapes) - material = kwargs.get("material", material) - joints = kwargs.get("joints", joints) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - parent = kwargs.get("parent", parent) - children = kwargs.get("children", children) - - if shapes: - obj = Compound._make_compound([s.wrapped for s in shapes]) - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - self.material = "" if material is None else material - self.joints = {} if joints is None else joints - self.children = [] if children is None else children - - def __repr__(self): - """Return Compound info as string""" - if hasattr(self, "label") and hasattr(self, "children"): - result = ( - f"{self.__class__.__name__} at {id(self):#x}, label({self.label}), " - + f"#children({len(self.children)})" - ) - else: - result = f"{self.__class__.__name__} at {id(self):#x}" - return result - - @property - def volume(self) -> float: - """volume - the volume of this Compound""" - # when density == 1, mass == volume - return sum(i.volume for i in [*self.get_type(Solid), *self.get_type(Shell)]) - - def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: - """Return center of object - - Find center of object - - Args: - center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS. - - Raises: - ValueError: Center of GEOMETRY is not supported for this object - NotImplementedError: Unable to calculate center of mass of this object - - Returns: - Vector: center - """ - if center_of == CenterOf.GEOMETRY: - raise ValueError("Center of GEOMETRY is not supported for this object") - if center_of == CenterOf.MASS: - properties = GProp_GProps() - calc_function = shape_properties_LUT[unwrapped_shapetype(self)] - if calc_function: - calc_function(self.wrapped, properties) - middle = Vector(properties.CentreOfMass()) - else: - raise NotImplementedError - elif center_of == CenterOf.BOUNDING_BOX: - middle = self.bounding_box().center() - return middle - - @staticmethod - def _make_compound(occt_shapes: Iterable[TopoDS_Shape]) -> TopoDS_Compound: - """Create an OCCT TopoDS_Compound - - Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects - - Args: - occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes - - Returns: - TopoDS_Compound: OCCT compound - """ - comp = TopoDS_Compound() - comp_builder = TopoDS_Builder() - comp_builder.MakeCompound(comp) - - for shape in occt_shapes: - comp_builder.Add(comp, shape) - - return comp - - @classmethod - def make_compound(cls, shapes: Iterable[Shape]) -> Compound: - """Create a compound out of a list of shapes - Args: - shapes: Iterable[Shape]: - Returns: - """ - warnings.warn( - "make_compound() will be deprecated - use the Compound constructor instead", - DeprecationWarning, - stacklevel=2, - ) - - return cls(Compound._make_compound([s.wrapped for s in shapes])) - - def _remove(self, shape: Shape) -> Compound: - """Return self with the specified shape removed. - - Args: - shape: Shape: - """ - comp_builder = TopoDS_Builder() - comp_builder.Remove(self.wrapped, shape.wrapped) - return self - - def _post_detach(self, parent: Compound): - """Method call after detaching from `parent`.""" - logger.debug("Removing parent of %s (%s)", self.label, parent.label) - if parent.children: - parent.wrapped = Compound._make_compound( - [c.wrapped for c in parent.children] - ) - else: - parent.wrapped = None - - def _pre_attach(self, parent: Compound): - """Method call before attaching to `parent`.""" - if not isinstance(parent, Compound): - raise ValueError("`parent` must be of type Compound") - - def _post_attach(self, parent: Compound): - """Method call after attaching to `parent`.""" - logger.debug("Updated parent of %s to %s", self.label, parent.label) - parent.wrapped = Compound._make_compound([c.wrapped for c in parent.children]) - - def _post_detach_children(self, children): - """Method call before detaching `children`.""" - if children: - kids = ",".join([child.label for child in children]) - logger.debug("Removing children %s from %s", kids, self.label) - self.wrapped = Compound._make_compound([c.wrapped for c in self.children]) - # else: - # logger.debug("Removing no children from %s", self.label) - - def _pre_attach_children(self, children): - """Method call before attaching `children`.""" - if not all([isinstance(child, Shape) for child in children]): - raise ValueError("Each child must be of type Shape") - - def _post_attach_children(self, children: Iterable[Shape]): - """Method call after attaching `children`.""" - if children: - kids = ",".join([child.label for child in children]) - logger.debug("Adding children %s to %s", kids, self.label) - self.wrapped = Compound._make_compound([c.wrapped for c in self.children]) - # else: - # logger.debug("Adding no children to %s", self.label) - - def do_children_intersect( - self, include_parent: bool = False, tolerance: float = 1e-5 - ) -> tuple[bool, tuple[Shape, Shape], float]: - """Do Children Intersect - - Determine if any of the child objects within a Compound/assembly intersect by - intersecting each of the shapes with each other and checking for - a common volume. - - Args: - include_parent (bool, optional): check parent for intersections. Defaults to False. - tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5. - - Returns: - tuple[bool, tuple[Shape, Shape], float]: - do the object intersect, intersecting objects, volume of intersection - """ - children: list[Shape] = list(PreOrderIter(self)) - if not include_parent: - children.pop(0) # remove parent - # children_bbox = [child.bounding_box().to_solid() for child in children] - children_bbox = [ - Solid.from_bounding_box(child.bounding_box()) for child in children - ] - child_index_pairs = [ - tuple(map(int, comb)) - for comb in combinations(list(range(len(children))), 2) - ] - for child_index_pair in child_index_pairs: - # First check for bounding box intersections .. - # .. then confirm with actual object intersections which could be complex - bbox_common_volume = ( - children_bbox[child_index_pair[0]] - .intersect(children_bbox[child_index_pair[1]]) - .volume - ) - if bbox_common_volume > tolerance: - common_volume = ( - children[child_index_pair[0]] - .intersect(children[child_index_pair[1]]) - .volume - ) - if common_volume > tolerance: - return ( - True, - (children[child_index_pair[0]], children[child_index_pair[1]]), - common_volume, - ) - return (False, (), 0.0) - - @classmethod - def make_text( - cls, - txt: str, - font_size: float, - font: str = "Arial", - font_path: Optional[str] = None, - font_style: FontStyle = FontStyle.REGULAR, - align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), - position_on_path: float = 0.0, - text_path: Union[Edge, Wire] = None, - ) -> "Compound": - """2D Text that optionally follows a path. - - The text that is created can be combined as with other sketch features by specifying - a mode or rotated by the given angle. In addition, edges have been previously created - with arc or segment, the text will follow the path defined by these edges. The start - parameter can be used to shift the text along the path to achieve precise positioning. - - Args: - txt: text to be rendered - 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. - align (Union[Align, tuple[Align, Align]], optional): align min, center, or max - of object. Defaults to (Align.CENTER, Align.CENTER). - 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. - - Returns: - a Compound object containing multiple Faces representing the text - - Examples:: - - fox = Compound.make_text( - txt="The quick brown fox jumped over the lazy dog", - font_size=10, - position_on_path=0.1, - text_path=jump_edge, - ) - - """ - # pylint: disable=too-many-locals - - def position_face(orig_face: "Face") -> "Face": - """ - Reposition a face to the provided path - - Local coordinates are used to calculate the position of the face - relative to the path. Global coordinates to position the face. - """ - bbox = orig_face.bounding_box() - face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0) - relative_position_on_wire = ( - position_on_path + face_bottom_center.X / path_length - ) - wire_tangent = text_path.tangent_at(relative_position_on_wire) - wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent) - wire_position = text_path.position_at(relative_position_on_wire) - - return orig_face.translate(wire_position - face_bottom_center).rotate( - Axis(wire_position, (0, 0, 1)), - -wire_angle, - ) - - if sys.platform.startswith("linux"): - os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf" - os.environ["FONTCONFIG_PATH"] = "/etc/fonts/" - - font_kind = { - FontStyle.REGULAR: Font_FA_Regular, - FontStyle.BOLD: Font_FA_Bold, - FontStyle.ITALIC: Font_FA_Italic, - }[font_style] - - mgr = Font_FontMgr.GetInstance_s() - - if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()): - font_t = Font_SystemFont(TCollection_AsciiString(font_path)) - font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path)) - mgr.RegisterFont(font_t, True) - - else: - font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind) - - logger.info( - "Creating text with font %s located at %s", - font_t.FontName().ToCString(), - font_t.FontPath(font_kind).ToCString(), - ) - - builder = Font_BRepTextBuilder() - font_i = StdPrs_BRepFont( - NCollection_Utf8String(font_t.FontName().ToCString()), - font_kind, - float(font_size), - ) - text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt))) - - # Align the text from the bounding box - align = tuplify(align, 2) - text_flat = text_flat.translate( - Vector(*text_flat.bounding_box().to_align_offset(align)) - ) - - if text_path is not None: - path_length = text_path.length - text_flat = Compound([position_face(f) for f in text_flat.faces()]) - - return text_flat - - @classmethod - def make_triad(cls, axes_scale: float) -> Compound: - """The coordinate system triad (X, Y, Z axes)""" - x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0)) - y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0)) - z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale)) - arrow_arc = Edge.make_spline( - [(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)], - [(-1, 0, 0), (-1, 1.5, 0)], - ) - arrow = arrow_arc.fuse(copy.copy(arrow_arc).mirror(Plane.XZ)) - x_label = ( - Compound.make_text( - "X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) - ) - .move(Location(x_axis @ 1)) - .edges() - ) - y_label = ( - Compound.make_text( - "Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) - ) - .rotate(Axis.Z, 90) - .move(Location(y_axis @ 1)) - .edges() - ) - z_label = ( - Compound.make_text( - "Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN) - ) - .rotate(Axis.Y, 90) - .rotate(Axis.X, 90) - .move(Location(z_axis @ 1)) - .edges() - ) - triad = Edge.fuse( - x_axis, - y_axis, - z_axis, - arrow.moved(Location(x_axis @ 1)), - arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)), - arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)), - *x_label, - *y_label, - *z_label, - ) - return triad - - def __iter__(self) -> Iterator[Shape]: - """ - Iterate over subshapes. - - """ - - iterator = TopoDS_Iterator(self.wrapped) - - while iterator.More(): - yield Shape.cast(iterator.Value()) - iterator.Next() - - def __len__(self) -> int: - """Return the number of subshapes""" - count = 0 - if self.wrapped is not None: - for _ in self: - count += 1 - return count - - def __bool__(self) -> bool: - """ - Check if empty. - """ - - return TopoDS_Iterator(self.wrapped).More() - - def cut(self, *to_cut: Shape) -> Compound: - """Remove a shape from another one - - Args: - *to_cut: Shape: - - Returns: - - """ - - cut_op = BRepAlgoAPI_Cut() - - return tcast(Compound, self._bool_op(self, to_cut, cut_op)) - - def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Compound: - """Fuse shapes together - - Args: - *to_fuse: Shape: - glue: bool: (Default value = False) - tol: float: (Default value = None) - - Returns: - - """ - - fuse_op = BRepAlgoAPI_Fuse() - if glue: - fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift) - if tol: - fuse_op.SetFuzzyValue(tol) - - args = tuple(self) + to_fuse - - if len(args) <= 1: - return_value: Shape = args[0] - else: - return_value = self._bool_op(args[:1], args[1:], fuse_op) - - # fuse_op.RefineEdges() - # fuse_op.FuseEdges() - - return tcast(Compound, return_value) - - def intersect(self, *to_intersect: Shape) -> Compound: - """Construct shape intersection - - Args: - *to_intersect: Shape: - - Returns: - - """ - - intersect_op = BRepAlgoAPI_Common() - - return tcast(Compound, self._bool_op(self, to_intersect, intersect_op)) - - def get_type( - self, - obj_type: Union[ - Type[Vertex], Type[Edge], Type[Face], Type[Shell], Type[Solid], Type[Wire] - ], - ) -> list[Union[Vertex, Edge, Face, Shell, Solid, Wire]]: - """get_type - - Extract the objects of the given type from a Compound. Note that this - isn't the same as Faces() etc. which will extract Faces from Solids. - - Args: - obj_type (Union[Vertex, Edge, Face, Shell, Solid, Wire]): Object types to extract - - Returns: - list[Union[Vertex, Edge, Face, Shell, Solid, Wire]]: Extracted objects - """ - - type_map = { - Vertex: TopAbs_ShapeEnum.TopAbs_VERTEX, - Edge: TopAbs_ShapeEnum.TopAbs_EDGE, - Face: TopAbs_ShapeEnum.TopAbs_FACE, - Shell: TopAbs_ShapeEnum.TopAbs_SHELL, - Solid: TopAbs_ShapeEnum.TopAbs_SOLID, - Wire: TopAbs_ShapeEnum.TopAbs_WIRE, - Compound: TopAbs_ShapeEnum.TopAbs_COMPOUND, - } - results = [] - for comp in self.compounds(): - iterator = TopoDS_Iterator() - iterator.Initialize(comp.wrapped) - while iterator.More(): - child = iterator.Value() - if child.ShapeType() == type_map[obj_type]: - results.append(obj_type(downcast(child))) - iterator.Next() - - return results - - def first_level_shapes( - self, _shapes: list[TopoDS_Shape] = None - ) -> ShapeList[Shape]: - """first_level_shapes - - This method iterates through the immediate children of the compound and - collects all non-compound shapes (e.g., vertices, edges, faces, solids). - If a child shape is itself a compound, the method recursively explores it, - retrieving all first-level shapes within any nested compounds. - - Note: the _shapes parameter is not to be assigned by the user. - - Returns: - ShapeList[Shape]: Shapes contained within the Compound - """ - if self.wrapped is None: - return ShapeList() - if _shapes is None: - _shapes = [] - iterator = TopoDS_Iterator() - iterator.Initialize(self.wrapped) - while iterator.More(): - child = Shape.cast(iterator.Value()) - if isinstance(child, Compound): - child.first_level_shapes(_shapes) - else: - _shapes.append(child) - iterator.Next() - return ShapeList(_shapes) - - def unwrap(self, fully: bool = True) -> Union[Self, Shape]: - """Strip unnecessary Compound wrappers - - Args: - fully (bool, optional): return base shape without any Compound - wrappers (otherwise one Compound is left). Defaults to True. - - Returns: - Union[Self, Shape]: base shape - """ - if len(self) == 1: - single_element = next(iter(self)) - self.copy_attributes_to(single_element, ["wrapped", "_NodeMixin__children"]) - - # If the single element is another Compound, unwrap it recursively - if isinstance(single_element, Compound): - # Unwrap recursively and copy attributes down - unwrapped = single_element.unwrap(fully) - if not fully: - unwrapped = type(self)(unwrapped.wrapped) - self.copy_attributes_to(unwrapped, ["wrapped", "_NodeMixin__children"]) - return unwrapped - - return single_element if fully else self - - # If there are no elements or more than one element, return self - return self - - -class Part(Compound): - """A Compound containing 3D objects - aka Solids""" - - _dim = 3 - - @property - def _dim(self) -> int: - return 3 - - -class Sketch(Compound): - """A Compound containing 2D objects - aka Faces""" - - _dim = 2 - - @property - def _dim(self) -> int: - return 2 - - -class Curve(Compound): - """A Compound containing 1D objects - aka Edges""" - - _dim = 1 - - @property - def _dim(self) -> int: - return 1 - - def __matmul__(self, position: float) -> Vector: - """Position on curve operator @ - only works if continuous""" - return Wire(self.edges()).position_at(position) - - def __mod__(self, position: float) -> Vector: - """Tangent on wire operator % - only works if continuous""" - return Wire(self.edges()).tangent_at(position) - - def __xor__(self, position: float) -> Location: - """Location on wire operator ^ - only works if continuous""" - return Wire(self.edges()).location_at(position) - - def wires(self) -> list[Wire]: - """A list of wires created from the edges""" - return Wire.combine(self.edges()) - - -class Edge(Mixin1D, Shape): - """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 - defined shape. Edge is crucial in for precise modeling and manipulation of curves, - facilitating operations like filleting, chamfering, and Boolean operations. It - serves as a building block for constructing complex structures, such as wires - and faces.""" - - # pylint: disable=too-many-public-methods - - _dim = 1 - - @property - def _dim(self) -> int: - return 1 - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build an Edge from an OCCT TopoDS_Shape/TopoDS_Edge - - Args: - obj (TopoDS_Shape, optional): OCCT Face. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - axis: Axis, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build an infinite Edge from an Axis - - Args: - axis (Axis): Axis to be converted to an infinite Edge - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - axis, obj, label, color, parent = (None,) * 5 - - if args: - l_a = len(args) - if isinstance(args[0], TopoDS_Shape): - obj, label, color, parent = args[:4] + (None,) * (4 - l_a) - elif isinstance(args[0], Axis): - axis, label, color, parent = args[:4] + (None,) * (4 - l_a) - - unknown_args = ", ".join( - set(kwargs.keys()).difference(["axis", "obj", "label", "color", "parent"]) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - axis = kwargs.get("axis", axis) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - parent = kwargs.get("parent", parent) - - if axis is not None: - obj = BRepBuilderAPI_MakeEdge( - Geom_Line( - axis.position.to_pnt(), - axis.direction.to_dir(), - ) - ).Edge() - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - - def _geom_adaptor(self) -> BRepAdaptor_Curve: - """ """ - return BRepAdaptor_Curve(self.wrapped) - - def close(self) -> Union[Edge, Wire]: - """Close an Edge""" - if not self.is_closed: - return_value = Wire([self]).close() - else: - return_value = self - - return return_value - - def to_wire(self) -> Wire: - """Edge as Wire""" - return Wire([self]) - - @property - def arc_center(self) -> Vector: - """center of an underlying circle or ellipse geometry.""" - - geom_type = self.geom_type - geom_adaptor = self._geom_adaptor() - - if geom_type == GeomType.CIRCLE: - return_value = Vector(geom_adaptor.Circle().Position().Location()) - elif geom_type == GeomType.ELLIPSE: - return_value = Vector(geom_adaptor.Ellipse().Position().Location()) - else: - raise ValueError(f"{geom_type} has no arc center") - - return return_value - - def find_tangent( - self, - angle: float, - ) -> list[float]: - """find_tangent - - Find the parameter values of self where the tangent is equal to angle. - - Args: - angle (float): target angle in degrees - - Returns: - list[float]: u values between 0.0 and 1.0 - """ - angle = angle % 360 # angle needs to always be positive 0..360 - - if self.geom_type == GeomType.LINE: - if self.tangent_angle_at(0) == angle: - u_values = [0] - else: - u_values = [] - else: - # Solve this problem geometrically by creating a tangent curve and finding intercepts - periodic = int(self.is_closed) # if closed don't include end point - tan_pnts = [] - previous_tangent = None - - # When angles go from 360 to 0 a discontinuity is created so add 360 to these - # values and intercept another line - discontinuities = 0.0 - for i in range(101 - periodic): - tangent = self.tangent_angle_at(i / 100) + discontinuities * 360 - if ( - previous_tangent is not None - and abs(previous_tangent - tangent) > 300 - ): - discontinuities = copysign(1.0, previous_tangent - tangent) - tangent += 360 * discontinuities - previous_tangent = tangent - tan_pnts.append((i / 100, tangent)) - - # Generate a first differential curve from the tangent points - tan_curve = Edge.make_spline(tan_pnts) - - # Use the bounding box to find the min and max values - tan_curve_bbox = tan_curve.bounding_box() - min_range = 360 * (floor(tan_curve_bbox.min.Y / 360)) - max_range = 360 * (ceil(tan_curve_bbox.max.Y / 360)) - - # Create a horizontal line for each 360 cycle and intercept it - intercept_pnts = [] - for i in range(min_range, max_range + 1, 360): - line = Edge.make_line((0, angle + i, 0), (100, angle + i, 0)) - intercept_pnts.extend(tan_curve.find_intersection_points(line)) - - u_values = [p.X for p in intercept_pnts] - - return u_values - - def _intersect_with_edge(self, edge: Edge) -> Shape: - # Find any intersection points - vertex_intersections = [ - Vertex(pnt) for pnt in self.find_intersection_points(edge) - ] - - # Find Edge/Edge overlaps - intersect_op = BRepAlgoAPI_Common() - edge_intersections = self._bool_op((self,), (edge,), intersect_op).edges() - - return Compound(vertex_intersections + edge_intersections) - - def _intersect_with_axis(self, axis: Axis) -> Shape: - # Find any intersection points - vertex_intersections = [ - Vertex(pnt) for pnt in self.find_intersection_points(axis) - ] - - # Find Edge/Edge overlaps - intersect_op = BRepAlgoAPI_Common() - edge_intersections = self._bool_op((self,), (Edge(axis),), intersect_op).edges() - - return Compound(vertex_intersections + edge_intersections) - - def find_intersection_points( - self, edge: Union[Axis, Edge] = None, tolerance: float = TOLERANCE - ) -> ShapeList[Vector]: - """find_intersection_points - - Determine the points where a 2D edge crosses itself or another 2D edge - - Args: - edge (Union[Axis, Edge]): curve to compare with - tolerance (float, optional): the precision of computing the intersection points. - Defaults to TOLERANCE. - - Returns: - ShapeList[Vector]: list of intersection points - """ - # Convert an Axis into an edge at least as large as self and Axis start point - if isinstance(edge, Axis): - self_bbox_w_edge = self.bounding_box().add( - Vertex(edge.position).bounding_box() - ) - edge = Edge.make_line( - edge.position + edge.direction * (-1 * self_bbox_w_edge.diagonal), - edge.position + edge.direction * self_bbox_w_edge.diagonal, - ) - # To determine the 2D plane to work on - plane = self.common_plane(edge) - if plane is None: - raise ValueError("All objects must be on the same plane") - edge_surface: Geom_Surface = Face.make_plane(plane)._geom_adaptor() - - self_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( - self.wrapped, - edge_surface, - TopLoc_Location(), - self.param_at(0), - self.param_at(1), - ) - if edge is not None: - edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( - edge.wrapped, - edge_surface, - TopLoc_Location(), - edge.param_at(0), - edge.param_at(1), - ) - intersector = Geom2dAPI_InterCurveCurve( - self_2d_curve, edge_2d_curve, tolerance - ) - else: - intersector = Geom2dAPI_InterCurveCurve(self_2d_curve, tolerance) - - crosses = [ - Vector(intersector.Point(i + 1).X(), intersector.Point(i + 1).Y()) - for i in range(intersector.NbPoints()) - ] - # Convert back to global coordinates - crosses = [plane.from_local_coords(p) for p in crosses] - - # crosses may contain points beyond the ends of the edge so - # .. filter those out - valid_crosses = [] - for pnt in crosses: - try: - if edge is not None: - if ( - self.distance_to(pnt) <= TOLERANCE - and edge.distance_to(pnt) <= TOLERANCE - ): - valid_crosses.append(pnt) - else: - if self.distance_to(pnt) <= TOLERANCE: - valid_crosses.append(pnt) - except ValueError: - pass # skip invalid points - - return ShapeList(valid_crosses) - - def intersect(self, other: Union[Edge, Axis]) -> Union[Shape, None]: - intersection: Compound - if isinstance(other, Edge): - intersection = self._intersect_with_edge(other) - elif isinstance(other, Axis): - intersection = self._intersect_with_axis(other) - else: - return NotImplemented - - if intersection is not None: - # If there is just one vertex or edge return it - vertices = intersection.get_type(Vertex) - edges = intersection.get_type(Edge) - if len(vertices) == 1 and len(edges) == 0: - return vertices[0] - elif len(vertices) == 0 and len(edges) == 1: - return edges[0] - else: - return intersection - - def reversed(self) -> Edge: - """Return a copy of self with the opposite orientation""" - 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 - return reversed_edge - - def trim(self, start: float, end: float) -> Edge: - """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 - - Raises: - ValueError: start >= end - - Returns: - Edge: trimmed edge - """ - if start >= end: - raise ValueError(f"start ({start}) must be less than end ({end})") - - new_curve = BRep_Tool.Curve_s( - copy.deepcopy(self).wrapped, self.param_at(0), self.param_at(1) - ) - parm_start = self.param_at(start) - parm_end = self.param_at(end) - trimmed_curve = Geom_TrimmedCurve( - new_curve, - parm_start, - parm_end, - ) - new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() - return Edge(new_edge) - - def trim_to_length(self, start: float, 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 - length (float): target length - - Returns: - Edge: trimmed edge - """ - new_curve = BRep_Tool.Curve_s( - copy.deepcopy(self).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) - abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start) - - # Get the parameter at the desired length - parm_end = abscissa_point.Parameter() - - # Trim the curve to the desired length - trimmed_curve = Geom_TrimmedCurve(new_curve, parm_start, parm_end) - - new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() - return Edge(new_edge) - - def param_at_point(self, point: VectorLike) -> float: - """Normalized parameter at point along Edge""" - - # 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 - # 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. - # - # 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) - - point = Vector(point) - - if not isclose_b(self.distance_to(point), 0, abs_tol=TOLERANCE): - raise ValueError(f"point ({point}) is not on edge") - - # Function to be minimized - def func(param: ndarray) -> float: - return (self.position_at(param[0]) - point).length - - # 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 - - @classmethod - def make_bezier(cls, *cntl_pnts: VectorLike, weights: list[float] = None) -> Edge: - """make_bezier - - 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. - - Args: - cntl_pnts (sequence[VectorLike]): points defining the curve - weights (list[float], optional): control point weights list. Defaults to None. - - Raises: - ValueError: Too few control points - ValueError: Too many control points - ValueError: A weight is required for each control point - - Returns: - Edge: bezier curve - """ - if len(cntl_pnts) < 2: - raise ValueError( - "At least two control points must be provided (start, end)" - ) - if len(cntl_pnts) > 25: - raise ValueError("The maximum number of control points is 25") - if weights: - if len(cntl_pnts) != len(weights): - raise ValueError("A weight must be provided for each control point") - - cntl_gp_pnts = [Vector(cntl_pnt).to_pnt() for cntl_pnt in cntl_pnts] - - # The poles are stored in an OCCT Array object - poles = TColgp_Array1OfPnt(1, len(cntl_gp_pnts)) - for i, cntl_gp_pnt in enumerate(cntl_gp_pnts): - poles.SetValue(i + 1, cntl_gp_pnt) - - if weights: - pole_weights = TColStd_Array1OfReal(1, len(weights)) - for i, weight in enumerate(weights): - pole_weights.SetValue(i + 1, float(weight)) - bezier_curve = Geom_BezierCurve(poles, pole_weights) - else: - bezier_curve = Geom_BezierCurve(poles) - - return cls(BRepBuilderAPI_MakeEdge(bezier_curve).Edge()) - - @classmethod - def make_circle( - cls, - radius: float, - plane: Plane = Plane.XY, - start_angle: float = 360.0, - end_angle: float = 360, - angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, - ) -> Edge: - """make circle - - Create a circle centered on the origin of plane - - Args: - radius (float): circle radius - plane (Plane, optional): base plane. Defaults to Plane.XY. - start_angle (float, optional): start of arc angle. Defaults to 360.0. - end_angle (float, optional): end of arc angle. Defaults to 360. - angular_direction (AngularDirection, optional): arc direction. - Defaults to AngularDirection.COUNTER_CLOCKWISE. - - Returns: - Edge: full or partial circle - """ - circle_gp = gp_Circ(plane.to_gp_ax2(), radius) - - if start_angle == end_angle: # full circle case - return_value = cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge()) - else: # arc case - ccw = angular_direction == AngularDirection.COUNTER_CLOCKWISE - if ccw: - start = radians(start_angle) - end = radians(end_angle) - else: - start = radians(end_angle) - end = radians(start_angle) - circle_geom = GC_MakeArcOfCircle(circle_gp, start, end, ccw).Value() - return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) - return return_value - - @classmethod - def make_ellipse( - cls, - x_radius: float, - y_radius: float, - plane: Plane = Plane.XY, - start_angle: float = 360.0, - end_angle: float = 360.0, - angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, - ) -> Edge: - """make ellipse - - Makes an ellipse centered at the origin of plane. - - Args: - 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) - plane (Plane, optional): base plane. Defaults to Plane.XY. - start_angle (float, optional): Defaults to 360.0. - end_angle (float, optional): Defaults to 360.0. - angular_direction (AngularDirection, optional): arc direction. - Defaults to AngularDirection.COUNTER_CLOCKWISE. - - Returns: - Edge: full or partial ellipse - """ - ax1 = gp_Ax1(plane.origin.to_pnt(), plane.z_dir.to_dir()) - - if y_radius > x_radius: - # swap x and y radius and rotate by 90° afterwards to create an ellipse - # with x_radius < y_radius - correction_angle = 90.0 * DEG2RAD - ellipse_gp = gp_Elips(plane.to_gp_ax2(), y_radius, x_radius).Rotated( - ax1, correction_angle - ) - else: - correction_angle = 0.0 - ellipse_gp = gp_Elips(plane.to_gp_ax2(), x_radius, y_radius) - - if start_angle == end_angle: # full ellipse case - ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_gp).Edge()) - else: # arc case - # take correction_angle into account - ellipse_geom = GC_MakeArcOfEllipse( - ellipse_gp, - start_angle * DEG2RAD - correction_angle, - end_angle * DEG2RAD - correction_angle, - angular_direction == AngularDirection.COUNTER_CLOCKWISE, - ).Value() - ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_geom).Edge()) - - return ellipse - - @classmethod - def make_mid_way(cls, first: Edge, second: Edge, middle: float = 0.5) -> Edge: - """make line between edges - - Create a new linear Edge between the two provided Edges. If the Edges are parallel - but in the opposite directions one Edge is flipped such that the mid way Edge isn't - truncated. - - Args: - first (Edge): first reference Edge - second (Edge): second reference Edge - middle (float, optional): factional distance between Edges. Defaults to 0.5. - - Returns: - Edge: linear Edge between two Edges - """ - flip = first.to_axis().is_opposite(second.to_axis()) - pnts = [ - Edge.make_line( - first.position_at(i), second.position_at(1 - i if flip else i) - ).position_at(middle) - for i in [0, 1] - ] - return Edge.make_line(*pnts) - - @classmethod - def make_spline( - cls, - points: list[VectorLike], - tangents: list[VectorLike] = None, - periodic: bool = False, - parameters: list[float] = None, - scale: bool = True, - tol: float = 1e-6, - ) -> Edge: - """Spline - - Interpolate a spline through the provided points. - - Args: - points (list[VectorLike]): the points defining the spline - tangents (list[VectorLike], optional): start and finish tangent. - Defaults to None. - periodic (bool, optional): creation of periodic curves. Defaults to False. - parameters (list[float], optional): the value of the parameter at each - interpolation point. (The interpolated curve is represented as a vector-valued - function of a scalar parameter.) If periodic == True, then len(parameters) - must be len(interpolation points) + 1, otherwise len(parameters) - must be equal to len(interpolation points). Defaults to None. - scale (bool, optional): whether to scale the specified tangent vectors before - interpolating. Each tangent is scaled, so it's length is equal to the derivative - of the Lagrange interpolated curve. I.e., set this to True, if you want to use - only the direction of the tangent vectors specified by `tangents` , but not - their magnitude. Defaults to True. - tol (float, optional): tolerance of the algorithm (consult OCC documentation). - Used to check that the specified points are not too close to each other, and - that tangent vectors are not too short. (In either case interpolation may fail.). - Defaults to 1e-6. - - Raises: - ValueError: Parameter for each interpolation point - ValueError: Tangent for each interpolation point - ValueError: B-spline interpolation failed - - Returns: - Edge: the spline - """ - # pylint: disable=too-many-locals - points = [Vector(point) for point in points] - if tangents: - tangents = tuple(Vector(v) for v in tangents) - pnts = TColgp_HArray1OfPnt(1, len(points)) - for i, point in enumerate(points): - pnts.SetValue(i + 1, point.to_pnt()) - - if parameters is None: - spline_builder = GeomAPI_Interpolate(pnts, periodic, tol) - else: - if len(parameters) != (len(points) + periodic): - raise ValueError( - "There must be one parameter for each interpolation point " - "(plus one if periodic), or none specified. Parameter count: " - f"{len(parameters)}, point count: {len(points)}" - ) - parameters_array = TColStd_HArray1OfReal(1, len(parameters)) - for p_index, p_value in enumerate(parameters): - parameters_array.SetValue(p_index + 1, p_value) - - spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol) - - if tangents: - if len(tangents) == 2 and len(points) != 2: - # Specify only initial and final tangent: - spline_builder.Load(tangents[0].wrapped, tangents[1].wrapped, scale) - else: - if len(tangents) != len(points): - raise ValueError( - f"There must be one tangent for each interpolation point, " - f"or just two end point tangents. Tangent count: " - f"{len(tangents)}, point count: {len(points)}" - ) - - # Specify a tangent for each interpolation point: - tangents_array = TColgp_Array1OfVec(1, len(tangents)) - tangent_enabled_array = TColStd_HArray1OfBoolean(1, len(tangents)) - for t_index, t_value in enumerate(tangents): - tangent_enabled_array.SetValue(t_index + 1, t_value is not None) - tangent_vec = t_value if t_value is not None else Vector() - tangents_array.SetValue(t_index + 1, tangent_vec.wrapped) - - spline_builder.Load(tangents_array, tangent_enabled_array, scale) - - spline_builder.Perform() - if not spline_builder.IsDone(): - raise ValueError("B-spline interpolation failed") - - spline_geom = spline_builder.Curve() - - return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge()) - - @classmethod - def make_spline_approx( - cls, - points: list[VectorLike], - tol: float = 1e-3, - smoothing: Tuple[float, float, float] = None, - min_deg: int = 1, - max_deg: int = 6, - ) -> Edge: - """make_spline_approx - - Approximate a spline through the provided points. - - Args: - points (list[Vector]): - tol (float, optional): tolerance of the algorithm. Defaults to 1e-3. - smoothing (Tuple[float, float, float], optional): optional tuple of 3 weights - use for variational smoothing. Defaults to None. - min_deg (int, optional): minimum spline degree. Enforced only when smoothing - is None. Defaults to 1. - max_deg (int, optional): maximum spline degree. Defaults to 6. - - Raises: - ValueError: B-spline approximation failed - - Returns: - Edge: spline - """ - pnts = TColgp_HArray1OfPnt(1, len(points)) - for i, point in enumerate(points): - pnts.SetValue(i + 1, Vector(point).to_pnt()) - - if smoothing: - spline_builder = GeomAPI_PointsToBSpline( - pnts, *smoothing, DegMax=max_deg, Tol3D=tol - ) - else: - spline_builder = GeomAPI_PointsToBSpline( - pnts, DegMin=min_deg, DegMax=max_deg, Tol3D=tol - ) - - if not spline_builder.IsDone(): - raise ValueError("B-spline approximation failed") - - spline_geom = spline_builder.Curve() - - return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge()) - - @classmethod - def make_three_point_arc( - cls, point1: VectorLike, point2: VectorLike, point3: VectorLike - ) -> Edge: - """Three Point Arc - - Makes a three point arc through the provided points - - Args: - point1 (VectorLike): start point - point2 (VectorLike): middle point - point3 (VectorLike): end point - - Returns: - Edge: a circular arc through the three points - """ - circle_geom = GC_MakeArcOfCircle( - Vector(point1).to_pnt(), Vector(point2).to_pnt(), Vector(point3).to_pnt() - ).Value() - - return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) - - @classmethod - def make_tangent_arc( - cls, start: VectorLike, tangent: VectorLike, end: VectorLike - ) -> Edge: - """Tangent Arc - - Makes a tangent arc from point start, in the direction of tangent and ends at end. - - Args: - start (VectorLike): start point - tangent (VectorLike): start tangent - end (VectorLike): end point - - Returns: - Edge: circular arc - """ - circle_geom = GC_MakeArcOfCircle( - Vector(start).to_pnt(), Vector(tangent).wrapped, Vector(end).to_pnt() - ).Value() - - return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) - - @classmethod - def make_line(cls, point1: VectorLike, point2: VectorLike) -> Edge: - """Create a line between two points - - Args: - point1: VectorLike: that represents the first point - point2: VectorLike: that represents the second point - - Returns: - A linear edge between the two provided points - - """ - return cls( - BRepBuilderAPI_MakeEdge( - Vector(point1).to_pnt(), Vector(point2).to_pnt() - ).Edge() - ) - - @classmethod - def make_helix( - cls, - pitch: float, - height: float, - radius: float, - center: VectorLike = (0, 0, 0), - normal: VectorLike = (0, 0, 1), - angle: float = 0.0, - lefthand: bool = False, - ) -> Wire: - """make_helix - - Make a helix with a given pitch, height and radius. By default a cylindrical surface is - used to create the helix. If the :angle: is set (the apex given in degree) a conical - surface is used instead. - - Args: - pitch (float): distance per revolution along normal - height (float): total height - radius (float): - center (VectorLike, optional): Defaults to (0, 0, 0). - normal (VectorLike, optional): Defaults to (0, 0, 1). - angle (float, optional): conical angle. Defaults to 0.0. - lefthand (bool, optional): Defaults to False. - - Returns: - Wire: helix - """ - # pylint: disable=too-many-locals - # 1. build underlying cylindrical/conical surface - if angle == 0.0: - geom_surf: Geom_Surface = Geom_CylindricalSurface( - gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), radius - ) - else: - geom_surf = Geom_ConicalSurface( - gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), - angle * DEG2RAD, - radius, - ) - - # 2. construct an segment in the u,v domain - - # Determine the length of the 2d line which will be wrapped around the surface - line_sign = -1 if lefthand else 1 - line_dir = Vector(line_sign * 2 * pi, pitch).normalized() - line_len = (height / line_dir.Y) / cos(radians(angle)) - - # Create an infinite 2d line in the direction of the helix - helix_line = Geom2d_Line(gp_Pnt2d(0, 0), gp_Dir2d(line_dir.X, line_dir.Y)) - # Trim the line to the desired length - helix_curve = Geom2d_TrimmedCurve( - helix_line, 0, line_len, theAdjustPeriodic=True - ) - - # 3. Wrap the line around the surface - edge_builder = BRepBuilderAPI_MakeEdge(helix_curve, geom_surf) - topods_edge = edge_builder.Edge() - - # 4. Convert the edge made with 2d geometry to 3d - BRepLib.BuildCurves3d_s(topods_edge, 1e-9, MaxSegment=2000) - - return cls(topods_edge) - - def distribute_locations( - self: Union[Wire, Edge], - count: int, - start: float = 0.0, - stop: float = 1.0, - positions_only: bool = False, - ) -> list[Location]: - """Distribute Locations - - Distribute locations along edge or wire. - - Args: - self: Union[Wire:Edge]: - count(int): Number of locations to generate - start(float): position along Edge|Wire to start. Defaults to 0.0. - stop(float): position along Edge|Wire to end. Defaults to 1.0. - positions_only(bool): only generate position not orientation. Defaults to False. - - Returns: - list[Location]: locations distributed along Edge|Wire - - Raises: - ValueError: count must be two or greater - - """ - if count < 2: - raise ValueError("count must be two or greater") - - t_values = [start + i * (stop - start) / (count - 1) for i in range(count)] - - locations = self.locations(t_values) - if positions_only: - for loc in locations: - loc.orientation = (0, 0, 0) - - return locations - - def project_to_shape( - self, - target_object: Shape, - direction: VectorLike = None, - center: VectorLike = None, - ) -> list[Edge]: - """Project Edge - - Project an Edge onto a Shape generating new wires on the surfaces of the object - one and only one of `direction` or `center` must be provided. Note that one or - more wires may be generated depending on the topology of the target object and - location/direction of projection. - - To avoid flipping the normal of a face built with the projected wire the orientation - of the output wires are forced to be the same as self. - - Args: - target_object: Object to project onto - direction: Parallel projection direction. Defaults to None. - center: Conical center of projection. Defaults to None. - target_object: Shape: - direction: VectorLike: (Default value = None) - center: VectorLike: (Default value = None) - - Returns: - : Projected Edge(s) - - Raises: - ValueError: Only one of direction or center must be provided - - """ - wire = Wire([self]) - projected_wires = wire.project_to_shape(target_object, direction, center) - projected_edges = [w.edges()[0] for w in projected_wires] - return projected_edges - - def to_axis(self) -> Axis: - """Translate a linear Edge to an Axis""" - if self.geom_type != GeomType.LINE: - raise ValueError( - f"to_axis is only valid for linear Edges not {self.geom_type}" - ) - return Axis(self.position_at(0), self.position_at(1) - self.position_at(0)) - - -class Face(Shape): - """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 - shells. Face enables precise modeling and manipulation of surfaces, supporting - operations like trimming, filleting, and Boolean operations.""" - - # pylint: disable=too-many-public-methods - - _dim = 2 - - @property - def _dim(self) -> int: - return 2 - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a Face from an OCCT TopoDS_Shape/TopoDS_Face - - Args: - obj (TopoDS_Shape, optional): OCCT Face. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - outer_wire: Wire, - inner_wires: Iterable[Wire] = None, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a planar Face from a boundary Wire with optional hole Wires. - - Args: - outer_wire (Wire): closed perimeter wire - inner_wires (Iterable[Wire], optional): holes. Defaults to None. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - outer_wire, inner_wires, obj, label, color, parent = (None,) * 6 - - if args: - l_a = len(args) - if 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,) * ( - 5 - l_a - ) - - unknown_args = ", ".join( - set(kwargs.keys()).difference( - [ - "outer_wire", - "inner_wires", - "obj", - "label", - "color", - "parent", - ] - ) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - outer_wire = kwargs.get("outer_wire", outer_wire) - inner_wires = kwargs.get("inner_wires", inner_wires) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - parent = kwargs.get("parent", parent) - - if outer_wire is not None: - obj = Face._make_from_wires(outer_wire, inner_wires) - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - # Faces can optionally record the plane it was created on for later extrusion - self.created_on: Plane = None - - @property - def length(self) -> float: - """length of planar face""" - result = None - if self.is_planar: - # Reposition on Plane.XY - flat_face = Plane(self).to_local_coords(self) - face_vertices = flat_face.vertices().sort_by(Axis.X) - result = face_vertices[-1].X - face_vertices[0].X - return result - - @property - def volume(self) -> float: - """volume - the volume of this Face, which is always zero""" - return 0.0 - - @property - def width(self) -> float: - """width of planar face""" - result = None - if self.is_planar: - # Reposition on Plane.XY - flat_face = Plane(self).to_local_coords(self) - face_vertices = flat_face.vertices().sort_by(Axis.Y) - result = face_vertices[-1].Y - face_vertices[0].Y - return result - - @property - def geometry(self) -> str: - """geometry of planar face""" - result = None - if self.is_planar: - flat_face = Plane(self).to_local_coords(self) - flat_face_edges = flat_face.edges() - if all([e.geom_type == GeomType.LINE for e in flat_face_edges]): - flat_face_vertices = flat_face.vertices() - result = "POLYGON" - if len(flat_face_edges) == 4: - edge_pairs = [] - for vertex in flat_face_vertices: - edge_pairs.append( - [e for e in flat_face_edges if vertex in e.vertices()] - ) - edge_pair_directions = [ - [edge.tangent_at(0) for edge in pair] for pair in edge_pairs - ] - if all( - [ - edge_directions[0].get_angle(edge_directions[1]) == 90 - for edge_directions in edge_pair_directions - ] - ): - result = "RECTANGLE" - if len(flat_face_edges.group_by(SortBy.LENGTH)) == 1: - result = "SQUARE" - - return result - - @property - def center_location(self) -> Location: - """Location at the center of face""" - origin = self.position_at(0.5, 0.5) - return Plane(origin, z_dir=self.normal_at(origin)).location - - @property - def is_planar(face: Face) -> bool: - """Is the face planar even though its geom_type may not be PLANE""" - surface = BRep_Tool.Surface_s(face.wrapped) - is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE) - return is_face_planar.IsPlanar() - - def _geom_adaptor(self) -> Geom_Surface: - """ """ - return BRep_Tool.Surface_s(self.wrapped) - - def _uv_bounds(self) -> Tuple[float, float, float, float]: - return BRepTools.UVBounds_s(self.wrapped) - - def __neg__(self) -> Face: - """Reverse normal operator -""" - new_face = copy.deepcopy(self) - new_face.wrapped = downcast(self.wrapped.Complemented()) - return new_face - - def offset(self, amount: float) -> Face: - """Return a copy of self moved along the normal by amount""" - return copy.deepcopy(self).moved(Location(self.normal_at() * amount)) - - @overload - def normal_at(self, surface_point: VectorLike = None) -> Vector: - """normal_at point on surface - - Args: - surface_point (VectorLike, optional): a point that lies on the surface where - the normal. Defaults to the center (None). - - Returns: - Vector: surface normal direction - """ - - @overload - def normal_at(self, u: float, v: float) -> Vector: - """normal_at u, v values on Face - - Args: - u (float): the horizontal coordinate in the parameter space of the Face, - between 0.0 and 1.0 - v (float): the vertical coordinate in the parameter space of the Face, - between 0.0 and 1.0 - Defaults to the center (None/None) - - Raises: - ValueError: Either neither or both u v values must be provided - - Returns: - Vector: surface normal direction - """ - - def normal_at(self, *args, **kwargs) -> Vector: - """normal_at - - Computes the normal vector at the desired location on the face. - - Args: - surface_point (VectorLike, optional): a point that lies on the surface where the normal. - Defaults to None. - - Returns: - Vector: surface normal direction - """ - surface_point, u, v = (None,) * 3 - - if args: - if isinstance(args[0], Iterable): - 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 = ", ".join( - set(kwargs.keys()).difference(["surface_point", "u", "v"]) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - surface_point = kwargs.get("surface_point", surface_point) - u = kwargs.get("u", u) - v = kwargs.get("v", v) - if surface_point is None and u is None and v is None: - u, v = 0.5, 0.5 - elif surface_point is None and sum(i is None for i in [u, v]) == 1: - raise ValueError("Both u & v values must be specified") - - # get the geometry - surface = self._geom_adaptor() - - 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) - else: - # project point on surface - projector = GeomAPI_ProjectPointOnSurf( - Vector(surface_point).to_pnt(), surface - ) - - u_val, v_val = projector.LowerDistanceParameters() - - gp_pnt = gp_Pnt() - normal = gp_Vec() - BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) - - return Vector(normal).normalized() - - def position_at(self, u: float, v: float) -> Vector: - """position_at - - Computes a point on the Face given u, v coordinates. - - Args: - u (float): the horizontal coordinate in the parameter space of the Face, - between 0.0 and 1.0 - v (float): the vertical coordinate in the parameter space of the Face, - between 0.0 and 1.0 - - Returns: - Vector: point on Face - """ - u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() - u_val = u_val0 + u * (u_val1 - u_val0) - v_val = v_val0 + v * (v_val1 - v_val0) - - gp_pnt = gp_Pnt() - normal = gp_Vec() - BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) - - return Vector(gp_pnt) - - def location_at(self, u: float, v: float, x_dir: VectorLike = 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)) - else: - pln = Plane(origin, x_dir=Vector(x_dir), z_dir=self.normal_at(origin)) - return Location(pln) - - def center(self, center_of=CenterOf.GEOMETRY) -> Vector: - """Center of Face - - Return the center based on center_of - - Args: - center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY. - - Returns: - Vector: center - """ - if (center_of == CenterOf.MASS) or ( - center_of == CenterOf.GEOMETRY and self.is_planar - ): - properties = GProp_GProps() - BRepGProp.SurfaceProperties_s(self.wrapped, properties) - center_point = properties.CentreOfMass() - - elif center_of == CenterOf.BOUNDING_BOX: - center_point = self.bounding_box().center() - - elif center_of == CenterOf.GEOMETRY: - u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() - u_val = 0.5 * (u_val0 + u_val1) - v_val = 0.5 * (v_val0 + v_val1) - - center_point = gp_Pnt() - normal = gp_Vec() - BRepGProp_Face(self.wrapped).Normal(u_val, v_val, center_point, normal) - - return Vector(center_point) - - def outer_wire(self) -> Wire: - """Extract the perimeter wire from this Face""" - return Wire(BRepTools.OuterWire_s(self.wrapped)) - - 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)]) - - def wire(self) -> Wire: - """Return the outerwire, generate a warning if inner_wires present""" - if self.inner_wires(): - warnings.warn( - "Found holes, returning outer_wire", - stacklevel=2, - ) - return self.outer_wire() - - @classmethod - def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face: - """make_rect - - Make a Rectangle centered on center with the given normal - - Args: - width (float, optional): width (local x). - height (float, optional): height (local y). - plane (Plane, optional): base plane. Defaults to Plane.XY. - - Returns: - Face: The centered rectangle - """ - pln_shape = BRepBuilderAPI_MakeFace( - plane.wrapped, -width * 0.5, width * 0.5, -height * 0.5, height * 0.5 - ).Face() - - return cls(pln_shape) - - @classmethod - def make_plane( - cls, - plane: Plane = Plane.XY, - ) -> Face: - """Create a unlimited size Face aligned with plane""" - pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face() - return cls(pln_shape) - - @overload - @classmethod - def make_surface_from_curves( - cls, edge1: Edge, edge2: Edge - ) -> Face: # pragma: no cover - ... - - @overload - @classmethod - def make_surface_from_curves( - cls, wire1: Wire, wire2: Wire - ) -> Face: # pragma: no cover - ... - - @classmethod - def make_surface_from_curves( - cls, curve1: Union[Edge, Wire], curve2: Union[Edge, Wire] - ) -> Face: - """make_surface_from_curves - - Create a ruled surface out of two edges or two wires. If wires are used then - these must have the same number of edges. - - Args: - curve1 (Union[Edge,Wire]): side of surface - curve2 (Union[Edge,Wire]): opposite side of surface - - Returns: - Face: potentially non planar surface - """ - if isinstance(curve1, Wire): - return_value = cls.cast(BRepFill.Shell_s(curve1.wrapped, curve2.wrapped)) - else: - return_value = cls.cast(BRepFill.Face_s(curve1.wrapped, curve2.wrapped)) - return return_value - - @classmethod - def make_from_wires( - cls, outer_wire: Wire, inner_wires: Iterable[Wire] = None - ) -> Face: - """make_from_wires - - Makes a planar face from one or more wires - - Args: - outer_wire (Wire): closed perimeter wire - inner_wires (list[Wire], optional): holes. Defaults to None. - - Raises: - ValueError: outer wire not closed - ValueError: wires not planar - ValueError: inner wire not closed - ValueError: internal error - - Returns: - Face: planar face potentially with holes - """ - warnings.warn( - "make_from_wires() will be deprecated - use the Face constructor instead", - DeprecationWarning, - stacklevel=2, - ) - - return Face(Face._make_from_wires(outer_wire, inner_wires)) - - @classmethod - def _make_from_wires( - cls, outer_wire: Wire, inner_wires: Iterable[Wire] = None - ) -> TopoDS_Shape: - """make_from_wires - - Makes a planar face from one or more wires - - Args: - outer_wire (Wire): closed perimeter wire - inner_wires (list[Wire], optional): holes. Defaults to None. - - Raises: - ValueError: outer wire not closed - ValueError: wires not planar - ValueError: inner wire not closed - ValueError: internal error - - Returns: - Face: planar face potentially with holes - """ - if inner_wires and not outer_wire.is_closed: - raise ValueError("Cannot build face(s): outer wire is not closed") - inner_wires = inner_wires if inner_wires else [] - - # check if wires are coplanar - verification_compound = Compound([outer_wire] + inner_wires) - if not BRepLib_FindSurface( - verification_compound.wrapped, OnlyPlane=True - ).Found(): - raise ValueError("Cannot build face(s): wires not planar") - - # fix outer wire - sf_s = ShapeFix_Shape(outer_wire.wrapped) - sf_s.Perform() - topo_wire = TopoDS.Wire_s(sf_s.Shape()) - - face_builder = BRepBuilderAPI_MakeFace(topo_wire, True) - - for inner_wire in inner_wires: - if not inner_wire.is_closed: - raise ValueError("Cannot build face(s): inner wire is not closed") - face_builder.Add(inner_wire.wrapped) - - face_builder.Build() - - if not face_builder.IsDone(): - raise ValueError(f"Cannot build face(s): {face_builder.Error()}") - - face = face_builder.Face() - - sf_f = ShapeFix_Face(face) - sf_f.FixOrientation() - sf_f.Perform() - - return sf_f.Result() - - @classmethod - def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]: - """sew faces - - Group contiguous faces and return them in a list of ShapeList - - Args: - faces (Iterable[Face]): Faces to sew together - - Raises: - RuntimeError: OCCT SewedShape generated unexpected output - - Returns: - list[ShapeList[Face]]: grouped contiguous faces - """ - # Create the shell build - shell_builder = BRepBuilderAPI_Sewing() - # Add the given faces to it - for face in faces: - shell_builder.Add(face.wrapped) - # Attempt to sew the faces into a contiguous shell - shell_builder.Perform() - # Extract the sewed shape - a face, a shell, a solid or a compound - sewed_shape = downcast(shell_builder.SewedShape()) - - # Create a list of ShapeList of Faces - if isinstance(sewed_shape, TopoDS_Face): - sewn_faces = [ShapeList([Face(sewed_shape)])] - elif isinstance(sewed_shape, TopoDS_Shell): - sewn_faces = [Shell(sewed_shape).faces()] - elif isinstance(sewed_shape, TopoDS_Compound): - sewn_faces = [] - for face in Compound(sewed_shape).get_type(Face): - sewn_faces.append(ShapeList([face])) - for shell in Compound(sewed_shape).get_type(Shell): - sewn_faces.append(shell.faces()) - elif isinstance(sewed_shape, TopoDS_Solid): - sewn_faces = [Solid(sewed_shape).faces()] - else: - raise RuntimeError( - f"SewedShape returned a {type(sewed_shape)} which was unexpected" - ) - - return sewn_faces - - @classmethod - def sweep( - cls, - profile: Union[Curve, Edge, Wire], - path: Union[Curve, Edge, Wire], - transition=Transition.TRANSFORMED, - ) -> Face: - """sweep - - Sweep a 1D profile along a 1D path. Both the profile and path must be composed - of only 1 Edge. - - Args: - profile (Union[Curve,Edge,Wire]): the object to sweep - path (Union[Curve,Edge,Wire]): the path to follow when sweeping - transition (Transition, optional): handling of profile orientation at C1 path - discontinuities. Defaults to Transition.TRANSFORMED. - - Raises: - ValueError: Only 1 Edge allowed in profile & path - - Returns: - Face: resulting face, may be non-planar - """ - # Note: BRepOffsetAPI_MakePipe is an option here - # pipe_sweep = BRepOffsetAPI_MakePipe(path.wrapped, profile.wrapped) - # pipe_sweep.Build() - # return Face(pipe_sweep.Shape()) - - 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()]) - builder = BRepOffsetAPI_MakePipeShell(path.wrapped) - builder.Add(profile.wrapped, False, False) - builder.SetTransitionMode(Solid._transModeDict[transition]) - builder.Build() - return Shape.cast(builder.Shape()).clean().face() - - @classmethod - def make_surface_from_array_of_points( - cls, - points: list[list[VectorLike]], - tol: float = 1e-2, - smoothing: Tuple[float, float, float] = None, - min_deg: int = 1, - max_deg: int = 3, - ) -> Face: - """make_surface_from_array_of_points - - Approximate a spline surface through the provided 2d array of points. - The first dimension correspond to points on the vertical direction in the parameter space of the face. - The second dimension correspond to points on the horizontal direction in the parameter space of the face. - The 2 dimensions are U,V dimensions of the parameter space of the face. - - Args: - points (list[list[VectorLike]]): a 2D list of points, first dimension is V parameters second is U parameters. - tol (float, optional): tolerance of the algorithm. Defaults to 1e-2. - smoothing (Tuple[float, float, float], optional): optional tuple of - 3 weights use for variational smoothing. Defaults to None. - min_deg (int, optional): minimum spline degree. Enforced only when - smoothing is None. Defaults to 1. - max_deg (int, optional): maximum spline degree. Defaults to 3. - - Raises: - ValueError: B-spline approximation failed - - Returns: - Face: a potentially non-planar face defined by points - """ - points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0])) - - for i, point_row in enumerate(points): - for j, point in enumerate(point_row): - points_.SetValue(i + 1, j + 1, Vector(point).to_pnt()) - - if smoothing: - spline_builder = GeomAPI_PointsToBSplineSurface( - points_, *smoothing, DegMax=max_deg, Tol3D=tol - ) - else: - spline_builder = GeomAPI_PointsToBSplineSurface( - points_, DegMin=min_deg, DegMax=max_deg, Tol3D=tol - ) - - if not spline_builder.IsDone(): - raise ValueError("B-spline approximation failed") - - spline_geom = spline_builder.Surface() - - return cls(BRepBuilderAPI_MakeFace(spline_geom, Precision.Confusion_s()).Face()) - - @classmethod - def make_bezier_surface( - cls, - points: list[list[VectorLike]], - weights: list[list[float]] = None, - ) -> Face: - """make_bezier_surface - - Construct a Bézier surface from the provided 2d array of points. - - Args: - points (list[list[VectorLike]]): a 2D list of control points - weights (list[list[float]], optional): control point weights. Defaults to None. - - Raises: - ValueError: Too few control points - ValueError: Too many control points - ValueError: A weight is required for each control point - - Returns: - Face: a potentially non-planar face - """ - if len(points) < 2 or len(points[0]) < 2: - raise ValueError( - "At least two control points must be provided (start, end)" - ) - if len(points) > 25 or len(points[0]) > 25: - raise ValueError("The maximum number of control points is 25") - if weights and ( - len(points) != len(weights) or len(points[0]) != len(weights[0]) - ): - raise ValueError("A weight must be provided for each control point") - - points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0])) - for i, row in enumerate(points): - for j, point in enumerate(row): - points_.SetValue(i + 1, j + 1, Vector(point).to_pnt()) - - if weights: - weights_ = TColStd_HArray2OfReal(1, len(weights), 1, len(weights[0])) - for i, row in enumerate(weights): - for j, weight in enumerate(row): - weights_.SetValue(i + 1, j + 1, float(weight)) - bezier = Geom_BezierSurface(points_, weights_) - else: - bezier = Geom_BezierSurface(points_) - - return cls(BRepBuilderAPI_MakeFace(bezier, Precision.Confusion_s()).Face()) - - @classmethod - def make_surface( - cls, - exterior: Union[Wire, Iterable[Edge]], - surface_points: Iterable[VectorLike] = None, - interior_wires: Iterable[Wire] = None, - ) -> Face: - """Create Non-Planar Face - - Create a potentially non-planar face bounded by exterior (wire or edges), - optionally refined by surface_points with optional holes defined by - interior_wires. - - Args: - exterior (Union[Wire, list[Edge]]): Perimeter of face - surface_points (list[VectorLike], optional): Points on the surface that - refine the shape. Defaults to None. - interior_wires (list[Wire], optional): Hole(s) in the face. Defaults to None. - - Raises: - RuntimeError: Internal error building face - RuntimeError: Error building non-planar face with provided surface_points - RuntimeError: Error adding interior hole - RuntimeError: Generated face is invalid - - Returns: - Face: Potentially non-planar face - """ - # pylint: disable=too-many-branches - if surface_points: - surface_points = [Vector(p) for p in surface_points] - else: - surface_points = None - - # First, create the non-planar surface - surface = BRepOffsetAPI_MakeFilling( - # order of energy criterion to minimize for computing the deformation of the surface - Degree=3, - # average number of points for discretisation of the edges - NbPtsOnCur=15, - NbIter=2, - Anisotropie=False, - # the maximum distance allowed between the support surface and the constraints - Tol2d=0.00001, - # the maximum distance allowed between the support surface and the constraints - Tol3d=0.0001, - # the maximum angle allowed between the normal of the surface and the constraints - TolAng=0.01, - # the maximum difference of curvature allowed between the surface and the constraint - TolCurv=0.1, - # the highest degree which the polynomial defining the filling surface can have - MaxDeg=8, - # the greatest number of segments which the filling surface can have - MaxSegments=9, - ) - if isinstance(exterior, Wire): - outside_edges = exterior.edges() - elif isinstance(exterior, Iterable) and all( - [isinstance(o, Edge) for o in exterior] - ): - outside_edges = exterior - else: - raise ValueError("exterior must be a Wire or list of Edges") - - for edge in outside_edges: - surface.Add(edge.wrapped, GeomAbs_C0) - - try: - surface.Build() - surface_face = Face(surface.Shape()) - except ( - Standard_Failure, - StdFail_NotDone, - Standard_NoSuchObject, - Standard_ConstructionError, - ) as err: - raise RuntimeError( - "Error building non-planar face with provided exterior" - ) from err - if surface_points: - for point in surface_points: - surface.Add(gp_Pnt(*point.to_tuple())) - try: - surface.Build() - surface_face = Face(surface.Shape()) - except StdFail_NotDone as err: - raise RuntimeError( - "Error building non-planar face with provided surface_points" - ) from err - - # Next, add wires that define interior holes - note these wires must be entirely interior - if interior_wires: - makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped) - for wire in interior_wires: - makeface_object.Add(wire.wrapped) - try: - surface_face = Face(makeface_object.Face()) - except StdFail_NotDone as err: - raise RuntimeError( - "Error adding interior hole in non-planar face with provided interior_wires" - ) from err - - surface_face = surface_face.fix() - if not surface_face.is_valid(): - raise RuntimeError("non planar face is invalid") - - return surface_face - - def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Face: - """Apply 2D fillet to a face - - Args: - radius: float: - vertices: Iterable[Vertex]: - - Returns: - - """ - - fillet_builder = BRepFilletAPI_MakeFillet2d(self.wrapped) - - for vertex in vertices: - fillet_builder.AddFillet(vertex.wrapped, radius) - - fillet_builder.Build() - - return self.__class__(fillet_builder.Shape()) - - def chamfer_2d( - self, - distance: float, - distance2: float, - vertices: Iterable[Vertex], - edge: Edge = None, - ) -> Face: - """Apply 2D chamfer to a face - - Args: - distance (float): chamfer length - distance2 (float): chamfer length - vertices (Iterable[Vertex]): vertices to chamfer - edge (Edge): identifies the side where length is measured. The vertices must be - part of the edge - - Raises: - ValueError: Cannot chamfer at this location - ValueError: One or more vertices are not part of edge - - Returns: - Face: face with a chamfered corner(s) - - """ - reference_edge = edge - del edge - - chamfer_builder = BRepFilletAPI_MakeFillet2d(self.wrapped) - - vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape() - TopExp.MapShapesAndAncestors_s( - self.wrapped, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map - ) - - for v in vertices: - edges = 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 = [edges.First(), edges.Last()] - - # Need to wrap in b3d objects for comparison to work - # ref.wrapped != edge.wrapped but ref == edge - edges = [Shape.cast(e) for e in edges] - - if reference_edge: - if reference_edge not in edges: - raise ValueError("One or more vertices are not part of edge") - edge1 = reference_edge - edge2 = [x for x in edges if x != reference_edge][0] - else: - edge1, edge2 = edges - - chamfer_builder.AddChamfer( - TopoDS.Edge_s(edge1.wrapped), - TopoDS.Edge_s(edge2.wrapped), - distance, - distance2, - ) - - chamfer_builder.Build() - return self.__class__(chamfer_builder.Shape()).fix() - - def is_coplanar(self, plane: Plane) -> bool: - """Is this planar face coplanar with the provided plane""" - u_val0, _u_val1, v_val0, _v_val1 = self._uv_bounds() - gp_pnt = gp_Pnt() - normal = gp_Vec() - BRepGProp_Face(self.wrapped).Normal(u_val0, v_val0, gp_pnt, normal) - - return ( - plane.contains(Vector(gp_pnt)) - and 1 - abs(plane.z_dir.dot(Vector(normal))) < TOLERANCE - ) - - def thicken( - self, depth: float, normal_override: Optional[VectorLike] = None - ) -> Solid: - """Thicken Face - - Create a solid from a potentially non planar face by thickening along the normals. - - .. image:: thickenFace.png - - Non-planar faces are thickened both towards and away from the center of the sphere. - - Args: - depth (float): Amount to thicken face(s), can be positive or negative. - normal_override (Vector, optional): The normal_override vector can be used to - indicate which way is 'up', potentially flipping the face normal direction - such that many faces with different normals all go in the same direction - (direction need only be +/- 90 degrees from the face normal). Defaults to None. - - Raises: - RuntimeError: Opencascade internal failures - - Returns: - Solid: The resulting Solid object - """ - # Check to see if the normal needs to be flipped - adjusted_depth = depth - if normal_override is not None: - face_center = self.center() - face_normal = self.normal_at(face_center).normalized() - if face_normal.dot(Vector(normal_override).normalized()) < 0: - adjusted_depth = -depth - - return _thicken(self.wrapped, adjusted_depth) - - def project_to_shape( - self, target_object: Shape, direction: VectorLike, taper: float = 0 - ) -> ShapeList[Face]: - """Project Face to target Object - - Project a Face onto a Shape generating new Face(s) on the surfaces of the object. - - A projection with no taper is illustrated below: - - .. image:: flatProjection.png - :alt: flatProjection - - Note that an array of faces is returned as the projection might result in faces - on the "front" and "back" of the object (or even more if there are intermediate - surfaces in the projection path). faces "behind" the projection are not - returned. - - Args: - target_object (Shape): Object to project onto - direction (VectorLike): projection direction - taper (float, optional): taper angle. Defaults to 0. - - Returns: - ShapeList[Face]: Face(s) projected on target object ordered by distance - """ - max_dimension = Compound([self, target_object]).bounding_box().diagonal - if taper == 0: - face_extruded = Solid.extrude(self, Vector(direction) * max_dimension) - else: - face_extruded = Solid.extrude_taper( - self, Vector(direction) * max_dimension, taper=taper - ) - - intersected_faces = ShapeList() - for target_face in target_object.faces(): - intersected_faces.extend(face_extruded.intersect(target_face).faces()) - - # intersected faces may be fragmented so we'll put them back together - sewed_face_list = Face.sew_faces(intersected_faces) - sewed_faces = ShapeList() - for face_group in sewed_face_list: - if len(face_group) > 1: - sewed_faces.append(face_group.pop(0).fuse(*face_group).clean()) - else: - sewed_faces.append(face_group[0]) - - return sewed_faces.sort_by(Axis(self.center(), direction)) - - def project_to_shape_alt( - self, target_object: Shape, direction: VectorLike - ) -> Union[None, Face, Compound]: - """project_to_shape_alt - - Return the Faces contained within the first projection of self onto - the target. - - Args: - target_object (Shape): Object to project onto - direction (VectorLike): projection direction - - Returns: - Union[None, Face, Compound]: projection - """ - - perimeter = self.outer_wire() - direction = Vector(direction) - projection_axis = Axis((0, 0, 0), direction) - max_size = target_object.bounding_box().add(self.bounding_box()).diagonal - projection_faces: list[Face] = [] - - def get(los: TopTools_ListOfShape, shape_cls) -> list: - shapes = [] - for _i in range(los.Size()): - shapes.append(shape_cls(los.First())) - los.RemoveFirst() - return shapes - - def desired_faces(face_list: list[Face]) -> bool: - return ( - face_list - and face_list[0]._extrude(direction * -max_size).intersect(self).area - > TOLERANCE - ) - - # - # Self projection - # - projection_plane = Plane(direction * -max_size, z_dir=-direction) - - # Setup the projector - hidden_line_remover = HLRBRep_Algo() - hidden_line_remover.Add(target_object.wrapped) - hlr_projector = HLRAlgo_Projector(projection_plane.to_gp_ax2()) - hidden_line_remover.Projector(hlr_projector) - hidden_line_remover.Update() - hidden_line_remover.Hide() - hlr_shapes = HLRBRep_HLRToShape(hidden_line_remover) - - # Find the visible edges - target_edges_on_xy = [] - for edge_compound in [ - hlr_shapes.VCompound(), - hlr_shapes.Rg1LineVCompound(), - hlr_shapes.OutLineVCompound(), - ]: - if not edge_compound.IsNull(): - target_edges_on_xy.extend(Compound(edge_compound).edges()) - - target_edges = [ - projection_plane.from_local_coords(e) for e in target_edges_on_xy - ] - target_wires = edges_to_wires(target_edges) - # return target_wires - - # projection_plane = Plane(self.center(), z_dir=direction) - # projection_plane = Plane((0, 0, 0), z_dir=direction) - # visible, _hidden = target_object.project_to_viewport( - # viewport_origin=direction * -max_size, - # # viewport_up=projection_plane.x_dir, - # viewport_up=(direction.X, direction.Y, 0), - # # viewport_up=(direction.Y,direction.X,0), - # # viewport_up=projection_plane.y_dir.cross(direction), - # look_at=projection_plane.z_dir, - # ) - # self_visible_edges = [projection_plane.from_local_coords(e) for e in visible] - # self_visible_wires = edges_to_wires(self_visible_edges) - - # Project the perimeter onto the target object - hlr_projector = BRepProj_Projection( - perimeter.wrapped, target_object.wrapped, direction.to_dir() - ) - # print(len(Compound(hlr_projector.Shape()).wires().sort_by(projection_axis))) - projected_wires = ( - Compound(hlr_projector.Shape()).wires().sort_by(projection_axis) - ) - - # target_projected_wires = [] - # for target_wire in target_wires: - # hlr_projector = BRepProj_Projection( - # target_wire.wrapped, target_object.wrapped, direction.to_dir() - # ) - # target_projected_wires.extend( - # Compound(hlr_projector.Shape()).wires().sort_by(projection_axis) - # ) - # return target_projected_wires - # target_projected_edges = [e for w in target_projected_wires for e in w.edges()] - - edge_sequence = TopTools_SequenceOfShape() - for e in projected_wires.edges(): - edge_sequence.Append(e.wrapped) - - # Split the faces by the projection edges & keep the part of - # these faces bound by the projection - for target_face in target_object.faces(): - constructor = BRepFeat_SplitShape(target_face.wrapped) - constructor.Add(edge_sequence) - constructor.Build() - lefts = get(constructor.Left(), Face) - rights = get(constructor.Right(), Face) - # Keep the faces that correspond to the projection - if desired_faces(lefts): - projection_faces.extend(lefts) - if desired_faces(rights): - projection_faces.extend(rights) - - # # Filter out faces on the back - # projection_faces = ShapeList(projection_faces).filter_by( - # lambda f: f._extrude(direction * -1).intersect(target_object).area > 0, - # reverse=True, - # ) - - # Project the targets own edges on the projection_faces - # trim_wires = [] - # for projection_face in projection_faces: - # for target_wire in target_wires: - # hlr_projector = BRepProj_Projection( - # target_wire.wrapped, projection_face.wrapped, direction.to_dir() - # ) - # # print(len(Compound(hlr_projector.Shape()).wires().sort_by(projection_axis))) - # trim_wires.extend( - # Compound(hlr_projector.Shape()).wires() - # ) - - # return trim_wires - - # Create the object to return depending on the # projected faces - if not projection_faces: - projection = None - elif len(projection_faces) == 1: - projection = projection_faces[0] - else: - projection = projection_faces.pop(0).fuse(*projection_faces).clean() - - return projection - - def make_holes(self, interior_wires: list[Wire]) -> Face: - """Make Holes in Face - - Create holes in the Face 'self' from interior_wires which must be entirely interior. - Note that making holes in faces is more efficient than using boolean operations - with solid object. Also note that OCCT core may fail unless the orientation of the wire - is correct - use `Wire(forward_wire.wrapped.Reversed())` to reverse a wire. - - Example: - - For example, make a series of slots on the curved walls of a cylinder. - - .. image:: slotted_cylinder.png - - Args: - interior_wires: a list of hole outline wires - interior_wires: list[Wire]: - - Returns: - Face: 'self' with holes - - Raises: - RuntimeError: adding interior hole in non-planar face with provided interior_wires - RuntimeError: resulting face is not valid - - """ - # Add wires that define interior holes - note these wires must be entirely interior - makeface_object = BRepBuilderAPI_MakeFace(self.wrapped) - for interior_wire in interior_wires: - makeface_object.Add(interior_wire.wrapped) - try: - surface_face = Face(makeface_object.Face()) - except StdFail_NotDone as err: - raise RuntimeError( - "Error adding interior hole in non-planar face with provided interior_wires" - ) from err - - surface_face = surface_face.fix() - # if not surface_face.is_valid(): - # raise RuntimeError("non planar face is invalid") - - return surface_face - - def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: - """Point inside Face - - Returns whether or not the point is inside a Face within the specified tolerance. - Points on the edge of the Face are considered inside. - - Args: - point(VectorLike): tuple or Vector representing 3D point to be tested - tolerance(float): tolerance for inside determination. Defaults to 1.0e-6. - point: VectorLike: - tolerance: float: (Default value = 1.0e-6) - - Returns: - bool: indicating whether or not point is within Face - - """ - return Compound([self]).is_inside(point, tolerance) - - -class Shell(Shape): - """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 - in solid modeling. Shells group faces in a coherent manner, playing a crucial role - in representing complex shapes with voids and surfaces. This hierarchical structure - allows for efficient handling of surfaces within a model, supporting various - operations and analyses.""" - - _dim = 2 - - @property - def _dim(self) -> int: - return 2 - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a shell from an OCCT TopoDS_Shape/TopoDS_Shell - - Args: - obj (TopoDS_Shape, optional): OCCT Shell. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - face: Face, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a shell from a single Face - - Args: - face (Face): Face to convert to Shell - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - faces: Iterable[Face], - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a shell from Faces - - Args: - faces (Iterable[Face]): Faces to assemble - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - face, faces, obj, label, color, parent = (None,) * 6 - - if args: - l_a = len(args) - if isinstance(args[0], TopoDS_Shape): - obj, label, color, parent = args[:4] + (None,) * (4 - l_a) - elif isinstance(args[0], Face): - face, label, color, parent = args[:4] + (None,) * (4 - l_a) - elif isinstance(args[0], Iterable): - faces, label, color, parent = args[:4] + (None,) * (4 - l_a) - - unknown_args = ", ".join( - set(kwargs.keys()).difference( - [ - "face", - "faces", - "obj", - "label", - "color", - "parent", - ] - ) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - face = kwargs.get("face", face) - faces = kwargs.get("faces", faces) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - parent = kwargs.get("parent", parent) - - if faces: - if len(faces) == 1: - face = faces[0] - else: - obj = Shell._make_shell(faces) - if face: - builder = BRepBuilderAPI_MakeShell( - BRepAdaptor_Surface(face.wrapped).Surface().Surface() - ) - obj = builder.Shape() - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - - @property - def volume(self) -> float: - """volume - the volume of this Shell if manifold, otherwise zero""" - # when density == 1, mass == volume - if self.is_manifold: - return Solid(self).volume - return 0.0 - - @classmethod - def make_shell(cls, faces: Iterable[Face]) -> Shell: - """Create a Shell from provided faces""" - warnings.warn( - "make_shell() will be deprecated - use the Shell constructor instead", - DeprecationWarning, - stacklevel=2, - ) - return Shell(Shell._make_shell(faces)) - - @classmethod - def _make_shell(cls, faces: Iterable[Face]) -> TopoDS_Shape: - """Create a Shell from provided faces""" - shell_builder = BRepBuilderAPI_Sewing() - - for face in faces: - shell_builder.Add(face.wrapped) - - shell_builder.Perform() - shape = shell_builder.SewedShape() - - return shape - - def center(self) -> Vector: - """Center of mass of the shell""" - properties = GProp_GProps() - BRepGProp.LinearProperties_s(self.wrapped, properties) - return Vector(properties.CentreOfMass()) - - @classmethod - def sweep( - cls, - profile: Union[Curve, Edge, Wire], - path: Union[Curve, Edge, Wire], - transition=Transition.TRANSFORMED, - ) -> Shell: - """sweep - - Sweep a 1D profile along a 1D path - - Args: - profile (Union[Curve, Edge, Wire]): the object to sweep - path (Union[Curve, Edge, Wire]): the path to follow when sweeping - transition (Transition, optional): handling of profile orientation at C1 path - discontinuities. Defaults to Transition.TRANSFORMED. - - Returns: - Shell: resulting Shell, may be non-planar - """ - profile = Wire(profile.edges()) - path = Wire(Wire(path.edges()).order_edges()) - builder = BRepOffsetAPI_MakePipeShell(path.wrapped) - builder.Add(profile.wrapped, False, False) - builder.SetTransitionMode(Solid._transModeDict[transition]) - builder.Build() - return Shape.cast(builder.Shape()) - - @classmethod - def make_loft( - cls, objs: Iterable[Union[Vertex, Wire]], ruled: bool = False - ) -> Shell: - """make loft - - Makes a loft from a list of wires and vertices. - Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list - nor between wires. - Wires may be closed or opened. - - Args: - objs (list[Vertex, Wire]): wire perimeters or vertices - ruled (bool, optional): stepped or smooth. Defaults to False (smooth). - - Raises: - ValueError: Too few wires - - Returns: - Shell: Lofted object - """ - return cls(_make_loft(objs, False, ruled)) - - def thicken(self, depth: float) -> Solid: - """Thicken Shell - - Create a solid from a shell by thickening along the normals. - - Args: - depth (float): Amount to thicken face(s), can be positive or negative. - - Raises: - RuntimeError: Opencascade internal failures - - Returns: - Solid: The resulting Solid object - """ - return _thicken(self.wrapped, depth) - - -class Solid(Mixin3D, Shape): - """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 - well-defined manner. Solid modeling operations, such as Boolean - operations (union, intersection, and difference), are often performed on - Solid objects to create or modify complex geometries.""" - - _dim = 3 - - @property - def _dim(self) -> int: - return 3 - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - material: str = "", - joints: dict[str, Joint] = None, - parent: Compound = None, - ): - """Build a solid from an OCCT TopoDS_Shape/TopoDS_Solid - - Args: - obj (TopoDS_Shape, optional): OCCT Solid. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - material (str, optional): tag for external tools. Defaults to ''. - joints (dict[str, Joint], optional): names joints. Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - shell: Shell, - label: str = "", - color: Color = None, - material: str = "", - joints: dict[str, Joint] = None, - parent: Compound = None, - ): - """Build a shell from Faces - - Args: - shell (Shell): manifold shell of the new solid - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - material (str, optional): tag for external tools. Defaults to ''. - joints (dict[str, Joint], optional): names joints. Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - shell, obj, label, color, material, joints, parent = (None,) * 7 - - if args: - l_a = len(args) - if isinstance(args[0], TopoDS_Shape): - obj, label, color, material, joints, parent = args[:6] + (None,) * ( - 6 - l_a - ) - elif isinstance(args[0], Shell): - shell, label, color, material, joints, parent = args[:6] + (None,) * ( - 6 - l_a - ) - - unknown_args = ", ".join( - set(kwargs.keys()).difference( - [ - "shell", - "obj", - "label", - "color", - "material", - "joints", - "parent", - ] - ) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - shell = kwargs.get("shell", shell) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - material = kwargs.get("material", material) - joints = kwargs.get("joints", joints) - parent = kwargs.get("parent", parent) - - if shell is not None: - obj = Solid._make_solid(shell) - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - self.material = "" if material is None else material - self.joints = {} if joints is None else joints - - @property - def volume(self) -> float: - """volume - the volume of this Solid""" - # when density == 1, mass == volume - return Shape.compute_mass(self) - - @classmethod - def make_solid(cls, shell: Shell) -> Solid: - """Create a Solid object from the surface shell""" - warnings.warn( - "make_compound() will be deprecated - use the Compound constructor instead", - DeprecationWarning, - stacklevel=2, - ) - return Solid(Solid._make_solid(shell)) - - @classmethod - def _make_solid(cls, shell: Shell) -> TopoDS_Solid: - """Create a Solid object from the surface shell""" - return ShapeFix_Solid().SolidFromShell(shell.wrapped) - - @classmethod - def from_bounding_box(cls, bbox: BoundBox) -> Solid: - """A box of the same dimensions and location""" - return Solid.make_box(*bbox.size).locate(Location(bbox.min)) - - @classmethod - def make_box( - cls, length: float, width: float, height: float, plane: Plane = Plane.XY - ) -> Solid: - """make box - - Make a box at the origin of plane extending in positive direction of each axis. - - Args: - length (float): - width (float): - height (float): - plane (Plane, optional): base plane. Defaults to Plane.XY. - - Returns: - Solid: Box - """ - return cls( - BRepPrimAPI_MakeBox( - plane.to_gp_ax2(), - length, - width, - height, - ).Shape() - ) - - @classmethod - def make_cone( - cls, - base_radius: float, - top_radius: float, - height: float, - plane: Plane = Plane.XY, - angle: float = 360, - ) -> Solid: - """make cone - - Make a cone with given radii and height - - Args: - base_radius (float): - top_radius (float): - height (float): - plane (Plane): base plane. Defaults to Plane.XY. - angle (float, optional): arc size. Defaults to 360. - - Returns: - Solid: Full or partial cone - """ - return cls( - BRepPrimAPI_MakeCone( - plane.to_gp_ax2(), - base_radius, - top_radius, - height, - angle * DEG2RAD, - ).Shape() - ) - - @classmethod - def make_cylinder( - cls, - radius: float, - height: float, - plane: Plane = Plane.XY, - angle: float = 360, - ) -> Solid: - """make cylinder - - Make a cylinder with a given radius and height with the base center on plane origin. - - Args: - radius (float): - height (float): - plane (Plane): base plane. Defaults to Plane.XY. - angle (float, optional): arc size. Defaults to 360. - - Returns: - Solid: Full or partial cylinder - """ - return cls( - BRepPrimAPI_MakeCylinder( - plane.to_gp_ax2(), - radius, - height, - angle * DEG2RAD, - ).Shape() - ) - - @classmethod - def make_torus( - cls, - major_radius: float, - minor_radius: float, - plane: Plane = Plane.XY, - start_angle: float = 0, - end_angle: float = 360, - major_angle: float = 360, - ) -> Solid: - """make torus - - Make a torus with a given radii and angles - - Args: - major_radius (float): - minor_radius (float): - plane (Plane): base plane. Defaults to Plane.XY. - start_angle (float, optional): start major arc. Defaults to 0. - end_angle (float, optional): end major arc. Defaults to 360. - - Returns: - 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() - ) - - @classmethod - def make_loft( - cls, objs: Iterable[Union[Vertex, Wire]], ruled: bool = False - ) -> Solid: - """make loft - - Makes a loft from a list of wires and vertices. - Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list - nor between wires. - - Args: - objs (list[Vertex, Wire]): wire perimeters or vertices - ruled (bool, optional): stepped or smooth. Defaults to False (smooth). - - Raises: - ValueError: Too few wires - - Returns: - Solid: Lofted object - """ - return cls(_make_loft(objs, True, ruled)) - - @classmethod - def make_wedge( - cls, - delta_x: float, - delta_y: float, - delta_z: float, - min_x: float, - min_z: float, - max_x: float, - max_z: float, - plane: Plane = Plane.XY, - ) -> Solid: - """Make a wedge - - Args: - delta_x (float): - delta_y (float): - delta_z (float): - min_x (float): - min_z (float): - max_x (float): - max_z (float): - plane (Plane): base plane. Defaults to Plane.XY. - - Returns: - 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() - ) - - @classmethod - def make_sphere( - cls, - radius: float, - plane: Plane = Plane.XY, - angle1: float = -90, - angle2: float = 90, - angle3: float = 360, - ) -> Solid: - """Sphere - - Make a full or partial sphere - with a given radius center on the origin or plane. - - Args: - radius (float): - plane (Plane): base plane. Defaults to Plane.XY. - angle1 (float, optional): Defaults to -90. - angle2 (float, optional): Defaults to 90. - angle3 (float, optional): Defaults to 360. - - Returns: - Solid: sphere - """ - return cls( - BRepPrimAPI_MakeSphere( - plane.to_gp_ax2(), - radius, - angle1 * DEG2RAD, - angle2 * DEG2RAD, - angle3 * DEG2RAD, - ).Shape() - ) - - @classmethod - def extrude_taper( - cls, profile: Face, direction: VectorLike, taper: float, flip_inner: bool = True - ) -> Solid: - """Extrude a cross section with a taper - - Extrude a cross section into a prismatic solid in the provided direction. - - Note that two difference algorithms are used. If direction aligns with - the profile normal (which must be positive), the taper is positive and the profile - contains no holes the OCP LocOpe_DPrism algorithm is used as it generates the most - accurate results. Otherwise, a loft is created between the profile and the profile - with a 2D offset set at the appropriate direction. - - Args: - section (Face]): cross section - normal (VectorLike): a vector along which to extrude the wires. The length - of the vector controls the length of the extrusion. - taper (float): taper angle in degrees. - flip_inner (bool, optional): outer and inner geometry have opposite tapers to - allow for part extraction when injection molding. - - Returns: - Solid: extruded cross section - """ - # pylint: disable=too-many-locals - direction = Vector(direction) - - if ( - direction.normalized() == profile.normal_at() - and Plane(profile).z_dir.Z > 0 - and taper > 0 - and not profile.inner_wires() - ): - prism_builder = LocOpe_DPrism( - profile.wrapped, - direction.length / cos(radians(taper)), - radians(taper), - ) - new_solid = Solid(prism_builder.Shape()) - else: - # Determine the offset to get the taper - offset_amt = -direction.length * tan(radians(taper)) - - outer = profile.outer_wire() - local_outer: Wire = Plane(profile).to_local_coords(outer) - local_taper_outer = local_outer.offset_2d( - offset_amt, kind=Kind.INTERSECTION - ) - taper_outer = Plane(profile).from_local_coords(local_taper_outer) - taper_outer.move(Location(direction)) - - profile_wires = [profile.outer_wire()] + profile.inner_wires() - - taper_wires = [] - for i, wire in enumerate(profile_wires): - flip = -1 if i > 0 and flip_inner else 1 - local: Wire = Plane(profile).to_local_coords(wire) - local_taper = local.offset_2d(flip * offset_amt, kind=Kind.INTERSECTION) - taper = Plane(profile).from_local_coords(local_taper) - taper.move(Location(direction)) - taper_wires.append(taper) - - solids = [ - Solid.make_loft([p, t]) for p, t in zip(profile_wires, taper_wires) - ] - if len(solids) > 1: - new_solid = solids[0].cut(*solids[1:]) - else: - new_solid = solids[0] - - return new_solid - - @classmethod - def extrude_linear_with_rotation( - cls, - section: Union[Face, Wire], - center: VectorLike, - normal: VectorLike, - angle: float, - inner_wires: list[Wire] = None, - ) -> Solid: - """Extrude with Rotation - - Creates a 'twisted prism' by extruding, while simultaneously rotating around the - extrusion vector. - - Args: - section (Union[Face,Wire]): cross section - vec_center (VectorLike): the center point about which to rotate - vec_normal (VectorLike): a vector along which to extrude the wires - angle (float): the angle to rotate through while extruding - inner_wires (list[Wire], optional): holes - only used if section is of type Wire. - Defaults to None. - - Returns: - Solid: extruded object - """ - # Though the signature may appear to be similar enough to extrude to merit - # combining them, the construction methods used here are different enough that they - # should be separate. - - # At a high level, the steps followed are: - # (1) accept a set of wires - # (2) create another set of wires like this one, but which are transformed and rotated - # (3) create a ruledSurface between the sets of wires - # (4) create a shell and compute the resulting object - - inner_wires = inner_wires if inner_wires else [] - center = Vector(center) - normal = Vector(normal) - - def extrude_aux_spine( - wire: TopoDS_Wire, spine: TopoDS_Wire, aux_spine: TopoDS_Wire - ) -> TopoDS_Shape: - """Helper function""" - extrude_builder = BRepOffsetAPI_MakePipeShell(spine) - extrude_builder.SetMode(aux_spine, False) # auxiliary spine - extrude_builder.Add(wire) - extrude_builder.Build() - extrude_builder.MakeSolid() - return extrude_builder.Shape() - - if isinstance(section, Face): - outer_wire = section.outer_wire() - inner_wires = section.inner_wires() - else: - outer_wire = section - - # make straight spine - straight_spine_e = Edge.make_line(center, center.add(normal)) - straight_spine_w = Wire.combine([straight_spine_e])[0].wrapped - - # make an auxiliary spine - pitch = 360.0 / angle * normal.length - aux_spine_w = Wire( - [Edge.make_helix(pitch, normal.length, 1, center=center, normal=normal)] - ).wrapped - - # extrude the outer wire - outer_solid = extrude_aux_spine( - outer_wire.wrapped, straight_spine_w, aux_spine_w - ) - - # extrude inner wires - inner_solids = [ - Shape(extrude_aux_spine(w.wrapped, straight_spine_w, aux_spine_w)) - for w in inner_wires - ] - - # combine the inner solids into compound - inner_comp = Compound._make_compound(inner_solids) - - # subtract from the outer solid - return Solid(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape()) - - @classmethod - def extrude_until( - cls, - section: Face, - target_object: Union[Compound, Solid], - direction: VectorLike, - until: Until = Until.NEXT, - ) -> Union[Compound, 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. - - 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. - - Raises: - ValueError: provided face does not intersect target_object - - Returns: - Union[Compound, Solid]: extruded Face - """ - direction = Vector(direction) - if until in [Until.PREVIOUS, Until.FIRST]: - direction *= -1 - until = Until.NEXT if until == Until.PREVIOUS else Until.LAST - - max_dimension = Compound([section, target_object]).bounding_box().diagonal - clipping_direction = ( - direction * max_dimension - if until == Until.NEXT - else -direction * max_dimension - ) - 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) - ] - if not clip_faces: - raise ValueError("provided face does not intersect target_object") - - # 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] - - if until == Until.NEXT: - extrusion = extrusion.cut(target_object) - 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 = ( - extrusion.cut(clipping_object) - .solids() - .sort_by(direction_axis)[0] - ) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) - else: - extrusion_parts = [extrusion.intersect(target_object)] - for clipping_object in clipping_objects: - try: - extrusion_parts.append( - extrusion.intersect(clipping_object) - .solids() - .sort_by(direction_axis)[0] - ) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) - extrusion = Shape.fuse(*extrusion_parts) - - return extrusion - - @classmethod - def revolve( - cls, - section: Union[Face, Wire], - angle: float, - axis: Axis, - inner_wires: list[Wire] = None, - ) -> Solid: - """Revolve - - Revolve a cross section about the given Axis by the given angle. - - Args: - section (Union[Face,Wire]): cross section - angle (float): the angle to revolve through - axis (Axis): rotation Axis - inner_wires (list[Wire], optional): holes - only used if section is of type Wire. - Defaults to []. - - Returns: - Solid: the revolved cross section - """ - inner_wires = inner_wires if inner_wires else [] - if isinstance(section, Wire): - section_face = Face(section, inner_wires) - else: - section_face = section - - revol_builder = BRepPrimAPI_MakeRevol( - section_face.wrapped, - axis.wrapped, - angle * DEG2RAD, - True, - ) - - return cls(revol_builder.Shape()) - - _transModeDict = { - Transition.TRANSFORMED: BRepBuilderAPI_Transformed, - Transition.ROUND: BRepBuilderAPI_RoundCorner, - Transition.RIGHT: BRepBuilderAPI_RightCorner, - } - - @classmethod - def _set_sweep_mode( - cls, - builder: BRepOffsetAPI_MakePipeShell, - path: Union[Wire, Edge], - mode: Union[Vector, Wire, Edge], - ) -> bool: - rotate = False - - if isinstance(mode, Vector): - coordinate_system = gp_Ax2() - coordinate_system.SetLocation(path.start_point().to_pnt()) - coordinate_system.SetDirection(mode.to_dir()) - builder.SetMode(coordinate_system) - rotate = True - elif isinstance(mode, (Wire, Edge)): - builder.SetMode(mode.to_wire().wrapped, True) - - return rotate - - @classmethod - def sweep( - cls, - section: Union[Face, Wire], - path: Union[Wire, Edge], - inner_wires: list[Wire] = None, - make_solid: bool = True, - is_frenet: bool = False, - mode: Union[Vector, Wire, Edge, None] = None, - transition: Transition = Transition.TRANSFORMED, - ) -> Solid: - """Sweep - - Sweep the given cross section into a prismatic solid along the provided path - - Args: - section (Union[Face, Wire]): cross section to sweep - path (Union[Wire, Edge]): sweep path - inner_wires (list[Wire]): holes - only used if section is a wire - make_solid (bool, optional): return Solid or Shell. Defaults to True. - is_frenet (bool, optional): Frenet mode. Defaults to False. - mode (Union[Vector, Wire, Edge, None], optional): additional sweep - mode parameters. Defaults to None. - transition (Transition, optional): handling of profile orientation at C1 path - discontinuities. Defaults to Transition.TRANSFORMED. - - Returns: - Solid: the swept cross section - """ - if isinstance(section, Face): - outer_wire = section.outer_wire() - inner_wires = section.inner_wires() - else: - outer_wire = section - inner_wires = inner_wires if inner_wires else [] - - shapes = [] - for wire in [outer_wire] + inner_wires: - builder = BRepOffsetAPI_MakePipeShell(path.to_wire().wrapped) - - rotate = False - - # handle sweep mode - if mode: - rotate = Solid._set_sweep_mode(builder, path, mode) - else: - builder.SetMode(is_frenet) - - builder.SetTransitionMode(Solid._transModeDict[transition]) - - builder.Add(wire.wrapped, False, rotate) - - builder.Build() - if make_solid: - builder.MakeSolid() - - shapes.append(Shape.cast(builder.Shape())) - - return_value, inner_shapes = shapes[0], shapes[1:] - - if inner_shapes: - return_value = return_value.cut(*inner_shapes) - - return return_value - - @classmethod - def sweep_multi( - cls, - profiles: Iterable[Union[Wire, Face]], - path: Union[Wire, Edge], - make_solid: bool = True, - is_frenet: bool = False, - mode: Union[Vector, Wire, Edge, None] = None, - ) -> Solid: - """Multi section sweep - - Sweep through a sequence of profiles following a path. - - Args: - profiles (Iterable[Union[Wire, Face]]): list of profiles - path (Union[Wire, Edge]): The wire to sweep the face resulting from the wires over - make_solid (bool, optional): Solid or Shell. Defaults to True. - is_frenet (bool, optional): Select frenet mode. Defaults to False. - mode (Union[Vector, Wire, Edge, None], optional): additional sweep mode parameters. - Defaults to None. - - Returns: - Solid: swept object - """ - path_as_wire = path.to_wire().wrapped - - builder = BRepOffsetAPI_MakePipeShell(path_as_wire) - - translate = False - rotate = False - - if mode: - rotate = cls._set_sweep_mode(builder, path, mode) - else: - builder.SetMode(is_frenet) - - for profile in profiles: - path_as_wire = ( - profile.wrapped - if isinstance(profile, Wire) - else profile.outer_wire().wrapped - ) - builder.Add(path_as_wire, translate, rotate) - - builder.Build() - - if make_solid: - builder.MakeSolid() - - return cls(builder.Shape()) - - -class Vertex(Shape): - """A Vertex in build123d represents a zero-dimensional point in the topological - data structure. It marks the endpoints of edges within a 3D model, defining precise - locations in space. Vertices play a crucial role in defining the geometry of objects - and the connectivity between edges, facilitating accurate representation and - manipulation of 3D shapes. They hold coordinate information and are essential - for constructing complex structures like wires, faces, and solids.""" - - _dim = 0 - - @property - def _dim(self) -> int: - return 0 - - @overload - def __init__(self): # pragma: no cover - """Default Vertext at the origin""" - - @overload - def __init__(self, v: TopoDS_Vertex): # pragma: no cover - """Vertex from OCCT TopoDS_Vertex object""" - - @overload - def __init__(self, X: float, Y: float, Z: float): # pragma: no cover - """Vertex from three float values""" - - @overload - def __init__(self, v: Iterable[float]): - """Vertex from Vector or other iterators""" - - @overload - def __init__(self, v: tuple[float]): - """Vertex from tuple of floats""" - - def __init__(self, *args, **kwargs): - self.vertex_index = 0 - x, y, z, ocp_vx = 0, 0, 0, None - - unknown_args = ", ".join(set(kwargs.keys()).difference(["v", "X", "Y", "Z"])) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - if args and all(isinstance(args[i], (int, float)) for i in range(len(args))): - values = list(args) - values += [0.0] * max(0, (3 - len(args))) - x, y, z = values[0:3] - elif len(args) == 1 or "v" in kwargs: - first_arg = args[0] if args else None - first_arg = kwargs.get("v", first_arg) # override with kwarg - if isinstance(first_arg, (tuple, Iterable)): - try: - values = [float(value) for value in first_arg] - except (TypeError, ValueError) as exc: - raise TypeError("Expected floats") from exc - if len(values) < 3: - values += [0.0] * (3 - len(values)) - x, y, z = values - elif isinstance(first_arg, TopoDS_Vertex): - ocp_vx = first_arg - else: - raise TypeError("Expected floats, TopoDS_Vertex, or iterable") - x = kwargs.get("X", x) - y = kwargs.get("Y", y) - z = kwargs.get("Z", z) - ocp_vx = ( - downcast(BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex()) - if ocp_vx is None - else ocp_vx - ) - - super().__init__(ocp_vx) - self.X, self.Y, self.Z = self.to_tuple() - - @property - def volume(self) -> float: - """volume - the volume of this Vertex, which is always zero""" - return 0.0 - - def to_tuple(self) -> tuple[float, float, float]: - """Return vertex as three tuple of floats""" - geom_point = BRep_Tool.Pnt_s(self.wrapped) - return (geom_point.X(), geom_point.Y(), geom_point.Z()) - - def center(self) -> Vector: - """The center of a vertex is itself!""" - return Vector(self) - - def __add__( - self, other: Union[Vertex, Vector, Tuple[float, float, float]] - ) -> Vertex: - """Add - - Add to a Vertex with a Vertex, Vector or Tuple - - Args: - other: Value to add - - Raises: - TypeError: other not in [Tuple,Vector,Vertex] - - Returns: - Result - - Example: - part.faces(">z").vertices(" Vertex: - """Subtract - - Substract a Vertex with a Vertex, Vector or Tuple from self - - Args: - other: Value to add - - Raises: - TypeError: other not in [Tuple,Vector,Vertex] - - Returns: - Result - - Example: - part.faces(">z").vertices(" str: - """To String - - Convert Vertex to String for display - - Returns: - Vertex as String - """ - return f"Vertex: ({self.X}, {self.Y}, {self.Z})" - - def __iter__(self): - """Initialize to beginning""" - self.vertex_index = 0 - return self - - def __next__(self): - """return the next value""" - if self.vertex_index == 0: - self.vertex_index += 1 - value = self.X - elif self.vertex_index == 1: - self.vertex_index += 1 - value = self.Y - elif self.vertex_index == 2: - self.vertex_index += 1 - value = self.Z - else: - raise StopIteration - return value - - -class Wire(Mixin1D, Shape): - """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 - solids. They store information about the connectivity and order of edges, - allowing precise definition of paths within a 3D model.""" - - _dim = 1 - - @property - def _dim(self) -> int: - return 1 - - @overload - def __init__( - self, - obj: TopoDS_Shape, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a wire from an OCCT TopoDS_Wire - - Args: - obj (TopoDS_Shape, optional): OCCT Wire. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - edge: Edge, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a Wire from an Edge - - Args: - edge (Edge): Edge to convert to Wire - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - wire: Wire, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a Wire from an Wire - used when the input could be an Edge or Wire. - - Args: - wire (Wire): Wire to convert to another Wire - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - wire: Curve, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a Wire from an Curve. - - Args: - curve (Curve): Curve to convert to a Wire - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - @overload - def __init__( - self, - edges: Iterable[Edge], - sequenced: bool = False, - label: str = "", - color: Color = None, - parent: Compound = None, - ): - """Build a wire from Edges - - Build a Wire from the provided unsorted Edges. If sequenced is True the - Edges are placed in such that the end of the nth Edge is coincident with - the n+1th Edge forming an unbroken sequence. Note that sequencing a list - is relatively slow. - - Args: - edges (Iterable[Edge]): Edges to assemble - sequenced (bool, optional): arrange in order. Defaults to False. - label (str, optional): Defaults to ''. - color (Color, optional): Defaults to None. - parent (Compound, optional): assembly parent. Defaults to None. - """ - - def __init__(self, *args, **kwargs): - curve, edge, edges, wire, sequenced, obj, label, color, parent = (None,) * 9 - - if args: - l_a = len(args) - if isinstance(args[0], TopoDS_Shape): - obj, label, color, parent = args[:4] + (None,) * (4 - l_a) - elif isinstance(args[0], Edge): - 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): - curve, label, color, parent = args[:4] + (None,) * (4 - l_a) - elif isinstance(args[0], Iterable): - edges, sequenced, label, color, parent = args[:5] + (None,) * (5 - l_a) - - unknown_args = ", ".join( - set(kwargs.keys()).difference( - [ - "curve", - "wire", - "edge", - "edges", - "sequenced", - "obj", - "label", - "color", - "parent", - ] - ) - ) - if unknown_args: - raise ValueError(f"Unexpected argument(s) {unknown_args}") - - obj = kwargs.get("obj", obj) - edge = kwargs.get("edge", edge) - edges = kwargs.get("edges", edges) - sequenced = kwargs.get("sequenced", sequenced) - label = kwargs.get("label", label) - color = kwargs.get("color", color) - parent = kwargs.get("parent", parent) - wire = kwargs.get("wire", wire) - curve = kwargs.get("curve", curve) - - if edge is not None: - edges = [edge] - elif curve is not None: - edges = curve.edges() - if wire is not None: - obj = wire.wrapped - elif edges: - obj = Wire._make_wire(edges, False if sequenced is None else sequenced) - - super().__init__( - obj=obj, - label="" if label is None else label, - color=color, - parent=parent, - ) - - def _geom_adaptor(self) -> BRepAdaptor_CompCurve: - """ """ - return BRepAdaptor_CompCurve(self.wrapped) - - def close(self) -> Wire: - """Close a Wire""" - if not self.is_closed: - edge = Edge.make_line(self.end_point(), self.start_point()) - return_value = Wire.combine((self, edge))[0] - else: - return_value = self - - return return_value - - def to_wire(self) -> Wire: - """Return Wire - used as a pair with Edge.to_wire when self is Wire | Edge""" - return self - - @classmethod - def combine( - cls, wires: Iterable[Union[Wire, Edge]], tol: float = 1e-9 - ) -> ShapeList[Wire]: - """combine - - Combine a list of wires and edges into a list of Wires. - - Args: - wires (Iterable[Union[Wire, Edge]]): unsorted - tol (float, optional): tolerance. Defaults to 1e-9. - - Returns: - ShapeList[Wire]: Wires - """ - - edges_in = TopTools_HSequenceOfShape() - wires_out = TopTools_HSequenceOfShape() - - for edge in Compound(wires).edges(): - 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)))) - - return wires - - def fix_degenerate_edges(self, precision: float) -> Wire: - """fix_degenerate_edges - - Fix a Wire that contains degenerate (very small) edges - - Args: - precision (float): minimum value edge length - - Returns: - Wire: fixed wire - """ - 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())) - - def param_at_point(self, point: VectorLike) -> float: - """Parameter at point on 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. - - 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 - - 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)) - - # 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 - - # Set the next start to "end" of the current edge - target = edge_p0 if flipped else edge_p1 - - # 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 - break - distance += edge.length - - if not found: - raise ValueError(f"{point} not on wire") - - return distance / wire_length - - def trim(self: Wire, start: float, end: float) -> Wire: - """trim - - Create a new wire by keeping only the section between start and end. - - Args: - start (float): 0.0 <= start < 1.0 - end (float): 0.0 < end <= 1.0 - - Raises: - ValueError: start >= end - - Returns: - Wire: trimmed wire - """ - - # pylint: disable=too-many-branches - if start >= end: - raise ValueError("start must be less than end") - - edges = self.edges() - - # If this is really just an edge, skip the complexity of a Wire - if len(edges) == 1: - return Wire([edges[0].trim(start, end)]) - - # 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 e in edges: - u = self.param_at_point(e.position_at(0)) - v = self.param_at_point(e.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, e)) - - new_edges = [] - for u, v, e in edges_uv_values: - if v < start or u > end: # Edge not needed - continue - - elif start <= u and v <= end: # keep whole Edge - new_edges.append(e) - - elif start >= u and end <= v: # Wire trimmed to single Edge - u_edge = e.param_at_point(self.position_at(start)) - v_edge = e.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) - ) - new_edges.append(e.trim(u_edge, v_edge)) - - elif start <= u: # keep start of Edge - u_edge = e.param_at_point(self.position_at(end)) - if u_edge != 0: - new_edges.append(e.trim(0, u_edge)) - - else: # v <= end keep end of Edge - v_edge = e.param_at_point(self.position_at(start)) - if v_edge != 1: - new_edges.append(e.trim(v_edge, 1)) - - return Wire(new_edges) - - 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) - - @classmethod - def make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> Wire: - """make_wire - - Build a Wire from the provided unsorted Edges. If sequenced is True the - Edges are placed in such that the end of the nth Edge is coincident with - the n+1th Edge forming an unbroken sequence. Note that sequencing a list - is relatively slow. - - Args: - edges (Iterable[Edge]): Edges to assemble - sequenced (bool, optional): arrange in order. Defaults to False. - - Raises: - ValueError: Edges are disconnected and can't be sequenced. - RuntimeError: Wire is empty - - Returns: - Wire: assembled edges - """ - warnings.warn( - "make_wire() will be deprecated - use the Wire constructor instead", - DeprecationWarning, - stacklevel=2, - ) - return Wire(edges, sequenced) - - @classmethod - def _make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> TopoDS_Wire: - """_make_wire - - Build a Wire from the provided unsorted Edges. If sequenced is True the - Edges are placed in such that the end of the nth Edge is coincident with - the n+1th Edge forming an unbroken sequence. Note that sequencing a list - is relatively slow. - - Args: - edges (Iterable[Edge]): Edges to assemble - sequenced (bool, optional): arrange in order. Defaults to False. - - Raises: - ValueError: Edges are disconnected and can't be sequenced. - RuntimeError: Wire is empty - - Returns: - Wire: assembled edges - """ - - def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge: - """Return the Edge closest to the end of last_edge""" - target_point = current.position_at(1) - - sorted_edges = sorted( - unplaced_edges, - key=lambda e: min( - (target_point - e.position_at(0)).length, - (target_point - e.position_at(1)).length, - ), - ) - return sorted_edges[0] - - edges = list(edges) - if sequenced: - placed_edges = [edges.pop(0)] - unplaced_edges = edges - - while unplaced_edges: - next_edge = closest_to_end(Wire(placed_edges), unplaced_edges) - next_edge_index = unplaced_edges.index(next_edge) - placed_edges.append(unplaced_edges.pop(next_edge_index)) - - edges = placed_edges - - wire_builder = BRepBuilderAPI_MakeWire() - combined_edges = TopTools_ListOfShape() - for edge in edges: - combined_edges.Append(edge.wrapped) - wire_builder.Add(combined_edges) - - wire_builder.Build() - if not wire_builder.IsDone(): - if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire: - warnings.warn("Wire is non manifold", stacklevel=2) - elif wire_builder.Error() == BRepBuilderAPI_EmptyWire: - raise RuntimeError("Wire is empty") - elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire: - raise ValueError("Edges are disconnected") - - return wire_builder.Wire() - - @classmethod - def make_circle(cls, radius: float, plane: Plane = Plane.XY) -> Wire: - """make_circle - - Makes a circle centered at the origin of plane - - Args: - radius (float): circle radius - plane (Plane): base plane. Defaults to Plane.XY - - Returns: - Wire: a circle - """ - circle_edge = Edge.make_circle(radius, plane=plane) - return Wire([circle_edge]) - - @classmethod - def make_ellipse( - cls, - x_radius: float, - y_radius: float, - plane: Plane = Plane.XY, - start_angle: float = 360.0, - end_angle: float = 360.0, - angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, - closed: bool = True, - ) -> Wire: - """make ellipse - - Makes an ellipse centered at the origin of plane. - - Args: - 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) - plane (Plane, optional): base plane. Defaults to Plane.XY. - start_angle (float, optional): _description_. Defaults to 360.0. - end_angle (float, optional): _description_. Defaults to 360.0. - angular_direction (AngularDirection, optional): arc direction. - Defaults to AngularDirection.COUNTER_CLOCKWISE. - closed (bool, optional): close the arc. Defaults to True. - - Returns: - Wire: an ellipse - """ - ellipse_edge = Edge.make_ellipse( - x_radius, y_radius, plane, start_angle, end_angle, angular_direction - ) - - if start_angle != end_angle and closed: - line = Edge.make_line(ellipse_edge.end_point(), ellipse_edge.start_point()) - wire = Wire([ellipse_edge, line]) - else: - wire = Wire([ellipse_edge]) - - return wire - - @classmethod - def make_polygon(cls, vertices: Iterable[VectorLike], close: bool = True) -> Wire: - """make_polygon - - Create an irregular polygon by defining vertices - - Args: - vertices (Iterable[VectorLike]): - close (bool, optional): close the polygon. Defaults to True. - - Returns: - Wire: an irregular polygon - """ - vertices = [Vector(v) for v in vertices] - if (vertices[0] - vertices[-1]).length > TOLERANCE and close: - vertices.append(vertices[0]) - - wire_builder = BRepBuilderAPI_MakePolygon() - for vertex in vertices: - wire_builder.Add(vertex.to_pnt()) - - return cls(wire_builder.Wire()) - - def stitch(self, other: Wire) -> Wire: - """Attempt to stich wires - - Args: - other: Wire: - - Returns: - - """ - - wire_builder = BRepBuilderAPI_MakeWire() - wire_builder.Add(TopoDS.Wire_s(self.wrapped)) - wire_builder.Add(TopoDS.Wire_s(other.wrapped)) - wire_builder.Build() - - return self.__class__(wire_builder.Wire()) - - def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Wire: - """fillet_2d - - Apply 2D fillet to a wire - - Args: - radius (float): - vertices (Iterable[Vertex]): vertices to fillet - - Returns: - Wire: filleted wire - """ - return Face(self).fillet_2d(radius, vertices).outer_wire() - - def chamfer_2d( - self, - distance: float, - distance2: float, - vertices: Iterable[Vertex], - edge: Edge = None, - ) -> Wire: - """chamfer_2d - - Apply 2D chamfer to a wire - - Args: - distance (float): chamfer length - distance2 (float): chamfer length - vertices (Iterable[Vertex]): vertices to chamfer - edge (Edge): identifies the side where length is measured. The vertices must be - part of the edge - - Returns: - Wire: chamfered wire - """ - return Face(self).chamfer_2d(distance, distance2, vertices, edge).outer_wire() - - @classmethod - def make_rect( - cls, - width: float, - height: float, - plane: Plane = Plane.XY, - ) -> Wire: - """Make Rectangle - - Make a Rectangle centered on center with the given normal - - Args: - width (float): width (local x) - height (float): height (local y) - plane (Plane, optional): plane containing rectangle. Defaults to Plane.XY. - - Returns: - Wire: The centered rectangle - """ - corners_local = [ - (width / 2, height / 2), - (width / 2, height / -2), - (width / -2, height / -2), - (width / -2, height / 2), - ] - corners_world = [plane.from_local_coords(c) for c in corners_local] - return Wire.make_polygon(corners_world, close=True) - - @classmethod - def make_convex_hull(cls, edges: Iterable[Edge], tolerance: float = 1e-3) -> Wire: - """make_convex_hull - - Create a wire of minimum length enclosing all of the provided edges. - - Note that edges can't overlap each other. - - Args: - edges (Iterable[Edge]): edges defining the convex hull - tolerance (float): allowable error as a fraction of each edge length. - Defaults to 1e-3. - - Raises: - ValueError: edges overlap - - Returns: - Wire: convex hull perimeter - """ - # pylint: disable=too-many-branches, too-many-locals - # Algorithm: - # 1) create a cloud of points along all edges - # 2) create a convex hull which returns facets/simplices as pairs of point indices - # 3) find facets that are within an edge but not adjacent and store trim and - # new connecting edge data - # 4) find facets between edges and store trim and new connecting edge data - # 5) post process the trim data to remove duplicates and store in pairs - # 6) create connecting edges - # 7) create trim edges from the original edges and the trim data - # 8) return a wire version of all the edges - - # Possible enhancement: The accuracy of the result could be improved and the - # execution time reduced by adaptively placing more points around where the - # connecting edges contact the arc. - - # if any( - # [ - # edge_pair[0].overlaps(edge_pair[1]) - # for edge_pair in combinations(edges, 2) - # ] - # ): - # raise ValueError("edges overlap") - - fragments_per_edge = int(2 / tolerance) - points_lookup = {} # lookup from point index to edge/position on edge - points = [] # convex hull point cloud - - # Create points along each edge and the lookup structure - 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_lookup[edge_index * fragments_per_edge + i] = (edge_index, param) - - convex_hull = ConvexHull(points) - - # Filter the fragments - connecting_edge_data = [] - trim_points = {} - for simplice in convex_hull.simplices: - edge0 = points_lookup[simplice[0]][0] - edge1 = points_lookup[simplice[1]][0] - # Look for connecting edges between edges - if edge0 != edge1: - if edge0 not in trim_points: - trim_points[edge0] = [simplice[0]] - else: - trim_points[edge0].append(simplice[0]) - if edge1 not in trim_points: - trim_points[edge1] = [simplice[1]] - else: - trim_points[edge1].append(simplice[1]) - connecting_edge_data.append( - ( - (edge0, points_lookup[simplice[0]][1], simplice[0]), - (edge1, points_lookup[simplice[1]][1], simplice[1]), - ) - ) - # Look for connecting edges within an edge - elif abs(simplice[0] - simplice[1]) != 1: - start_pnt = min(simplice.tolist()) - end_pnt = max(simplice.tolist()) - if edge0 not in trim_points: - trim_points[edge0] = [start_pnt, end_pnt] - else: - trim_points[edge0].extend([start_pnt, end_pnt]) - connecting_edge_data.append( - ( - (edge0, points_lookup[start_pnt][1], start_pnt), - (edge0, points_lookup[end_pnt][1], end_pnt), - ) - ) - - trim_data = {} - for edge, points in trim_points.items(): - s_points = sorted(points) - f_points = [] - for i in range(0, len(s_points) - 1, 2): - if s_points[i] != s_points[i + 1]: - f_points.append(tuple(s_points[i : i + 2])) - trim_data[edge] = f_points - - connecting_edges = [ - Edge.make_line( - edges[line[0][0]] @ line[0][1], edges[line[1][0]] @ line[1][1] - ) - for line in connecting_edge_data - ] - trimmed_edges = [ - edges[edge].trim( - points_lookup[trim_pair[0]][1], points_lookup[trim_pair[1]][1] - ) - for edge, trim_pairs in trim_data.items() - for trim_pair in trim_pairs - ] - hull_wire = Wire(connecting_edges + trimmed_edges, sequenced=True) - return hull_wire - - def project_to_shape( - self, - target_object: Shape, - direction: VectorLike = None, - center: VectorLike = None, - ) -> list[Wire]: - """Project Wire - - Project a Wire onto a Shape generating new wires on the surfaces of the object - one and only one of `direction` or `center` must be provided. Note that one or - more wires may be generated depending on the topology of the target object and - location/direction of projection. - - To avoid flipping the normal of a face built with the projected wire the orientation - of the output wires are forced to be the same as self. - - Args: - target_object: Object to project onto - direction: Parallel projection direction. Defaults to None. - center: Conical center of projection. Defaults to None. - target_object: Shape: - direction: VectorLike: (Default value = None) - center: VectorLike: (Default value = None) - - Returns: - : Projected wire(s) - - Raises: - ValueError: Only one of direction or center must be provided - - """ - # pylint: disable=too-many-branches - if not (direction is None) ^ (center is None): - raise ValueError("One of either direction or center must be provided") - if direction is not None: - direction_vector = Vector(direction).normalized() - center_point = None - else: - direction_vector = None - center_point = Vector(center) - - # Project the wire on the target object - if direction_vector is not None: - projection_object = BRepProj_Projection( - self.wrapped, - Shape.cast(target_object.wrapped).wrapped, - gp_Dir(*direction_vector.to_tuple()), - ) - else: - projection_object = BRepProj_Projection( - self.wrapped, - Shape.cast(target_object.wrapped).wrapped, - gp_Pnt(*center_point.to_tuple()), - ) - - # Generate a list of the projected wires with aligned orientation - output_wires = [] - target_orientation = self.wrapped.Orientation() - while projection_object.More(): - projected_wire = projection_object.Current() - if target_orientation == projected_wire.Orientation(): - output_wires.append(Wire(projected_wire)) - else: - output_wires.append(Wire(projected_wire.Reversed())) - projection_object.Next() - - logger.debug("wire generated %d projected wires", len(output_wires)) - - # BRepProj_Projection is inconsistent in the order that it returns projected - # wires, sometimes front first and sometimes back - so sort this out by sorting - # by distance from the original planar wire - if len(output_wires) > 1: - output_wires_distances = [] - planar_wire_center = self.center() - for output_wire in output_wires: - output_wire_center = output_wire.center() - if direction_vector is not None: - output_wire_direction = ( - output_wire_center - planar_wire_center - ).normalized() - if output_wire_direction.dot(direction_vector) >= 0: - output_wires_distances.append( - ( - output_wire, - (output_wire_center - planar_wire_center).length, - ) - ) - else: - output_wires_distances.append( - ( - output_wire, - (output_wire_center - center_point).length, - ) - ) - - output_wires_distances.sort(key=lambda x: x[1]) - logger.debug( - "projected, filtered and sorted wire list is of length %d", - len(output_wires_distances), - ) - output_wires = [w[0] for w in output_wires_distances] - - return output_wires - - -class Joint(ABC): - """Joint - - Abstract Base Joint class - used to join two components together - - Args: - parent (Union[Solid, Compound]): object that joint to bound to - - Attributes: - label (str): user assigned label - parent (Shape): object joint is bound to - connected_to (Joint): joint that is connect to this joint - - """ - - def __init__(self, label: str, parent: Union[Solid, Compound]): - self.label = label - self.parent = parent - self.connected_to: Joint = None - - def _connect_to(self, other: Joint, **kwargs): # pragma: no cover - """Connect Joint self by repositioning other""" - - if not isinstance(other, Joint): - raise TypeError(f"other must of type Joint not {type(other)}") - - relative_location = self.relative_to(other, **kwargs) - other.parent.locate(self.parent.location * relative_location) - self.connected_to = other - - @abstractmethod - def connect_to(self, other: Joint): - """All derived classes must provide a connect_to method""" - raise NotImplementedError - - @abstractmethod - def relative_to(self, other: Joint) -> Location: - """Return relative location to another joint""" - raise NotImplementedError - - @property - @abstractmethod - def location(self) -> Location: # pragma: no cover - """Location of joint""" - raise NotImplementedError - - @property - @abstractmethod - def symbol(self) -> Compound: # pragma: no cover - """A CAD object positioned in global space to illustrate the joint""" - raise NotImplementedError - - -def _thicken(obj: TopoDS_Shape, depth: float): - solid = BRepOffset_MakeOffset() - solid.Initialize( - obj, - Offset=depth, - Tol=1.0e-5, - Mode=BRepOffset_Skin, - # BRepOffset_RectoVerso - which describes the offset of a given surface shell along both - # sides of the surface but doesn't seem to work - Intersection=True, - SelfInter=False, - Join=GeomAbs_Intersection, # Could be GeomAbs_Arc,GeomAbs_Tangent,GeomAbs_Intersection - Thickening=True, - RemoveIntEdges=True, - ) - solid.MakeOffsetShape() - try: - result = Solid(solid.Shape()) - except StdFail_NotDone as err: - raise RuntimeError("Error applying thicken to given Face") from err - - return result.clean() - - -def _make_loft( - objs: Iterable[Union[Vertex, Wire]], - filled: bool, - ruled: bool = False, -) -> TopoDS_Shape: - """make loft - - Makes a loft from a list of wires and vertices. - Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list - nor between wires. - - Args: - wires (list[Wire]): section perimeters - ruled (bool, optional): stepped or smooth. Defaults to False (smooth). - - Raises: - ValueError: Too few wires - - Returns: - TopoDS_Shape: Lofted object - """ - if len(objs) < 2: - raise ValueError("More than one wire is required") - vertices = [obj for obj in objs if isinstance(obj, Vertex)] - vertex_count = len(vertices) - - if vertex_count > 2: - raise ValueError("Only two vertices are allowed") - - if vertex_count == 1 and not ( - isinstance(objs[0], Vertex) or isinstance(objs[-1], Vertex) - ): - raise ValueError( - "The vertex must be either at the beginning or end of the list" - ) - - if vertex_count == 2: - if len(objs) == 2: - raise ValueError( - "You can't have only 2 vertices to loft; try adding some wires" - ) - if not (isinstance(objs[0], Vertex) and isinstance(objs[-1], Vertex)): - raise ValueError( - "The vertices must be at the beginning and end of the list" - ) - - loft_builder = BRepOffsetAPI_ThruSections(filled, ruled) - - for obj in objs: - if isinstance(obj, Vertex): - loft_builder.AddVertex(obj.wrapped) - elif isinstance(obj, Wire): - loft_builder.AddWire(obj.wrapped) - - loft_builder.Build() - - return loft_builder.Shape() - - -def downcast(obj: TopoDS_Shape) -> TopoDS_Shape: - """Downcasts a TopoDS object to suitable specialized type - - Args: - obj: TopoDS_Shape: - - Returns: - - """ - - f_downcast: Any = downcast_LUT[shapetype(obj)] - return_value = f_downcast(obj) - - return return_value - - -def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> list[Wire]: - """Convert edges to a list of wires. - - Args: - edges: Iterable[Edge]: - tol: float: (Default value = 1e-6) - - Returns: - - """ - - edges_in = TopTools_HSequenceOfShape() - wires_out = TopTools_HSequenceOfShape() - - for edge in edges: - 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)))) - - return wires - - -def fix(obj: TopoDS_Shape) -> TopoDS_Shape: - """Fix a TopoDS object to suitable specialized type - - Args: - obj: TopoDS_Shape: - - Returns: - - """ - - shape_fix = ShapeFix_Shape(obj) - shape_fix.Perform() - - return downcast(shape_fix.Shape()) - - -def isclose_b(a: float, b: float, rel_tol=1e-9, abs_tol=1e-14) -> bool: - """Determine whether two floating point numbers are close in value. - Overridden abs_tol default for the math.isclose function. - - Args: - a (float): First value to compare - b (float): Second value to compare - rel_tol (float, optional): Maximum difference for being considered "close", relative to the - magnitude of the input values. Defaults to 1e-9. - abs_tol (float, optional): Maximum difference for being considered "close", regardless of the - magnitude of the input values. Defaults to 1e-14 (unlike math.isclose which defaults to zero). - - Returns: True if a is close in value to b, and False otherwise. - """ - return isclose(a, b, rel_tol=rel_tol, abs_tol=abs_tol) - - -def shapetype(obj: TopoDS_Shape) -> TopAbs_ShapeEnum: - """Return TopoDS_Shape's TopAbs_ShapeEnum""" - if obj.IsNull(): - raise ValueError("Null TopoDS_Shape object") - - return obj.ShapeType() - - -def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum: - """Return Shape's TopAbs_ShapeEnum""" - if isinstance(obj, Compound): - shapetypes = set(shapetype(o.wrapped) for o in obj) - if len(shapetypes) == 1: - result = shapetypes.pop() - else: - result = shapetype(obj) - else: - result = shapetype(obj.wrapped) - return result - - -def sort_wires_by_build_order(wire_list: list[Wire]) -> list[list[Wire]]: - """Tries to determine how wires should be combined into faces. - - Assume: - The wires make up one or more faces, which could have 'holes' - Outer wires are listed ahead of inner wires - there are no wires inside wires inside wires - ( IE, islands -- we can deal with that later on ) - none of the wires are construction wires - - Compute: - one or more sets of wires, with the outer wire listed first, and inner - ones - - Returns, list of lists. - - Args: - wire_list: list[Wire]: - - Returns: - - """ - - # check if we have something to sort at all - if len(wire_list) < 2: - return [ - wire_list, - ] - - # make a Face, NB: this might return a compound of faces - faces = Face(wire_list[0], wire_list[1:]) - - return_value = [] - for face in faces.faces(): - return_value.append( - [ - face.outer_wire(), - ] - + face.inner_wires() - ) - - return return_value - - -def polar(length: float, angle: float) -> tuple[float, float]: - """Convert polar coordinates into cartesian coordinates""" - return (length * cos(radians(angle)), length * sin(radians(angle))) - - -def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]: - """Compare the OCCT objects of each list and return the differences""" - occt_one = set(shape.wrapped for shape in shapes_one) - occt_two = set(shape.wrapped for shape in shapes_two) - occt_delta = list(occt_one - occt_two) - - all_shapes = [] - for shapes in [shapes_one, shapes_two]: - all_shapes.extend(shapes if isinstance(shapes, list) else [*shapes]) - shape_delta = [shape for shape in all_shapes if shape.wrapped in occt_delta] - return shape_delta - - -def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]: - """new_edges - - Given a sequence of shapes and the combination of those shapes, find the newly added edges - - Args: - objects (Shape): sequence of shapes - combined (Shape): result of the combination of objects - - Returns: - ShapeList[Edge]: new edges - """ - # Create a list of combined object edges - combined_topo_edges = TopTools_ListOfShape() - for edge in combined.edges(): - combined_topo_edges.Append(edge.wrapped) - - # Create a list of original object edges - original_topo_edges = TopTools_ListOfShape() - for edge in [e for obj in objects for e in obj.edges()]: - original_topo_edges.Append(edge.wrapped) - - # Cut the original edges from the combined edges - operation = BRepAlgoAPI_Cut() - operation.SetArguments(combined_topo_edges) - operation.SetTools(original_topo_edges) - operation.SetRunParallel(True) - operation.Build() - - edges = Shape.cast(operation.Shape()).edges() - for edge in edges: - edge.topo_parent = combined - return ShapeList(edges) - - -def topo_explore_connected_edges(edge: Edge, parent: Shape = None) -> ShapeList[Edge]: - """Given an edge extracted from a Shape, return the edges connected to it""" - - parent = parent if parent is not None else edge.topo_parent - given_topods_edge = edge.wrapped - connected_edges = set() - - # Find all the TopoDS_Edges for this Shape - topods_edges = ShapeList([e.wrapped for e in parent.edges()]) - - for topods_edge in topods_edges: - # # Don't match with the given edge - 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) - - return ShapeList([Edge(e) for e in connected_edges]) - - -def topo_explore_common_vertex( - edge1: Union[Edge, TopoDS_Edge], edge2: Union[Edge, TopoDS_Edge] -) -> Union[Vertex, None]: - """Given two edges, find the common vertex""" - topods_edge1 = edge1.wrapped if isinstance(edge1, Edge) else edge1 - topods_edge2 = edge2.wrapped if isinstance(edge2, Edge) else edge2 - - # Explore vertices of the first edge - vert_exp = TopExp_Explorer(topods_edge1, ta.TopAbs_VERTEX) - while vert_exp.More(): - vertex1 = vert_exp.Current() - - # Explore vertices of the second edge - explorer2 = TopExp_Explorer(topods_edge2, ta.TopAbs_VERTEX) - while explorer2.More(): - vertex2 = explorer2.Current() - - # Check if the vertices are the same - if vertex1.IsSame(vertex2): - return Vertex(downcast(vertex1)) # Common vertex found - - explorer2.Next() - vert_exp.Next() - - return None # No common vertex found - - -class SkipClean: - """Skip clean context for use in operator driven code where clean=False wouldn't work""" - - clean = True - - def __enter__(self): - SkipClean.clean = False - - def __exit__(self, exception_type, exception_value, traceback): - SkipClean.clean = True diff --git a/src/build123d/topology/__init__.py b/src/build123d/topology/__init__.py new file mode 100644 index 0000000..6471c29 --- /dev/null +++ b/src/build123d/topology/__init__.py @@ -0,0 +1,102 @@ +""" +build123d.topology package + +name: __init__.py +by: Gumyr +date: January 07, 2025 + +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 + manipulate and analyze those 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 .shape_core import ( + Shape, + Comparable, + ShapePredicate, + GroupBy, + ShapeList, + Joint, + SkipClean, + BoundBox, + downcast, + fix, + unwrap_topods_compound, +) +from .utils import ( + tuplify, + isclose_b, + polar, + delta, + new_edges, + find_max_dimension, +) +from .zero_d import Vertex, topo_explore_common_vertex +from .one_d import ( + Edge, + Wire, + Mixin1D, + edges_to_wires, + topo_explore_connected_edges, + offset_topods_face, + topo_explore_connected_faces, +) +from .two_d import Face, Shell, Mixin2D, sort_wires_by_build_order +from .three_d import Solid, Mixin3D, DraftAngleError +from .composite import Compound, Curve, Sketch, Part + +__all__ = [ + "Shape", + "Comparable", + "DraftAngleError", + "ShapePredicate", + "GroupBy", + "ShapeList", + "Joint", + "SkipClean", + "BoundBox", + "downcast", + "fix", + "unwrap_topods_compound", + "tuplify", + "isclose_b", + "polar", + "delta", + "new_edges", + "find_max_dimension", + "Vertex", + "topo_explore_common_vertex", + "Edge", + "Wire", + "edges_to_wires", + "offset_topods_face", + "topo_explore_connected_edges", + "topo_explore_connected_faces", + "Face", + "Shell", + "sort_wires_by_build_order", + "Solid", + "Compound", + "Curve", + "Sketch", + "Part", +] diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py new file mode 100644 index 0000000..0919312 --- /dev/null +++ b/src/build123d/topology/composite.py @@ -0,0 +1,1021 @@ +""" +build123d topology + +name: composite.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module defines advanced composite geometric entities for the build123d CAD system. It +introduces the `Compound` class as a central concept for managing groups of shapes, alongside +specialized subclasses such as `Curve`, `Sketch`, and `Part` for 1D, 2D, and 3D objects, +respectively. These classes streamline the construction and manipulation of complex geometric +assemblies. + +Key Features: +- **Compound Class**: + - Represents a collection of geometric shapes (e.g., vertices, edges, faces, solids) grouped + hierarchically. + - Supports operations like adding, removing, and combining shapes, as well as querying volumes, + centers, and intersections. + - Provides utility methods for unwrapping nested compounds and generating 3D text or coordinate + system triads. + +- **Specialized Subclasses**: + - `Curve`: Handles 1D objects like edges and wires. + - `Sketch`: Focused on 2D objects, such as faces. + - `Part`: Manages 3D solids and assemblies. + +- **Advanced Features**: + - Includes Boolean operations, hierarchy traversal, and bounding box-based intersection detection. + - Supports transformations, child-parent relationships, and dynamic updates. + +This module leverages OpenCascade for robust geometric operations while offering a Pythonic +interface for efficient and extensible CAD modeling workflows. + +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 + +import copy +import os +import sys +import warnings +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_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 +from OCP.TCollection import TCollection_AsciiString +from OCP.TopAbs import TopAbs_ShapeEnum +from OCP.TopoDS import ( + TopoDS, + TopoDS_Builder, + TopoDS_Compound, + TopoDS_Iterator, + TopoDS_Shape, +) +from anytree import PreOrderIter +from build123d.build_enums import Align, CenterOf, FontStyle, TextAlign +from build123d.geometry import ( + TOLERANCE, + Axis, + Color, + Location, + Plane, + Vector, + VectorLike, + logger, +) + +from .one_d import Edge, Wire, Mixin1D +from .shape_core import ( + Shape, + ShapeList, + SkipClean, + Joint, + downcast, + shapetype, + topods_dim, +) +from .three_d import Mixin3D, Solid +from .two_d import Face, Shell +from .utils import ( + _extrude_topods_shape, + _make_topods_compound_from_shapes, + tuplify, + unwrapped_shapetype, +) +from .zero_d import Vertex + + +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 + hierarchical arrangement facilitates the construction of complex models by + combining simpler shapes. Compound plays a pivotal role in managing the + composition and structure of intricate 3D models in computer-aided design + (CAD) applications, allowing engineers and designers to work with assemblies + of shapes as unified entities for efficient modeling and analysis.""" + + order = 4.0 + + # ---- Constructor ---- + + def __init__( + self, + obj: TopoDS_Compound | Iterable[Shape] | None = None, + label: str = "", + color: Color | None = None, + material: str = "", + joints: dict[str, Joint] | None = None, + parent: Compound | None = None, + children: Sequence[Shape] | None = None, + ): + """Build a Compound from Shapes + + Args: + obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + material (str, optional): tag for external tools. Defaults to ''. + joints (dict[str, Joint], optional): names joints. Defaults to None. + 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] + ) + else: + topods_compound = obj + + super().__init__( + obj=topods_compound, + label=label, + color=color, + parent=parent, + ) + self.material = "" if material is None else material + self.joints = {} if joints is None else joints + self.children = [] if children is None else children + + # ---- Properties ---- + + @property + def _dim(self) -> int | None: + """The dimension of the shapes within the Compound - None if inconsistent""" + return topods_dim(self.wrapped) + + @property + def volume(self) -> float: + """volume - the volume of this Compound""" + # when density == 1, mass == volume + return sum(i.volume for i in [*self.get_type(Solid), *self.get_type(Shell)]) + + # ---- Class Methods ---- + + @classmethod + def cast( + cls, obj: TopoDS_Shape + ) -> Vertex | Edge | Wire | Face | Shell | Solid | Compound: + "Returns the right type of wrapper, given a OCCT object" + + # define the shape lookup table for casting + constructor_lut = { + ta.TopAbs_VERTEX: Vertex, + ta.TopAbs_EDGE: Edge, + ta.TopAbs_WIRE: Wire, + ta.TopAbs_FACE: Face, + ta.TopAbs_SHELL: Shell, + ta.TopAbs_SOLID: Solid, + ta.TopAbs_COMPOUND: Compound, + ta.TopAbs_COMPSOLID: Compound, + } + + shape_type = shapetype(obj) + # NB downcast is needed to handle TopoDS_Shape types + return constructor_lut[shape_type](downcast(obj)) + + @classmethod + def extrude(cls, obj: Shell, direction: VectorLike) -> Compound: + """extrude + + Extrude a Shell into a Compound. + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + Returns: + Edge: extruded shape + """ + return Compound( + TopoDS.Compound_s(_extrude_topods_shape(obj.wrapped, direction)) + ) + + @classmethod + def make_text( + cls, + txt: str, + font_size: float, + font: str = "Arial", + font_path: str | None = None, + font_style: FontStyle = FontStyle.REGULAR, + 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: + """2D Text that optionally follows a path. + + The text that is created can be combined as with other sketch features by specifying + a mode or rotated by the given angle. In addition, edges have been previously created + with arc or segment, the text will follow the path defined by these edges. The start + parameter can be used to shift the text along the path to achieve precise positioning. + + Args: + txt: text to be rendered + 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 + 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 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) + + Returns: + a Compound object containing multiple Faces representing the text + + Examples:: + + fox = Compound.make_text( + txt="The quick brown fox jumped over the lazy dog", + font_size=10, + position_on_path=0.1, + text_path=jump_edge, + ) + + """ + # pylint: disable=too-many-locals + + def position_face(orig_face: Face) -> Face: + """ + Reposition a face to the provided path + + Local coordinates are used to calculate the position of the face + relative to the path. Global coordinates to position the face. + """ + assert text_path is not None + bbox = orig_face.bounding_box() + face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0) + relative_position_on_wire = ( + position_on_path + face_bottom_center.X / path_length + ) + wire_tangent = text_path.tangent_at(relative_position_on_wire) + wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent) + wire_position = text_path.position_at(relative_position_on_wire) + + return orig_face.translate(wire_position - face_bottom_center).rotate( + Axis(wire_position, (0, 0, 1)), + -wire_angle, + ) + + if sys.platform.startswith("linux"): + os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf" + os.environ["FONTCONFIG_PATH"] = "/etc/fonts/" + + font_kind = { + 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()): + font_t = Font_SystemFont(TCollection_AsciiString(font_path)) + font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path)) + mgr.RegisterFont(font_t, True) + + else: + font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind) + + logger.info( + "Creating text with font %s located at %s", + font_t.FontName().ToCString(), + font_t.FontPath(font_kind).ToCString(), + ) + + builder = Font_BRepTextBuilder() + font_i = StdPrs_BRepFont( + NCollection_Utf8String(font_t.FontName().ToCString()), + font_kind, + float(font_size), + ) + + 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) + text_flat = text_flat.translate( + Vector(*text_flat.bounding_box().to_align_offset(align_text)) + ) + + if text_path is not None: + path_length = text_path.length + text_flat = Compound([position_face(f) for f in text_flat.faces()]) + + return text_flat + + @classmethod + def make_triad(cls, axes_scale: float) -> Compound: + """The coordinate system triad (X, Y, Z axes)""" + x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0)) + y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0)) + z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale)) + arrow_arc = Edge.make_spline( + [(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)], + [(-1, 0, 0), (-1, 1.5, 0)], + ) + arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)]) + x_label = ( + Compound.make_text( + "X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) + ) + .move(Location(x_axis @ 1)) + .edges() + ) + y_label = ( + Compound.make_text( + "Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) + ) + .rotate(Axis.Z, 90) + .move(Location(y_axis @ 1)) + .edges() + ) + z_label = ( + Compound.make_text( + "Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN) + ) + .rotate(Axis.Y, 90) + .rotate(Axis.X, 90) + .move(Location(z_axis @ 1)) + .edges() + ) + triad = Curve( + [ + x_axis, + y_axis, + z_axis, + arrow.moved(Location(x_axis @ 1)), + arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)), + arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)), + *x_label, + *y_label, + *z_label, + ] + ) + + return triad + + # ---- Instance Methods ---- + + 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) + 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: + summands = ShapeList() + else: + summands = ShapeList( + shape + for o in ([other] if isinstance(other, Shape) else other) + if o is not None + for shape in o.get_top_level_shapes() + ) + # If there is nothing to add return the original object + if not summands: + return self + + summands = ShapeList( + s for s in self.get_top_level_shapes() + summands if s is not None + ) + + # Only fuse the parts if necessary + if len(summands) <= 1: + result: Shape = Compound(summands[0:1]) + else: + fuse_op = BRepAlgoAPI_Fuse() + fuse_op.SetFuzzyValue(TOLERANCE) + self.copy_attributes_to(summands[0], ["wrapped", "_NodeMixin__children"]) + bool_result = self._bool_op(summands[:1], summands[1:], fuse_op) + if isinstance(bool_result, list): + result = Compound(bool_result) + self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) + else: + result = bool_result + + if SkipClean.clean: + result = result.clean() + + return result + + 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] + ) + self.copy_attributes_to(intersection, ["wrapped", "_NodeMixin__children"]) + return intersection + + def __bool__(self) -> bool: + """ + Check if empty. + """ + + return self._wrapped is not None and TopoDS_Iterator(self.wrapped).More() + + def __iter__(self) -> Iterator[Shape]: + """ + Iterate over subshapes. + + """ + + iterator = TopoDS_Iterator(self.wrapped) + + while iterator.More(): + yield Compound.cast(iterator.Value()) + iterator.Next() + + def __len__(self) -> int: + """Return the number of subshapes""" + count = 0 + if self._wrapped is not None: + for _ in self: + count += 1 + return count + + def __repr__(self): + """Return Compound info as string""" + if hasattr(self, "label") and hasattr(self, "children"): + result = ( + f"{self.__class__.__name__} at {id(self):#x}, label({self.label}), " + + f"#children({len(self.children)})" + ) + else: + result = f"{self.__class__.__name__} at {id(self):#x}" + return result + + def __sub__(self, other: None | Shape | Iterable[Shape]) -> Compound: + """Cut other to self `-` operator""" + difference = Shape.__sub__(self, other) + difference = Compound( + difference if isinstance(difference, list) else [difference] + ) + self.copy_attributes_to(difference, ["wrapped", "_NodeMixin__children"]) + + return difference + + def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: + """Return center of object + + Find center of object + + Args: + center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS. + + Raises: + ValueError: Center of GEOMETRY is not supported for this object + NotImplementedError: Unable to calculate center of mass of this object + + Returns: + Vector: center + """ + if center_of == CenterOf.GEOMETRY: + raise ValueError("Center of GEOMETRY is not supported for this object") + if center_of == CenterOf.MASS: + properties = GProp_GProps() + calc_function = Shape.shape_properties_LUT[unwrapped_shapetype(self)] + if calc_function: + calc_function(self.wrapped, properties) + middle = Vector(properties.CentreOfMass()) + else: + raise NotImplementedError + elif center_of == CenterOf.BOUNDING_BOX: + middle = self.bounding_box().center() + return middle + + def compound(self) -> Compound | None: + """Return the Compound""" + shape_list = self.compounds() + entity_count = len(shape_list) + if entity_count > 1: + warnings.warn( + f"Found {entity_count} compounds, returning first", + stacklevel=2, + ) + return shape_list[0] if shape_list else None + + def compounds(self) -> ShapeList[Compound]: + """compounds - all the compounds in this Shape""" + if self._wrapped is None: + return ShapeList() + if isinstance(self.wrapped, TopoDS_Compound): + # pylint: disable=not-an-iterable + sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)] + sub_compounds.append(self) + else: + sub_compounds = [] + return ShapeList(sub_compounds) + + def do_children_intersect( + self, include_parent: bool = False, tolerance: float = 1e-5 + ) -> tuple[bool, tuple[Shape | None, Shape | None], float]: + """Do Children Intersect + + Determine if any of the child objects within a Compound/assembly intersect by + intersecting each of the shapes with each other and checking for + a common volume. + + Args: + include_parent (bool, optional): check parent for intersections. Defaults to False. + tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5. + + Returns: + tuple[bool, tuple[Shape, Shape], float]: + do the object intersect, intersecting objects, volume of intersection + """ + children: list[Shape] = list(PreOrderIter(self)) + if not include_parent: + children.pop(0) # remove parent + # children_bbox = [child.bounding_box().to_solid() for child in children] + children_bbox = [ + Solid.from_bounding_box(child.bounding_box()) for child in children + ] + child_index_pairs = [ + tuple(map(int, comb)) + for comb in combinations(list(range(len(children))), 2) + ] + for child_index_pair in child_index_pairs: + # First check for bounding box intersections .. + # .. then confirm with actual object intersections which could be complex + bbox_intersection = children_bbox[child_index_pair[0]].intersect( + children_bbox[child_index_pair[1]] + ) + if bbox_intersection is not None: + obj_intersection = children[child_index_pair[0]].intersect( + children[child_index_pair[1]] + ) + if obj_intersection is not None: + common_volume = sum(s.volume for s in obj_intersection.solids()) + if common_volume > tolerance: + return ( + True, + ( + children[child_index_pair[0]], + children[child_index_pair[1]], + ), + common_volume, + ) + return (False, (None, None), 0.0) + + def get_type( + self, + obj_type: ( + type[Vertex] + | type[Edge] + | type[Face] + | type[Shell] + | type[Solid] + | type[Wire] + ), + ) -> list[Vertex | Edge | Face | Shell | Solid | Wire]: + """get_type + + Extract the objects of the given type from a Compound. Note that this + isn't the same as Faces() etc. which will extract Faces from Solids. + + Args: + obj_type (Union[Vertex, Edge, Face, Shell, Solid, Wire]): Object types to extract + + Returns: + list[Union[Vertex, Edge, Face, Shell, Solid, Wire]]: Extracted objects + """ + + type_map = { + Vertex: TopAbs_ShapeEnum.TopAbs_VERTEX, + Edge: TopAbs_ShapeEnum.TopAbs_EDGE, + Face: TopAbs_ShapeEnum.TopAbs_FACE, + Shell: TopAbs_ShapeEnum.TopAbs_SHELL, + Solid: TopAbs_ShapeEnum.TopAbs_SOLID, + Wire: TopAbs_ShapeEnum.TopAbs_WIRE, + Compound: TopAbs_ShapeEnum.TopAbs_COMPOUND, + } + results = [] + for comp in self.compounds(): + iterator = TopoDS_Iterator() + iterator.Initialize(comp.wrapped) + while iterator.More(): + child = iterator.Value() + if child.ShapeType() == type_map[obj_type]: + 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 + + Args: + fully (bool, optional): return base shape without any Compound + wrappers (otherwise one Compound is left). Defaults to True. + + Returns: + Union[Self, Shape]: base shape + """ + if len(self) == 1: + single_element = next(iter(self)) + self.copy_attributes_to(single_element, ["wrapped", "_NodeMixin__children"]) + + # If the single element is another Compound, unwrap it recursively + if isinstance(single_element, Compound): + # Unwrap recursively and copy attributes down + unwrapped = single_element.unwrap(fully) + if not fully: + unwrapped = type(self)(unwrapped.wrapped) + self.copy_attributes_to(unwrapped, ["wrapped", "_NodeMixin__children"]) + return unwrapped + + return single_element if fully else self + + # If there are no elements or more than one element, return self + return self + + def _post_attach(self, parent: Compound): + """Method call after attaching to `parent`.""" + logger.debug("Updated parent of %s to %s", self.label, parent.label) + parent.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in parent.children] + ) + + def _post_attach_children(self, children: Iterable[Shape]): + """Method call after attaching `children`.""" + if children: + kids = ",".join([child.label for child in children]) + logger.debug("Adding children %s to %s", kids, self.label) + self.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in self.children] + ) + # else: + # logger.debug("Adding no children to %s", self.label) + + def _post_detach(self, parent: Compound): + """Method call after detaching from `parent`.""" + logger.debug("Removing parent of %s (%s)", self.label, parent.label) + if parent.children: + parent.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in parent.children] + ) + # else: + # parent.wrapped = None + + def _post_detach_children(self, children): + """Method call before detaching `children`.""" + if children: + kids = ",".join([child.label for child in children]) + logger.debug("Removing children %s from %s", kids, self.label) + self.wrapped = _make_topods_compound_from_shapes( + [c.wrapped for c in self.children] + ) + # else: + # logger.debug("Removing no children from %s", self.label) + + def _pre_attach(self, parent: Compound): + """Method call before attaching to `parent`.""" + if not isinstance(parent, Compound): + raise ValueError("`parent` must be of type Compound") + + def _pre_attach_children(self, children): + """Method call before attaching `children`.""" + if not all(isinstance(child, Shape) for child in children): + raise ValueError("Each child must be of type Shape") + + def _remove(self, shape: Shape) -> Compound: + """Return self with the specified shape removed. + + Args: + shape: Shape: + """ + comp_builder = TopoDS_Builder() + comp_builder.Remove(self.wrapped, shape.wrapped) + return self + + +class Curve(Compound): + """A Compound containing 1D objects - aka Edges""" + + __add__ = Mixin1D.__add__ # type: ignore + # ---- Properties ---- + + @property + def _dim(self) -> int: + return 1 + + # ---- Instance Methods ---- + + def __matmul__(self, position: float) -> Vector: + """Position on curve operator @ - only works if continuous""" + return Wire(self.edges()).position_at(position) + + def __mod__(self, position: float) -> Vector: + """Tangent on wire operator % - only works if continuous""" + return Wire(self.edges()).tangent_at(position) + + def __xor__(self, position: float) -> Location: + """Location on wire operator ^ - only works if continuous""" + return Wire(self.edges()).location_at(position) + + def wires(self) -> ShapeList[Wire]: # type: ignore + """A list of wires created from the edges""" + return Wire.combine(self.edges()) + + +class Sketch(Compound): + """A Compound containing 2D objects - aka Faces""" + + # ---- Properties ---- + + @property + def _dim(self) -> int: + return 2 + + +class Part(Compound): + """A Compound containing 3D objects - aka Solids""" + + # ---- Properties ---- + + @property + def _dim(self) -> int: + return 3 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 new file mode 100644 index 0000000..d2a4913 --- /dev/null +++ b/src/build123d/topology/one_d.py @@ -0,0 +1,4184 @@ +""" +build123d topology + +name: one_d.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module defines the classes and methods for one-dimensional geometric entities in the build123d +CAD library. It focuses on `Edge` and `Wire`, representing essential topological elements like +curves and connected sequences of curves within a 3D model. These entities are pivotal for +constructing complex shapes, boundaries, and paths in CAD applications. + +Key Features: +- **Edge Class**: + - Represents curves such as lines, arcs, splines, and circles. + - Supports advanced operations like trimming, offsetting, splitting, and projecting onto shapes. + - Includes methods for geometric queries like finding tangent angles, normals, and intersection + points. + +- **Wire Class**: + - Represents a connected sequence of edges forming a continuous path. + - Supports operations such as closure, projection, and edge manipulation. + +- **Mixin1D**: + - Shared functionality for both `Edge` and `Wire` classes, enabling splitting, extrusion, and + 1D-specific operations. + +This module integrates deeply with OpenCascade, leveraging its robust geometric and topological +operations. It provides utility functions to create, manipulate, and query 1D geometric entities, +ensuring precise and efficient workflows in 3D modeling tasks. + +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 + +import copy +import warnings +from collections.abc import Iterable, Sequence +from itertools import combinations +from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians +from typing import TYPE_CHECKING, Literal, 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 +from OCP.BRepAlgoAPI import ( + BRepAlgoAPI_Common, + BRepAlgoAPI_Section, + BRepAlgoAPI_Splitter, +) +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, 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, BRepTools_WireExplorer +from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse +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, +) +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.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_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.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum +from OCP.TopExp import TopExp, TopExp_Explorer +from OCP.TopLoc import TopLoc_Location +from OCP.TopoDS import ( + TopoDS, + TopoDS_Compound, + TopoDS_Edge, + TopoDS_Face, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Vertex, + TopoDS_Wire, +) +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, + Location, + Plane, + Vector, + VectorLike, + logger, +) + +from .shape_core import ( + TOPODS, + Shape, + ShapeList, + SkipClean, + TrimmingTool, + downcast, + get_top_level_topods_shapes, + shapetype, + topods_dim, + unwrap_topods_compound, + _topods_bool_op, +) +from .utils import ( + _extrude_topods_shape, + _make_topods_face_from_wires, + 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, +) + +if TYPE_CHECKING: # pragma: no cover + from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801 + from .three_d import Solid # pylint: disable=R0801 + from .two_d import Face, Shell # pylint: disable=R0801 + + +class Mixin1D(Shape[TOPODS]): + """Methods to add to the Edge and Wire classes""" + + # ---- Properties ---- + + @property + def _dim(self) -> int: + """Dimension of Edges and Wires""" + return 1 + + @property + def is_closed(self) -> bool: + """Are the start and end points equal?""" + 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: + raise ValueError("Can't determine direction of empty Edge or Wire") + return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD + + @property + def is_interior(self) -> bool: + """ + Check if the edge is an interior edge. + + An interior edge lies between surfaces that are part of the body (internal + to the geometry) and does not form part of the exterior boundary. + + Returns: + bool: True if the edge is an interior edge, False otherwise. + """ + # Find the faces connected to this edge and offset them + topods_face_pair = topo_explore_connected_faces(self) + offset_face_pair = [ + offset_topods_face(f, self.length / 100) for f in topods_face_pair + ] + + # Intersect the offset faces + sectionor = BRepAlgoAPI_Section( + offset_face_pair[0], offset_face_pair[1], PerformNow=False + ) + sectionor.Build() + face_intersection_result = sectionor.Shape() + + # If an edge was created the faces intersect and the edge is interior + explorer = TopExp_Explorer(face_intersection_result, ta.TopAbs_EDGE) + return explorer.More() + + @property + def length(self) -> float: + """Edge or Wire length""" + props = GProp_GProps() + BRepGProp.LinearProperties_s(self.wrapped, props) + return props.Mass() + + @property + def radius(self) -> float: + """Calculate the radius. + + Note that when applied to a Wire, the radius is simply the radius of the first edge. + + Args: + + Returns: + radius + + Raises: + ValueError: if kernel can not reduce the shape to a circular edge + + """ + geom = self.geom_adaptor() + try: + circ = geom.Circle() + except (Standard_NoSuchObject, Standard_Failure) as err: + raise ValueError("Shape could not be reduced to a circle") from err + return circ.Radius() + + @property + def volume(self) -> float: + """volume - the volume of this Edge or Wire, which is always zero""" + return 0.0 + + # ---- Class Methods ---- + + @classmethod + def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire: + "Returns the right type of wrapper, given a OCCT object" + + # Extend the lookup table with additional entries + constructor_lut = { + ta.TopAbs_VERTEX: Vertex, + ta.TopAbs_EDGE: Edge, + ta.TopAbs_WIRE: Wire, + } + + shape_type = shapetype(obj) + # NB downcast is needed to handle TopoDS_Shape types + return constructor_lut[shape_type](downcast(obj)) + + @classmethod + def extrude( + cls, obj: Shape, direction: VectorLike + ) -> Edge | Face | Shell | Solid | Compound: + """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__( + self, other: None | Shape | Iterable[Shape] + ) -> Edge | Wire | ShapeList[Edge]: + """fuse shape to wire/edge operator +""" + + # Convert `other` to list of base topods objects and filter out None values + if other is None: + topods_summands = [] + else: + topods_summands = [ + shape + # for o in (other if isinstance(other, (list, tuple)) else [other]) + for o in ([other] if isinstance(other, Shape) else other) + for shape in get_top_level_topods_shapes(o.wrapped if o else None) + ] + # If there is nothing to add return the original object + if not topods_summands: + return self + + 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 = 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 len(summands) == 1: + 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) # type: ignore + else: + try: + sum_shape = Wire(self.edges() + ShapeList(summand_edges)) + except Exception: + sum_shape = self.fuse(*summands) + + if SkipClean.clean and not isinstance(sum_shape, list): + 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 # type: ignore + + return sum_shape + + def __matmul__(self, position: float) -> Vector: + """Position on wire operator @""" + return self.position_at(position) + + def __mod__(self, position: float) -> Vector: + """Tangent on wire operator %""" + return self.tangent_at(position) + + def __xor__(self, position: float) -> Location: + """Location on wire operator ^""" + return self.location_at(position) + + def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector: + """Center of object + + Return the center based on center_of + + Args: + center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY. + + 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()) + else: # center_of == CenterOf.BOUNDING_BOX: + middle = self.bounding_box().center() + return middle + + def common_plane(self, *lines: Edge | Wire | None) -> None | Plane: + """common_plane + + Find the plane containing all the edges/wires (including self). If there + is no common plane return None. If the edges are coaxial, select one + of the infinite number of valid planes. + + Args: + lines (sequence of Edge | Wire): edges in common with self + + Returns: + None | Plane: Either the common plane or None + """ + # pylint: disable=too-many-locals + # Note: BRepLib_FindSurface is not helpful as it requires the + # Edges to form a surface perimeter. + points: list[Vector] = [] + all_lines: list[Edge | Wire] = [ + line for line in [self, *lines] if line is not None + ] + if any(not isinstance(line, (Edge, Wire)) for line in all_lines): + raise ValueError("Only Edges or Wires are valid") + + result = None + # Are they all co-axial - if so, select one of the infinite planes + all_edges: list[Edge] = [e for l in all_lines for e in l.edges()] + if all(e.geom_type == GeomType.LINE for e in all_edges): + as_axis = [Axis(e @ 0, e % 0) for e in all_edges] + 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 = Plane(as_axis[0]).x_dir + c_plane = Plane(origin, z_dir=z_dir) + result = c_plane.shift_origin((0, 0)) + + if result is None: # not coaxial + # Shorten any infinite lines (from converted Axis) + normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines)) + infinite_lines = filter(lambda line: line.length > 1e50, all_lines) + shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines] + all_lines = normal_lines + shortened_lines + + for line in all_lines: + num_points = 2 if line.geom_type == GeomType.LINE else 8 + points.extend( + [line.position_at(i / (num_points - 1)) for i in range(num_points)] + ) + points = list(set(points)) # unique points + extreme_areas = {} + for subset in combinations(points, 3): + vector1 = subset[1] - subset[0] + vector2 = subset[2] - subset[0] + area = 0.5 * (vector1.cross(vector2).length) + extreme_areas[area] = subset + # The points that create the largest area make the most accurate plane + extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]] + + # Create a plane from these points + x_dir = (extremes[1] - extremes[0]).normalized() + z_dir = (extremes[2] - extremes[0]).cross(x_dir) + try: + c_plane = Plane( + origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir + ) + c_plane = c_plane.shift_origin((0, 0)) + except ValueError: + # There is no valid common plane + result = None + else: + # Are all of the points on the common plane + common = all(c_plane.contains(p) for p in points) + result = c_plane if common else None + + return result + + 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. + + 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. + + Note that circles may have identical start and end points. + """ + curve = self.geom_adaptor() + 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 | None = None, + x_dir: VectorLike | None = None, + ) -> Location: + """Locations along curve + + Generate a location along the underlying curve. + + Args: + distance (float): distance or parameter value + 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 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 + at the specified distance. + """ + 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: + param = self.param_at(distance / self.length) + + law: GeomFill_TrihedronLaw + if frame_method == FrameMethod.FRENET: + law = GeomFill_Frenet() + else: + law = GeomFill_CorrectedFrenet() + + law.SetCurve(curve) + + tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec() + + law.D0(param, tangent, normal, binormal) + pnt = curve.Value(param) + + transformation = gp_Trsf() + 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)) + + 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 | None = None, + x_dir: VectorLike | None = None, + ) -> list[Location]: + """Locations along curve + + Generate location along the curve + + Args: + distances (Iterable[float]): distance or parameter values + position_mode (PositionMode, optional): position calculation mode. + Defaults to PositionMode.PARAMETER. + 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, x_dir) + for d in distances + ] + + def normal(self) -> Vector: + """Calculate the normal Vector. Only possible for planar curves. + + :return: normal vector + + Args: + + Returns: + + """ + if self._wrapped is None: + raise ValueError("Can't find normal of empty edge/wire") + + curve = self.geom_adaptor() + gtype = self.geom_type + + if gtype == GeomType.CIRCLE: + circ = curve.Circle() + return_value = Vector(circ.Axis().Direction()) + elif gtype == GeomType.ELLIPSE: + ell = curve.Ellipse() + return_value = Vector(ell.Axis().Direction()) + else: + find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True) + surf = find_surface.Surface() + + if isinstance(surf, Geom_Plane): + pln = surf.Pln() + return_value = Vector(pln.Axis().Direction()) + else: + raise ValueError("Normal not defined") + + return return_value + + def offset_2d( + self, + distance: float, + kind: Kind = Kind.ARC, + side: Side = Side.BOTH, + closed: bool = True, + ) -> Edge | Wire: + """2d Offset + + Offsets a planar edge/wire + + Args: + distance (float): distance from edge/wire to offset + kind (Kind, optional): offset corner transition. Defaults to Kind.ARC. + side (Side, optional): side to place offset. Defaults to Side.BOTH. + closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT + offset. Defaults to True. + Raises: + RuntimeError: Multiple Wires generated + RuntimeError: Unexpected result type + + Returns: + Wire: offset wire + """ + # pylint: disable=too-many-branches, too-many-locals, too-many-statements + kind_dict = { + Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, + Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, + Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, + } + line = self if isinstance(self, Wire) else Wire([self]) + + # 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]) + # offset_builder.SetApprox(True) + offset_builder.AddWire(topods_wire) + offset_builder.Perform(distance) + + obj = downcast(offset_builder.Shape()) + if isinstance(obj, TopoDS_Compound): + obj = unwrap_topods_compound(obj, fully=True) + if isinstance(obj, TopoDS_Wire): + offset_wire = Wire(obj) + else: # Likely multiple Wires were generated + raise RuntimeError("Unexpected result type") + + if side != Side.BOTH: + # Find and remove the end arcs + 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)) + for c in centers + ] + if side == Side.LEFT: + offset_wire = wires[int(angles[0] > angles[1])] + else: + offset_wire = wires[int(angles[0] <= angles[1])] + + if closed: + self0 = line.position_at(0) + self1 = line.position_at(1) + end0 = offset_wire.position_at(0) + end1 = offset_wire.position_at(1) + if (self0 - end0).length - abs(distance) <= TOLERANCE: + edge0 = Edge.make_line(self0, end0) + edge1 = Edge.make_line(self1, end1) + else: + edge0 = Edge.make_line(self0, end1) + edge1 = Edge.make_line(self1, end0) + offset_wire = Wire( + line.edges() + offset_wire.edges() + ShapeList([edge0, edge1]) + ) + + offset_edges = offset_wire.edges() + return offset_edges[0] if len(offset_edges) == 1 else offset_wire + + def param_at(self, position: float) -> float: + """ + Map a normalized arc-length position to the underlying OCCT parameter. + + 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: + 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: 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 * position, curve.FirstParameter() + ).Parameter() + + def perpendicular_line( + self, length: float, u_value: float, plane: Plane = Plane.XY + ) -> Edge: + """perpendicular_line + + Create a line on the given plane perpendicular to and centered on beginning of self + + Args: + length (float): line length + u_value (float): position along line between 0.0 and 1.0 + plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY. + + Returns: + Edge: perpendicular line + """ + start = self.position_at(u_value) + local_plane = Plane( + origin=start, x_dir=self.tangent_at(u_value), z_dir=plane.z_dir + ) + line = Edge.make_line( + start + local_plane.y_dir * length / 2, + start - local_plane.y_dir * length / 2, + ) + return line + + def position_at( + self, position: float, position_mode: PositionMode = PositionMode.PARAMETER + ) -> Vector: + """Position At + + Generate a position along the underlying Wire. + + Args: + position (float): distance or parameter value + position_mode (PositionMode, optional): position calculation mode. Defaults to + PositionMode.PARAMETER. + + Returns: + Vector: position on the underlying curve + """ + # 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 + ) + + return Vector(edge_curve_adaptor.Value(occt_edge_param)) + + def positions( + self, + 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] | 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 + """ + 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 + ) -> Edge | Wire | ShapeList[Edge | Wire]: + """Project onto a face along the specified direction + + Args: + face: Face: + direction: VectorLike: + closest: bool: (Default value = True) + + Returns: + + """ + 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() + ) + shapes: TopoDS_Compound = bldr.Shape() + + # select the closest projection if requested + return_value: Edge | Wire | ShapeList[Edge | Wire] + + if closest: + dist_calc = BRepExtrema_DistShapeShape() + dist_calc.LoadS1(self.wrapped) + + min_dist = inf + + # for shape in shapes: + for shape in get_top_level_topods_shapes(shapes): + dist_calc.LoadS2(shape) + dist_calc.Perform() + dist = dist_calc.Value() + + if dist < min_dist: + min_dist = dist + return_value = Mixin1D.cast(shape) + + else: + return_value = ShapeList( + Mixin1D.cast(shape) for shape in get_top_level_topods_shapes(shapes) + ) + + return return_value + + 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 + """ + + def extract_edges(compound): + edges = [] # List to store the extracted edges + + # Create a TopExp_Explorer to traverse the sub-shapes of the compound + explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE) + + # Loop through the sub-shapes and extract edges + while explorer.More(): + edge = downcast(explorer.Current()) + edges.append(edge) + explorer.Next() + + 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) + + viewport_origin = Vector(viewport_origin) + look_at = Vector(look_at) if look_at else self.center() + projection_dir: Vector = (viewport_origin - look_at).normalized() + viewport_up = Vector(viewport_up).normalized() + camera_coordinate_system = gp_Ax2() + camera_coordinate_system.SetAxis( + gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir()) + ) + camera_coordinate_system.SetYDirection(viewport_up.to_dir()) + projector = ( + HLRAlgo_Projector(camera_coordinate_system, focus) + if focus + else HLRAlgo_Projector(camera_coordinate_system) + ) + + hidden_line_removal.Projector(projector) + hidden_line_removal.Update() + hidden_line_removal.Hide() + + hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal) + + # Create the visible edges + visible_edges = [] + for edges in [ + hlr_shapes.VCompound(), + hlr_shapes.Rg1LineVCompound(), + hlr_shapes.OutLineVCompound(), + ]: + if not edges.IsNull(): + visible_edges.extend(extract_edges(downcast(edges))) + + # Create the hidden edges + hidden_edges = [] + for edges in [ + hlr_shapes.HCompound(), + hlr_shapes.OutLineHCompound(), + hlr_shapes.Rg1LineHCompound(), + ]: + if not edges.IsNull(): + hidden_edges.extend(extract_edges(downcast(edges))) + + # Fix the underlying geometry - otherwise we will get segfaults + for edge in visible_edges: + BRepLib.BuildCurves3d_s(edge, TOLERANCE) + for edge in hidden_edges: + BRepLib.BuildCurves3d_s(edge, TOLERANCE) + + # convert to native shape objects + visible_edges = ShapeList(Edge(e) for e in visible_edges) + hidden_edges = ShapeList(Edge(e) for e in hidden_edges) + + return (visible_edges, hidden_edges) + + 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() if self.is_forward else curve.LastParameter() + + return Vector(curve.Value(umin)) + + def tangent_angle_at( + self, + location_param: float = 0.5, + position_mode: PositionMode = PositionMode.PARAMETER, + plane: Plane = Plane.XY, + ) -> float: + """tangent_angle_at + + Compute the tangent angle at the specified location + + Args: + location_param (float, optional): distance or parameter value. Defaults to 0.5. + position_mode (PositionMode, optional): position calculation mode. + Defaults to PositionMode.PARAMETER. + plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY. + + Returns: + float: angle in degrees between 0 and 360 + """ + tan_vector = self.tangent_at(location_param, position_mode) + angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0 + return angle + + def tangent_at( + self, + position: float | VectorLike = 0.5, + position_mode: PositionMode = PositionMode.PARAMETER, + ) -> Vector: + """tangent_at + + Find the tangent at a given position on the 1D shape where the position + is either a float (or int) parameter or a point that lies on the shape. + + Args: + position (float | VectorLike): distance, parameter value, or + point on shape. Defaults to 0.5. + position_mode (PositionMode, optional): position calculation mode. + Defaults to PositionMode.PARAMETER. + + Returns: + Vector: tangent value + """ + return self.derivative_at(position, 1, position_mode).normalized() + + # def vertex(self) -> Vertex | None: + # """Return the Vertex""" + # return Shape.get_single_shape(self, "Vertex") + + # def 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") + + +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 + defined shape. Edge is crucial in for precise modeling and manipulation of curves, + facilitating operations like filleting, chamfering, and Boolean operations. It + serves as a building block for constructing complex structures, such as wires + and faces.""" + + # pylint: disable=too-many-public-methods + + order = 1.0 + # ---- Constructor ---- + + def __init__( + self, + obj: TopoDS_Edge | Axis | None | None = None, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build an Edge from an OCCT TopoDS_Shape/TopoDS_Edge + + Args: + obj (TopoDS_Edge | Axis, optional): OCCT Edge or Axis. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + if isinstance(obj, Axis): + obj = BRepBuilderAPI_MakeEdge( + Geom_Line( + obj.position.to_pnt(), + obj.direction.to_dir(), + ) + ).Edge() + + super().__init__( + obj=obj, + label=label, + color=color, + parent=parent, + ) + + # ---- Properties ---- + + @property + def arc_center(self) -> Vector: + """center of an underlying circle or ellipse geometry.""" + + geom_type = self.geom_type + geom_adaptor = self.geom_adaptor() + + if geom_type == GeomType.CIRCLE: + return_value = Vector(geom_adaptor.Circle().Position().Location()) + elif geom_type == GeomType.ELLIPSE: + return_value = Vector(geom_adaptor.Ellipse().Position().Location()) + else: + raise ValueError(f"{geom_type} has no arc center") + + return return_value + + # ---- Class Methods ---- + + @classmethod + def extrude(cls, obj: Vertex, direction: VectorLike) -> Edge: + """extrude + + Extrude a Vertex into an Edge. + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + 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 + def make_bezier( + cls, *cntl_pnts: VectorLike, weights: list[float] | None = None + ) -> Edge: + """make_bezier + + 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. + + Args: + cntl_pnts (sequence[VectorLike]): points defining the curve + weights (list[float], optional): control point weights list. Defaults to None. + + Raises: + ValueError: Too few control points + ValueError: Too many control points + ValueError: A weight is required for each control point + + Returns: + Edge: bezier curve + """ + if len(cntl_pnts) < 2: + raise ValueError( + "At least two control points must be provided (start, end)" + ) + if len(cntl_pnts) > 25: + raise ValueError("The maximum number of control points is 25") + if weights: + if len(cntl_pnts) != len(weights): + raise ValueError("A weight must be provided for each control point") + + cntl_gp_pnts = [Vector(cntl_pnt).to_pnt() for cntl_pnt in cntl_pnts] + + # The poles are stored in an OCCT Array object + poles = TColgp_Array1OfPnt(1, len(cntl_gp_pnts)) + for i, cntl_gp_pnt in enumerate(cntl_gp_pnts): + poles.SetValue(i + 1, cntl_gp_pnt) + + if weights: + pole_weights = TColStd_Array1OfReal(1, len(weights)) + for i, weight in enumerate(weights): + pole_weights.SetValue(i + 1, float(weight)) + bezier_curve = Geom_BezierCurve(poles, pole_weights) + else: + bezier_curve = Geom_BezierCurve(poles) + + return cls(BRepBuilderAPI_MakeEdge(bezier_curve).Edge()) + + @classmethod + def make_circle( + cls, + radius: float, + plane: Plane = Plane.XY, + start_angle: float = 360.0, + end_angle: float = 360, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + ) -> Edge: + """make circle + + Create a circle centered on the origin of plane + + Args: + radius (float): circle radius + plane (Plane, optional): base plane. Defaults to Plane.XY. + start_angle (float, optional): start of arc angle. Defaults to 360.0. + end_angle (float, optional): end of arc angle. Defaults to 360. + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE. + + Returns: + Edge: full or partial circle + """ + circle_gp = gp_Circ(plane.to_gp_ax2(), radius) + + if start_angle == end_angle: # full circle case + return_value = cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge()) + else: # arc case + ccw = angular_direction == AngularDirection.COUNTER_CLOCKWISE + if ccw: + start = radians(start_angle) + end = radians(end_angle) + else: + start = radians(end_angle) + end = radians(start_angle) + circle_geom = GC_MakeArcOfCircle(circle_gp, start, end, ccw).Value() + 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, + x_radius: float, + y_radius: float, + plane: Plane = Plane.XY, + start_angle: float = 360.0, + end_angle: float = 360.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + ) -> Edge: + """make ellipse + + Makes an ellipse centered at the origin of plane. + + Args: + 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) + plane (Plane, optional): base plane. Defaults to Plane.XY. + start_angle (float, optional): Defaults to 360.0. + end_angle (float, optional): Defaults to 360.0. + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE. + + Returns: + Edge: full or partial ellipse + """ + ax1 = gp_Ax1(plane.origin.to_pnt(), plane.z_dir.to_dir()) + + if y_radius > x_radius: + # swap x and y radius and rotate by 90° afterwards to create an ellipse + # with x_radius < y_radius + correction_angle = 90.0 * DEG2RAD + ellipse_gp = gp_Elips(plane.to_gp_ax2(), y_radius, x_radius).Rotated( + ax1, correction_angle + ) + else: + correction_angle = 0.0 + ellipse_gp = gp_Elips(plane.to_gp_ax2(), x_radius, y_radius) + + if start_angle == end_angle: # full ellipse case + ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_gp).Edge()) + else: # arc case + # take correction_angle into account + ellipse_geom = GC_MakeArcOfEllipse( + ellipse_gp, + start_angle * DEG2RAD - correction_angle, + end_angle * DEG2RAD - correction_angle, + angular_direction == AngularDirection.COUNTER_CLOCKWISE, + ).Value() + ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_geom).Edge()) + + return ellipse + + @classmethod + def make_helix( + cls, + pitch: float, + height: float, + radius: float, + center: VectorLike = (0, 0, 0), + normal: VectorLike = (0, 0, 1), + angle: float = 0.0, + lefthand: bool = False, + ) -> Wire: + """make_helix + + Make a helix with a given pitch, height and radius. By default a cylindrical surface is + used to create the helix. If the :angle: is set (the apex given in degree) a conical + surface is used instead. + + Args: + pitch (float): distance per revolution along normal + height (float): total height + radius (float): + center (VectorLike, optional): Defaults to (0, 0, 0). + normal (VectorLike, optional): Defaults to (0, 0, 1). + angle (float, optional): conical angle. Defaults to 0.0. + lefthand (bool, optional): Defaults to False. + + Returns: + Wire: helix + """ + # pylint: disable=too-many-locals + # 1. build underlying cylindrical/conical surface + if angle == 0.0: + geom_surf: Geom_Surface = Geom_CylindricalSurface( + gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), radius + ) + else: + geom_surf = Geom_ConicalSurface( + gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), + angle * DEG2RAD, + radius, + ) + + # 2. construct an segment in the u,v domain + + # Determine the length of the 2d line which will be wrapped around the surface + line_sign = -1 if lefthand else 1 + line_dir = Vector(line_sign * 2 * pi, pitch).normalized() + line_len = (height / line_dir.Y) / cos(radians(angle)) + + # Create an infinite 2d line in the direction of the helix + helix_line = Geom2d_Line(gp_Pnt2d(0, 0), gp_Dir2d(line_dir.X, line_dir.Y)) + # Trim the line to the desired length + helix_curve = Geom2d_TrimmedCurve( + helix_line, 0, line_len, theAdjustPeriodic=True + ) + + # 3. Wrap the line around the surface + edge_builder = BRepBuilderAPI_MakeEdge(helix_curve, geom_surf) + topods_edge = edge_builder.Edge() + + # 4. Convert the edge made with 2d geometry to 3d + BRepLib.BuildCurves3d_s(topods_edge, 1e-9, MaxSegment=2000) + + return cls(topods_edge) + + @classmethod + def make_line(cls, point1: VectorLike, point2: VectorLike) -> Edge: + """Create a line between two points + + Args: + point1: VectorLike: that represents the first point + point2: VectorLike: that represents the second point + + Returns: + A linear edge between the two provided points + + """ + return cls( + BRepBuilderAPI_MakeEdge( + Vector(point1).to_pnt(), Vector(point2).to_pnt() + ).Edge() + ) + + @classmethod + def make_mid_way(cls, first: Edge, second: Edge, middle: float = 0.5) -> Edge: + """make line between edges + + Create a new linear Edge between the two provided Edges. If the Edges are parallel + but in the opposite directions one Edge is flipped such that the mid way Edge isn't + truncated. + + Args: + first (Edge): first reference Edge + second (Edge): second reference Edge + middle (float, optional): factional distance between Edges. Defaults to 0.5. + + Returns: + Edge: linear Edge between two Edges + """ + flip = Axis(first).is_opposite(Axis(second)) + pnts = [ + Edge.make_line( + first.position_at(i), second.position_at(1 - i if flip else i) + ).position_at(middle) + for i in [0, 1] + ] + return Edge.make_line(*pnts) + + @classmethod + def make_spline( + cls, + points: list[VectorLike], + tangents: list[VectorLike] | None = None, + periodic: bool = False, + parameters: list[float] | None = None, + scale: bool = True, + tol: float = 1e-6, + ) -> Edge: + """Spline + + Interpolate a spline through the provided points. + + Args: + points (list[VectorLike]): the points defining the spline + tangents (list[VectorLike], optional): start and finish tangent. + Defaults to None. + periodic (bool, optional): creation of periodic curves. Defaults to False. + parameters (list[float], optional): the value of the parameter at each + interpolation point. (The interpolated curve is represented as a vector-valued + function of a scalar parameter.) If periodic == True, then len(parameters) + must be len(interpolation points) + 1, otherwise len(parameters) + must be equal to len(interpolation points). Defaults to None. + scale (bool, optional): whether to scale the specified tangent vectors before + interpolating. Each tangent is scaled, so it's length is equal to the derivative + of the Lagrange interpolated curve. I.e., set this to True, if you want to use + only the direction of the tangent vectors specified by `tangents` , but not + their magnitude. Defaults to True. + tol (float, optional): tolerance of the algorithm (consult OCC documentation). + Used to check that the specified points are not too close to each other, and + that tangent vectors are not too short. (In either case interpolation may fail.). + Defaults to 1e-6. + + Raises: + ValueError: Parameter for each interpolation point + ValueError: Tangent for each interpolation point + ValueError: B-spline interpolation failed + + Returns: + Edge: the spline + """ + # pylint: disable=too-many-locals + point_vectors = [Vector(point) for point in points] + if tangents: + tangent_vectors = tuple(Vector(v) for v in tangents) + pnts = TColgp_HArray1OfPnt(1, len(point_vectors)) + for i, point in enumerate(point_vectors): + pnts.SetValue(i + 1, point.to_pnt()) + + if parameters is None: + spline_builder = GeomAPI_Interpolate(pnts, periodic, tol) + else: + if len(parameters) != (len(point_vectors) + periodic): + raise ValueError( + "There must be one parameter for each interpolation point " + "(plus one if periodic), or none specified. Parameter count: " + f"{len(parameters)}, point count: {len(point_vectors)}" + ) + parameters_array = TColStd_HArray1OfReal(1, len(parameters)) + for p_index, p_value in enumerate(parameters): + parameters_array.SetValue(p_index + 1, p_value) + + spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol) + + if tangents: + if len(tangent_vectors) == 2 and len(point_vectors) != 2: + # Specify only initial and final tangent: + spline_builder.Load( + tangent_vectors[0].wrapped, tangent_vectors[1].wrapped, scale + ) + else: + if len(tangent_vectors) != len(point_vectors): + raise ValueError( + f"There must be one tangent for each interpolation point, " + f"or just two end point tangents. Tangent count: " + f"{len(tangent_vectors)}, point count: {len(point_vectors)}" + ) + + # Specify a tangent for each interpolation point: + tangents_array = TColgp_Array1OfVec(1, len(tangent_vectors)) + tangent_enabled_array = TColStd_HArray1OfBoolean( + 1, len(tangent_vectors) + ) + for t_index, t_value in enumerate(tangent_vectors): + tangent_enabled_array.SetValue(t_index + 1, t_value is not None) + tangent_vec = t_value if t_value is not None else Vector() + tangents_array.SetValue(t_index + 1, tangent_vec.wrapped) + + spline_builder.Load(tangents_array, tangent_enabled_array, scale) + + spline_builder.Perform() + if not spline_builder.IsDone(): + raise ValueError("B-spline interpolation failed") + + spline_geom = spline_builder.Curve() + + return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge()) + + @classmethod + def make_spline_approx( + cls, + points: list[VectorLike], + tol: float = 1e-3, + smoothing: tuple[float, float, float] | None = None, + min_deg: int = 1, + max_deg: int = 6, + ) -> Edge: + """make_spline_approx + + Approximate a spline through the provided points. + + Args: + points (list[Vector]): + tol (float, optional): tolerance of the algorithm. Defaults to 1e-3. + smoothing (Tuple[float, float, float], optional): optional tuple of 3 weights + use for variational smoothing. Defaults to None. + min_deg (int, optional): minimum spline degree. Enforced only when smoothing + is None. Defaults to 1. + max_deg (int, optional): maximum spline degree. Defaults to 6. + + Raises: + ValueError: B-spline approximation failed + + Returns: + Edge: spline + """ + pnts = TColgp_HArray1OfPnt(1, len(points)) + for i, point in enumerate(points): + pnts.SetValue(i + 1, Vector(point).to_pnt()) + + if smoothing: + spline_builder = GeomAPI_PointsToBSpline( + pnts, *smoothing, DegMax=max_deg, Tol3D=tol + ) + else: + spline_builder = GeomAPI_PointsToBSpline( + pnts, DegMin=min_deg, DegMax=max_deg, Tol3D=tol + ) + + if not spline_builder.IsDone(): + raise ValueError("B-spline approximation failed") + + spline_geom = spline_builder.Curve() + + return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge()) + + @classmethod + def make_tangent_arc( + cls, start: VectorLike, tangent: VectorLike, end: VectorLike + ) -> Edge: + """Tangent Arc + + Makes a tangent arc from point start, in the direction of tangent and ends at end. + + Args: + start (VectorLike): start point + tangent (VectorLike): start tangent + end (VectorLike): end point + + Returns: + Edge: circular arc + """ + circle_geom = GC_MakeArcOfCircle( + Vector(start).to_pnt(), Vector(tangent).wrapped, Vector(end).to_pnt() + ).Value() + + return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) + + @classmethod + def make_three_point_arc( + cls, point1: VectorLike, point2: VectorLike, point3: VectorLike + ) -> Edge: + """Three Point Arc + + Makes a three point arc through the provided points + + Args: + point1 (VectorLike): start point + point2 (VectorLike): middle point + point3 (VectorLike): end point + + Returns: + Edge: a circular arc through the three points + """ + circle_geom = GC_MakeArcOfCircle( + Vector(point1).to_pnt(), Vector(point2).to_pnt(), Vector(point3).to_pnt() + ).Value() + + return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) + + # ---- Instance Methods ---- + + def close(self) -> Edge | Wire: + """Close an Edge""" + if not self.is_closed: + return_value = Wire([self]).close() + else: + return_value = self + + return return_value + + def distribute_locations( + self: Wire | Edge, + count: int, + start: float = 0.0, + stop: float = 1.0, + positions_only: bool = False, + ) -> list[Location]: + """Distribute Locations + + Distribute locations along edge or wire. + + Args: + self: Wire:Edge: + count(int): Number of locations to generate + start(float): position along Edge|Wire to start. Defaults to 0.0. + stop(float): position along Edge|Wire to end. Defaults to 1.0. + positions_only(bool): only generate position not orientation. Defaults to False. + + Returns: + list[Location]: locations distributed along Edge|Wire + + Raises: + ValueError: count must be two or greater + + """ + if count < 2: + raise ValueError("count must be two or greater") + + t_values = [start + i * (stop - start) / (count - 1) for i in range(count)] + + locations = self.locations(t_values) + if positions_only: + for loc in locations: + loc.orientation = Vector(0, 0, 0) + + 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]: + """find_intersection_points + + Determine the points where a 2D edge crosses itself or another 2D edge + + Args: + other (Axis | Edge): curve to compare with + 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): + pos = tcast(Vector, other.position) + self_bbox_w_edge = self.bounding_box().add(Vertex(pos).bounding_box()) + other = Edge.make_line( + 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) + if plane is None: + raise ValueError("All objects must be on the same plane") + # Convert the plane into a Geom_Surface + pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face() + edge_surface = BRep_Tool.Surface_s(pln_shape) + + self_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( + self.wrapped, + edge_surface, + TopLoc_Location(), + self.param_at(0), + self.param_at(1), + ) + if other is not None and other.wrapped is not None: + edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( + other.wrapped, + edge_surface, + TopLoc_Location(), + other.param_at(0), + other.param_at(1), + ) + intersector = Geom2dAPI_InterCurveCurve( + self_2d_curve, edge_2d_curve, tolerance + ) + else: + intersector = Geom2dAPI_InterCurveCurve(self_2d_curve, tolerance) + + crosses = [ + Vector(intersector.Point(i + 1).X(), intersector.Point(i + 1).Y()) + for i in range(intersector.NbPoints()) + ] + # Convert back to global coordinates + crosses = [plane.from_local_coords(p) for p in crosses] + + # crosses may contain points beyond the ends of the edge so + # .. filter those out + valid_crosses = [] + for pnt in crosses: + try: + if other is not None: + if ( + self.distance_to(pnt) <= TOLERANCE + and other.distance_to(pnt) <= TOLERANCE + ): + valid_crosses.append(pnt) + else: + if self.distance_to(pnt) <= TOLERANCE: + valid_crosses.append(pnt) + except ValueError: + pass # skip invalid points + + return ShapeList(valid_crosses) + + def find_tangent( + self, + angle: float, + ) -> list[float]: + """find_tangent + + Find the parameter values of self where the tangent is equal to angle. + + Args: + angle (float): target angle in degrees + + Returns: + list[float]: u values between 0.0 and 1.0 + """ + angle = angle % 360 # angle needs to always be positive 0..360 + u_values: list[float] + + if self.geom_type == GeomType.LINE: + if self.tangent_angle_at(0) == angle: + u_values = [0] + else: + u_values = [] + else: + # Solve this problem geometrically by creating a tangent curve and finding intercepts + periodic = int(self.is_closed) # if closed don't include end point + tan_pnts: list[VectorLike] = [] + previous_tangent = None + + # When angles go from 360 to 0 a discontinuity is created so add 360 to these + # values and intercept another line + discontinuities = 0.0 + for i in range(101 - periodic): + tangent = self.tangent_angle_at(i / 100) + discontinuities * 360 + if ( + previous_tangent is not None + and abs(previous_tangent - tangent) > 300 + ): + discontinuities = copysign(1.0, previous_tangent - tangent) + tangent += 360 * discontinuities + previous_tangent = tangent + tan_pnts.append((i / 100, tangent)) + + # Generate a first differential curve from the tangent points + tan_curve = Edge.make_spline(tan_pnts) + + # Use the bounding box to find the min and max values + tan_curve_bbox = tan_curve.bounding_box() + min_range = 360 * (floor(tan_curve_bbox.min.Y / 360)) + max_range = 360 * (ceil(tan_curve_bbox.max.Y / 360)) + + # Create a horizontal line for each 360 cycle and intercept it + intercept_pnts: list[Vector] = [] + for i in range(min_range, max_range + 1, 360): + line = Edge.make_line((0, angle + i, 0), (100, angle + i, 0)) + intercept_pnts.extend(tan_curve.find_intersection_points(line)) + + u_values = [p.X for p in intercept_pnts] + + return u_values + + 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 _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: + 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: + tuple[BRepAdaptor_CompCurve, float, bool]: The curve adaptor for this edge, + the corresponding OCCT curve parameter and is_forward. + """ + comp_curve = self.geom_adaptor() + length = GCPnts_AbscissaPoint.Length_s(comp_curve) + + 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 + + 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: + """ + Return the normalized parameter (∈ [0.0, 1.0]) of the location on this edge + closest to `point`. + + 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. + + # 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. + # + # 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. + + max_divisions = 10 # Logarithmic refinement depth + + for division in range(max_divisions): + intervals = 2**division + step = 1.0 / intervals + + for i in range(intervals): + lo, hi = i * step, (i + 1) * step + + 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, + target_object: Shape, + direction: VectorLike | None = None, + center: VectorLike | None = None, + ) -> list[Edge]: + """Project Edge + + Project an Edge onto a Shape generating new wires on the surfaces of the object + one and only one of `direction` or `center` must be provided. Note that one or + more wires may be generated depending on the topology of the target object and + location/direction of projection. + + To avoid flipping the normal of a face built with the projected wire the orientation + of the output wires are forced to be the same as self. + + Args: + target_object: Object to project onto + direction: Parallel projection direction. Defaults to None. + center: Conical center of projection. Defaults to None. + target_object: Shape: + direction: VectorLike: (Default value = None) + center: VectorLike: (Default value = None) + + Returns: + : Projected Edge(s) + + Raises: + ValueError: Only one of direction or center must be provided + + """ + wire = Wire([self]) + projected_wires = wire.project_to_shape(target_object, direction, center) + projected_edges = [w.edges()[0] for w in projected_wires] + return projected_edges + + 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) + 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}" + ) + return Axis(self.position_at(0), self.position_at(1) - self.position_at(0)) + + 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 | 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 | VectorLike): 0.0 <= start < 1.0 or point on edge + end (float | VectorLike): 0.0 < end <= 1.0 or point on edge + + Raises: + TypeError: invalid input, must be float or VectorLike + ValueError: can't trim empty edge + + Returns: + Edge: trimmed edge + """ + + 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( + self_copy.wrapped, self.param_at(0), self.param_at(1) + ) + parm_start = self.param_at(start_u) + parm_end = self.param_at(end_u) + trimmed_curve = Geom_TrimmedCurve( + new_curve, + parm_start, + parm_end, + ) + new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() + return Edge(new_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 | 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( + 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_u) + abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start) + + # Get the parameter at the desired length + parm_end = abscissa_point.Parameter() + + # Trim the curve to the desired length + trimmed_curve = Geom_TrimmedCurve(new_curve, parm_start, parm_end) + + new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() + return Edge(new_edge) + + +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 + solids. They store information about the connectivity and order of edges, + allowing precise definition of paths within a 3D model.""" + + order = 1.5 + # ---- Constructor ---- + + @overload + def __init__( + self, + obj: TopoDS_Wire, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a wire from an OCCT TopoDS_Wire + + Args: + obj (TopoDS_Wire, optional): OCCT Wire. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + @overload + def __init__( + self, + edge: Edge, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a Wire from an Edge + + Args: + edge (Edge): Edge to convert to Wire + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + @overload + def __init__( + self, + wire: Wire, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a Wire from an Wire - used when the input could be an Edge or Wire. + + Args: + wire (Wire): Wire to convert to another Wire + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + @overload + def __init__( + self, + wire: Curve, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a Wire from an Curve. + + Args: + curve (Curve): Curve to convert to a Wire + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + @overload + def __init__( + self, + edges: Iterable[Edge], + sequenced: bool = False, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a wire from Edges + + Build a Wire from the provided unsorted Edges. If sequenced is True the + Edges are placed in such that the end of the nth Edge is coincident with + the n+1th Edge forming an unbroken sequence. Note that sequencing a list + is relatively slow. + + Args: + edges (Iterable[Edge]): Edges to assemble + sequenced (bool, optional): arrange in order. Defaults to False. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + def __init__(self, *args, **kwargs): + curve, edge, edges, wire, sequenced, obj, label, color, parent = (None,) * 9 + + if args: + l_a = len(args) + if isinstance(args[0], TopoDS_Wire): + obj, label, color, parent = args[:4] + (None,) * (4 - l_a) + elif isinstance(args[0], Edge): + 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 ( + hasattr(args[0], "wrapped") + and isinstance(args[0].wrapped, TopoDS_Compound) + and topods_dim(args[0].wrapped) == 1 + ): # Curve + curve, label, color, parent = args[:4] + (None,) * (4 - l_a) + elif isinstance(args[0], Iterable): + edges, sequenced, label, color, parent = args[:5] + (None,) * (5 - l_a) + + unknown_args = ", ".join( + set(kwargs.keys()).difference( + [ + "curve", + "wire", + "edge", + "edges", + "sequenced", + "obj", + "label", + "color", + "parent", + ] + ) + ) + if unknown_args: + raise ValueError(f"Unexpected argument(s) {unknown_args}") + + obj = kwargs.get("obj", obj) + edge = kwargs.get("edge", edge) + edges = kwargs.get("edges", edges) + sequenced = kwargs.get("sequenced", sequenced) + label = kwargs.get("label", label) + color = kwargs.get("color", color) + parent = kwargs.get("parent", parent) + wire = kwargs.get("wire", wire) + curve = kwargs.get("curve", curve) + + if edge is not None: + edges = [edge] + elif curve is not None: + edges = curve.edges() + if wire is not None: + obj = wire.wrapped + elif edges: + obj = Wire._make_wire(edges, False if sequenced is None else sequenced) + + super().__init__( + obj=obj, + label="" if label is None else label, + color=color, + parent=parent, + ) + + # ---- Class Methods ---- + + @classmethod + def _make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> TopoDS_Wire: + """_make_wire + + Build a Wire from the provided unsorted Edges. If sequenced is True the + Edges are placed in such that the end of the nth Edge is coincident with + the n+1th Edge forming an unbroken sequence. Note that sequencing a list + is relatively slow. + + Args: + edges (Iterable[Edge]): Edges to assemble + sequenced (bool, optional): arrange in order. Defaults to False. + + Raises: + ValueError: Edges are disconnected and can't be sequenced. + RuntimeError: Wire is empty + + Returns: + Wire: assembled edges + """ + + def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge: + """Return the Edge closest to the end of last_edge""" + target_point = current.position_at(1) + + sorted_edges = sorted( + unplaced_edges, + key=lambda e: min( + (target_point - e.position_at(0)).length, + (target_point - e.position_at(1)).length, + ), + ) + return sorted_edges[0] + + edges = list(edges) + if sequenced: + placed_edges = [edges.pop(0)] + unplaced_edges = edges + + while unplaced_edges: + next_edge = closest_to_end(Wire(placed_edges), unplaced_edges) + next_edge_index = unplaced_edges.index(next_edge) + placed_edges.append(unplaced_edges.pop(next_edge_index)) + + edges = placed_edges + + wire_builder = BRepBuilderAPI_MakeWire() + combined_edges = TopTools_ListOfShape() + for edge in edges: + if edge.wrapped is not None: + combined_edges.Append(edge.wrapped) + wire_builder.Add(combined_edges) + + wire_builder.Build() + if not wire_builder.IsDone(): + if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire: + warnings.warn( + "Wire is non manifold (e.g. branching, self intersecting)", + stacklevel=2, + ) + elif wire_builder.Error() == BRepBuilderAPI_EmptyWire: + raise RuntimeError("Wire is empty") + elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire: + raise ValueError("Edges are disconnected") + + return wire_builder.Wire() + + @classmethod + def combine( + cls, wires: Iterable[Wire | Edge], tol: float = 1e-9 + ) -> ShapeList[Wire]: + """combine + + Combine a list of wires and edges into a list of Wires. + + Args: + wires (Iterable[Wire | Edge]): unsorted + tol (float, optional): tolerance. Defaults to 1e-9. + + Returns: + ShapeList[Wire]: Wires + """ + + edges_in = TopTools_HSequenceOfShape() + wires_out = TopTools_HSequenceOfShape() + + for edge in [e for w in wires for e in w.edges()]: + 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(tcast(TopoDS_Wire, downcast(wires_out.Value(i + 1))))) + + return wires + + @classmethod + def extrude(cls, obj: Shape, direction: VectorLike) -> Wire: + """extrude - invalid operation for Wire""" + raise NotImplementedError("Wires can't be created by extrusion") + + @classmethod + def make_circle(cls, radius: float, plane: Plane = Plane.XY) -> Wire: + """make_circle + + Makes a circle centered at the origin of plane + + Args: + radius (float): circle radius + plane (Plane): base plane. Defaults to Plane.XY + + Returns: + Wire: a circle + """ + circle_edge = Edge.make_circle(radius, plane=plane) + return Wire([circle_edge]) + + @classmethod + def make_convex_hull(cls, edges: Iterable[Edge], tolerance: float = 1e-3) -> Wire: + """make_convex_hull + + Create a wire of minimum length enclosing all of the provided edges. + + Note that edges can't overlap each other. + + Args: + edges (Iterable[Edge]): edges defining the convex hull + tolerance (float): allowable error as a fraction of each edge length. + Defaults to 1e-3. + + Raises: + ValueError: edges overlap + + Returns: + Wire: convex hull perimeter + """ + # pylint: disable=too-many-branches, too-many-locals + # Algorithm: + # 1) create a cloud of points along all edges + # 2) create a convex hull which returns facets/simplices as pairs of point indices + # 3) find facets that are within an edge but not adjacent and store trim and + # new connecting edge data + # 4) find facets between edges and store trim and new connecting edge data + # 5) post process the trim data to remove duplicates and store in pairs + # 6) create connecting edges + # 7) create trim edges from the original edges and the trim data + # 8) return a wire version of all the edges + + # Possible enhancement: The accuracy of the result could be improved and the + # execution time reduced by adaptively placing more points around where the + # connecting edges contact the arc. + + # if any( + # [ + # edge_pair[0].overlaps(edge_pair[1]) + # for edge_pair in combinations(edges, 2) + # ] + # ): + # raise ValueError("edges overlap") + edges = list(edges) + fragments_per_edge = int(2 / tolerance) + points_lookup = {} # lookup from point index to edge/position on edge + points = [] # convex hull point cloud + + # Create points along each edge and the lookup structure + for edge_index, edge in enumerate(edges): + for i in range(fragments_per_edge): + param = i / (fragments_per_edge - 1) + points.append(tuple(edge.position_at(param))[:2]) + points_lookup[edge_index * fragments_per_edge + i] = (edge_index, param) + + convex_hull = ConvexHull(points) + + # Filter the fragments + connecting_edge_data = [] + trim_points: dict[int, list[int]] = {} + for simplice in convex_hull.simplices: + edge0 = points_lookup[simplice[0]][0] + edge1 = points_lookup[simplice[1]][0] + # Look for connecting edges between edges + if edge0 != edge1: + if edge0 not in trim_points: + trim_points[edge0] = [simplice[0]] + else: + trim_points[edge0].append(simplice[0]) + if edge1 not in trim_points: + trim_points[edge1] = [simplice[1]] + else: + trim_points[edge1].append(simplice[1]) + connecting_edge_data.append( + ( + (edge0, points_lookup[simplice[0]][1], simplice[0]), + (edge1, points_lookup[simplice[1]][1], simplice[1]), + ) + ) + # Look for connecting edges within an edge + elif abs(simplice[0] - simplice[1]) != 1: + start_pnt = min(simplice.tolist()) + end_pnt = max(simplice.tolist()) + if edge0 not in trim_points: + trim_points[edge0] = [start_pnt, end_pnt] + else: + trim_points[edge0].extend([start_pnt, end_pnt]) + connecting_edge_data.append( + ( + (edge0, points_lookup[start_pnt][1], start_pnt), + (edge0, points_lookup[end_pnt][1], end_pnt), + ) + ) + + trim_data = {} + for edge_index, start_end_pnts in trim_points.items(): + s_points = sorted(start_end_pnts) + f_points = [] + for i in range(0, len(s_points) - 1, 2): + if s_points[i] != s_points[i + 1]: + f_points.append(tuple(s_points[i : i + 2])) + trim_data[edge_index] = f_points + + connecting_edges = [ + Edge.make_line( + edges[line[0][0]] @ line[0][1], edges[line[1][0]] @ line[1][1] + ) + for line in connecting_edge_data + ] + trimmed_edges = [ + edges[edge_index].trim( + points_lookup[trim_pair[0]][1], points_lookup[trim_pair[1]][1] + ) + for edge_index, trim_pairs in trim_data.items() + for trim_pair in trim_pairs + ] + hull_wire = Wire(connecting_edges + trimmed_edges, sequenced=True) + return hull_wire + + @classmethod + def make_ellipse( + cls, + x_radius: float, + y_radius: float, + plane: Plane = Plane.XY, + start_angle: float = 360.0, + end_angle: float = 360.0, + angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE, + closed: bool = True, + ) -> Wire: + """make ellipse + + Makes an ellipse centered at the origin of plane. + + Args: + 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) + plane (Plane, optional): base plane. Defaults to Plane.XY. + start_angle (float, optional): _description_. Defaults to 360.0. + end_angle (float, optional): _description_. Defaults to 360.0. + angular_direction (AngularDirection, optional): arc direction. + Defaults to AngularDirection.COUNTER_CLOCKWISE. + closed (bool, optional): close the arc. Defaults to True. + + Returns: + Wire: an ellipse + """ + ellipse_edge = Edge.make_ellipse( + x_radius, y_radius, plane, start_angle, end_angle, angular_direction + ) + + if start_angle != end_angle and closed: + line = Edge.make_line(ellipse_edge.end_point(), ellipse_edge.start_point()) + wire = Wire([ellipse_edge, line]) + else: + wire = Wire([ellipse_edge]) + + return wire + + @classmethod + def make_polygon(cls, vertices: Iterable[VectorLike], close: bool = True) -> Wire: + """make_polygon + + Create an irregular polygon by defining vertices + + Args: + vertices (Iterable[VectorLike]): + close (bool, optional): close the polygon. Defaults to True. + + Returns: + Wire: an irregular polygon + """ + vectors = [Vector(v) for v in vertices] + if (vectors[0] - vectors[-1]).length > TOLERANCE and close: + vectors.append(vectors[0]) + + wire_builder = BRepBuilderAPI_MakePolygon() + for vertex in vectors: + wire_builder.Add(vertex.to_pnt()) + + return cls(wire_builder.Wire()) + + @classmethod + def make_rect( + cls, + width: float, + height: float, + plane: Plane = Plane.XY, + ) -> Wire: + """Make Rectangle + + Make a Rectangle centered on center with the given normal + + Args: + width (float): width (local x) + height (float): height (local y) + plane (Plane, optional): plane containing rectangle. Defaults to Plane.XY. + + Returns: + Wire: The centered rectangle + """ + corners_local = [ + (width / 2, height / 2), + (width / 2, height / -2), + (width / -2, height / -2), + (width / -2, height / 2), + ] + corners_world = [plane.from_local_coords(c) for c in corners_local] + return Wire.make_polygon(corners_world, close=True) + + # ---- Static Methods ---- + @staticmethod + def order_chamfer_edges( + reference_edge: Edge | None, edges: tuple[Edge, Edge] + ) -> tuple[Edge, Edge]: + """Order the edges of a chamfer relative to a reference Edge""" + if reference_edge: + edge1, edge2 = edges + if edge1 == reference_edge: + return edge1, edge2 + if edge2 == reference_edge: + return edge2, edge1 + raise ValueError("reference edge not in edges") + return edges + + # ---- Instance Methods ---- + + def chamfer_2d( + self, + distance: float, + distance2: float, + vertices: Iterable[Vertex], + edge: Edge | None = None, + ) -> Wire: + """chamfer_2d + + Apply 2D chamfer to a wire + + Args: + distance (float): chamfer length + distance2 (float): chamfer length + vertices (Iterable[Vertex]): vertices to chamfer + edge (Edge): identifies the side where length is measured. The vertices must be + part of the edge + + Returns: + Wire: chamfered wire + """ + if self._wrapped is None: + raise ValueError("Can't chamfer empty wire") + + reference_edge = edge + + # Create a face to chamfer + unchamfered_face = _make_topods_face_from_wires(self.wrapped) + chamfer_builder = BRepFilletAPI_MakeFillet2d(unchamfered_face) + + vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + unchamfered_face, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map + ) + + 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(tcast(TopoDS_Edge, downcast(edge_list.First()))), + Edge(tcast(TopoDS_Edge, downcast(edge_list.Last()))), + ) + + edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges) + 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() + # Fix the shape + 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)) + + def close(self) -> Wire: + """Close a Wire""" + if not self.is_closed: + edge = Edge.make_line(self.end_point(), self.start_point()) + return_value = Wire.combine((self, edge))[0] + else: + return_value = self + + 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 + + Apply 2D fillet to a wire + + Args: + 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: + 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)) + + def fix_degenerate_edges(self, precision: float) -> Wire: + """fix_degenerate_edges + + Fix a Wire that contains degenerate (very small) edges + + Args: + precision (float): minimum value edge length + + 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(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""" + + 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: + """ + Return the normalized wire parameter for the point closest to this wire. + + 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: + + - 0.0 corresponds to the start of the wire + - 1.0 corresponds to the end of the wire + + 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. + + Args: + point (VectorLike): The point to project onto the wire. + + Raises: + ValueError: Can't find point on empty wire + + 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 + # 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() + + return distance_along_wire / self.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, + target_object: Shape, + direction: VectorLike | None = None, + center: VectorLike | None = None, + ) -> list[Wire]: + """Project Wire + + Project a Wire onto a Shape generating new wires on the surfaces of the object + one and only one of `direction` or `center` must be provided. Note that one or + more wires may be generated depending on the topology of the target object and + location/direction of projection. + + To avoid flipping the normal of a face built with the projected wire the orientation + of the output wires are forced to be the same as self. + + Args: + target_object: Object to project onto + direction: Parallel projection direction. Defaults to None. + center: Conical center of projection. Defaults to None. + target_object: Shape: + direction: VectorLike: (Default value = None) + center: VectorLike: (Default value = None) + + Returns: + : Projected wire(s) + + Raises: + ValueError: Only one of direction or center must be provided + + """ + # pylint: disable=too-many-branches + if self._wrapped is None or not target_object: + raise ValueError("Can't project empty Wires or to empty Shapes") + + if direction is not None and center is None: + direction_vector = Vector(direction).normalized() + center_point = Vector() # for typing, never used + 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), + ) + else: + projection_object = BRepProj_Projection( + self.wrapped, + target_object.wrapped, + gp_Pnt(*center_point), + ) + + # Generate a list of the projected wires with aligned orientation + output_wires = [] + target_orientation = self.wrapped.Orientation() + while projection_object.More(): + projected_wire = projection_object.Current() + if target_orientation == projected_wire.Orientation(): + output_wires.append(Wire(projected_wire)) + else: + output_wires.append( + Wire(tcast(TopoDS_Wire, downcast(projected_wire.Reversed()))) + ) + projection_object.Next() + + logger.debug("wire generated %d projected wires", len(output_wires)) + + # BRepProj_Projection is inconsistent in the order that it returns projected + # wires, sometimes front first and sometimes back - so sort this out by sorting + # by distance from the original planar wire + if len(output_wires) > 1: + output_wires_distances = [] + planar_wire_center = self.center() + for output_wire in output_wires: + output_wire_center = output_wire.center() + if direction_vector is not None: + output_wire_direction = ( + output_wire_center - planar_wire_center + ).normalized() + if output_wire_direction.dot(direction_vector) >= 0: + output_wires_distances.append( + ( + output_wire, + (output_wire_center - planar_wire_center).length, + ) + ) + else: + output_wires_distances.append( + ( + output_wire, + (output_wire_center - center_point).length, + ) + ) + + output_wires_distances.sort(key=lambda x: x[1]) + logger.debug( + "projected, filtered and sorted wire list is of length %d", + len(output_wires_distances), + ) + output_wires = [w[0] for w in output_wires_distances] + + return output_wires + + def stitch(self, other: Wire) -> Wire: + """Attempt to stitch wires + + Args: + 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)) + wire_builder.Add(TopoDS.Wire_s(other.wrapped)) + wire_builder.Build() + + return self.__class__.cast(wire_builder.Wire()) + + def _to_bspline(self) -> Edge: + """ + Collapse this wire into a single BSpline edge (internal use). + + 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**. + + ⚠️ 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). + + 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: + RuntimeError: If any segment cannot be appended to the composite spline + or the final BSpline edge cannot be built. + ValueError: Empty Wire + + Returns: + 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) + + 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) + + # 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(ordered_edges) == 1: + return Wire([ordered_edges[0].trim(start_u, end_u)]) + + total_length = self.length + start_len = start_u * total_length + end_len = end_u * total_length + + trimmed_edges = [] + cur_length = 0.0 + + for edge in ordered_edges: + edge_len = edge.length + edge_start = cur_length + edge_end = cur_length + edge_len + cur_length = edge_end + + if edge_end <= start_len or edge_start >= end_len: + continue # skip + + 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) + + 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) + + +def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> ShapeList[Wire]: + """Convert edges to a list of wires. + + Args: + edges: Iterable[Edge]: + tol: float: (Default value = 1e-6) + + Returns: + + """ + + edges_in = TopTools_HSequenceOfShape() + wires_out = TopTools_HSequenceOfShape() + + for edge in edges: + if edge.wrapped is not None: + edges_in.Append(edge.wrapped) + ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out) + + wires: ShapeList[Wire] = ShapeList() + for i in range(wires_out.Length()): + # wires.append(Wire(downcast(wires_out.Value(i + 1)))) + wires.append(Wire(TopoDS.Wire_s(wires_out.Value(i + 1)))) + + return wires + + +def offset_topods_face(face: TopoDS_Face, amount: float) -> TopoDS_Shape: + """Offset a topods_face""" + offsetor = BRepOffset_MakeOffset() + offsetor.Initialize(face, Offset=amount, Tol=TOLERANCE) + offsetor.MakeOffsetShape() + + return offsetor.Shape() + + +def topo_explore_connected_edges( + edge: Edge, + parent: Shape | None = None, + continuity: ContinuityLevel = ContinuityLevel.C0, +) -> ShapeList[Edge]: + """ + 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") + if not edge: + raise ValueError("edge is empty") + given_topods_edge = edge.wrapped + connected_edges = set() + + # Find all the TopoDS_Edges for this Shape + topods_edges = [e.wrapped for e in parent.edges() if e.wrapped is not None] + + for topods_edge in topods_edges: + # # Don't match with the given edge + if given_topods_edge.IsSame(topods_edge): + continue + # If the edge shares a vertex with the given edge they are connected + 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) + + +def topo_explore_connected_faces( + edge: Edge, parent: Shape | None = None +) -> 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 not parent: + raise ValueError("edge has no valid parent") + + # make a edge --> faces mapping + edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map + ) + + # Query the map and select only unique faces + unique_face_map = TopTools_IndexedMapOfShape() + unique_faces = [] + if edge_face_map.Contains(edge.wrapped): + 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))) + + return unique_faces diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py new file mode 100644 index 0000000..6e84222 --- /dev/null +++ b/src/build123d/topology/shape_core.py @@ -0,0 +1,3371 @@ +""" +build123d topology + +name: shape_core.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module defines the foundational classes and methods for the build123d CAD library, enabling +detailed geometric operations and 3D modeling capabilities. It provides a hierarchy of classes +representing various geometric entities like vertices, edges, wires, faces, shells, solids, and +compounds. These classes are designed to work seamlessly with the OpenCascade Python bindings, +leveraging its robust CAD kernel. + +Key Features: +- **Shape Base Class:** Implements core functionalities such as transformations (rotation, + translation, scaling), geometric queries, and boolean operations (cut, fuse, intersect). +- **Custom Utilities:** Includes helper classes like `ShapeList` for advanced filtering, sorting, + and grouping of shapes, and `GroupBy` for organizing shapes by specific criteria. +- **Type Safety:** Extensive use of Python typing features ensures clarity and correctness in type + handling. +- **Advanced Geometry:** Supports operations like finding intersections, computing bounding boxes, + projecting faces, and generating triangulated meshes. + +The module is designed for extensibility, enabling developers to build complex 3D assemblies and +perform detailed CAD operations programmatically while maintaining a clean and structured API. + +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 + +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 ( + TYPE_CHECKING, + Any, + Generic, + Literal, + Optional, + Protocol, + SupportsIndex, + TypeVar, + Union, +) +from typing import cast as tcast +from typing import overload + +import OCP.GeomAbs as ga +import OCP.TopAbs as ta +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_TEdge, BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.BRepAlgoAPI import ( + BRepAlgoAPI_BooleanOperation, + BRepAlgoAPI_Common, + BRepAlgoAPI_Cut, + BRepAlgoAPI_Fuse, + BRepAlgoAPI_Section, + BRepAlgoAPI_Splitter, +) +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_Copy, + BRepBuilderAPI_GTransform, + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeVertex, + BRepBuilderAPI_RightCorner, + BRepBuilderAPI_RoundCorner, + BRepBuilderAPI_Sewing, + BRepBuilderAPI_Transform, + BRepBuilderAPI_Transformed, +) +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepExtrema import BRepExtrema_DistShapeShape +from OCP.BRepFeat import BRepFeat_SplitShape +from OCP.BRepGProp import BRepGProp, BRepGProp_Face +from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter +from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace +from OCP.BRepTools import BRepTools, 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 +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.TopoDS import ( + TopoDS, + TopoDS_Compound, + TopoDS_Edge, + TopoDS_Face, + TopoDS_Iterator, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Solid, + TopoDS_Vertex, + TopoDS_Wire, +) +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, + TOLERANCE, + Axis, + BoundBox, + Color, + ColorLike, + Location, + Matrix, + OrientedBoundBox, + Plane, + Vector, + VectorLike, + logger, +) + +if TYPE_CHECKING: # pragma: no cover + 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]): + """Shape + + Base class for all CAD objects such as Edge, Face, Solid, etc. + + Args: + obj (TopoDS_Shape, optional): OCCT object. Defaults to None. + label (str, optional): Defaults to ''. + color (ColorLike, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + + Attributes: + wrapped (TopoDS_Shape): the OCP object + label (str): user assigned label + color (Color): object color + joints (dict[str:Joint]): dictionary of joints bound to this object (Solid only) + children (Shape): list of assembly children of this object (Compound only) + topo_parent (Shape): assembly parent of this object + + """ + + shape_LUT = { + ta.TopAbs_VERTEX: "Vertex", + ta.TopAbs_EDGE: "Edge", + ta.TopAbs_WIRE: "Wire", + ta.TopAbs_FACE: "Face", + ta.TopAbs_SHELL: "Shell", + ta.TopAbs_SOLID: "Solid", + ta.TopAbs_COMPOUND: "Compound", + ta.TopAbs_COMPSOLID: "CompSolid", + } + + shape_properties_LUT: dict[TopAbs_ShapeEnum, CalcFn | None] = { + ta.TopAbs_VERTEX: None, + ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, + ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, + ta.TopAbs_FACE: BRepGProp.SurfaceProperties_s, + ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s, + ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s, + ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s, + ta.TopAbs_COMPSOLID: BRepGProp.VolumeProperties_s, + } + + inverse_shape_LUT = {v: k for k, v in shape_LUT.items()} + + downcast_LUT = { + ta.TopAbs_VERTEX: TopoDS.Vertex_s, + ta.TopAbs_EDGE: TopoDS.Edge_s, + ta.TopAbs_WIRE: TopoDS.Wire_s, + ta.TopAbs_FACE: TopoDS.Face_s, + ta.TopAbs_SHELL: TopoDS.Shell_s, + ta.TopAbs_SOLID: TopoDS.Solid_s, + ta.TopAbs_COMPOUND: TopoDS.Compound_s, + ta.TopAbs_COMPSOLID: TopoDS.CompSolid_s, + } + + geom_LUT_EDGE: dict[ga.GeomAbs_CurveType, GeomType] = { + ga.GeomAbs_Line: GeomType.LINE, + ga.GeomAbs_Circle: GeomType.CIRCLE, + ga.GeomAbs_Ellipse: GeomType.ELLIPSE, + ga.GeomAbs_Hyperbola: GeomType.HYPERBOLA, + ga.GeomAbs_Parabola: GeomType.PARABOLA, + ga.GeomAbs_BezierCurve: GeomType.BEZIER, + ga.GeomAbs_BSplineCurve: GeomType.BSPLINE, + ga.GeomAbs_OffsetCurve: GeomType.OFFSET, + ga.GeomAbs_OtherCurve: GeomType.OTHER, + } + geom_LUT_FACE: dict[ga.GeomAbs_SurfaceType, GeomType] = { + ga.GeomAbs_Plane: GeomType.PLANE, + ga.GeomAbs_Cylinder: GeomType.CYLINDER, + ga.GeomAbs_Cone: GeomType.CONE, + ga.GeomAbs_Sphere: GeomType.SPHERE, + ga.GeomAbs_Torus: GeomType.TORUS, + ga.GeomAbs_BezierSurface: GeomType.BEZIER, + ga.GeomAbs_BSplineSurface: GeomType.BSPLINE, + ga.GeomAbs_SurfaceOfRevolution: GeomType.REVOLUTION, + ga.GeomAbs_SurfaceOfExtrusion: GeomType.EXTRUSION, + ga.GeomAbs_OffsetSurface: GeomType.OFFSET, + ga.GeomAbs_OtherSurface: GeomType.OTHER, + } + _transModeDict = { + Transition.TRANSFORMED: BRepBuilderAPI_Transformed, + Transition.ROUND: BRepBuilderAPI_RoundCorner, + Transition.RIGHT: BRepBuilderAPI_RightCorner, + } + + _color: Color | None + + class _DisplayNode(NodeMixin): + """Used to create anytree structures from TopoDS_Shapes""" + + def __init__( + self, + label: str = "", + address: int | None = None, + position: Vector | Location | None = None, + parent: Shape._DisplayNode | None = None, + ): + self.label = label + self.address = address + self.position = position + self.parent = parent + self.children: list[Shape] = [] + + _ordered_shapes = [ + TopAbs_ShapeEnum.TopAbs_COMPOUND, + TopAbs_ShapeEnum.TopAbs_COMPSOLID, + TopAbs_ShapeEnum.TopAbs_SOLID, + TopAbs_ShapeEnum.TopAbs_SHELL, + TopAbs_ShapeEnum.TopAbs_FACE, + TopAbs_ShapeEnum.TopAbs_WIRE, + TopAbs_ShapeEnum.TopAbs_EDGE, + TopAbs_ShapeEnum.TopAbs_VERTEX, + ] + # ---- Constructor ---- + + def __init__( + self, + obj: TopoDS_Shape | None = None, + label: str = "", + color: ColorLike | None = None, + parent: Compound | None = 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 + + # parent must be set following children as post install accesses children + self.parent = parent + + # Extracted objects like Vertices and Edges may need to know where they came from + self.topo_parent: Shape | None = None + + # ---- Properties ---- + + # 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: + """Dimension of the object""" + + @property + def area(self) -> float: + """area -the surface area of all faces in this Shape""" + if self._wrapped is None: + return 0.0 + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(self.wrapped, properties) + + return properties.Mass() + + @property + def color(self) -> None | Color: + """Get the shape's color. If it's None, get the color of the nearest + ancestor, assign it to this Shape and return this value.""" + # Find the correct color for this node + if self._color is None: + # Find parent color + current_node: Compound | Shape | None = self + while current_node is not None: + parent_color = current_node._color + if parent_color is not None: + break + current_node = current_node.parent + node_color = parent_color + else: + node_color = self._color + self._color = node_color # Set the node's color for next time + return node_color + + @color.setter + def color(self, value: ColorLike | None) -> None: + """Set the shape's color""" + self._color = Color(value) if value is not None else None + + @property + def geom_type(self) -> GeomType: + """Gets the underlying geometry type. + + Returns: + GeomType: The geometry type of the shape + + """ + if self._wrapped is None: + raise ValueError("Cannot determine geometry type of an empty shape") + + shape: TopAbs_ShapeEnum = shapetype(self.wrapped) + + if shape == ta.TopAbs_EDGE: + geom = Shape.geom_LUT_EDGE[ + BRepAdaptor_Curve(tcast(TopoDS_Edge, self.wrapped)).GetType() + ] + elif shape == ta.TopAbs_FACE: + geom = Shape.geom_LUT_FACE[ + BRepAdaptor_Surface(tcast(TopoDS_Face, self.wrapped)).GetType() + ] + else: + geom = GeomType.OTHER + + return geom + + @property + def is_manifold(self) -> bool: + """is_manifold + + Check if each edge in the given Shape has exactly two faces associated with it + (skipping degenerate edges). If so, the shape is manifold. + + Returns: + bool: is the shape manifold or water tight + """ + # Extract one or more (if a Compound) shape from self + if self._wrapped is None: + return False + shape_stack = get_top_level_topods_shapes(self.wrapped) + + while shape_stack: + shape = shape_stack.pop(0) + + # Create an empty indexed data map to store the edges and their corresponding faces. + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + + # Fill the map with edges and their associated faces in the given shape. Each edge in + # the map is associated with a list of faces that share that edge. + TopExp.MapShapesAndAncestors_s( + # shape.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, shape_map + shape, + ta.TopAbs_EDGE, + ta.TopAbs_FACE, + shape_map, + ) + + # Iterate over the edges in the map and checks if each edge is non-degenerate and has + # exactly two faces associated with it. + for i in range(shape_map.Extent()): + # Access each edge in the map sequentially + edge = TopoDS.Edge_s(shape_map.FindKey(i + 1)) + + vertex0 = TopoDS_Vertex() + vertex1 = TopoDS_Vertex() + + # Extract the two vertices of the current edge and stores them in vertex0/1. + TopExp.Vertices_s(edge, vertex0, vertex1) + + # Check if both vertices are null and if they are the same vertex. If so, the + # edge is considered degenerate (i.e., has zero length), and it is skipped. + if vertex0.IsNull() and vertex1.IsNull() and vertex0.IsSame(vertex1): + continue + + # Check if the current edge has exactly two faces associated with it. If not, + # it means the edge is not shared by exactly two faces, indicating that the + # shape is not manifold. + if shape_map.FindFromIndex(i + 1).Extent() != 2: + return False + + 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): + return False + surface = BRep_Tool.Surface_s(self.wrapped) + is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE) + return is_face_planar.IsPlanar() + + @property + 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: + raise ValueError("Can't find the location of an empty shape") + return Location(self.wrapped.Location()) + + @location.setter + def location(self, value: Location): + """Set Shape's Location to value""" + if self.wrapped is not None: + self.wrapped.Location(value.wrapped) + + @property + def matrix_of_inertia(self) -> list[list[float]]: + """ + Compute the inertia matrix (moment of inertia tensor) of the shape. + + The inertia matrix represents how the mass of the shape is distributed + with respect to its reference frame. It is a 3×3 symmetric tensor that + describes the resistance of the shape to rotational motion around + different axes. + + Returns: + list[list[float]]: A 3×3 nested list representing the inertia matrix. + The elements of the matrix are given as: + + | Ixx Ixy Ixz | + | Ixy Iyy Iyz | + | Ixz Iyz Izz | + + where: + - Ixx, Iyy, Izz are the moments of inertia about the X, Y, and Z axes. + - Ixy, Ixz, Iyz are the products of inertia. + + Example: + >>> obj = MyShape() + >>> obj.matrix_of_inertia + [[1000.0, 50.0, 0.0], + [50.0, 1200.0, 0.0], + [0.0, 0.0, 300.0]] + + Notes: + - The inertia matrix is computed relative to the shape's center of mass. + - 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() + matrix = [] + for i in range(3): + matrix.append([inertia_matrix.Value(i + 1, j + 1) for j in range(3)]) + return matrix + + @property + def orientation(self) -> Vector: + """Get the orientation component of this Shape's Location""" + if self.location is None: + raise ValueError("Can't find the orientation of an empty shape") + return self.location.orientation + + @orientation.setter + def orientation(self, rotations: VectorLike): + """Set the orientation component of this Shape's Location to rotations""" + loc = self.location + if loc is not None: + loc.orientation = Vector(rotations) + self.location = loc + + @property + def position(self) -> Vector: + """Get the position component of this Shape's Location""" + 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 + def position(self, value: VectorLike): + """Set the position component of this Shape's Location to value""" + loc = self.location + if loc is not None: + loc.position = Vector(value) + self.location = loc + + @property + def principal_properties(self) -> list[tuple[Vector, float]]: + """ + Compute the principal moments of inertia and their corresponding axes. + + Returns: + list[tuple[Vector, float]]: A list of tuples, where each tuple contains: + - A `Vector` representing the axis of inertia. + - A `float` representing the moment of inertia for that axis. + + Example: + >>> obj = MyShape() + >>> obj.principal_properties + [(Vector(1, 0, 0), 1200.0), + (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() + principal_moments = principal_props.Moments() + return [ + (Vector(principal_props.FirstAxisOfInertia()), principal_moments[0]), + (Vector(principal_props.SecondAxisOfInertia()), principal_moments[1]), + (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]: + """ + Compute the static moments (first moments of mass) of the shape. + + The static moments represent the weighted sum of the coordinates + with respect to the mass distribution, providing insight into the + center of mass and mass distribution of the shape. + + Returns: + tuple[float, float, float]: The static moments (Mx, My, Mz), + where: + - Mx is the first moment of mass about the YZ plane. + - My is the first moment of mass about the XZ plane. + - Mz is the first moment of mass about the XY plane. + + Example: + >>> obj = MyShape() + >>> obj.static_moments + (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() + + # ---- Class Methods ---- + + @classmethod + @abstractmethod + def cast(cls: type[Self], obj: TopoDS_Shape) -> Self: + """Returns the right type of wrapper, given a OCCT object""" + + @classmethod + @abstractmethod + def extrude( + cls, obj: Shape, direction: VectorLike + ) -> Edge | Face | Shell | Solid | Compound: + """extrude + + Extrude a Shape in the provided direction. + * Vertices generate Edges + * Edges generate Faces + * Wires generate Shells + * Faces generate Solids + * Shells generate Compounds + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + Returns: + Edge | Face | Shell | Solid | Compound: extruded shape + """ + + # ---- Static Methods ---- + + @staticmethod + def _build_tree( + shape: TopoDS_Shape, + tree: list[_DisplayNode], + parent: _DisplayNode | None = None, + limit: TopAbs_ShapeEnum = TopAbs_ShapeEnum.TopAbs_VERTEX, + show_center: bool = True, + ) -> list[_DisplayNode]: + """Create an anytree copy of the TopoDS_Shape structure""" + + obj_type = Shape.shape_LUT[shape.ShapeType()] + loc: Vector | Location + if show_center: + loc = Shape(shape).bounding_box().center() + else: + loc = Location(shape.Location()) + tree.append(Shape._DisplayNode(obj_type, id(shape), loc, parent)) + iterator = TopoDS_Iterator() + iterator.Initialize(shape) + parent_node = tree[-1] + while iterator.More(): + child = iterator.Value() + if Shape._ordered_shapes.index( + child.ShapeType() + ) <= Shape._ordered_shapes.index(limit): + Shape._build_tree(child, tree, parent_node, limit) + iterator.Next() + return tree + + @staticmethod + def _show_tree(root_node, show_center: bool) -> str: + """Display an assembly or TopoDS_Shape anytree structure""" + + # Calculate the size of the tree labels + size_tuples = [(node.height, len(node.label)) for node in root_node.descendants] + size_tuples.append((root_node.height, len(root_node.label))) + # pylint: disable=cell-var-from-loop + size_tuples_per_level = [ + list(filter(lambda ll: ll[0] == l, size_tuples)) + for l in range(root_node.height + 1) + ] + max_sizes_per_level = [ + max(4, max(l[1] for l in level)) for level in size_tuples_per_level + ] + level_sizes_per_level = [ + l + i * 4 for i, l in enumerate(reversed(max_sizes_per_level)) + ] + tree_label_width = max(level_sizes_per_level) + 1 + + # Build the tree line by line + result = "" + for pre, _fill, node in RenderTree(root_node): + treestr = f"{pre}{node.label}".ljust(tree_label_width) + if hasattr(root_node, "address"): + address = node.address + name = "" + loc = ( + "Center" + str(tuple(node.position)) + if show_center + else "Position" + str(tuple(node.position)) + ) + else: + address = id(node) + name = node.__class__.__name__.ljust(9) + loc = ( + "Center" + str(tuple(node.center())) + if show_center + else "Location" + repr(node.location) + ) + result += f"{treestr}{name}at {address:#x}, {loc}\n" + return result + + @staticmethod + def combined_center( + objects: Iterable[Shape], center_of: CenterOf = CenterOf.MASS + ) -> Vector: + """combined center + + Calculates the center of a multiple objects. + + Args: + objects (Iterable[Shape]): list of objects + center_of (CenterOf, optional): centering option. Defaults to CenterOf.MASS. + + Raises: + ValueError: CenterOf.GEOMETRY not implemented + + Returns: + Vector: center of multiple objects + """ + objects = list(objects) + if center_of == CenterOf.MASS: + total_mass = sum(Shape.compute_mass(o) for o in objects) + weighted_centers = [ + o.center(CenterOf.MASS).multiply(Shape.compute_mass(o)) for o in objects + ] + + sum_wc = weighted_centers[0] + for weighted_center in weighted_centers[1:]: + sum_wc = sum_wc.add(weighted_center) + middle = Vector(sum_wc.multiply(1.0 / total_mass)) + elif center_of == CenterOf.BOUNDING_BOX: + total_mass = len(list(objects)) + + weighted_centers = [] + for obj in objects: + weighted_centers.append(obj.bounding_box().center()) + + sum_wc = weighted_centers[0] + for weighted_center in weighted_centers[1:]: + sum_wc = sum_wc.add(weighted_center) + + middle = Vector(sum_wc.multiply(1.0 / total_mass)) + else: + raise ValueError("CenterOf.GEOMETRY not implemented") + + return middle + + @staticmethod + def compute_mass(obj: Shape) -> float: + """Calculates the 'mass' of an object. + + Args: + obj: Compute the mass of this object + obj: Shape: + + Returns: + + """ + if not obj: + return 0.0 + + properties = GProp_GProps() + calc_function = Shape.shape_properties_LUT[shapetype(obj.wrapped)] + + if calc_function is None: + raise NotImplementedError + + calc_function(obj.wrapped, properties) + return properties.Mass() + + @staticmethod + def get_shape_list( + shape: Shape, + entity_type: Literal[ + "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound" + ], + ) -> ShapeList: + """Helper to extract entities of a specific type from a shape.""" + 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 if shape.topo_parent is None else shape.topo_parent + return shape_list + + @staticmethod + def get_single_shape( + shape: Shape, + entity_type: Literal[ + "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound" + ], + ) -> Shape | None: + """Helper to extract a single entity of a specific type from a shape, + with a warning if count != 1.""" + shape_list = Shape.get_shape_list(shape, entity_type) + entity_count = len(shape_list) + if entity_count == 0: + return None + elif entity_count > 1: + warnings.warn( + f"Found {entity_count} {entity_type.lower()}s, returning first", + stacklevel=3, + ) + return shape_list[0] if shape_list else None + + # ---- Instance Methods ---- + + def __add__(self, other: None | Shape | Iterable[Shape]) -> Self | ShapeList[Self]: + """fuse shape to self operator +""" + # Convert `other` to list of base objects and filter out None values + if other is None: + summands = [] + else: + 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 o.get_top_level_shapes() + ] + # If there is nothing to add return the original object + if not summands: + return self + + # Check that all dimensions are the same + addend_dim = self._dim + if addend_dim is None: + raise ValueError("Dimensions of objects to add to are inconsistent") + + 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 len(summands) == 1: + sum_shape = summands[0] + else: + sum_shape = summands[0].fuse(*summands[1:]) + else: + sum_shape = self.fuse(*summands) + + if SkipClean.clean and not isinstance(sum_shape, list): + sum_shape = sum_shape.clean() + + return sum_shape + + def __and__(self, other: Shape | Iterable[Shape]) -> None | Self | ShapeList[Self]: + """intersect shape with self operator &""" + others = other if isinstance(other, (list, tuple)) else [other] + + if not self or (isinstance(other, Shape) and not other): + raise ValueError("Cannot intersect shape with empty compound") + new_shape = self.intersect(*others) + + if ( + not isinstance(new_shape, list) + and new_shape is not None + and new_shape.wrapped is not None + and SkipClean.clean + ): + new_shape = new_shape.clean() + + return new_shape + + def __copy__(self) -> Self: + """Return shallow copy or reference of self + + Create an copy of this Shape that shares the underlying TopoDS_TShape. + + Used when there is a need for many objects with the same CAD structure but at + different Locations, etc. - for examples fasteners in a larger assembly. By + sharing the TopoDS_TShape, the memory size of such assemblies can be greatly reduced. + + Changes to the CAD structure of the base object will be reflected in all instances. + """ + reference = copy.deepcopy(self) + if self.wrapped is not None: + assert ( + reference.wrapped is not None + ) # Ensure mypy knows reference.wrapped is not None + reference.wrapped.TShape(self.wrapped.TShape()) + return reference + + def __deepcopy__(self, memo) -> Self: + """Return deepcopy of self""" + # The wrapped object is a OCCT TopoDS_Shape which can't be pickled or copied + # with the standard python copy/deepcopy, so create a deepcopy 'memo' with this + # value already copied which causes deepcopy to skip it. + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + if self.wrapped is not None: + memo[id(self.wrapped)] = downcast(BRepBuilderAPI_Copy(self.wrapped).Shape()) + for key, value in self.__dict__.items(): + 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 + return result + + def __eq__(self, other) -> bool: + """Check if two shapes are the same. + + This method checks if the current shape is the same as the other shape. + Two shapes are considered the same if they share the same TShape with + the same Locations. Orientations may differ. + + Args: + other (Shape): The shape to compare with. + + Returns: + bool: True if the shapes are the same, False otherwise. + """ + if isinstance(other, Shape): + return self.is_same(other) + return NotImplemented + + def __hash__(self) -> int: + """Return hash code""" + if self._wrapped is None: + return 0 + return hash(self.wrapped) + + def __rmul__(self, other): + """right multiply for positioning operator *""" + if not ( + isinstance(other, (list, tuple)) + and all(isinstance(o, (Location, Plane)) for o in other) + ): + raise ValueError( + "shapes can only be multiplied list of locations or planes" + ) + return [loc * self for loc in other] + + def __sub__(self, other: None | Shape | Iterable[Shape]) -> Self | ShapeList[Self]: + """cut shape from self operator -""" + + 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 + if other is None: + subtrahends = [] + else: + subtrahends = [ + 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 o.get_top_level_shapes() + ] + # If there is nothing to subtract return the original object + if not subtrahends: + return self + + # Check that all dimensions are the same + minuend_dim = self._dim + if minuend_dim is None or any(s._dim is None for s in subtrahends): + raise ValueError("Dimensions of objects to subtract from are inconsistent") + + # Check that the operation is valid + subtrahend_dims = [s._dim for s in subtrahends if s._dim is not None] + if any(d < minuend_dim for d in subtrahend_dims): + raise ValueError( + f"Only shapes with equal or greater dimension can be subtracted: " + f"not {type(self).__name__} ({minuend_dim}D) and " + f"{type(other).__name__} ({min(subtrahend_dims)}D)" + ) + + # Do the actual cut operation + difference = self.cut(*subtrahends) + + return difference + + def bounding_box( + self, tolerance: float | None = None, optimal: bool = True + ) -> BoundBox: + """Create a bounding box for this Shape. + + Args: + tolerance (float, optional): Defaults to None. + + Returns: + BoundBox: A box sized to contain this Shape + """ + 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) + + # Actually creating the abstract method causes the subclass to pass center_of + # even when not required - possibly this could be improved. + # @abstractmethod + # def center(self, center_of: CenterOf) -> Vector: + # """Compute the center with a specific type of calculation.""" + + def clean(self) -> Self: + """clean + + Remove internal edges + + Returns: + Shape: Original object with extraneous internal edges removed + """ + if self._wrapped is None: + return self + upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True) + upgrader.AllowInternalEdges(False) + # upgrader.SetAngularTolerance(1e-5) + try: + upgrader.Build() + self.wrapped = tcast(TOPODS, downcast(upgrader.Shape())) + except Exception: + warnings.warn(f"Unable to clean {self}", stacklevel=2) + return self + + def closest_points(self, other: Shape | VectorLike) -> tuple[Vector, Vector]: + """Points on two shapes where the distance between them is minimal""" + return self.distance_to_with_closest_points(other)[1:3] + + def compound(self) -> Compound | None: + """Return the Compound""" + return None + + def compounds(self) -> ShapeList[Compound]: + """compounds - all the compounds in this Shape""" + return ShapeList() + + def copy_attributes_to( + self, target: Shape, exceptions: Iterable[str] | None = None + ): + """Copy common object attributes to target + + Note that preset attributes of target will not be overridden. + + Args: + target (Shape): object to gain attributes + exceptions (Iterable[str], optional): attributes not to copy + + Raises: + ValueError: invalid attribute + """ + # Find common attributes and eliminate exceptions + attrs1 = set(self.__dict__.keys()) + attrs2 = set(target.__dict__.keys()) + common_attrs = attrs1 & attrs2 + if exceptions is not None: + common_attrs -= set(exceptions) + + for attr in common_attrs: + # Copy the attribute only if the target's attribute not set + if not getattr(target, attr): + setattr(target, attr, getattr(self, attr)) + # Attach joints to the new part + if attr == "joints": + joint: Joint + for joint in target.joints.values(): + joint.parent = target + + def cut(self, *to_cut: Shape) -> Self | ShapeList[Self]: + """Remove the positional arguments from this Shape. + + Args: + *to_cut: Shape: + + Returns: + Self | ShapeList[Self]: Resulting object may be of a different class than self + or a ShapeList if multiple non-Compound object created + """ + + cut_op = BRepAlgoAPI_Cut() + + return self._bool_op((self,), to_cut, cut_op) + + def distance(self, other: Shape) -> float: + """Minimal distance between two shapes + + Args: + other: Shape: + + Returns: + + """ + 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() + + def distance_to(self, other: Shape | VectorLike) -> float: + """Minimal distance between two shapes""" + return self.distance_to_with_closest_points(other)[0] + + def distance_to_with_closest_points( + 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 not other): + raise ValueError("Cannot calculate distance to or from an empty shape") + + if isinstance(other, Shape): + topods_shape = tcast(TopoDS_Shape, other.wrapped) + else: + vec = Vector(other) + topods_shape = BRepBuilderAPI_MakeVertex( + gp_Pnt(vec.X, vec.Y, vec.Z) + ).Vertex() + + dist_calc = BRepExtrema_DistShapeShape() + dist_calc.LoadS1(self.wrapped) + dist_calc.LoadS2(topods_shape) + dist_calc.Perform() + return ( + dist_calc.Value(), + Vector(dist_calc.PointOnShape1(1)), + Vector(dist_calc.PointOnShape2(1)), + ) + + def distances(self, *others: Shape) -> Iterator[float]: + """Minimal distances to between self and other shapes + + Args: + *others: Shape: + + Returns: + + """ + 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 not other_shape: + raise ValueError("Cannot calculate distance to or from an empty shape") + dist_calc.LoadS2(other_shape.wrapped) + dist_calc.Perform() + + yield dist_calc.Value() + + 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 - subclasses may override""" + 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: + return [] + return _topods_entities(self.wrapped, topo_type) + + 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_intersected_by_axis( + self, + axis: Axis, + tol: float = 1e-4, + ) -> ShapeList[Face]: + """Line Intersection + + Computes the intersections between the provided axis and the faces of this Shape + + Args: + axis (Axis): Axis on which the intersection line rests + tol (float, optional): Intersection tolerance. Defaults to 1e-4. + + Returns: + list[Face]: A list of intersected faces sorted by distance from axis.position + """ + if self._wrapped is None: + return ShapeList() + + line = gce_MakeLin(axis.wrapped).Value() + + intersect_maker = BRepIntCurveSurface_Inter() + intersect_maker.Init(self.wrapped, line, tol) + + faces_dist = [] # using a list instead of a dictionary to be able to sort it + while intersect_maker.More(): + inter_pt = intersect_maker.Pnt() + + distance = axis.position.to_pnt().SquareDistance(inter_pt) + + faces_dist.append( + ( + intersect_maker.Face(), + abs(distance), + ) + ) # will sort all intersected faces by distance whatever the direction is + + intersect_maker.Next() + + faces_dist.sort(key=lambda x: x[1]) + faces = [face[0] for face in faces_dist] + + return ShapeList([self.__class__.cast(face) for face in faces]) + + def fix(self) -> Self: + """fix - try to fix shape if not valid""" + if self._wrapped is None: + return self + if not self.is_valid: + shape_copy: Shape = copy.deepcopy(self, None) + shape_copy.wrapped = tcast(TOPODS, fix(self.wrapped)) + + return shape_copy + + return self + + def fuse( + self, *to_fuse: Shape, glue: bool = False, tol: float | None = None + ) -> Self | ShapeList[Self]: + """fuse + + Fuse a sequence of shapes into a single shape. + + Args: + to_fuse (sequence Shape): shapes to fuse + glue (bool, optional): performance improvement for some shapes. Defaults to False. + tol (float, optional): tolerance. Defaults to None. + + Returns: + Self | ShapeList[Self]: Resulting object may be of a different class than self + or a ShapeList if multiple non-Compound object created + + """ + + fuse_op = BRepAlgoAPI_Fuse() + if glue: + fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift) + if tol: + fuse_op.SetFuzzyValue(tol) + + return_value = self._bool_op((self,), to_fuse, fuse_op) + + return return_value + + # def _entities_from( + # 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: + # return {} + + # res = TopTools_IndexedDataMapOfShapeListOfShape() + + # TopExp.MapShapesAndAncestors_s( + # self.wrapped, + # Shape.inverse_shape_LUT[child_type], + # Shape.inverse_shape_LUT[parent_type], + # res, + # ) + + # out: Dict[Shape, list[Shape]] = {} + # for i in range(1, res.Extent() + 1): + # out[self.__class__.cast(res.FindKey(i))] = [ + # self.__class__.cast(el) for el in res.FindFromIndex(i) + # ] + + # return out + + def get_top_level_shapes(self) -> ShapeList[Shape]: + """ + Retrieve the first level of child shapes from the shape. + + This method collects all the non-compound shapes directly contained in the + current shape. If the wrapped shape is a `TopoDS_Compound`, it traverses + its immediate children and collects all shapes that are not further nested + compounds. Nested compounds are traversed to gather their non-compound elements + without returning the nested compound itself. + + Returns: + ShapeList[Shape]: A list of all first-level non-compound child shapes. + + Example: + If the current shape is a compound containing both simple shapes + (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: + return ShapeList() + return ShapeList( + self.__class__.cast(s) for s in get_top_level_topods_shapes(self.wrapped) + ) + + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Self]: + """Intersection of the arguments and this shape + + Args: + to_intersect (sequence of Union[Shape, Axis, Plane]): Shape(s) to + intersect with + + Returns: + None | ShapeList[Self]: Resulting ShapeList may contain different class + than self + """ + + def _to_vertex(vec: Vector) -> Vertex: + """Helper method to convert vector to shape""" + return self.__class__.cast( + downcast( + BRepBuilderAPI_MakeVertex(gp_Pnt(vec.X, vec.Y, vec.Z)).Vertex() + ) + ) + + def _to_edge(axis: Axis) -> Edge: + """Helper method to convert axis to shape""" + return self.__class__.cast( + BRepBuilderAPI_MakeEdge( + Geom_Line( + axis.position.to_pnt(), + axis.direction.to_dir(), + ) + ).Edge() + ) + + def _to_face(plane: Plane) -> Face: + """Helper method to convert plane to shape""" + return self.__class__.cast(BRepBuilderAPI_MakeFace(plane.wrapped).Face()) + + # Convert any geometry objects into their respective topology objects + objs = [] + for obj in to_intersect: + if isinstance(obj, Vector): + objs.append(_to_vertex(obj)) + elif isinstance(obj, Axis): + objs.append(_to_edge(obj)) + elif isinstance(obj, Plane): + objs.append(_to_face(obj)) + elif isinstance(obj, Location): + if obj.wrapped is None: + raise ValueError("Cannot intersect with an empty location") + objs.append(_to_vertex(tcast(Vector, obj.position))) + else: + objs.append(obj) + + # Find the shape intersections + intersect_op = BRepAlgoAPI_Common() + 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 + TShape with the same Locations and Orientations. Also see + :py:meth:`is_same`. + + Args: + other: Shape: + + Returns: + + """ + if self._wrapped is None or not other: + return False + return self.wrapped.IsEqual(other.wrapped) + + 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 + :py:meth:`is_equal` + + Args: + other: Shape: + + Returns: + + """ + if self._wrapped is None or not other: + return False + return self.wrapped.IsSame(other.wrapped) + + def locate(self, loc: Location) -> Self: + """Apply a location in absolute sense to self + + Args: + loc: Location: + + Returns: + + """ + 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") + self.wrapped.Location(loc.wrapped) + + return self + + def located(self, loc: Location) -> Self: + """located + + Apply a location in absolute sense to a copy of self + + Args: + loc (Location): new absolute location + + Returns: + Shape: copy of Shape at location + """ + 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") + shape_copy: Shape = copy.deepcopy(self, None) + shape_copy.wrapped.Location(loc.wrapped) # type: ignore + return shape_copy + + def mesh(self, tolerance: float, angular_tolerance: float = 0.1): + """Generate triangulation if none exists. + + Args: + tolerance: float: + angular_tolerance: float: (Default value = 0.1) + + Returns: + + """ + if self._wrapped is None: + raise ValueError("Cannot mesh an empty shape") + + if not BRepTools.Triangulation_s(self.wrapped, tolerance): + BRepMesh_IncrementalMesh( + self.wrapped, tolerance, True, angular_tolerance, True + ) + + def mirror(self, mirror_plane: Plane | None = None) -> Self: + """ + Applies a mirror transform to this Shape. Does not duplicate objects + about the plane. + + Args: + mirror_plane (Plane): The plane to mirror about. Defaults to Plane.XY + Returns: + The mirrored shape + """ + if not mirror_plane: + mirror_plane = Plane.XY + + if self._wrapped is None: + return self + transformation = gp_Trsf() + transformation.SetMirror( + gp_Ax2(mirror_plane.origin.to_pnt(), mirror_plane.z_dir.to_dir()) + ) + + return self._apply_transform(transformation) + + def move(self, loc: Location) -> Self: + """Apply a location in relative sense (i.e. update current location) to self + + Args: + loc: Location: + + Returns: + + """ + 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") + + self.wrapped.Move(loc.wrapped) + + return self + + def moved(self, loc: Location) -> Self: + """moved + + Apply a location in relative sense (i.e. update current location) to a copy of self + + Args: + loc (Location): new location relative to current location + + Returns: + Shape: copy of Shape moved to relative location + """ + 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") + shape_copy: Shape = copy.deepcopy(self, None) + 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, + path: Wire | Edge, + start: float = 0, + ) -> ShapeList[Face]: + """Projected Faces following the given path on Shape + + Project by positioning each face of to the shape along the path and + projecting onto the surface. + + Note that projection may result in distortion depending on + the shape at a position along the path. + + .. image:: projectText.png + + Args: + faces (Union[list[Face], Compound]): faces to project + path: Path on the Shape to follow + start: Relative location on path to start the faces. Defaults to 0. + + Returns: + The projected faces + + """ + # pylint: disable=too-many-locals + path_length = path.length + # The derived classes of Shape implement center + shape_center = self.center() # pylint: disable=no-member + + if ( + not isinstance(faces, (list, tuple)) + and faces.wrapped is not None + and isinstance(faces.wrapped, TopoDS_Compound) + ): + faces = faces.faces() + + first_face_min_x = faces[0].bounding_box().min.X + + logger.debug("projecting %d face(s)", len(faces)) + + # Position each face normal to the surface along the path and project to the surface + projected_faces = [] + for face in faces: + bbox = face.bounding_box() + face_center_x = (bbox.min.X + bbox.max.X) / 2 + relative_position_on_wire = ( + start + (face_center_x - first_face_min_x) / path_length + ) + path_position = path.position_at(relative_position_on_wire) + path_tangent = path.tangent_at(relative_position_on_wire) + projection_axis = Axis(path_position, shape_center - path_position) + (surface_point, surface_normal) = self.find_intersection_points( + projection_axis + )[0] + surface_normal_plane = Plane( + origin=surface_point, x_dir=path_tangent, z_dir=surface_normal + ) + projection_face: Face = surface_normal_plane.from_local_coords( + face.moved(Location((-face_center_x, 0, 0))) + ) + + logger.debug("projecting face at %0.2f", relative_position_on_wire) + projected_faces.append( + projection_face.project_to_shape(self, surface_normal * -1)[0] + ) + + logger.debug("finished projecting '%d' faces", len(faces)) + + return ShapeList(projected_faces) + + def radius_of_gyration(self, axis: Axis) -> float: + """ + Compute the radius of gyration of the shape about a given axis. + + The radius of gyration represents the distance from the axis at which the entire + mass of the shape could be concentrated without changing its moment of inertia. + It provides insight into how mass is distributed relative to the axis and is + useful in structural analysis, rotational dynamics, and mechanical simulations. + + Args: + axis (Axis): The axis about which the radius of gyration is computed. + The axis should be defined in the same coordinate system + as the shape. + + Returns: + float: The radius of gyration in the same units as the shape's dimensions. + + Example: + >>> obj = MyShape() + >>> axis = Axis((0, 0, 0), (0, 0, 1)) + >>> obj.radius_of_gyration(axis) + 5.47 + + Notes: + - 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) + + def relocate(self, loc: Location): + """Change the location of self while keeping it geometrically similar + + Args: + loc (Location): new location to set for self + """ + 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") + + if self.location != loc: + old_ax = gp_Ax3() + old_ax.Transform(self.location.wrapped.Transformation()) # type: ignore + + new_ax = gp_Ax3() + new_ax.Transform(loc.wrapped.Transformation()) + + trsf = gp_Trsf() + trsf.SetDisplacement(new_ax, old_ax) + builder = BRepBuilderAPI_Transform(self.wrapped, trsf, True, True) + + self.wrapped = tcast(TOPODS, downcast(builder.Shape())) + self.wrapped.Location(loc.wrapped) + + def rotate(self, axis: Axis, angle: float) -> Self: + """rotate a copy + + Rotates a shape around an axis. + + Args: + axis (Axis): rotation Axis + angle (float): angle to rotate, in degrees + + Returns: + a copy of the shape, rotated + """ + transformation = gp_Trsf() + transformation.SetRotation(axis.wrapped, angle * DEG2RAD) + + return self._apply_transform(transformation) + + def scale(self, factor: float) -> Self: + """Scales this shape through a transformation. + + Args: + factor: float: + + Returns: + + """ + + transformation = gp_Trsf() + transformation.SetScale(gp_Pnt(), factor) + + return self._apply_transform(transformation) + + def shell(self) -> Shell | None: + """Return the Shell""" + return Shape.get_single_shape(self, "Shell") + + def shells(self) -> ShapeList[Shell]: + """shells - all the shells in this Shape""" + return Shape.get_shape_list(self, "Shell") + + def show_topology( + self, + limit_class: Literal[ + "Compound", "Edge", "Face", "Shell", "Solid", "Vertex", "Wire" + ] = "Vertex", + show_center: bool | None = None, + ) -> str: + """Display internal topology + + Display the internal structure of a Compound 'assembly' or Shape. Example: + + .. code:: + + >>> c1.show_topology() + + c1 is the root Compound at 0x7f4a4cafafa0, Location(...)) + ├── Solid at 0x7f4a4cafafd0, Location(...)) + ├── c2 is 1st compound Compound at 0x7f4a4cafaee0, Location(...)) + │ ├── Solid at 0x7f4a4cafad00, Location(...)) + │ └── Solid at 0x7f4a11a52790, Location(...)) + └── c3 is 2nd Compound at 0x7f4a4cafad60, Location(...)) + ├── Solid at 0x7f4a11a52700, Location(...)) + └── Solid at 0x7f4a11a58550, Location(...)) + + Args: + limit_class: type of displayed leaf node. Defaults to 'Vertex'. + show_center (bool, optional): If None, shows the Location of Compound 'assemblies' + and the bounding box center of Shapes. True or False forces the display. + Defaults to None. + + Returns: + str: tree representation of internal structure + """ + if ( + self.wrapped is not None + and isinstance(self.wrapped, TopoDS_Compound) + and self.children + ): + show_center = False if show_center is None else show_center + result = Shape._show_tree(self, show_center) + else: + tree = Shape._build_tree( + tcast(TopoDS_Shape, self.wrapped), + tree=[], + limit=Shape.inverse_shape_LUT[limit_class], + ) + show_center = True if show_center is None else show_center + result = Shape._show_tree(tree[0], show_center) + return result + + def solid(self) -> Solid | None: + """Return the Solid""" + return Shape.get_single_shape(self, "Solid") + + def solids(self) -> ShapeList[Solid]: + """solids - all the solids in this Shape""" + return Shape.get_shape_list(self, "Solid") + + @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( + self, perimeter: Edge | Wire, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] + ) -> Face | Shell | ShapeList[Face] | None: + """split_by_perimeter and keep inside or outside""" + + @overload + def split_by_perimeter( + self, perimeter: Edge | Wire, keep: Literal[Keep.BOTH] + ) -> tuple[ + Face | Shell | ShapeList[Face] | None, + Face | Shell | ShapeList[Face] | None, + ]: + """split_by_perimeter and keep inside and outside""" + + @overload + def split_by_perimeter( + self, perimeter: Edge | Wire + ) -> Face | Shell | ShapeList[Face] | None: + """split_by_perimeter and keep inside (default)""" + + def split_by_perimeter(self, perimeter: Edge | Wire, keep: Keep = Keep.INSIDE): + """split_by_perimeter + + Divide the faces of this object into those within the perimeter + and those outside the perimeter. + + Note: this method may fail if the perimeter intersects shape edges. + + Args: + perimeter (Union[Edge,Wire]): closed perimeter + keep (Keep, optional): which object(s) to return. Defaults to Keep.INSIDE. + + Raises: + ValueError: perimeter must be closed + ValueError: keep must be one of Keep.INSIDE|OUTSIDE|BOTH + + Returns: + Union[Face | Shell | ShapeList[Face] | None, + Tuple[Face | Shell | ShapeList[Face] | None]: The result of the split operation. + + - **Keep.INSIDE**: Returns the inside part as a `Shell` or `Face`, or `None` + if no inside part is found. + - **Keep.OUTSIDE**: Returns the outside part as a `Shell` or `Face`, or `None` + if no outside part is found. + - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is + either a `Shell`, `Face`, or `None` if no corresponding part is found. + + """ + + def get(los: TopTools_ListOfShape) -> list: + """Return objects from TopTools_ListOfShape as list""" + shapes = [] + for _ in range(los.Size()): + first = los.First() + if not first.IsNull(): + shapes.append(self.__class__.cast(first)) + los.RemoveFirst() + return shapes + + def process_sides(sides): + """Process sides to determine if it should be None, a single element, + a Shell, or a ShapeList.""" + # if not sides: + # return None + if len(sides) == 1: + return sides[0] + # Attempt to create a shell + potential_shell = _sew_topods_faces([s.wrapped for s in sides]) + if isinstance(potential_shell, TopoDS_Shell): + return self.__class__.cast(potential_shell) + return ShapeList(sides) + + if keep not in {Keep.INSIDE, Keep.OUTSIDE, Keep.BOTH}: + raise ValueError( + "keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH" + ) + + if self._wrapped is None: + raise ValueError("Cannot split an empty shape") + + # Process the perimeter + if not perimeter.is_closed: + 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() + lefts.extend(get(constructor.Left())) + rights.extend(get(constructor.Right())) + + left = process_sides(lefts) + right = process_sides(rights) + + # Is left or right the inside? + perimeter_length = perimeter.length + left_perimeter_length = sum(e.length for e in left.edges()) if left else 0 + right_perimeter_length = sum(e.length for e in right.edges()) if right else 0 + left_inside = abs(perimeter_length - left_perimeter_length) < abs( + perimeter_length - right_perimeter_length + ) + if keep == Keep.BOTH: + return (left, right) if left_inside else (right, left) + if keep == Keep.INSIDE: + return left if left_inside else right + # keep == Keep.OUTSIDE: + return right if left_inside else left + + def tessellate( + self, tolerance: float, angular_tolerance: float = 0.1 + ) -> tuple[list[Vector], list[tuple[int, int, int]]]: + """General triangulated approximation""" + if self._wrapped is None: + raise ValueError("Cannot tessellate an empty shape") + + self.mesh(tolerance, angular_tolerance) + + vertices: list[Vector] = [] + triangles: list[tuple[int, int, int]] = [] + offset = 0 + + for face in self.faces(): + assert face.wrapped is not None + loc = TopLoc_Location() + poly = BRep_Tool.Triangulation_s(face.wrapped, loc) + trsf = loc.Transformation() + reverse = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED + + # add vertices + vertices += [ + Vector(v.X(), v.Y(), v.Z()) + for v in ( + poly.Node(i).Transformed(trsf) for i in range(1, poly.NbNodes() + 1) + ) + ] + # add triangles + triangles += [ + ( + ( + t.Value(1) + offset - 1, + t.Value(3) + offset - 1, + t.Value(2) + offset - 1, + ) + if reverse + else ( + t.Value(1) + offset - 1, + t.Value(2) + offset - 1, + t.Value(3) + offset - 1, + ) + ) + for t in poly.Triangles() + ] + + offset += poly.NbNodes() + + return vertices, triangles + + def to_splines( + self, degree: int = 3, tolerance: float = 1e-3, nurbs: bool = False + ) -> Self: + """to_splines + + 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. + tolerance (float, optional): Approximation tolerance. Defaults to 1e-3. + nurbs (bool, optional): Use rational splines. Defaults to False. + + Returns: + Self: Approximated shape + """ + if self._wrapped is None: + raise ValueError("Cannot approximate an empty shape") + + params = ShapeCustom_RestrictionParameters() + + result = ShapeCustom.BSplineRestriction_s( + self.wrapped, + tolerance, # 3D tolerance + tolerance, # 2D tolerance + degree, + 1, # dummy value, degree is leading + ga.GeomAbs_C0, + ga.GeomAbs_C0, + True, # set degree to be leading + not nurbs, + params, + ) + + return self.__class__.cast(result) + + def transform_geometry(self, t_matrix: Matrix) -> Self: + """Apply affine transform + + WARNING: transform_geometry will sometimes convert lines and circles to + splines, but it also has the ability to handle skew and stretching + transformations. + + If your transformation is only translation and rotation, it is safer to + use :py:meth:`transform_shape`, which doesn't change the underlying type + of the geometry, but cannot handle skew transformations. + + Args: + t_matrix (Matrix): affine transformation matrix + + Returns: + Shape: a copy of the object, but with geometry transformed + """ + if self._wrapped is None: + return self + new_shape = copy.deepcopy(self, None) + transformed = downcast( + BRepBuilderAPI_GTransform(self.wrapped, t_matrix.wrapped, True).Shape() + ) + new_shape.wrapped = tcast(TOPODS, transformed) + + return new_shape + + def transform_shape(self, t_matrix: Matrix) -> Self: + """Apply affine transform without changing type + + Transforms a copy of this Shape by the provided 3D affine transformation matrix. + Note that not all transformation are supported - primarily designed for translation + and rotation. See :transform_geometry: for more comprehensive transformations. + + Args: + t_matrix (Matrix): affine transformation matrix + + Returns: + Shape: copy of transformed shape with all objects keeping their type + """ + if self._wrapped is None: + return self + new_shape = copy.deepcopy(self, None) + transformed = downcast( + BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape() + ) + new_shape.wrapped = tcast(TOPODS, transformed) + + return new_shape + + def transformed( + self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0) + ) -> Self: + """Transform Shape + + Rotate and translate the Shape by the three angles (in degrees) and offset. + + Args: + rotate (VectorLike, optional): 3-tuple of angles to rotate, in degrees. + Defaults to (0, 0, 0). + offset (VectorLike, optional): 3-tuple to offset. Defaults to (0, 0, 0). + + Returns: + Shape: transformed object + + """ + # Convert to a Vector of radians + rotate_vector = Vector(rotate).multiply(DEG2RAD) + # Compute rotation matrix. + t_rx = gp_Trsf() + t_rx.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), rotate_vector.X) + t_ry = gp_Trsf() + t_ry.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), rotate_vector.Y) + t_rz = gp_Trsf() + t_rz.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), rotate_vector.Z) + t_o = gp_Trsf() + t_o.SetTranslation(Vector(offset).wrapped) + return self._apply_transform(t_o * t_rx * t_ry * t_rz) + + def translate(self, vector: VectorLike) -> Self: + """Translates this shape through a transformation. + + Args: + vector: VectorLike: + + Returns: + + """ + + transformation = gp_Trsf() + transformation.SetTranslation(Vector(vector).wrapped) + + return self._apply_transform(transformation) + + 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 _apply_transform(self, transformation: gp_Trsf) -> Self: + """Private Apply Transform + + Apply the provided transformation matrix to a copy of Shape + + Args: + transformation (gp_Trsf): transformation matrix + + Returns: + Shape: copy of transformed Shape + """ + if self._wrapped is None: + return self + shape_copy: Shape = copy.deepcopy(self, None) + transformed_shape = BRepBuilderAPI_Transform( + self.wrapped, + transformation, + True, + ).Shape() + shape_copy.wrapped = tcast(TOPODS, downcast(transformed_shape)) + return shape_copy + + def _bool_op( + self, + args: Iterable[Shape], + tools: Iterable[Shape], + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, + ) -> Self | ShapeList[Self]: + """Generic boolean operation + + Args: + args: Iterable[Shape]: + tools: Iterable[Shape]: + operation: Union[BRepAlgoAPI_BooleanOperation: + BRepAlgoAPI_Splitter]: + + Returns: + + """ + 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 + if hasattr(type(s), "order") + } + highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1] + + # The base of the operation + base = args[0] if isinstance(args, (list, tuple)) else args + + arg = TopTools_ListOfShape() + for obj in args: + if obj.wrapped is not None: + arg.Append(obj.wrapped) + + tool = TopTools_ListOfShape() + for obj in tools: + if obj.wrapped is not None: + tool.Append(obj.wrapped) + + operation.SetArguments(arg) + operation.SetTools(tool) + + operation.SetRunParallel(True) + operation.Build() + + topo_result = downcast(operation.Shape()) + + # Clean + if SkipClean.clean: + upgrader = ShapeUpgrade_UnifySameDomain(topo_result, True, True, True) + upgrader.AllowInternalEdges(False) + try: + upgrader.Build() + topo_result = downcast(upgrader.Shape()) + except Exception: + warnings.warn("Boolean operation unable to clean", stacklevel=2) + + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(topo_result, TopoDS_Compound): + topo_result = unwrap_topods_compound(topo_result, True) + + if isinstance(topo_result, TopoDS_Compound) and highest_order[1] != 4: + results = ShapeList( + highest_order[0].cast(s) + for s in get_top_level_topods_shapes(topo_result) + ) + for result in results: + base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) + return results + + result = highest_order[0].cast(topo_result) + base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) + + return result + + def _ocp_section( + self: Shape, other: Vertex | Edge | Wire | Face + ) -> tuple[ShapeList[Vertex], ShapeList[Edge]]: + """_ocp_section + + Create a BRepAlgoAPI_Section object + + The algorithm is to build a Section operation between arguments and tools. + The result of Section operation consists of vertices and edges. The result + of Section operation contains: + - new vertices that are subjects of V/V, E/E, E/F, F/F interferences + - vertices that are subjects of V/E, V/F interferences + - new edges that are subjects of F/F interferences + - edges that are Common Blocks + + + Args: + other (Union[Vertex, Edge, Wire, Face]): shape to section with + + Returns: + tuple[ShapeList[Vertex], ShapeList[Edge]]: section results + """ + if self._wrapped is None or not other: + return (ShapeList(), ShapeList()) + + 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: TopoDS_Shape = section.Shape() + + 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: 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 (ShapeList(set(vertices)), edges) + + def _repr_html_(self): + """Jupyter 3D representation support""" + + from build123d.jupyter_tools import shape_to_html + + 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""" + + # ---- Instance Methods ---- + + @abstractmethod + def __eq__(self, other: Any) -> bool: ... + + @abstractmethod + 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=SupportsLessThan) + + +class ShapePredicate(Protocol): + """Predicate for shape filters""" + + # ---- Instance Methods ---- + + def __call__(self, shape: Shape) -> bool: ... + + +class GroupBy(Generic[T, K]): + """Result of a Shape.groupby operation. Groups can be accessed by index or key""" + + # ---- Constructor ---- + + def __init__( + self, + key_f: Callable[[T], K], + shapelist: Iterable[T], + *, + reverse: bool = False, + ): + # can't be a dict because K may not be hashable + self.key_to_group_index: list[tuple[K, int]] = [] + self.groups: list[ShapeList[T]] = [] + self.key_f = key_f + + for i, (key, shapegroup) in enumerate( + itertools.groupby(sorted(shapelist, key=key_f, reverse=reverse), key=key_f) + ): + self.groups.append(ShapeList(shapegroup)) + self.key_to_group_index.append((key, i)) + + # ---- Instance Methods ---- + + def __getitem__(self, key: int): + return self.groups[key] + + def __iter__(self): + return iter(self.groups) + + def __len__(self): + return len(self.groups) + + def __repr__(self): + return repr(ShapeList(self)) + + def __str__(self): + return pretty(self) + + def group(self, key: K): + """Select group by key""" + for k, i in self.key_to_group_index: + if key == k: + return self.groups[i] + raise KeyError(key) + + def group_for(self, shape: T): + """Select group by shape""" + return self.group(self.key_f(shape)) + + def _repr_pretty_( + self, printer: RepresentationPrinter, cycle: bool = False + ) -> None: + """ + Render a formatted representation of the object for pretty-printing in + interactive environments. + + Args: + printer (PrettyPrinter): The pretty printer instance handling the output. + cycle (bool): Indicates if a reference cycle is detected to + prevent infinite recursion. + """ + if cycle: + printer.text("(...)") + else: + with printer.group(1, "[", "]"): + for idx, item in enumerate(self): + if idx: + printer.text(",") + printer.breakable() + printer.pretty(item) + + +class ShapeList(list[T]): + """Subclass of list with custom filter and sort methods appropriate to CAD""" + + # ---- Properties ---- + + # pylint: disable=too-many-public-methods + + @property + def first(self) -> T: + """First element in the ShapeList""" + return self[0] + + @property + def last(self) -> T: + """Last element in the ShapeList""" + return self[-1] + + # ---- Instance Methods ---- + + 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 &""" + return ShapeList(set(self) & set(other)) + + def __eq__(self, other: object) -> bool: + """ShapeLists equality operator ==""" + return ( + set(self) == set(other) if isinstance(other, ShapeList) else NotImplemented # type: ignore + ) + + @overload + def __getitem__(self, key: SupportsIndex) -> T: ... + + @overload + def __getitem__(self, key: slice) -> ShapeList[T]: ... + + def __getitem__(self, key: SupportsIndex | slice) -> T | ShapeList[T]: + """Return slices of ShapeList as ShapeList""" + if isinstance(key, slice): + return ShapeList(list(self).__getitem__(key)) + return list(self).__getitem__(key) + + def __gt__(self, sort_by: Axis | SortBy = Axis.Z) -> ShapeList[T]: # type: ignore + """Sort operator >""" + return self.sort_by(sort_by) + + def __lshift__(self, group_by: Axis | SortBy = Axis.Z) -> ShapeList[T]: + """Group and select smallest group operator <<""" + return self.group_by(group_by)[0] + + def __lt__(self, sort_by: Axis | SortBy = Axis.Z) -> ShapeList[T]: # type: ignore + """Reverse sort operator <""" + return self.sort_by(sort_by, reverse=True) + + # Normally implementing __eq__ is enough, but ShapeList subclasses list, + # which already implements __ne__, so we need to override it, too + def __ne__(self, other: ShapeList) -> bool: # type: ignore + """ShapeLists inequality operator !=""" + return ( + set(self) != set(other) if isinstance(other, ShapeList) else NotImplemented + ) + + def __or__(self, filter_by: Axis | GeomType = Axis.Z) -> ShapeList[T]: + """Filter by axis or geomtype operator |""" + return self.filter_by(filter_by) + + def __rshift__(self, group_by: Axis | SortBy = Axis.Z) -> ShapeList[T]: + """Group and select largest group operator >>""" + return self.group_by(group_by)[-1] + + def __sub__(self, other: ShapeList) -> ShapeList[T]: + """Differences between two ShapeLists operator -""" + return ShapeList(set(self) - set(other)) + + def center(self) -> Vector: + """The average of the center of objects within the ShapeList""" + if not self: + return Vector(0, 0, 0) + + total_center = sum((o.center() for o in self), Vector(0, 0, 0)) + return total_center / len(self) + + def compound(self) -> Compound: + """Return the Compound""" + compounds = self.compounds() + compound_count = len(compounds) + if compound_count != 1: + warnings.warn( + f"Found {compound_count} compounds, returning first", stacklevel=2 + ) + return compounds[0] + + def compounds(self) -> ShapeList[Compound]: + """compounds - all the compounds in this ShapeList""" + return ShapeList([c for shape in self for c in shape.compounds()]) # type: ignore + + def edge(self) -> Edge: + """Return the Edge""" + edges = self.edges() + edge_count = len(edges) + if edge_count != 1: + warnings.warn(f"Found {edge_count} edges, returning first", stacklevel=2) + return edges[0] + + def edges(self) -> ShapeList[Edge]: + """edges - all the edges in this ShapeList""" + return ShapeList([e for shape in self for e in shape.edges()]) # type: ignore + + def face(self) -> Face: + """Return the Face""" + faces = self.faces() + face_count = len(faces) + if face_count != 1: + msg = f"Found {face_count} faces, returning first" + warnings.warn(msg, stacklevel=2) + return faces[0] + + def faces(self) -> ShapeList[Face]: + """faces - all the faces in this ShapeList""" + return ShapeList([f for shape in self for f in shape.faces()]) # type: ignore + + def filter_by( + self, + filter_by: ShapePredicate | Axis | Plane | GeomType | property, + reverse: bool = False, + tolerance: float = 1e-5, + ) -> ShapeList[T]: + """filter by Axis, Plane, or GeomType + + Either: + - filter objects of type planar Face or linear Edge by their normal or tangent + (respectively) and sort the results by the given axis, or + - filter the objects by the provided type. Note that not all types apply to all + objects. + + Args: + filter_by (Union[Axis,Plane,GeomType]): axis, plane, or geom type to filter + and possibly sort by. Filtering by a plane returns faces/edges parallel + to that plane. + reverse (bool, optional): invert the geom type filter. Defaults to False. + tolerance (float, optional): maximum deviation from axis. Defaults to 1e-5. + + Raises: + ValueError: Invalid filter_by type + + Returns: + ShapeList: filtered list of objects + """ + + # could be moved out maybe? + def axis_parallel_predicate(axis: Axis, tolerance: float): + def pred(shape: Shape): + if shape.is_planar_face: + assert shape.wrapped is not None and isinstance( + shape.wrapped, TopoDS_Face + ) + gp_pnt = gp_Pnt() + surface_normal = gp_Vec() + u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped) + BRepGProp_Face(shape.wrapped).Normal( + u_val, v_val, gp_pnt, surface_normal + ) + normalized_surface_normal = Vector( + surface_normal.X(), surface_normal.Y(), surface_normal.Z() + ).normalized() + shape_axis = Axis(shape.center(), normalized_surface_normal) + elif ( + isinstance(shape.wrapped, TopoDS_Edge) + and shape.geom_type == GeomType.LINE + ): + curve = shape.geom_adaptor() + umin = curve.FirstParameter() + tmp = gp_Pnt() + res = gp_Vec() + curve.D1(umin, tmp, res) + start_pos = Vector(tmp) + start_dir = Vector(gp_Dir(res)) + shape_axis = Axis(start_pos, start_dir) + else: + return False + return axis.is_parallel(shape_axis, tolerance) + + return pred + + def plane_parallel_predicate(plane: Plane, tolerance: float): + plane_axis = Axis(plane.origin, plane.z_dir) + + def pred(shape: Shape): + + if shape.is_planar_face: + assert shape.wrapped is not None and isinstance( + shape.wrapped, TopoDS_Face + ) + gp_pnt: gp_Pnt = gp_Pnt() + surface_normal: gp_Vec = gp_Vec() + u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped) + BRepGProp_Face(shape.wrapped).Normal( + u_val, v_val, gp_pnt, surface_normal + ) + normalized_surface_normal = Vector(surface_normal).normalized() + shape_axis = Axis(shape.center(), normalized_surface_normal) + return plane_axis.is_parallel(shape_axis, tolerance) + if isinstance(shape.wrapped, TopoDS_Wire): + return all(pred(e) for e in shape.edges()) + if isinstance(shape.wrapped, TopoDS_Edge): + 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 + ) + return False + return False + + return pred + + # convert input to callable predicate + if callable(filter_by): + predicate = filter_by + elif isinstance(filter_by, property): + + def predicate(obj): + return filter_by.__get__(obj) + + elif isinstance(filter_by, Axis): + predicate = axis_parallel_predicate(filter_by, tolerance=tolerance) + elif isinstance(filter_by, Plane): + predicate = plane_parallel_predicate(filter_by, tolerance=tolerance) + elif isinstance(filter_by, GeomType): + + def predicate(obj): + return obj.geom_type == filter_by + + else: + raise ValueError(f"Unsupported filter_by predicate: {filter_by}") + + # final predicate is negated if `reverse=True` + if reverse: + + def actual_predicate(shape): + return not predicate(shape) + + else: + actual_predicate = predicate + + return ShapeList(filter(actual_predicate, self)) + + def filter_by_position( + self, + axis: Axis, + minimum: float, + maximum: float, + inclusive: tuple[bool, bool] = (True, True), + ) -> ShapeList[T]: + """filter by position + + Filter and sort objects by the position of their centers along given axis. + min and max values can be inclusive or exclusive depending on the inclusive tuple. + + Args: + axis (Axis): axis to sort by + minimum (float): minimum value + maximum (float): maximum value + inclusive (tuple[bool, bool], optional): include min,max values. + Defaults to (True, True). + + Returns: + ShapeList: filtered object list + """ + if inclusive == (True, True): + objects = filter( + lambda o: minimum + <= Plane(axis).to_local_coords(o).center().Z + <= maximum, + self, + ) + elif inclusive == (True, False): + objects = filter( + lambda o: minimum + <= Plane(axis).to_local_coords(o).center().Z + < maximum, + self, + ) + elif inclusive == (False, True): + objects = filter( + lambda o: minimum + < Plane(axis).to_local_coords(o).center().Z + <= maximum, + self, + ) + elif inclusive == (False, False): + objects = filter( + lambda o: minimum < Plane(axis).to_local_coords(o).center().Z < maximum, + self, + ) + + return ShapeList(objects).sort_by(axis) + + def group_by( + self, + group_by: ( + Callable[[Shape], K] | Axis | Edge | Wire | SortBy | property + ) = Axis.Z, + reverse=False, + tol_digits=6, + ) -> GroupBy[T, K]: + """group by + + Group objects by provided criteria and then sort the groups according to the criteria. + Note that not all group_by criteria apply to all objects. + + Args: + group_by (SortBy, optional): group and sort criteria. Defaults to Axis.Z. + reverse (bool, optional): flip order of sort. Defaults to False. + tol_digits (int, optional): Tolerance for building the group keys by + round(key, tol_digits) + + Returns: + GroupBy[K, ShapeList]: sorted list of ShapeLists + """ + + if isinstance(group_by, Axis): + if group_by.wrapped is None: + raise ValueError("Cannot group by an empty axis") + assert group_by.location is not None + axis_as_location = group_by.location.inverse() + + def key_f(obj): + return round( + (axis_as_location * Location(obj.center())).position.Z, + tol_digits, + ) + + elif not group_by: + raise ValueError("Cannot group by an empty object") + + 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) + + elif isinstance(group_by, SortBy): + if group_by == SortBy.LENGTH: + + def key_f(obj): + return round(obj.length, tol_digits) + + elif group_by == SortBy.RADIUS: + + def key_f(obj): + return round(obj.radius, tol_digits) + + elif group_by == SortBy.DISTANCE: + + def key_f(obj): + return round(obj.center().length, tol_digits) + + elif group_by == SortBy.AREA: + + def key_f(obj): + return round(obj.area, tol_digits) + + elif group_by == SortBy.VOLUME: + + def key_f(obj): + return round(obj.volume, tol_digits) + + elif callable(group_by): + key_f = group_by + + elif isinstance(group_by, property): + key_f = group_by.__get__ + + else: + raise ValueError(f"Unsupported group_by function: {group_by}") + + return GroupBy(key_f, self, reverse=reverse) + + def shell(self) -> Shell: + """Return the Shell""" + shells = self.shells() + shell_count = len(shells) + if shell_count != 1: + warnings.warn(f"Found {shell_count} shells, returning first", stacklevel=2) + return shells[0] + + def shells(self) -> ShapeList[Shell]: + """shells - all the shells in this ShapeList""" + return ShapeList([s for shape in self for s in shape.shells()]) # type: ignore + + def solid(self) -> Solid: + """Return the Solid""" + solids = self.solids() + solid_count = len(solids) + if solid_count != 1: + warnings.warn(f"Found {solid_count} solids, returning first", stacklevel=2) + return solids[0] + + def solids(self) -> ShapeList[Solid]: + """solids - all the solids in this ShapeList""" + return ShapeList([s for shape in self for s in shape.solids()]) # type: ignore + + def sort_by( + self, + sort_by: Axis | Callable[[T], K] | Edge | Wire | SortBy | property = Axis.Z, + reverse: bool = False, + ) -> ShapeList[T]: + """sort by + + Sort objects by provided criteria. Note that not all sort_by criteria apply to all + objects. + + Args: + sort_by (Axis | Callable[[T], K] | Edge | Wire | SortBy, optional): sort criteria. + Defaults to Axis.Z. + reverse (bool, optional): flip order of sort. Defaults to False. + + Raises: + ValueError: Cannot sort by an empty axis + ValueError: Cannot sort by an empty object + ValueError: Invalid sort_by criteria provided + + Returns: + ShapeList: sorted list of objects + """ + + if callable(sort_by): + # If a callable is provided, use it directly as the key + objects = sorted(self, key=sort_by, reverse=reverse) + + elif isinstance(sort_by, property): + objects = sorted(self, key=sort_by.__get__, reverse=reverse) + + elif isinstance(sort_by, Axis): + if sort_by.wrapped is None: + raise ValueError("Cannot sort by an empty axis") + assert sort_by.location is not None + axis_as_location = sort_by.location.inverse() + objects = sorted( + self, + key=lambda o: tcast( + Location, (axis_as_location * Location(o.center())) + ).position.Z, + reverse=reverse, + ) + 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) + ): + + 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 + ) + + elif isinstance(sort_by, SortBy): + if sort_by == SortBy.LENGTH: + objects = sorted( + self, + key=lambda obj: obj.length, + reverse=reverse, + ) + elif sort_by == SortBy.RADIUS: + with_radius = [obj for obj in self if hasattr(obj, "radius")] + objects = sorted( + with_radius, + key=lambda obj: obj.radius, # type: ignore + reverse=reverse, + ) + elif sort_by == SortBy.DISTANCE: + objects = sorted( + self, + key=lambda obj: obj.center().length, + reverse=reverse, + ) + elif sort_by == SortBy.AREA: + with_area = [obj for obj in self if hasattr(obj, "area")] + objects = sorted( + with_area, + key=lambda obj: obj.area, # type: ignore + reverse=reverse, + ) + elif sort_by == SortBy.VOLUME: + with_volume = [obj for obj in self if hasattr(obj, "volume")] + objects = sorted( + with_volume, + key=lambda obj: obj.volume, # type: ignore + reverse=reverse, + ) + else: + raise ValueError("Invalid sort_by criteria provided") + + return ShapeList(objects) + + def sort_by_distance( + self, other: Shape | VectorLike, reverse: bool = False + ) -> ShapeList[T]: + """Sort by distance + + Sort by minimal distance between objects and other + + Args: + other (Union[Shape,VectorLike]): reference object + reverse (bool, optional): flip order of sort. Defaults to False. + + Returns: + ShapeList: Sorted shapes + """ + distances = sorted( + [(obj.distance_to(other), obj) for obj in self], # type: ignore + key=lambda obj: obj[0], + reverse=reverse, + ) + return ShapeList([obj[1] for obj in distances]) + + def vertex(self) -> Vertex: + """Return the Vertex""" + vertices = self.vertices() + vertex_count = len(vertices) + if vertex_count != 1: + warnings.warn( + f"Found {vertex_count} vertices, returning first", stacklevel=2 + ) + return vertices[0] + + def vertices(self) -> ShapeList[Vertex]: + """vertices - all the vertices in this ShapeList""" + return ShapeList([v for shape in self for v in shape.vertices()]) # type: ignore + + def wire(self) -> Wire: + """Return the Wire""" + wires = self.wires() + wire_count = len(wires) + if wire_count != 1: + warnings.warn(f"Found {wire_count} wires, returning first", stacklevel=2) + return wires[0] + + def wires(self) -> ShapeList[Wire]: + """wires - all the wires in this ShapeList""" + return ShapeList([w for shape in self for w in shape.wires()]) # type: ignore + + +class Joint(ABC): + """Joint + + Abstract Base Joint class - used to join two components together + + Args: + parent (Union[Solid, Compound]): object that joint to bound to + + Attributes: + label (str): user assigned label + parent (Shape): object joint is bound to + connected_to (Joint): joint that is connect to this joint + + """ + + # ---- Constructor ---- + + def __init__(self, label: str, parent: BuildPart | Solid | Compound): + self.label = label + self.parent = parent + self.connected_to: Joint | None = None + + # ---- Properties ---- + + @property + @abstractmethod + def location(self) -> Location: + """Location of joint""" + + @property + @abstractmethod + def symbol(self) -> Compound: + """A CAD object positioned in global space to illustrate the joint""" + + # ---- Instance Methods ---- + + @abstractmethod + def connect_to(self, *args, **kwargs): + """All derived classes must provide a connect_to method""" + + @abstractmethod + def relative_to(self, *args, **kwargs) -> Location: + """Return relative location to another joint""" + + def _connect_to(self, other: Joint, **kwargs): # pragma: no cover + """Connect Joint self by repositioning other""" + + if not isinstance(other, Joint): + raise TypeError(f"other must of type Joint not {type(other)}") + if self.parent.location is None: + raise ValueError("Parent location is not set") + relative_location = self.relative_to(other, **kwargs) + other.parent.locate(tcast(Location, self.parent.location * relative_location)) + self.connected_to = other + + +class SkipClean: + """Skip clean context for use in operator driven code where clean=False wouldn't work""" + + clean = True + # ---- Instance Methods ---- + + def __enter__(self): + SkipClean.clean = False + + def __exit__(self, exception_type, exception_value, traceback): + SkipClean.clean = True + + +def _sew_topods_faces(faces: Iterable[TopoDS_Face]) -> TopoDS_Shape: + """Sew faces into a shell if possible""" + shell_builder = BRepBuilderAPI_Sewing() + for face in faces: + shell_builder.Add(face) + shell_builder.Perform() + 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 + + explorer = TopExp_Explorer(shape, Shape.inverse_shape_LUT[topo_type]) + + while explorer.More(): + item = explorer.Current() + out[hash(item)] = item # needed to avoid pseudo-duplicate entities + explorer.Next() + + return list(out.values()) + + +def _topods_face_normal_at(face: TopoDS_Face, surface_point: gp_Pnt) -> Vector: + """Find the normal at a point on surface""" + surface = BRep_Tool.Surface_s(face) + + # project point on surface + projector = GeomAPI_ProjectPointOnSurf(surface_point, surface) + u_val, v_val = projector.LowerDistanceParameters() + + gp_pnt = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(face).Normal(u_val, v_val, gp_pnt, normal) + + return Vector(normal).normalized() + + +def downcast(obj: TopoDS_Shape) -> TopoDS_Shape: + """Downcasts a TopoDS object to suitable specialized type + + Args: + obj: TopoDS_Shape: + + Returns: + + """ + + f_downcast: Any = Shape.downcast_LUT[shapetype(obj)] + return_value = f_downcast(obj) + + return return_value + + +def fix(obj: TopoDS_Shape) -> TopoDS_Shape: + """Fix a TopoDS object to suitable specialized type + + Args: + obj: TopoDS_Shape: + + Returns: + + """ + + shape_fix = ShapeFix_Shape(obj) + shape_fix.Perform() + + return downcast(shape_fix.Shape()) + + +def get_top_level_topods_shapes( + topods_shape: TopoDS_Shape | None, +) -> list[TopoDS_Shape]: + """ + Retrieve the first level of child shapes from the shape. + + This method collects all the non-compound shapes directly contained in the + current shape. If the wrapped shape is a `TopoDS_Compound`, it traverses + its immediate children and collects all shapes that are not further nested + compounds. Nested compounds are traversed to gather their non-compound elements + without returning the nested compound itself. + + Returns: + list[TopoDS_Shape]: A list of all first-level non-compound child shapes. + + Example: + If the current shape is a compound containing both simple shapes + (e.g., edges, vertices) and other compounds, the method returns a list + of only the simple shapes directly contained at the top level. + """ + if topods_shape is None: + return ShapeList() + + first_level_shapes = [] + stack = [topods_shape] + + while stack: + current_shape = stack.pop() + if isinstance(current_shape, TopoDS_Compound): + iterator = TopoDS_Iterator() + iterator.Initialize(current_shape) + while iterator.More(): + child_shape = downcast(iterator.Value()) + if isinstance(child_shape, TopoDS_Compound): + # Traverse further into the compound + stack.append(child_shape) + else: + # Add non-compound shape + first_level_shapes.append(child_shape) + iterator.Next() + else: + first_level_shapes.append(current_shape) + + return first_level_shapes + + +def shapetype(obj: TopoDS_Shape | None) -> TopAbs_ShapeEnum: + """Return TopoDS_Shape's TopAbs_ShapeEnum""" + if obj is None or obj.IsNull(): + raise ValueError("Null TopoDS_Shape object") + + return obj.ShapeType() + + +def topods_dim(topods: TopoDS_Shape) -> int | None: + """Return the dimension of this TopoDS_Shape""" + shape_dim_map = { + (TopoDS_Vertex,): 0, + (TopoDS_Edge, TopoDS_Wire): 1, + (TopoDS_Face, TopoDS_Shell): 2, + (TopoDS_Solid,): 3, + } + + for shape_types, dim in shape_dim_map.items(): + if isinstance(topods, shape_types): + return dim + + if isinstance(topods, TopoDS_Compound): + sub_dims = {topods_dim(s) for s in get_top_level_topods_shapes(topods)} + return sub_dims.pop() if len(sub_dims) == 1 else None + + return None + + +def unwrap_topods_compound( + compound: TopoDS_Compound, fully: bool = True +) -> TopoDS_Compound | TopoDS_Shape: + """Strip unnecessary Compound wrappers + + Args: + compound (TopoDS_Compound): The TopoDS_Compound to unwrap. + fully (bool, optional): return base shape without any TopoDS_Compound + wrappers (otherwise one TopoDS_Compound is left). Defaults to True. + + Returns: + TopoDS_Compound | TopoDS_Shape: base shape + """ + if compound.NbChildren() == 1: + iterator = TopoDS_Iterator(compound) + single_element = downcast(iterator.Value()) + + # If the single element is another TopoDS_Compound, unwrap it recursively + if isinstance(single_element, TopoDS_Compound): + return unwrap_topods_compound(single_element, fully) + + return single_element if fully else compound + + # If there are no elements or more than one element, return TopoDS_Compound + return compound diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py new file mode 100644 index 0000000..5b0fb30 --- /dev/null +++ b/src/build123d/topology/three_d.py @@ -0,0 +1,1666 @@ +""" +build123d topology + +name: three_d.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module defines the `Solid` class and associated methods for creating, manipulating, and +querying three-dimensional solid geometries in the build123d CAD system. It provides powerful tools +for constructing complex 3D models, including operations such as extrusion, sweeping, filleting, +chamfering, and Boolean operations. The module integrates with OpenCascade to leverage its robust +geometric kernel for precise 3D modeling. + +Key Features: +- **Solid Class**: + - Represents closed, bounded 3D shapes with methods for volume calculation, bounding box + computation, and validity checks. + - Includes constructors for primitive solids (e.g., box, cylinder, cone, torus) and advanced + operations like lofting, revolving, and sweeping profiles along paths. + +- **Mixin3D**: + - Adds shared methods for operations like filleting, chamfering, splitting, and hollowing solids. + - Supports advanced workflows such as finding maximum fillet radii and extruding with rotation or + taper. + +- **Boolean Operations**: + - Provides utilities for union, subtraction, and intersection of solids. + +- **Thickening and Offsetting**: + - Allows transformation of faces or shells into solids through thickening. + +This module is essential for generating and manipulating complex 3D geometries in the build123d +library, offering a comprehensive API for CAD modeling. + +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 collections.abc import Iterable, Sequence +from math import radians, cos, tan +from typing import TYPE_CHECKING, Literal +from typing_extensions import Self + +import OCP.TopAbs as ta +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_DraftAngle, + BRepOffsetAPI_MakePipeShell, + BRepOffsetAPI_MakeThickSolid, +) +from OCP.BRepPrimAPI import ( + BRepPrimAPI_MakeBox, + BRepPrimAPI_MakeCone, + BRepPrimAPI_MakeCylinder, + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakeSphere, + BRepPrimAPI_MakeTorus, + BRepPrimAPI_MakeWedge, +) +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, Standard_TypeMismatch +from OCP.StdFail import StdFail_NotDone +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_Shell, + TopoDS_Solid, + TopoDS_Wire, +) +from OCP.gp import gp_Ax2, gp_Pnt +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 .one_d import Edge, Wire, Mixin1D +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, + find_max_dimension, + _make_loft, + _make_topods_compound_from_shapes, +) +from .zero_d import Vertex + + +if TYPE_CHECKING: # pragma: no cover + from .composite import Compound # pylint: disable=R0801 + + +class Mixin3D(Shape[TOPODS]): + """Additional methods to add to 3D Shape classes""" + + find_intersection_points = Mixin2D.find_intersection_points + + # ---- Properties ---- + + @property + def _dim(self) -> int | None: + """Dimension of Solids""" + return 3 + + # ---- Class Methods ---- + + @classmethod + def cast(cls, obj: TopoDS_Shape) -> Self: + "Returns the right type of wrapper, given a OCCT object" + + # define the shape lookup table for casting + constructor_lut = { + ta.TopAbs_VERTEX: Vertex, + ta.TopAbs_EDGE: Edge, + ta.TopAbs_WIRE: Wire, + ta.TopAbs_FACE: Face, + ta.TopAbs_SHELL: Shell, + ta.TopAbs_SOLID: Solid, + } + + shape_type = shapetype(obj) + # NB downcast is needed to handle TopoDS_Shape types + return constructor_lut[shape_type](downcast(obj)) + + @classmethod + def extrude( + cls, obj: Shape, direction: VectorLike + ) -> Edge | Face | Shell | Solid | Compound: + """Unused - only here because Mixin1D is a subclass of Shape""" + return NotImplemented + + # ---- Instance Methods ---- + + def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: + """Return center of object + + Find center of object + + Args: + center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS. + + Raises: + ValueError: Center of GEOMETRY is not supported for this object + NotImplementedError: Unable to calculate center of mass of this object + + Returns: + Vector: center + """ + if center_of == CenterOf.GEOMETRY: + raise ValueError("Center of GEOMETRY is not supported for this object") + 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: + middle = self.bounding_box().center() + return middle + + def chamfer( + self, + length: float, + length2: float | None, + edge_list: Iterable[Edge], + face: Face | None = None, + ) -> Self: + """Chamfer + + Chamfers the specified edges of this solid. + + Args: + length (float): length > 0, the length (length) of the chamfer + length2 (Optional[float]): length2 > 0, optional parameter for asymmetrical + chamfer. Should be `None` if not required. + edge_list (Iterable[Edge]): a list of Edge objects, which must belong to + this solid + face (Face, optional): identifies the side where length is measured. The edge(s) + must be part of the face + + Returns: + Self: Chamfered solid + """ + edge_list = list(edge_list) + if face: + if any(edge for edge in edge_list if edge not in face.edges()): + raise ValueError("Some edges are not part of the face") + + native_edges = [e.wrapped for e in edge_list] + + # make a edge --> faces mapping + edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map + ) + + # note: we prefer 'length' word to 'radius' as opposed to FreeCAD's API + chamfer_builder = BRepFilletAPI_MakeChamfer(self.wrapped) + + if length2: + distance1 = length + distance2 = length2 + else: + distance1 = length + distance2 = length + + for native_edge in native_edges: + if face: + topo_face = face.wrapped + else: + topo_face = edge_face_map.FindFromKey(native_edge).First() + + chamfer_builder.Add( + distance1, distance2, native_edge, TopoDS.Face_s(topo_face) + ) # NB: edge_face_map return a generic TopoDS_Shape + + try: + new_shape = self.__class__(chamfer_builder.Shape()) + if not new_shape.is_valid: + raise Standard_Failure + except (StdFail_NotDone, Standard_Failure) as err: + raise ValueError( + "Failed creating a chamfer, try a smaller length value(s)" + ) from err + + return new_shape + + def dprism( + self, + basis: Face | None, + bounds: list[Face | Wire], + depth: float | None = None, + taper: float = 0, + up_to_face: Face | None = None, + thru_all: bool = True, + additive: bool = True, + ) -> Solid: + """dprism + + Make a prismatic feature (additive or subtractive) + + Args: + basis (Optional[Face]): face to perform the operation on + bounds (list[Union[Face,Wire]]): list of profiles + depth (float, optional): depth of the cut or extrusion. Defaults to None. + taper (float, optional): in degrees. Defaults to 0. + up_to_face (Face, optional): a face to extrude until. Defaults to None. + thru_all (bool, optional): cut thru_all. Defaults to True. + additive (bool, optional): Defaults to True. + + Returns: + Solid: prismatic feature + """ + if isinstance(bounds[0], Wire): + sorted_profiles = sort_wires_by_build_order(bounds) + faces = [Face(p[0], p[1:]) for p in sorted_profiles] + else: + faces = bounds + + shape: TopoDS_Shape | TopoDS_Solid = self.wrapped + for face in faces: + feat = BRepFeat_MakeDPrism( + shape, + face.wrapped, + basis.wrapped if basis else TopoDS_Face(), + taper * DEG2RAD, + additive, + False, + ) + + if up_to_face is not None: + feat.Perform(up_to_face.wrapped) + elif thru_all or depth is None: + feat.PerformThruAll() + else: + feat.Perform(depth) + + shape = feat.Shape() + + return self.__class__(shape) + + def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self: + """Fillet + + Fillets the specified edges of this solid. + + Args: + radius (float): float > 0, the radius of the fillet + edge_list (Iterable[Edge]): a list of Edge objects, which must belong to this solid + + Returns: + Any: Filleted solid + """ + native_edges = [e.wrapped for e in edge_list] + + fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped) + + for native_edge in native_edges: + fillet_builder.Add(radius, native_edge) + + try: + new_shape = self.__class__(fillet_builder.Shape()) + if not new_shape.is_valid: + raise Standard_Failure + except (StdFail_NotDone, Standard_Failure) as err: + raise ValueError( + f"Failed creating a fillet with radius of {radius}, try a smaller value" + f" or use max_fillet() to find the largest valid fillet radius" + ) from err + + return new_shape + + def hollow( + self, + faces: Iterable[Face] | None, + thickness: float, + tolerance: float = 0.0001, + kind: Kind = Kind.ARC, + ) -> Solid: + """Hollow + + Return the outer shelled solid of self. + + Args: + faces (Optional[Iterable[Face]]): faces to be removed, + which must be part of the solid. Can be an empty list. + thickness (float): shell thickness - positive shells outwards, negative + shells inwards. + tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001. + kind (Kind, optional): intersection type. Defaults to Kind.ARC. + + Raises: + ValueError: Kind.TANGENT not supported + + Returns: + Solid: A hollow solid. + """ + faces = list(faces) if faces else [] + if kind == Kind.TANGENT: + raise ValueError("Kind.TANGENT not supported") + + kind_dict = { + Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, + Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, + } + + occ_faces_list = TopTools_ListOfShape() + for face in faces: + occ_faces_list.Append(face.wrapped) + + shell_builder = BRepOffsetAPI_MakeThickSolid() + shell_builder.MakeThickSolidByJoin( + self.wrapped, + occ_faces_list, + thickness, + tolerance, + Intersection=True, + Join=kind_dict[kind], + ) + shell_builder.Build() + + if faces: + return_value = self.__class__.cast(shell_builder.Shape()) + + else: # if no faces provided a watertight solid will be constructed + shell1 = self.__class__.cast(shell_builder.Shape()).shells()[0].wrapped + shell2 = self.shells()[0].wrapped + + # s1 can be outer or inner shell depending on the thickness sign + if thickness > 0: + sol = BRepBuilderAPI_MakeSolid(shell1, shell2) + else: + sol = BRepBuilderAPI_MakeSolid(shell2, shell1) + + # fix needed for the orientations + return_value = self.__class__.cast(sol.Shape()).fix() + + 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. + + Args: + point: tuple or Vector representing 3D point to be tested + tolerance: tolerance for inside determination, default=1.0e-6 + point: VectorLike: + tolerance: float: (Default value = 1.0e-6) + + Returns: + bool indicating whether or not point is within solid + + """ + solid_classifier = BRepClass3d_SolidClassifier(self.wrapped) + solid_classifier.Perform(gp_Pnt(*Vector(point)), tolerance) + + return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace() + + def max_fillet( + self, + edge_list: Iterable[Edge], + tolerance=0.1, + max_iterations: int = 10, + ) -> float: + """Find Maximum Fillet Size + + Find the largest fillet radius for the given Shape and edges with a + recursive binary search. + + Example: + + max_fillet_radius = my_shape.max_fillet(shape_edges) + max_fillet_radius = my_shape.max_fillet(shape_edges, tolerance=0.5, max_iterations=8) + + + Args: + edge_list (Iterable[Edge]): a sequence of Edge objects, which must belong to this solid + tolerance (float, optional): maximum error from actual value. Defaults to 0.1. + max_iterations (int, optional): maximum number of recursive iterations. Defaults to 10. + + Raises: + RuntimeError: failed to find the max value + ValueError: the provided Shape is invalid + + Returns: + float: maximum fillet radius + """ + + def __max_fillet(window_min: float, window_max: float, current_iteration: int): + window_mid = (window_min + window_max) / 2 + + if current_iteration == max_iterations: + raise RuntimeError( + f"Failed to find the max value within {tolerance} in {max_iterations}" + ) + + fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped) + + for native_edge in native_edges: + fillet_builder.Add(window_mid, native_edge) + + # 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 + 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 + if window_mid - window_min <= tolerance: + return_value = window_mid + else: + return_value = __max_fillet( + window_mid, window_max, current_iteration + 1 + ) + return return_value + + 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 + + max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0) + + return max_radius + + def offset_3d( + self, + openings: Iterable[Face] | None, + thickness: float, + tolerance: float = 0.0001, + kind: Kind = Kind.ARC, + ) -> Solid: + """Shell + + Make an offset solid of self. + + Args: + openings (Optional[Iterable[Face]]): faces to be removed, + which must be part of the solid. Can be an empty list. + thickness (float): offset amount - positive offset outwards, negative inwards + tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001. + kind (Kind, optional): intersection type. Defaults to Kind.ARC. + + Raises: + ValueError: Kind.TANGENT not supported + + Returns: + Solid: A shelled solid. + """ + openings = list(openings) if openings else [] + if kind == Kind.TANGENT: + raise ValueError("Kind.TANGENT not supported") + + kind_dict = { + Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, + Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, + Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, + } + + occ_faces_list = TopTools_ListOfShape() + for face in openings: + occ_faces_list.Append(face.wrapped) + + offset_builder = BRepOffsetAPI_MakeThickSolid() + offset_builder.MakeThickSolidByJoin( + self.wrapped, + occ_faces_list, + thickness, + tolerance, + Intersection=True, + RemoveIntEdges=True, + Join=kind_dict[kind], + ) + offset_builder.Build() + + try: + offset_occt_solid = offset_builder.Shape() + except (StdFail_NotDone, Standard_Failure) as err: + raise RuntimeError( + "offset Error, an alternative kind may resolve this error" + ) from err + + offset_solid = self.__class__.cast(offset_occt_solid) + assert offset_solid.wrapped is not None + + # The Solid can be inverted, if so reverse + if offset_solid.volume < 0: + offset_solid.wrapped.Reverse() + + return offset_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 + + Project a shape onto a viewport returning visible and hidden Edges. + + Args: + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike, optional): direction of the viewport y axis. + Defaults to (0, 0, 1). + look_at (VectorLike, optional): point to look at. + Defaults to None (center of shape). + focus (float, optional): the focal length for perspective projection + Defaults to None (orthographic projection) + + Returns: + tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges + """ + return Mixin1D.project_to_viewport( + self, viewport_origin, viewport_up, look_at, focus + ) + + +class Solid(Mixin3D[TopoDS_Solid]): + """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 + well-defined manner. Solid modeling operations, such as Boolean + operations (union, intersection, and difference), are often performed on + Solid objects to create or modify complex geometries.""" + + order = 3.0 + # ---- Constructor ---- + + def __init__( + self, + obj: TopoDS_Solid | Shell | None = None, + label: str = "", + color: Color | None = None, + material: str = "", + joints: dict[str, Joint] | None = None, + parent: Compound | None = None, + ): + """Build a solid from an OCCT TopoDS_Shape/TopoDS_Solid + + Args: + obj (TopoDS_Shape | Shell, optional): OCCT Solid or Shell. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + material (str, optional): tag for external tools. Defaults to ''. + joints (dict[str, Joint], optional): names joints. Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + if isinstance(obj, Shell): + obj = Solid._make_solid(obj) + + super().__init__( + obj=obj, + # label="" if label is None else label, + label=label, + color=color, + parent=parent, + ) + self.material = "" if material is None else material + self.joints = {} if joints is None else joints + + # ---- Properties ---- + + @property + def volume(self) -> float: + """volume - the volume of this Solid""" + # when density == 1, mass == volume + return Shape.compute_mass(self) + + # ---- Class Methods ---- + + @classmethod + def _make_solid(cls, shell: Shell) -> TopoDS_Solid: + """Create a Solid object from the surface shell""" + return ShapeFix_Solid().SolidFromShell(shell.wrapped) + + @classmethod + def _set_sweep_mode( + cls, + builder: BRepOffsetAPI_MakePipeShell, + path: Wire | Edge, + binormal: Vector | Wire | Edge, + ) -> bool: + rotate = False + + if isinstance(binormal, Vector): + coordinate_system = gp_Ax2() + coordinate_system.SetLocation(path.start_point().to_pnt()) + coordinate_system.SetDirection(binormal.to_dir()) + builder.SetMode(coordinate_system) + rotate = True + elif isinstance(binormal, (Wire, Edge)): + builder.SetMode(Wire(binormal).wrapped, True) + + return rotate + + @classmethod + def extrude(cls, obj: Face, direction: VectorLike) -> Solid: + """extrude + + Extrude a Face into a Solid. + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + Returns: + Edge: extruded shape + """ + return Solid(TopoDS.Solid_s(_extrude_topods_shape(obj.wrapped, direction))) + + @classmethod + def extrude_linear_with_rotation( + cls, + section: Face | Wire, + center: VectorLike, + normal: VectorLike, + angle: float, + inner_wires: list[Wire] | None = None, + ) -> Solid: + """Extrude with Rotation + + Creates a 'twisted prism' by extruding, while simultaneously rotating around the + extrusion vector. + + Args: + section (Union[Face,Wire]): cross section + vec_center (VectorLike): the center point about which to rotate + vec_normal (VectorLike): a vector along which to extrude the wires + angle (float): the angle to rotate through while extruding + inner_wires (list[Wire], optional): holes - only used if section is of type Wire. + Defaults to None. + + Returns: + Solid: extruded object + """ + # Though the signature may appear to be similar enough to extrude to merit + # combining them, the construction methods used here are different enough that they + # should be separate. + + # At a high level, the steps followed are: + # (1) accept a set of wires + # (2) create another set of wires like this one, but which are transformed and rotated + # (3) create a ruledSurface between the sets of wires + # (4) create a shell and compute the resulting object + + inner_wires = inner_wires if inner_wires else [] + center = Vector(center) + normal = Vector(normal) + + def extrude_aux_spine( + wire: TopoDS_Wire, spine: TopoDS_Wire, aux_spine: TopoDS_Wire + ) -> TopoDS_Shape: + """Helper function""" + extrude_builder = BRepOffsetAPI_MakePipeShell(spine) + extrude_builder.SetMode(aux_spine, False) # auxiliary spine + extrude_builder.Add(wire) + extrude_builder.Build() + extrude_builder.MakeSolid() + return extrude_builder.Shape() + + if isinstance(section, Face): + outer_wire = section.outer_wire() + inner_wires = section.inner_wires() + else: + outer_wire = section + + # make straight spine + straight_spine_e = Edge.make_line(center, center.add(normal)) + straight_spine_w = Wire.combine([straight_spine_e])[0].wrapped + + # make an auxiliary spine + pitch = 360.0 / angle * normal.length + aux_spine_w = Wire( + [Edge.make_helix(pitch, normal.length, 1, center=center, normal=normal)] + ).wrapped + + # extrude the outer wire + outer_solid = extrude_aux_spine( + outer_wire.wrapped, straight_spine_w, aux_spine_w + ) + + # extrude inner wires + inner_solids = [ + extrude_aux_spine(w.wrapped, straight_spine_w, aux_spine_w) + for w in inner_wires + ] + + # combine the inner solids into compound + inner_comp = _make_topods_compound_from_shapes(inner_solids) + + # subtract from the outer solid + 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( + cls, profile: Face, direction: VectorLike, taper: float, flip_inner: bool = True + ) -> Solid: + """Extrude a cross section with a taper + + Extrude a cross section into a prismatic solid in the provided direction. + + Note that two difference algorithms are used. If direction aligns with + the profile normal (which must be positive), the taper is positive and the profile + contains no holes the OCP LocOpe_DPrism algorithm is used as it generates the most + accurate results. Otherwise, a loft is created between the profile and the profile + with a 2D offset set at the appropriate direction. + + Args: + section (Face]): cross section + normal (VectorLike): a vector along which to extrude the wires. The length + of the vector controls the length of the extrusion. + taper (float): taper angle in degrees. + flip_inner (bool, optional): outer and inner geometry have opposite tapers to + allow for part extraction when injection molding. + + Returns: + Solid: extruded cross section + """ + # pylint: disable=too-many-locals + direction = Vector(direction) + + if ( + direction.normalized() == profile.normal_at() + and Plane(profile).z_dir.Z > 0 + and taper > 0 + and not profile.inner_wires() + ): + prism_builder = LocOpe_DPrism( + profile.wrapped, + direction.length / cos(radians(taper)), + radians(taper), + ) + new_solid = Solid(TopoDS.Solid_s(prism_builder.Shape())) + else: + # Determine the offset to get the taper + offset_amt = -direction.length * tan(radians(taper)) + + outer = profile.outer_wire() + local_outer: Wire = Plane(profile).to_local_coords(outer) + local_taper_outer = local_outer.offset_2d( + offset_amt, kind=Kind.INTERSECTION + ) + taper_outer = Plane(profile).from_local_coords(local_taper_outer) + taper_outer.move(Location(direction)) + + profile_wires = [profile.outer_wire()] + profile.inner_wires() + + taper_wires = [] + for i, wire in enumerate(profile_wires): + flip = -1 if i > 0 and flip_inner else 1 + local: Wire = Plane(profile).to_local_coords(wire) + local_taper = local.offset_2d(flip * offset_amt, kind=Kind.INTERSECTION) + taper_wire: Wire = Plane(profile).from_local_coords(local_taper) + taper_wire.move(Location(direction)) + taper_wires.append(taper_wire) + + solids = [ + Solid.make_loft([p, t]) for p, t in zip(profile_wires, taper_wires) + ] + if len(solids) > 1: + complex_solid = solids[0].cut(*solids[1:]) + assert isinstance(complex_solid, Solid) # Can't be a list + new_solid = complex_solid + else: + new_solid = solids[0] + + return new_solid + + @classmethod + def extrude_until( + cls, + profile: Face, + target: Compound | Solid, + direction: VectorLike, + until: Until = Until.NEXT, + ) -> Solid: + """extrude_until + + 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: + 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: If the provided profile does not intersect the target. + + Returns: + 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 + + # 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) + ) + limit = modified_target_surfaces[ + 0 if until in [Until.NEXT, Until.PREVIOUS] else -1 + ] + keep: Literal[Keep.TOP, Keep.BOTTOM] = ( + Keep.TOP if until in [Until.NEXT, Until.PREVIOUS] else Keep.BOTTOM + ) + + # 4: Split the extrusion by the appropriate shell + clipped_extrusion = extrusion.split(limit, keep=keep) + + # 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: + # isinstance(clipped_extrusion, list): + return ShapeList(clipped_extrusion).sort_by( + Axis(profile.center(), direction) + )[0] + + @classmethod + def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid: + """A box of the same dimensions and location""" + 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( + cls, length: float, width: float, height: float, plane: Plane = Plane.XY + ) -> Solid: + """make box + + Make a box at the origin of plane extending in positive direction of each axis. + + Args: + length (float): + width (float): + height (float): + plane (Plane, optional): base plane. Defaults to Plane.XY. + + Returns: + Solid: Box + """ + return cls( + TopoDS.Solid_s( + BRepPrimAPI_MakeBox( + plane.to_gp_ax2(), + length, + width, + height, + ).Shape() + ) + ) + + @classmethod + def make_cone( + cls, + base_radius: float, + top_radius: float, + height: float, + plane: Plane = Plane.XY, + angle: float = 360, + ) -> Solid: + """make cone + + Make a cone with given radii and height + + Args: + base_radius (float): + top_radius (float): + height (float): + plane (Plane): base plane. Defaults to Plane.XY. + angle (float, optional): arc size. Defaults to 360. + + Returns: + Solid: Full or partial cone + """ + return cls( + TopoDS.Solid_s( + BRepPrimAPI_MakeCone( + plane.to_gp_ax2(), + base_radius, + top_radius, + height, + angle * DEG2RAD, + ).Shape() + ) + ) + + @classmethod + def make_cylinder( + cls, + radius: float, + height: float, + plane: Plane = Plane.XY, + angle: float = 360, + ) -> Solid: + """make cylinder + + Make a cylinder with a given radius and height with the base center on plane origin. + + Args: + radius (float): + height (float): + plane (Plane): base plane. Defaults to Plane.XY. + angle (float, optional): arc size. Defaults to 360. + + Returns: + Solid: Full or partial cylinder + """ + return cls( + TopoDS.Solid_s( + BRepPrimAPI_MakeCylinder( + plane.to_gp_ax2(), + radius, + height, + angle * DEG2RAD, + ).Shape() + ) + ) + + @classmethod + def make_loft(cls, objs: Iterable[Vertex | Wire], ruled: bool = False) -> Solid: + """make loft + + Makes a loft from a list of wires and vertices. Vertices can appear only at the + beginning or end of the list, but cannot appear consecutively within the list + nor between wires. + + Args: + objs (list[Vertex, Wire]): wire perimeters or vertices + ruled (bool, optional): stepped or smooth. Defaults to False (smooth). + + Raises: + ValueError: Too few wires + + Returns: + Solid: Lofted object + """ + return cls(TopoDS.Solid_s(_make_loft(objs, True, ruled))) + + @classmethod + def make_sphere( + cls, + radius: float, + plane: Plane = Plane.XY, + angle1: float = -90, + angle2: float = 90, + angle3: float = 360, + ) -> Solid: + """Sphere + + Make a full or partial sphere - with a given radius center on the origin or plane. + + Args: + radius (float): + plane (Plane): base plane. Defaults to Plane.XY. + angle1 (float, optional): Defaults to -90. + angle2 (float, optional): Defaults to 90. + angle3 (float, optional): Defaults to 360. + + Returns: + Solid: sphere + """ + return cls( + TopoDS.Solid_s( + BRepPrimAPI_MakeSphere( + plane.to_gp_ax2(), + radius, + angle1 * DEG2RAD, + angle2 * DEG2RAD, + angle3 * DEG2RAD, + ).Shape() + ) + ) + + @classmethod + def make_torus( + cls, + major_radius: float, + minor_radius: float, + plane: Plane = Plane.XY, + start_angle: float = 0, + end_angle: float = 360, + major_angle: float = 360, + ) -> Solid: + """make torus + + Make a torus with a given radii and angles + + Args: + major_radius (float): + minor_radius (float): + plane (Plane): base plane. Defaults to Plane.XY. + start_angle (float, optional): start major arc. Defaults to 0. + end_angle (float, optional): end major arc. Defaults to 360. + + Returns: + Solid: Full or partial torus + """ + return cls( + TopoDS.Solid_s( + BRepPrimAPI_MakeTorus( + plane.to_gp_ax2(), + major_radius, + minor_radius, + start_angle * DEG2RAD, + end_angle * DEG2RAD, + major_angle * DEG2RAD, + ).Shape() + ) + ) + + @classmethod + def make_wedge( + cls, + delta_x: float, + delta_y: float, + delta_z: float, + min_x: float, + min_z: float, + max_x: float, + max_z: float, + plane: Plane = Plane.XY, + ) -> Solid: + """Make a wedge + + Args: + delta_x (float): + delta_y (float): + delta_z (float): + min_x (float): + min_z (float): + max_x (float): + max_z (float): + plane (Plane): base plane. Defaults to Plane.XY. + + Returns: + Solid: wedge + """ + return cls( + 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 + def revolve( + cls, + section: Face | Wire, + angle: float, + axis: Axis, + inner_wires: list[Wire] | None = None, + ) -> Solid: + """Revolve + + Revolve a cross section about the given Axis by the given angle. + + Args: + section (Union[Face,Wire]): cross section + angle (float): the angle to revolve through + axis (Axis): rotation Axis + inner_wires (list[Wire], optional): holes - only used if section is of type Wire. + Defaults to []. + + Returns: + Solid: the revolved cross section + """ + inner_wires = inner_wires if inner_wires else [] + if isinstance(section, Wire): + section_face = Face(section, inner_wires) + else: + section_face = section + + revol_builder = BRepPrimAPI_MakeRevol( + section_face.wrapped, + axis.wrapped, + angle * DEG2RAD, + True, + ) + + return cls(TopoDS.Solid_s(revol_builder.Shape())) + + @classmethod + def sweep( + cls, + section: Face | Wire, + path: Wire | Edge, + inner_wires: list[Wire] | None = None, + make_solid: bool = True, + is_frenet: bool = False, + mode: Vector | Wire | Edge | None = None, + transition: Transition = Transition.TRANSFORMED, + ) -> Solid: + """Sweep + + 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 + inner_wires (list[Wire]): holes - only used if section is a wire + make_solid (bool, optional): return Solid or Shell. Defaults to True. + is_frenet (bool, optional): Frenet mode. Defaults to False. + mode (Union[Vector, Wire, Edge, None], optional): additional sweep + mode parameters. Defaults to None. + transition (Transition, optional): handling of profile orientation at C1 path + discontinuities. Defaults to Transition.TRANSFORMED. + + Returns: + Solid: the swept cross section + """ + if isinstance(section, Face): + outer_wire = section.outer_wire() + inner_wires = section.inner_wires() + else: + outer_wire = section + inner_wires = inner_wires if inner_wires else [] + + shapes: list[Mixin3D[TopoDS_Shape]] = [] + for wire in [outer_wire] + inner_wires: + builder = BRepOffsetAPI_MakePipeShell(Wire(path).wrapped) + + rotate = False + + # handle sweep mode + if mode: + rotate = Solid._set_sweep_mode(builder, path, mode) + else: + builder.SetMode(is_frenet) + + builder.SetTransitionMode(Shape._transModeDict[transition]) + + builder.Add(wire.wrapped, False, rotate) + + builder.Build() + if make_solid: + builder.MakeSolid() + + shapes.append(Mixin3D.cast(builder.Shape())) + + outer_shape, inner_shapes = shapes[0], shapes[1:] + + if inner_shapes: + hollow_outer_shape = outer_shape.cut(*inner_shapes) + assert isinstance(hollow_outer_shape, Solid) + return hollow_outer_shape + + return outer_shape + + @classmethod + def sweep_multi( + cls, + profiles: Iterable[Wire | Face], + path: Wire | Edge, + make_solid: bool = True, + is_frenet: bool = False, + binormal: Vector | Wire | Edge | None = None, + ) -> Solid: + """Multi section sweep + + 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 + make_solid (bool, optional): Solid or Shell. Defaults to True. + is_frenet (bool, optional): Select frenet mode. Defaults to False. + binormal (Union[Vector, Wire, Edge, None], optional): additional sweep mode parameters. + Defaults to None. + + Returns: + Solid: swept object + """ + path_as_wire = Wire(path).wrapped + + builder = BRepOffsetAPI_MakePipeShell(path_as_wire) + + translate = False + rotate = False + + if binormal: + rotate = cls._set_sweep_mode(builder, path, binormal) + else: + builder.SetMode(is_frenet) + + for profile in profiles: + path_as_wire = ( + profile.wrapped + if isinstance(profile, Wire) + else profile.outer_wire().wrapped + ) + builder.Add(path_as_wire, translate, rotate) + + builder.Build() + + if make_solid: + builder.MakeSolid() + + return cls(TopoDS.Solid_s(builder.Shape())) + + @classmethod + def thicken( + cls, + surface: Face | Shell, + depth: float, + normal_override: VectorLike | None = None, + ) -> Solid: + """Thicken Face or Shell + + Create a solid from a potentially non planar face or shell by thickening along + the normals. + + .. image:: thickenFace.png + + Non-planar faces are thickened both towards and away from the center of the sphere. + + Args: + depth (float): Amount to thicken face(s), can be positive or negative. + normal_override (Vector, optional): Face only. The normal_override vector can be + used to indicate which way is 'up', potentially flipping the face normal + direction such that many faces with different normals all go in the same + direction (direction need only be +/- 90 degrees from the face normal). + Defaults to None. + + Raises: + RuntimeError: Opencascade internal failures + + Returns: + Solid: The resulting Solid object + """ + # Check to see if the normal needs to be flipped + adjusted_depth = depth + if isinstance(surface, Face) and normal_override is not None: + surface_center = surface.center() + surface_normal = surface.normal_at(surface_center).normalized() + if surface_normal.dot(Vector(normal_override).normalized()) < 0: + adjusted_depth = -depth + + offset_builder = BRepOffset_MakeOffset() + offset_builder.Initialize( + surface.wrapped, + Offset=adjusted_depth, + Tol=1.0e-5, + Mode=BRepOffset_Skin, + # BRepOffset_RectoVerso - which describes the offset of a given surface shell along both + # sides of the surface but doesn't seem to work + Intersection=True, + SelfInter=False, + Join=GeomAbs_Intersection, # Could be GeomAbs_Arc,GeomAbs_Tangent,GeomAbs_Intersection + Thickening=True, + RemoveIntEdges=True, + ) + offset_builder.MakeOffsetShape() + try: + 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 new file mode 100644 index 0000000..279eb5c --- /dev/null +++ b/src/build123d/topology/two_d.py @@ -0,0 +1,2739 @@ +""" +build123d topology + +name: two_d.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module provides classes and methods for two-dimensional geometric entities in the build123d CAD +library, focusing on the `Face` and `Shell` classes. These entities form the building blocks for +creating and manipulating complex 2D surfaces and 3D shells, enabling precise modeling for CAD +applications. + +Key Features: +- **Mixin2D**: + - Adds shared functionality to `Face` and `Shell` classes, such as splitting, extrusion, and + projection operations. + +- **Face Class**: + - Represents a 3D bounded surface with advanced features like trimming, offsetting, and Boolean + operations. + - Provides utilities for creating faces from wires, arrays of points, Bézier surfaces, and ruled + surfaces. + - Enables geometry queries like normal vectors, surface centers, and planarity checks. + +- **Shell Class**: + - Represents a collection of connected faces forming a closed surface. + - Supports operations like lofting and sweeping profiles along paths. + +- **Utilities**: + - Includes methods for sorting wires into buildable faces and creating holes within faces + efficiently. + +The module integrates deeply with OpenCascade to leverage its powerful CAD kernel, offering robust +and extensible tools for surface and shell creation, manipulation, and analysis. + +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 + +import copy +import sys +import warnings +from abc import ABC, abstractmethod +from collections.abc import Iterable, Sequence +from math import degrees +from typing import TYPE_CHECKING, Any, TypeVar, overload +from typing import cast as tcast + +import OCP.TopAbs as ta +from OCP.BRep import BRep_Builder, BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.BRepAlgo import BRepAlgo +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section +from OCP.BRepBuilderAPI import ( + 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.Precision import Precision +from OCP.ShapeFix import ShapeFix_Solid, ShapeFix_Wire +from OCP.Standard import ( + Standard_ConstructionError, + Standard_Failure, + Standard_NoSuchObject, + Standard_TypeMismatch, +) +from OCP.StdFail import StdFail_NotDone +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_HArray2OfPnt +from OCP.TColStd import ( + TColStd_Array1OfInteger, + TColStd_Array1OfReal, + TColStd_HArray2OfReal, +) +from OCP.TopExp import TopExp +from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid +from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape +from ocp_gordon import interpolate_curve_network +from typing_extensions import Self + +from build123d.build_enums import ( + CenterOf, + ContinuityLevel, + GeomType, + Keep, + SortBy, + Transition, +) +from build123d.geometry import ( + DEG2RAD, + TOLERANCE, + Axis, + Color, + Location, + OrientedBoundBox, + Plane, + Vector, + VectorLike, +) + +from .one_d import Edge, Mixin1D, Wire +from .shape_core import ( + TOPODS, + Shape, + ShapeList, + SkipClean, + _sew_topods_faces, + _topods_bool_op, + _topods_entities, + _topods_face_normal_at, + downcast, + get_top_level_topods_shapes, + shapetype, +) +from .utils import ( + _extrude_topods_shape, + _make_loft, + _make_topods_face_from_wires, + 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 + +T = TypeVar("T", Edge, Wire, "Face") + + +class Mixin2D(ABC, Shape[TOPODS]): + """Additional methods to add to Face and Shell class""" + + # project_to_viewport = Mixin1D.project_to_viewport + + # ---- Properties ---- + + @property + def _dim(self) -> int: + """Dimension of Faces and Shells""" + return 2 + + # ---- Class Methods ---- + + @classmethod + def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire | Face | Shell: + "Returns the right type of wrapper, given a OCCT object" + + # define the shape lookup table for casting + constructor_lut = { + ta.TopAbs_VERTEX: Vertex, + ta.TopAbs_EDGE: Edge, + ta.TopAbs_WIRE: Wire, + ta.TopAbs_FACE: Face, + ta.TopAbs_SHELL: Shell, + } + + shape_type = shapetype(obj) + # NB downcast is needed to handle TopoDS_Shape types + return constructor_lut[shape_type](downcast(obj)) + + @classmethod + def extrude( + cls, obj: Shape, direction: VectorLike + ) -> Edge | Face | Shell | Solid | Compound: + """Unused - only here because Mixin1D is a subclass of Shape""" + return NotImplemented + + # ---- Instance Methods ---- + + def __neg__(self) -> Self: + """Reverse normal operator -""" + if self._wrapped is None: + raise ValueError("Invalid Shape") + new_surface = copy.deepcopy(self) + 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 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 + ) -> list[tuple[Vector, Vector]]: + """Find point and normal at intersection + + Return both the point(s) and normal(s) of the intersection of the axis and the shape + + Args: + axis (Axis): axis defining the intersection line + + Returns: + list[tuple[Vector, Vector]]: Point and normal of intersection + """ + if self._wrapped is None: + return [] + + intersection_line = gce_MakeLin(other.wrapped).Value() + intersect_maker = BRepIntCurveSurface_Inter() + intersect_maker.Init(self.wrapped, intersection_line, tolerance) + + intersections = [] + while intersect_maker.More(): + inter_pt = intersect_maker.Pnt() + # Calculate distance along axis + distance = Plane(other).to_local_coords(Vector(inter_pt)).Z + intersections.append( + ( + intersect_maker.Face(), # TopoDS_Face + Vector(inter_pt), + distance, + ) + ) + intersect_maker.Next() + + intersections.sort(key=lambda x: x[2]) + intersecting_faces = [i[0] for i in intersections] + intersecting_points = [i[1] for i in intersections] + intersecting_normals = [ + _topods_face_normal_at(f, intersecting_points[i].to_pnt()) + for i, f in enumerate(intersecting_faces) + ] + result = [] + for pnt, normal in zip(intersecting_points, intersecting_normals): + result.append((pnt, normal)) + + 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 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 _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[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 + shells. Face enables precise modeling and manipulation of surfaces, supporting + operations like trimming, filleting, and Boolean operations.""" + + # pylint: disable=too-many-public-methods + + order = 2.0 + # ---- Constructor ---- + + @overload + def __init__( + self, + obj: TopoDS_Face | Plane, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a Face from an OCCT TopoDS_Shape/TopoDS_Face + + Args: + 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. + """ + + @overload + def __init__( + self, + outer_wire: Wire, + inner_wires: Iterable[Wire] | None = None, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a planar Face from a boundary Wire with optional hole Wires. + + Args: + outer_wire (Wire): closed perimeter wire + inner_wires (Iterable[Wire], optional): holes. Defaults to None. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + + 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], 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,) * ( + 5 - l_a + ) + + unknown_args = ", ".join( + set(kwargs.keys()).difference( + [ + "outer_wire", + "inner_wires", + "obj", + "label", + "color", + "parent", + ] + ) + ) + if unknown_args: + raise ValueError(f"Unexpected argument(s) {unknown_args}") + + obj = kwargs.get("obj", obj) + outer_wire = kwargs.get("outer_wire", outer_wire) + inner_wires = kwargs.get("inner_wires", inner_wires) + label = kwargs.get("label", label) + 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 [] + ) + obj = _make_topods_face_from_wires(outer_wire.wrapped, inner_topods_wires) + + super().__init__( + obj=obj, + label="" if label is None else label, + color=color, + parent=parent, + ) + # Faces can optionally record the plane it was created on for later extrusion + self.created_on: Plane | None = None + + # ---- 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""" + origin = self.position_at(0.5, 0.5) + return Plane(origin, z_dir=self.normal_at(origin)).location + + @property + def geometry(self) -> None | str: + """geometry of planar face""" + result = None + if self.is_planar: + flat_face: Face = Plane(self).to_local_coords(self) + flat_face_edges = flat_face.edges() + if all(e.geom_type == GeomType.LINE for e in flat_face_edges): + flat_face_vertices = flat_face.vertices() + result = "POLYGON" + if len(flat_face_edges) == 4: + edge_pairs: list[list[Edge]] = [] + for vertex in flat_face_vertices: + edge_pairs.append( + [e for e in flat_face_edges if vertex in e.vertices()] + ) + edge_pair_directions = [ + [edge.tangent_at(0) for edge in pair] for pair in edge_pairs + ] + if all( + edge_directions[0].get_angle(edge_directions[1]) == 90 + for edge_directions in edge_pair_directions + ): + result = "RECTANGLE" + if len(flat_face_edges.group_by(SortBy.LENGTH)) == 1: + result = "SQUARE" + + 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""" + return self.is_planar_face + + @property + def length(self) -> None | float: + """length of planar face""" + result = None + if self.is_planar: + # Reposition on Plane.XY + flat_face = Plane(self).to_local_coords(self) + face_vertices = flat_face.vertices().sort_by(Axis.X) + result = face_vertices[-1].X - face_vertices[0].X + return result + + @property + 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] + ) + + return None + + @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 + + @property + def semi_angle(self) -> None | float: + """Return the semi angle of a cone, otherwise None""" + if ( + self.geom_type == GeomType.CONE + and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface + ): + return degrees(self.geom_adaptor().SemiAngle()) # type:ignore[attr-defined] + else: + return None + + @property + def volume(self) -> float: + """volume - the volume of this Face, which is always zero""" + return 0.0 + + @property + def width(self) -> None | float: + """width of planar face""" + result = None + if self.is_planar: + # Reposition on Plane.XY + flat_face = Plane(self).to_local_coords(self) + face_vertices = flat_face.vertices().sort_by(Axis.Y) + result = face_vertices[-1].Y - face_vertices[0].Y + return result + + # ---- Class Methods ---- + + @classmethod + def extrude(cls, obj: Edge, direction: VectorLike) -> Face: + """extrude + + Extrude an Edge into a Face. + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + 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 + def make_bezier_surface( + cls, + points: list[list[VectorLike]], + weights: list[list[float]] | None = None, + ) -> Face: + """make_bezier_surface + + Construct a Bézier surface from the provided 2d array of points. + + Args: + points (list[list[VectorLike]]): a 2D list of control points + weights (list[list[float]], optional): control point weights. Defaults to None. + + Raises: + ValueError: Too few control points + ValueError: Too many control points + ValueError: A weight is required for each control point + + Returns: + Face: a potentially non-planar face + """ + if len(points) < 2 or len(points[0]) < 2: + raise ValueError( + "At least two control points must be provided (start, end)" + ) + if len(points) > 25 or len(points[0]) > 25: + raise ValueError("The maximum number of control points is 25") + if weights and ( + len(points) != len(weights) or len(points[0]) != len(weights[0]) + ): + raise ValueError("A weight must be provided for each control point") + + points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0])) + for i, row_points in enumerate(points): + for j, point in enumerate(row_points): + points_.SetValue(i + 1, j + 1, Vector(point).to_pnt()) + + if weights: + weights_ = TColStd_HArray2OfReal(1, len(weights), 1, len(weights[0])) + for i, row_weights in enumerate(weights): + for j, weight in enumerate(row_weights): + weights_.SetValue(i + 1, j + 1, float(weight)) + bezier = Geom_BezierSurface(points_, weights_) + else: + bezier = Geom_BezierSurface(points_) + + 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) + + @classmethod + def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face: + """make_rect + + Make a Rectangle centered on center with the given normal + + Args: + width (float, optional): width (local x). + height (float, optional): height (local y). + plane (Plane, optional): base plane. Defaults to Plane.XY. + + Returns: + Face: The centered rectangle + """ + pln_shape = BRepBuilderAPI_MakeFace( + plane.wrapped, -width * 0.5, width * 0.5, -height * 0.5, height * 0.5 + ).Face() + + return cls(pln_shape) + + @classmethod + def make_surface( + cls, + exterior: Wire | Iterable[Edge], + surface_points: Iterable[VectorLike] | None = None, + interior_wires: Iterable[Wire] | None = None, + ) -> Face: + """Create Non-Planar Face + + Create a potentially non-planar face bounded by exterior (wire or edges), + optionally refined by surface_points with optional holes defined by + interior_wires. + + Args: + exterior (Union[Wire, list[Edge]]): Perimeter of face + surface_points (list[VectorLike], optional): Points on the surface that + refine the shape. Defaults to None. + interior_wires (list[Wire], optional): Hole(s) in the face. Defaults to None. + + Raises: + RuntimeError: Internal error building face + RuntimeError: Error building non-planar face with provided surface_points + RuntimeError: Error adding interior hole + RuntimeError: Generated face is invalid + + Returns: + Face: Potentially non-planar face + """ + exterior = list(exterior) if isinstance(exterior, Iterable) else exterior + # pylint: disable=too-many-branches + if surface_points: + surface_point_vectors = [Vector(p) for p in surface_points] + else: + surface_point_vectors = None + + # First, create the non-planar surface + surface = BRepOffsetAPI_MakeFilling( + # order of energy criterion to minimize for computing the deformation of the surface + Degree=3, + # average number of points for discretisation of the edges + NbPtsOnCur=15, + NbIter=2, + Anisotropie=False, + # the maximum distance allowed between the support surface and the constraints + Tol2d=0.00001, + # the maximum distance allowed between the support surface and the constraints + Tol3d=0.0001, + # the maximum angle allowed between the normal of the surface and the constraints + TolAng=0.01, + # the maximum difference of curvature allowed between the surface and the constraint + TolCurv=0.1, + # the highest degree which the polynomial defining the filling surface can have + MaxDeg=8, + # the greatest number of segments which the filling surface can have + MaxSegments=9, + ) + if isinstance(exterior, Wire): + outside_edges = exterior.edges() + elif isinstance(exterior, Iterable) and all( + isinstance(o, Edge) for o in exterior + ): + outside_edges = ShapeList(exterior) + else: + 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()) # type:ignore[call-overload] + except ( + Standard_Failure, + StdFail_NotDone, + Standard_NoSuchObject, + Standard_ConstructionError, + ) as err: + raise RuntimeError( + "Error building non-planar face with provided exterior" + ) from err + if surface_point_vectors: + for point in surface_point_vectors: + surface.Add(gp_Pnt(*point)) + try: + surface.Build() + 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" + ) from err + + # Next, add wires that define interior holes - note these wires must be entirely interior + 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()) + except StdFail_NotDone as err: + raise RuntimeError( + "Error adding interior hole in non-planar face with provided interior_wires" + ) from err + + surface_face = surface_face.fix() + if not surface_face.is_valid: + raise RuntimeError("non planar face is invalid") + + return surface_face + + @classmethod + def make_surface_from_array_of_points( + cls, + points: list[list[VectorLike]], + tol: float = 1e-2, + smoothing: tuple[float, float, float] | None = None, + min_deg: int = 1, + max_deg: int = 3, + ) -> Face: + """make_surface_from_array_of_points + + Approximate a spline surface through the provided 2d array of points. + The first dimension correspond to points on the vertical direction in the parameter + space of the face. The second dimension correspond to points on the horizontal + direction in the parameter space of the face. The 2 dimensions are U,V dimensions + of the parameter space of the face. + + Args: + points (list[list[VectorLike]]): a 2D list of points, first dimension is V + parameters second is U parameters. + tol (float, optional): tolerance of the algorithm. Defaults to 1e-2. + smoothing (Tuple[float, float, float], optional): optional tuple of + 3 weights use for variational smoothing. Defaults to None. + min_deg (int, optional): minimum spline degree. Enforced only when + smoothing is None. Defaults to 1. + max_deg (int, optional): maximum spline degree. Defaults to 3. + + Raises: + ValueError: B-spline approximation failed + + Returns: + Face: a potentially non-planar face defined by points + """ + points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0])) + + for i, point_row in enumerate(points): + for j, point in enumerate(point_row): + points_.SetValue(i + 1, j + 1, Vector(point).to_pnt()) + + if smoothing: + spline_builder = GeomAPI_PointsToBSplineSurface( + points_, *smoothing, DegMax=max_deg, Tol3D=tol + ) + else: + spline_builder = GeomAPI_PointsToBSplineSurface( + points_, DegMin=min_deg, DegMax=max_deg, Tol3D=tol + ) + + if not spline_builder.IsDone(): + raise ValueError("B-spline approximation failed") + + spline_geom = spline_builder.Surface() + + return cls(BRepBuilderAPI_MakeFace(spline_geom, Precision.Confusion_s()).Face()) + + @overload + @classmethod + def make_surface_from_curves( + cls, edge1: Edge, edge2: Edge + ) -> Face: # pragma: no cover + ... + + @overload + @classmethod + def make_surface_from_curves( + cls, wire1: Wire, wire2: Wire + ) -> Face: # pragma: no cover + ... + + @classmethod + def make_surface_from_curves(cls, *args, **kwargs) -> Face: + """make_surface_from_curves + + Create a ruled surface out of two edges or two wires. If wires are used then + these must have the same number of edges. + + Args: + curve1 (Union[Edge,Wire]): side of surface + curve2 (Union[Edge,Wire]): opposite side of surface + + Returns: + Face: potentially non planar surface + """ + curve1, curve2 = None, None + if args: + if len(args) != 2 or type(args[0]) is not type(args[1]): + raise TypeError( + "Both curves must be of the same type (both Edge or both Wire)." + ) + curve1, curve2 = args + + curve1 = kwargs.pop("edge1", curve1) + curve2 = kwargs.pop("edge2", curve2) + curve1 = kwargs.pop("wire1", curve1) + curve2 = kwargs.pop("wire2", curve2) + + # Handle unexpected kwargs + if kwargs: + raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + if not isinstance(curve1, (Edge, Wire)) or not isinstance(curve2, (Edge, Wire)): + raise TypeError( + "Both curves must be of the same type (both Edge or both Wire)." + ) + + if isinstance(curve1, Wire): + return_value = cls.cast(BRepFill.Shell_s(curve1.wrapped, curve2.wrapped)) + else: + 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 + + Group contiguous faces and return them in a list of ShapeList + + Args: + faces (Iterable[Face]): Faces to sew together + + Raises: + RuntimeError: OCCT SewedShape generated unexpected output + + Returns: + list[ShapeList[Face]]: grouped contiguous faces + """ + # Sew the faces + sewed_shape = _sew_topods_faces([f.wrapped for f in faces]) + top_level_shapes = get_top_level_topods_shapes(sewed_shape) + sewn_faces: list[ShapeList] = [] + + # For each of the top level shapes create a ShapeList of Face + for top_level_shape in top_level_shapes: + if isinstance(top_level_shape, TopoDS_Face): + sewn_faces.append(ShapeList([Face(top_level_shape)])) + elif isinstance(top_level_shape, TopoDS_Shell): + sewn_faces.append(Shell(top_level_shape).faces()) + elif isinstance(top_level_shape, TopoDS_Solid): + sewn_faces.append( + ShapeList( + Face(f) # type:ignore[call-overload] + for f in _topods_entities(top_level_shape, "Face") + ) + ) + else: + raise RuntimeError( + f"SewedShape returned a {type(top_level_shape)} which was unexpected" + ) + + return sewn_faces + + @classmethod + def sweep( + cls, + profile: Curve | Edge | Wire, + path: Curve | Edge | Wire, + transition=Transition.TRANSFORMED, + ) -> Face: + """sweep + + Sweep a 1D profile along a 1D path. Both the profile and path must be composed + of only 1 Edge. + + Args: + profile (Union[Curve,Edge,Wire]): the object to sweep + path (Union[Curve,Edge,Wire]): the path to follow when sweeping + transition (Transition, optional): handling of profile orientation at C1 path + discontinuities. Defaults to Transition.TRANSFORMED. + + Raises: + ValueError: Only 1 Edge allowed in profile & path + + Returns: + Face: resulting face, may be non-planar + """ + # Note: BRepOffsetAPI_MakePipe is an option here + # pipe_sweep = BRepOffsetAPI_MakePipe(path.wrapped, profile.wrapped) + # pipe_sweep.Build() + # return Face(pipe_sweep.Shape()) + + if len(profile.edges()) != 1 or len(path.edges()) != 1: + raise ValueError("Use Shell.sweep for multi Edge objects") + 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()) # type:ignore[call-overload] + if SkipClean.clean: + result = result.clean() + + return result + + # ---- Instance Methods ---- + + def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector: + """Center of Face + + Return the center based on center_of + + Args: + center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY. + + Returns: + Vector: center + """ + center_point: Vector | gp_Pnt + if (center_of == CenterOf.MASS) or ( + center_of == CenterOf.GEOMETRY and self.is_planar + ): + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(self.wrapped, properties) + center_point = properties.CentreOfMass() + + elif center_of == CenterOf.BOUNDING_BOX: + center_point = self.bounding_box().center() + + elif center_of == CenterOf.GEOMETRY: + u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() + u_val = 0.5 * (u_val0 + u_val1) + v_val = 0.5 * (v_val0 + v_val1) + + center_point = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(self.wrapped).Normal(u_val, v_val, center_point, normal) + + return Vector(center_point) + + def chamfer_2d( + self, + distance: float, + distance2: float, + vertices: Iterable[Vertex], + edge: Edge | None = None, + ) -> Face: + """Apply 2D chamfer to a face + + Args: + distance (float): chamfer length + distance2 (float): chamfer length + vertices (Iterable[Vertex]): vertices to chamfer + edge (Edge): identifies the side where length is measured. The vertices must be + part of the edge + + Raises: + ValueError: Cannot chamfer at this location + ValueError: One or more vertices are not part of edge + + Returns: + Face: face with a chamfered corner(s) + + """ + reference_edge = edge + + chamfer_builder = BRepFilletAPI_MakeFillet2d(self.wrapped) + + vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + self.wrapped, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map + ) + + for v in vertices: + 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(TopoDS.Edge_s(edge_list.First())), + Edge(TopoDS.Edge_s(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, + ) + + chamfer_builder.Build() + return self.__class__.cast(chamfer_builder.Shape()).fix() + + def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Face: + """Apply 2D fillet to a face + + Args: + radius: float: + vertices: Iterable[Vertex]: + + Returns: + + """ + + fillet_builder = BRepFilletAPI_MakeFillet2d(self.wrapped) + + for vertex in vertices: + fillet_builder.AddFillet(vertex.wrapped, radius) + + fillet_builder.Build() + + return self.__class__.cast(fillet_builder.Shape()) + + def geom_adaptor(self) -> Geom_Surface: + """Return the Geom Surface for this Face""" + return BRep_Tool.Surface_s(self.wrapped) + + def inner_wires(self) -> ShapeList[Wire]: + """Extract the inner or hole wires from this Face""" + outer = self.outer_wire() + 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""" + u_val0, _u_val1, v_val0, _v_val1 = self._uv_bounds() + gp_pnt = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(self.wrapped).Normal(u_val0, v_val0, gp_pnt, normal) + + return ( + plane.contains(Vector(gp_pnt)) + and 1 - abs(plane.z_dir.dot(Vector(normal))) < TOLERANCE + ) + + def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: + """Point inside Face + + Returns whether or not the point is inside a Face within the specified tolerance. + Points on the edge of the Face are considered inside. + + Args: + point(VectorLike): tuple or Vector representing 3D point to be tested + tolerance(float): tolerance for inside determination. Defaults to 1.0e-6. + point: VectorLike: + tolerance: float: (Default value = 1.0e-6) + + Returns: + bool: indicating whether or not point is within Face + + """ + solid_classifier = BRepClass3d_SolidClassifier(self.wrapped) + 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, + 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: + 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 + + Create holes in the Face 'self' from interior_wires which must be entirely interior. + Note that making holes in faces is more efficient than using boolean operations + with solid object. Also note that OCCT core may fail unless the orientation of the wire + is correct - use `Wire(forward_wire.wrapped.Reversed())` to reverse a wire. + + Example: + + For example, make a series of slots on the curved walls of a cylinder. + + .. image:: slotted_cylinder.png + + Args: + interior_wires: a list of hole outline wires + interior_wires: list[Wire]: + + Returns: + Face: 'self' with holes + + Raises: + RuntimeError: adding interior hole in non-planar face with provided interior_wires + RuntimeError: resulting face is not valid + + """ + # Add wires that define interior holes - note these wires must be entirely interior + makeface_object = BRepBuilderAPI_MakeFace(self.wrapped) + for interior_wire in interior_wires: + makeface_object.Add(interior_wire.wrapped) + try: + surface_face = Face(makeface_object.Face()) + except StdFail_NotDone as err: + raise RuntimeError( + "Error adding interior hole in non-planar face with provided interior_wires" + ) from err + + surface_face = surface_face.fix() + # if not surface_face.is_valid: + # raise RuntimeError("non planar face is invalid") + + return surface_face + + @overload + def normal_at(self, surface_point: VectorLike | None = None) -> Vector: + """normal_at point on surface + + Args: + surface_point (VectorLike, optional): a point that lies on the surface where + the normal. Defaults to the center (None). + + Returns: + Vector: surface normal direction + """ + + @overload + def normal_at(self, u: float, v: float) -> Vector: + """normal_at u, v values on Face + + Args: + u (float): the horizontal coordinate in the parameter space of the Face, + between 0.0 and 1.0 + v (float): the vertical coordinate in the parameter space of the Face, + between 0.0 and 1.0 + Defaults to the center (None/None) + + Raises: + ValueError: Either neither or both u v values must be provided + + Returns: + Vector: surface normal direction + """ + + def normal_at(self, *args, **kwargs) -> Vector: + """normal_at + + Computes the normal vector at the desired location on the face. + + Args: + surface_point (VectorLike, optional): a point that lies on the surface where the normal. + Defaults to None. + + Returns: + Vector: surface normal direction + """ + 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 = ", ".join( + set(kwargs.keys()).difference(["surface_point", "u", "v"]) + ) + if unknown_args: + raise ValueError(f"Unexpected argument(s) {unknown_args}") + + surface_point = kwargs.get("surface_point", surface_point) + u = kwargs.get("u", u) + v = kwargs.get("v", v) + if surface_point is None and u < 0 and v < 0: + u, v = 0.5, 0.5 + elif surface_point is None and sum(i == -1.0 for i in [u, v]) == 1: + raise ValueError("Both u & v values must be specified") + + # get the geometry + surface = self.geom_adaptor() + + if surface_point is None: + u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() + 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( + Vector(surface_point).to_pnt(), surface + ) + + u_val, v_val = projector.LowerDistanceParameters() + + gp_pnt = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) + + return Vector(normal).normalized() + + def outer_wire(self) -> Wire: + """Extract the perimeter wire from this Face""" + 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 + + Computes a point on the Face given u, v coordinates. + + Args: + u (float): the horizontal coordinate in the parameter space of the Face, + between 0.0 and 1.0 + v (float): the vertical coordinate in the parameter space of the Face, + between 0.0 and 1.0 + + Returns: + Vector: point on Face + """ + u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() + u_val = u_val0 + u * (u_val1 - u_val0) + v_val = v_val0 + v * (v_val1 - v_val0) + + gp_pnt = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) + + return Vector(gp_pnt) + + def project_to_shape( + self, target_object: Shape, direction: VectorLike + ) -> ShapeList[Face | Shell]: + """Project Face to target Object + + Project a Face onto a Shape generating new Face(s) on the surfaces of the object. + + A projection with no taper is illustrated below: + + .. image:: flatProjection.png + :alt: flatProjection + + Note that an array of faces is returned as the projection might result in faces + on the "front" and "back" of the object (or even more if there are intermediate + surfaces in the projection path). faces "behind" the projection are not + returned. + + Args: + target_object (Shape): Object to project onto + direction (VectorLike): projection direction + + Returns: + ShapeList[Face]: Face(s) projected on target object ordered by distance + """ + max_dimension = find_max_dimension([self, target_object]) + extruded_topods_self = _extrude_topods_shape( + self.wrapped, Vector(direction) * max_dimension + ) + + intersected_shapes: ShapeList[Face | Shell] = ShapeList() + if isinstance(target_object, Vertex): + raise TypeError("projection to a vertex is not supported") + if isinstance(target_object, Face): + topods_shape = _topods_bool_op( + (extruded_topods_self,), (target_object.wrapped,), BRepAlgoAPI_Common() + ) + if not topods_shape.IsNull(): + intersected_shapes.append( + Face(topods_shape) # type:ignore[call-overload] + ) + else: + for target_shell in target_object.shells(): + topods_shape = _topods_bool_op( + (extruded_topods_self,), + (target_shell.wrapped,), + BRepAlgoAPI_Common(), + ) + for topods_shell in get_top_level_topods_shapes(topods_shape): + 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() + for shape in intersected_shapes: + if len(shape.faces()) == 1: + shape_face = shape.face() + if shape_face is not None: + projected_shapes.append(shape_face) + else: + projected_shapes.append(shape) + return projected_shapes + + 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: + raise ValueError("Cannot remove holes from an empty face") + + if not (inner_wires := self.inner_wires()): + return self + + holeless = copy.deepcopy(self) + reshaper = BRepTools_ReShape() + for hole_wire in inner_wires: + reshaper.Remove(hole_wire.wrapped) + modified_shape = downcast(reshaper.Apply(self.wrapped)) + holeless.wrapped = TopoDS.Face_s(modified_shape) + return holeless + + def wire(self) -> Wire: + """Return the outerwire, generate a warning if inner_wires present""" + if self.inner_wires(): + warnings.warn( + "Found holes, returning outer_wire", + stacklevel=2, + ) + 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 + + 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 + in solid modeling. Shells group faces in a coherent manner, playing a crucial role + in representing complex shapes with voids and surfaces. This hierarchical structure + allows for efficient handling of surfaces within a model, supporting various + operations and analyses.""" + + order = 2.5 + # ---- Constructor ---- + + def __init__( + self, + obj: TopoDS_Shell | Face | Iterable[Face] | None = None, + label: str = "", + color: Color | None = None, + parent: Compound | None = None, + ): + """Build a shell from an OCCT TopoDS_Shape/TopoDS_Shell + + Args: + obj (TopoDS_Shape | Face | Iterable[Face], optional): OCCT Shell, Face or Faces. + label (str, optional): Defaults to ''. + color (Color, optional): Defaults to None. + parent (Compound, optional): assembly parent. Defaults to None. + """ + obj = list(obj) if isinstance(obj, Iterable) else obj + if isinstance(obj, Iterable) and len(obj_list := list(obj)) == 1: + obj = obj_list[0] + + if isinstance(obj, Face): + 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): + 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, + label=label, + color=color, + parent=parent, + ) + + # ---- Properties ---- + + @property + def volume(self) -> float: + """volume - the volume of this Shell if manifold, otherwise zero""" + if self.is_manifold: + 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 + + # ---- Class Methods ---- + + @classmethod + def extrude(cls, obj: Wire, direction: VectorLike) -> Shell: + """extrude + + Extrude a Wire into a Shell. + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + Returns: + Edge: extruded shape + """ + return Shell(TopoDS.Shell_s(_extrude_topods_shape(obj.wrapped, direction))) + + @classmethod + def make_loft(cls, objs: Iterable[Vertex | Wire], ruled: bool = False) -> Shell: + """make loft + + Makes a loft from a list of wires and vertices. Vertices can appear only at the + beginning or end of the list, but cannot appear consecutively within the list nor + between wires. Wires may be closed or opened. + + Args: + objs (list[Vertex, Wire]): wire perimeters or vertices + ruled (bool, optional): stepped or smooth. Defaults to False (smooth). + + Raises: + ValueError: Too few wires + + Returns: + Shell: Lofted object + """ + 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( + cls, + profile: Curve | Edge | Wire, + path: Curve | Edge | Wire, + transition=Transition.TRANSFORMED, + ) -> Shell: + """sweep + + Sweep a 1D profile along a 1D path + + Args: + profile (Union[Curve, Edge, Wire]): the object to sweep + path (Union[Curve, Edge, Wire]): the path to follow when sweeping + transition (Transition, optional): handling of profile orientation at C1 path + discontinuities. Defaults to Transition.TRANSFORMED. + + Returns: + Shell: resulting Shell, may be non-planar + """ + profile = Wire(profile.edges()) + path = Wire(Wire(path.edges()).order_edges()) + builder = BRepOffsetAPI_MakePipeShell(path.wrapped) + builder.Add(profile.wrapped, False, False) + builder.SetTransitionMode(Shape._transModeDict[transition]) + builder.Build() + result = Shell(TopoDS.Shell_s(builder.Shape())) + if SkipClean.clean: + result = result.clean() + + return result + + # ---- Instance Methods ---- + + def center(self) -> Vector: + """Center of mass of the shell""" + properties = GProp_GProps() + 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. + + Assume: + The wires make up one or more faces, which could have 'holes' + Outer wires are listed ahead of inner wires + there are no wires inside wires inside wires + ( IE, islands -- we can deal with that later on ) + none of the wires are construction wires + + Compute: + one or more sets of wires, with the outer wire listed first, and inner + ones + + Returns, list of lists. + + Args: + wire_list: list[Wire]: + + Returns: + + """ + + # check if we have something to sort at all + if len(wire_list) < 2: + return [ + wire_list, + ] + + # make a Face, NB: this might return a compound of faces + faces = Face(wire_list[0], wire_list[1:]) + + return_value = [] + for face in faces.faces(): + return_value.append( + [ + face.outer_wire(), + ] + + face.inner_wires() + ) + + return return_value diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py new file mode 100644 index 0000000..b59bcca --- /dev/null +++ b/src/build123d/topology/utils.py @@ -0,0 +1,393 @@ +""" +build123d topology + +name: utils.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module provides utility functions and helper classes for the build123d CAD library, enabling +advanced geometric operations and facilitating the use of the OpenCascade CAD kernel. It complements +the core library by offering reusable and modular tools for manipulating shapes, performing Boolean +operations, and validating geometry. + +Key Features: +- **Geometric Utilities**: + - `polar`: Converts polar coordinates to Cartesian. + - `tuplify`: Normalizes inputs into consistent tuples. + - `find_max_dimension`: Computes the maximum bounding dimension of shapes. + +- **Shape Creation**: + - `_make_loft`: Creates lofted shapes from wires and vertices. + - `_make_topods_compound_from_shapes`: Constructs compounds from multiple shapes. + - `_make_topods_face_from_wires`: Generates planar faces with optional holes. + +- **Boolean Operations**: + - `new_edges`: Identifies newly created edges from combined shapes. + +- **Enhanced Math**: + - `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance. + +This module is a critical component of build123d, supporting complex CAD workflows and geometric +transformations while maintaining a clean, extensible API. + +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 radians, sin, cos, isclose +from typing import Any, TYPE_CHECKING + +from collections.abc import Iterable + +from OCP.BRep import BRep_Tool +from OCP.BRepAlgoAPI import ( + BRepAlgoAPI_BooleanOperation, + BRepAlgoAPI_Cut, + BRepAlgoAPI_Splitter, +) +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace +from OCP.BRepLib import BRepLib_FindSurface +from OCP.BRepOffsetAPI import BRepOffsetAPI_ThruSections +from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism +from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape +from OCP.TopAbs import TopAbs_ShapeEnum +from OCP.TopExp import TopExp_Explorer +from OCP.TopTools import TopTools_ListOfShape +from OCP.TopoDS import ( + TopoDS, + TopoDS_Builder, + TopoDS_Compound, + TopoDS_Face, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Vertex, + TopoDS_Edge, + TopoDS_Wire, +) +from build123d.geometry import TOLERANCE, BoundBox, Vector, VectorLike + +from .shape_core import Shape, ShapeList, downcast, shapetype, unwrap_topods_compound + + +if TYPE_CHECKING: # pragma: no cover + from .zero_d import Vertex # pylint: disable=R0801 + from .one_d import Edge, Wire # pylint: disable=R0801 + + +def _extrude_topods_shape(obj: TopoDS_Shape, direction: VectorLike) -> TopoDS_Shape: + """extrude + + Extrude a Shape in the provided direction. + * Vertices generate Edges + * Edges generate Faces + * Wires generate Shells + * Faces generate Solids + * Shells generate Compounds + + Args: + direction (VectorLike): direction and magnitude of extrusion + + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result + + Returns: + TopoDS_Shape: extruded shape + """ + direction = Vector(direction) + + if obj is None or not isinstance( + obj, + (TopoDS_Vertex, TopoDS_Edge, TopoDS_Wire, TopoDS_Face, TopoDS_Shell), + ): + raise ValueError(f"extrude not supported for {type(obj)}") + + prism_builder = BRepPrimAPI_MakePrism(obj, direction.wrapped) + extrusion = downcast(prism_builder.Shape()) + shape_type = extrusion.ShapeType() + if shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID: + solids = [] + explorer = TopExp_Explorer(extrusion, TopAbs_ShapeEnum.TopAbs_SOLID) + while explorer.More(): + solids.append(downcast(explorer.Current())) + explorer.Next() + extrusion = _make_topods_compound_from_shapes(solids) + return extrusion + + +def _make_loft( + objs: Iterable[Vertex | Wire], + filled: bool, + ruled: bool = False, +) -> TopoDS_Shape: + """make loft + + Makes a loft from a list of wires and vertices. Vertices can appear only at the + beginning or end of the list, but cannot appear consecutively within the list + nor between wires. + + Args: + wires (list[Wire]): section perimeters + ruled (bool, optional): stepped or smooth. Defaults to False (smooth). + + Raises: + ValueError: Too few wires + + Returns: + TopoDS_Shape: Lofted object + """ + objs = list(objs) # To determine its length + if len(objs) < 2: + raise ValueError("More than one wire is required") + vertices = [obj for obj in objs if isinstance(obj.wrapped, TopoDS_Vertex)] + vertex_count = len(vertices) + + if vertex_count > 2: + raise ValueError("Only two vertices are allowed") + + if vertex_count == 1 and not ( + isinstance(objs[0].wrapped, TopoDS_Vertex) + or isinstance(objs[-1].wrapped, TopoDS_Vertex) + ): + raise ValueError( + "The vertex must be either at the beginning or end of the list" + ) + + if vertex_count == 2: + if len(objs) == 2: + raise ValueError( + "You can't have only 2 vertices to loft; try adding some wires" + ) + if not ( + isinstance(objs[0].wrapped, TopoDS_Vertex) + and isinstance(objs[-1].wrapped, TopoDS_Vertex) + ): + raise ValueError( + "The vertices must be at the beginning and end of the list" + ) + + loft_builder = BRepOffsetAPI_ThruSections(filled, ruled) + + for obj in objs: + if isinstance(obj.wrapped, TopoDS_Vertex): + loft_builder.AddVertex(obj.wrapped) + elif isinstance(obj.wrapped, TopoDS_Wire): + loft_builder.AddWire(obj.wrapped) + + loft_builder.Build() + + return loft_builder.Shape() + + +def _make_topods_compound_from_shapes( + occt_shapes: Iterable[TopoDS_Shape | None], +) -> TopoDS_Compound: + """Create an OCCT TopoDS_Compound + + Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects + + Args: + occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes + + Returns: + TopoDS_Compound: OCCT compound + """ + comp = TopoDS_Compound() + comp_builder = TopoDS_Builder() + comp_builder.MakeCompound(comp) + + for shape in occt_shapes: + if shape is not None: + comp_builder.Add(comp, shape) + + return comp + + +def _make_topods_face_from_wires( + outer_wire: TopoDS_Wire, inner_wires: Iterable[TopoDS_Wire] | None = None +) -> TopoDS_Face: + """_make_topods_face_from_wires + + Makes a planar face from one or more wires + + Args: + outer_wire (TopoDS_Wire): closed perimeter wire + inner_wires (Iterable[TopoDS_Wire], optional): holes. Defaults to None. + + Raises: + ValueError: outer wire not closed + ValueError: wires not planar + ValueError: inner wire not closed + ValueError: internal error + + Returns: + TopoDS_Face: planar face potentially with holes + """ + if inner_wires and not BRep_Tool.IsClosed_s(outer_wire): + raise ValueError("Cannot build face(s): outer wire is not closed") + inner_wires = list(inner_wires) if inner_wires else [] + + # check if wires are coplanar + verification_compound = _make_topods_compound_from_shapes( + [outer_wire] + inner_wires + ) + if not BRepLib_FindSurface(verification_compound, OnlyPlane=True).Found(): + raise ValueError("Cannot build face(s): wires not planar") + + # fix outer wire + sf_s = ShapeFix_Shape(outer_wire) + sf_s.Perform() + topo_wire = TopoDS.Wire_s(sf_s.Shape()) + + face_builder = BRepBuilderAPI_MakeFace(topo_wire, True) + + 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") + 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() + + if not face_builder.IsDone(): + raise ValueError(f"Cannot build face(s): {face_builder.Error()}") + + face = face_builder.Face() + + sf_f = ShapeFix_Face(face) + sf_f.FixOrientation() + sf_f.Perform() + + return TopoDS.Face_s(sf_f.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) + shapes_two = list(shapes_two) + occt_one = {shape.wrapped for shape in shapes_one} + occt_two = {shape.wrapped for shape in shapes_two} + occt_delta = list(occt_one - occt_two) + + all_shapes = [] + for shapes in [shapes_one, shapes_two]: + all_shapes.extend(shapes if isinstance(shapes, list) else [*shapes]) + shape_delta = [shape for shape in all_shapes if shape.wrapped in occt_delta] + return shape_delta + + +def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float: + """Return the maximum dimension of one or more shapes""" + shapes = shapes if isinstance(shapes, Iterable) else [shapes] + composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes]) + bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True) + return bbox.diagonal + + +def isclose_b(x: float, y: float, rel_tol=1e-9, abs_tol=1e-14) -> bool: + """Determine whether two floating point numbers are close in value. + Overridden abs_tol default for the math.isclose function. + + Args: + x (float): First value to compare + y (float): Second value to compare + rel_tol (float, optional): Maximum difference for being considered "close", + relative to the magnitude of the input values. Defaults to 1e-9. + abs_tol (float, optional): Maximum difference for being considered "close", + regardless of the magnitude of the input values. Defaults to 1e-14 + (unlike math.isclose which defaults to zero). + + Returns: True if a is close in value to b, and False otherwise. + """ + return isclose(x, y, rel_tol=rel_tol, abs_tol=abs_tol) + + +def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]: + """new_edges + + Given a sequence of shapes and the combination of those shapes, find the newly added edges + + Args: + objects (Shape): sequence of shapes + combined (Shape): result of the combination of objects + + Returns: + ShapeList[Edge]: new edges + """ + # Create a list of combined object edges + combined_topo_edges = TopTools_ListOfShape() + for edge in combined.edges(): + if edge.wrapped is not None: + combined_topo_edges.Append(edge.wrapped) + + # Create a list of original object edges + original_topo_edges = TopTools_ListOfShape() + for edge in [e for obj in objects for e in obj.edges()]: + if edge.wrapped is not None: + original_topo_edges.Append(edge.wrapped) + + # Cut the original edges from the combined edges + operation = BRepAlgoAPI_Cut() + operation.SetArguments(combined_topo_edges) + operation.SetTools(original_topo_edges) + operation.SetRunParallel(True) + operation.Build() + + edges = [] + explorer = TopExp_Explorer(operation.Shape(), TopAbs_ShapeEnum.TopAbs_EDGE) + while explorer.More(): + found_edge = combined.__class__.cast(downcast(explorer.Current())) + found_edge.topo_parent = combined + edges.append(found_edge) + explorer.Next() + + return ShapeList(edges) + + +def polar(length: float, angle: float) -> tuple[float, float]: + """Convert polar coordinates into cartesian coordinates""" + return (length * cos(radians(angle)), length * sin(radians(angle))) + + +def tuplify(obj: Any, dim: int) -> tuple | None: + """Create a size tuple""" + if obj is None: + result = None + elif isinstance(obj, (tuple, list)): + result = tuple(obj) + else: + result = tuple([obj] * dim) + return result + + +def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum: + """Return Shape's TopAbs_ShapeEnum""" + if isinstance(obj.wrapped, TopoDS_Compound): + shapetypes = {shapetype(o.wrapped) for o in obj} + if len(shapetypes) == 1: + result = shapetypes.pop() + else: + 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 new file mode 100644 index 0000000..dc536e9 --- /dev/null +++ b/src/build123d/topology/zero_d.py @@ -0,0 +1,381 @@ +""" +build123d topology + +name: zero_d.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module provides the foundational implementation for zero-dimensional geometry in the build123d +CAD system, focusing on the `Vertex` class and its related operations. A `Vertex` represents a +single point in 3D space, serving as the cornerstone for more complex geometric structures such as +edges, wires, and faces. It is directly integrated with the OpenCascade kernel, enabling precise +modeling and manipulation of 3D objects. + +Key Features: +- **Vertex Class**: + - Supports multiple constructors, including Cartesian coordinates, iterable inputs, and + OpenCascade `TopoDS_Vertex` objects. + - Offers robust arithmetic operations such as addition and subtraction with other vertices, + vectors, or tuples. + - Provides utility methods for transforming vertices, converting to tuples, and iterating over + coordinate components. + +- **Intersection Utilities**: + - Includes `topo_explore_common_vertex`, a utility to identify shared vertices between edges, + facilitating advanced topological queries. + +- **Integration with Shape Hierarchy**: + - Extends the `Shape` base class, inheriting essential features such as transformation matrices + and bounding box computations. + +This module plays a critical role in defining precise geometric points and their interactions, +serving as the building block for complex 3D models in the build123d library. + +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 + +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 +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, Location, Axis, Plane +from build123d.build_enums import Keep +from .shape_core import Shape, ShapeList, TrimmingTool, downcast, shapetype + + +if TYPE_CHECKING: # pragma: no cover + from .one_d import Edge, Wire # pylint: disable=R0801 + + +class Vertex(Shape[TopoDS_Vertex]): + """A Vertex in build123d represents a zero-dimensional point in the topological + data structure. It marks the endpoints of edges within a 3D model, defining precise + locations in space. Vertices play a crucial role in defining the geometry of objects + and the connectivity between edges, facilitating accurate representation and + manipulation of 3D shapes. They hold coordinate information and are essential + for constructing complex structures like wires, faces, and solids.""" + + order = 0.0 + # ---- Constructor ---- + + @overload + def __init__(self): # pragma: no cover + """Default Vertext at the origin""" + + @overload + def __init__(self, ocp_vx: TopoDS_Vertex): # pragma: no cover + """Vertex from OCCT TopoDS_Vertex object""" + + @overload + def __init__(self, X: float, Y: float, Z: float): # pragma: no cover + """Vertex from three float values""" + + @overload + def __init__(self, v: Iterable[float]): + """Vertex from Vector or other iterators""" + + def __init__(self, *args, **kwargs): + self.vertex_index = 0 + + ocp_vx = kwargs.pop("ocp_vx", None) + v = kwargs.pop("v", None) + x = kwargs.pop("X", 0) + y = kwargs.pop("Y", 0) + z = kwargs.pop("Z", 0) + + # Handle unexpected kwargs + if kwargs: + raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}") + + if args: + if isinstance(args[0], TopoDS_Vertex): + ocp_vx = args[0] + elif isinstance(args[0], Iterable): + v = args[0] + else: + x, y, z = args[:3] + (0,) * (3 - len(args)) + + if v is not None: + x, y, z = itertools.islice(itertools.chain(v, [0, 0, 0]), 3) + + ocp_vx = ( + downcast(BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex()) + if ocp_vx is None + else ocp_vx + ) + + super().__init__(ocp_vx) + pnt = BRep_Tool.Pnt_s(self.wrapped) + self.X, self.Y, self.Z = pnt.X(), pnt.Y(), pnt.Z() + + # ---- Properties ---- + + @property + def _dim(self) -> int: + return 0 + + @property + def volume(self) -> float: + """volume - the volume of this Vertex, which is always zero""" + return 0.0 + + # ---- Class Methods ---- + + @classmethod + def cast(cls, obj: TopoDS_Shape) -> Self: + "Returns the right type of wrapper, given a OCCT object" + + # define the shape lookup table for casting + constructor_lut = { + ta.TopAbs_VERTEX: Vertex, + } + + shape_type = shapetype(obj) + # NB downcast is needed to handle TopoDS_Shape types + 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 + self, other: Vertex | Vector | tuple[float, float, float] + ) -> Vertex: + """Add + + Add to a Vertex with a Vertex, Vector or Tuple + + Args: + other: Value to add + + Raises: + TypeError: other not in [Tuple,Vector,Vertex] + + Returns: + Result + + Example: + part.faces(">z").vertices(" str: + """To String + + Convert Vertex to String for display + + Returns: + Vertex as String + """ + return f"Vertex({self.X}, {self.Y}, {self.Z})" + + def __sub__(self, other: Vertex | Vector | tuple) -> Vertex: # type: ignore + """Subtract + + Subtract a Vertex with a Vertex, Vector or Tuple from self + + Args: + other: Value to add + + Raises: + TypeError: other not in [Tuple,Vector,Vertex] + + Returns: + Result + + Example: + part.faces(">z").vertices(" Vector: + """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()) + + def transform_shape(self, t_matrix: Matrix) -> Vertex: + """Apply affine transform without changing type + + Transforms a copy of this Vertex by the provided 3D affine transformation matrix. + Note that not all transformation are supported - primarily designed for translation + and rotation. See :transform_geometry: for more comprehensive transformations. + + Args: + t_matrix (Matrix): affine transformation matrix + + Returns: + Vertex: copy of transformed shape with all objects keeping their type + """ + return Vertex(*t_matrix.multiply(Vector(self))) + + def vertex(self) -> Vertex: + """Return the Vertex""" + return self + + def vertices(self) -> ShapeList[Vertex]: + """vertices - all the vertices in this Shape""" + return ShapeList((self,)) # Vertex is an iterable + + +def topo_explore_common_vertex( + edge1: Edge | TopoDS_Edge, edge2: Edge | TopoDS_Edge +) -> Vertex | None: + """Given two edges, find the common vertex""" + topods_edge1 = edge1 if isinstance(edge1, TopoDS_Edge) else edge1.wrapped + topods_edge2 = edge2 if isinstance(edge2, TopoDS_Edge) else edge2.wrapped + + if topods_edge1 is None or topods_edge2 is None: + raise ValueError("edge is empty") + + # Explore vertices of the first edge + vert_exp = TopExp_Explorer(topods_edge1, ta.TopAbs_VERTEX) + while vert_exp.More(): + vertex1 = vert_exp.Current() + + # Explore vertices of the second edge + explorer2 = TopExp_Explorer(topods_edge2, ta.TopAbs_VERTEX) + while explorer2.More(): + vertex2 = explorer2.Current() + + # Check if the vertices are the same + if vertex1.IsSame(vertex2): + return Vertex(TopoDS.Vertex_s(vertex1)) # Common vertex found + + explorer2.Next() + vert_exp.Next() + + return None # No common vertex found 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 new file mode 100644 index 0000000..a4af54a --- /dev/null +++ b/src/build123d/vtk_tools.py @@ -0,0 +1,149 @@ +""" +build123d topology + +name: vtk_tools.py +by: Gumyr +date: January 07, 2025 + +desc: + +This module defines the foundational classes and methods for the build123d CAD library, enabling +detailed geometric operations and 3D modeling capabilities. It provides a hierarchy of classes +representing various geometric entities like vertices, edges, wires, faces, shells, solids, and +compounds. These classes are designed to work seamlessly with the OpenCascade Python bindings, +leveraging its robust CAD kernel. + +Key Features: +- **Shape Base Class:** Implements core functionalities such as transformations (rotation, + translation, scaling), geometric queries, and boolean operations (cut, fuse, intersect). +- **Custom Utilities:** Includes helper classes like `ShapeList` for advanced filtering, sorting, + and grouping of shapes, and `GroupBy` for organizing shapes by specific criteria. +- **Type Safety:** Extensive use of Python typing features ensures clarity and correctness in type + handling. +- **Advanced Geometry:** Supports operations like finding intersections, computing bounding boxes, + projecting faces, and generating triangulated meshes. + +The module is designed for extensibility, enabling developers to build complex 3D assemblies and +perform detailed CAD operations programmatically while maintaining a clean and structured API. + +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 typing import Any +import warnings + +from OCP.Aspect import Aspect_TOL_SOLID +from OCP.Prs3d import Prs3d_IsoAspect +from OCP.Quantity import Quantity_Color + +HAS_VTK = True +try: + from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher + from OCP.IVtkVTK import IVtkVTK_ShapeData + from vtkmodules.vtkCommonDataModel import vtkPolyData + from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter + from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter +except ImportError: + HAS_VTK = False + + +def to_vtk_poly_data( + obj, + tolerance: float | None = None, + angular_tolerance: float | None = None, + normals: bool = False, +) -> "vtkPolyData": + """Convert shape to vtkPolyData + + Args: + tolerance: float: + angular_tolerance: float: (Default value = 0.1) + normals: bool: (Default value = True) + + Returns: data object in VTK consisting of points, vertices, lines, and polygons + """ + if not HAS_VTK: + warnings.warn("VTK not supported", stacklevel=2) + + if not obj: + raise ValueError("Cannot convert an empty shape") + + vtk_shape = IVtkOCC_Shape(obj.wrapped) + shape_data = IVtkVTK_ShapeData() + shape_mesher = IVtkOCC_ShapeMesher() + + drawer = vtk_shape.Attributes() + drawer.SetUIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0)) + drawer.SetVIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0)) + + if tolerance: + drawer.SetDeviationCoefficient(tolerance) + + if angular_tolerance: + drawer.SetDeviationAngle(angular_tolerance) + + shape_mesher.Build(vtk_shape, shape_data) + + vtk_poly_data = shape_data.getVtkPolyData() + + # convert to triangles and split edges + t_filter = vtkTriangleFilter() + t_filter.SetInputData(vtk_poly_data) + t_filter.Update() + + return_value = t_filter.GetOutput() + + # compute normals + if normals: + n_filter = vtkPolyDataNormals() + n_filter.SetComputePointNormals(True) + n_filter.SetComputeCellNormals(True) + n_filter.SetFeatureAngle(360) + n_filter.SetInputData(return_value) + n_filter.Update() + + return_value = n_filter.GetOutput() + + return return_value + + +def to_vtkpoly_string( + shape: Any, tolerance: float = 1e-3, angular_tolerance: float = 0.1 +) -> str: + """to_vtkpoly_string + + Args: + shape (Shape): object to convert + tolerance (float, optional): Defaults to 1e-3. + angular_tolerance (float, optional): Defaults to 0.1. + + Raises: + ValueError: not a valid Shape + + Returns: + str: vtkpoly str + """ + if not hasattr(shape, "wrapped"): + raise ValueError(f"Type {type(shape)} is not supported") + + writer = vtkXMLPolyDataWriter() + writer.SetWriteToOutputString(True) + writer.SetInputData(to_vtk_poly_data(shape, tolerance, angular_tolerance, True)) + writer.Write() + + return writer.GetOutputString() 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_algebra.py b/tests/test_algebra.py index e99b496..3d354a6 100644 --- a/tests/test_algebra.py +++ b/tests/test_algebra.py @@ -1,6 +1,5 @@ import math import unittest -import pytest from build123d import * from build123d.topology import Shape @@ -339,6 +338,31 @@ class ObjectTests(unittest.TestCase): class AlgebraTests(unittest.TestCase): + + # Shapes + + def test_shape_plus(self): + f1 = Face.make_rect(1, 3) + f2 = Face.make_rect(3, 1) + f3 = f1 + f2 + self.assertTupleAlmostEquals(f3.bounding_box().size, (3, 3, 0), 6) + + f4 = f1 + [] + self.assertTupleAlmostEquals(f4.bounding_box().size, (1, 3, 0), 6) + + e1 = Edge.make_line((0, 0), (1, 1)) + with self.assertRaises(ValueError): + _ = f1 + e1 + + with self.assertRaises(ValueError): + _ = Shape() + f2 + + f5 = Face() + f1 + self.assertTupleAlmostEquals(f5.bounding_box().size, (1, 3, 0), 6) + + f6 = Face() + [f1, f2] + self.assertTupleAlmostEquals(f6.bounding_box().size, (3, 3, 0), 6) + # Part def test_part_plus(self): @@ -507,16 +531,47 @@ class AlgebraTests(unittest.TestCase): self.assertTupleAlmostEquals(result.bounding_box().max, (0.4, 0.4, 0.0), 3) # Curve - def test_curve_plus(self): + def test_curve_plus_continuous(self): l1 = Polyline((0, 0), (1, 0), (1, 1)) l2 = Line((1, 1), (0, 0)) l = l1 + l2 - w = Wire(l) - self.assertTrue(w.is_closed) + self.assertTrue(isinstance(l, Wire)) + self.assertTrue(l.is_closed) self.assertTupleAlmostEquals( - w.center(CenterOf.MASS), (0.6464466094067263, 0.35355339059327373, 0.0), 6 + l.center(CenterOf.MASS), (0.6464466094067263, 0.35355339059327373, 0.0), 6 ) + def test_curve_plus_noncontinuous(self): + e1 = Edge.make_line((0, 1), (1, 1)) + e2 = Edge.make_line((1, 1), (2, 1)) + e3 = Edge.make_line((2, 1), (3, 1)) + l = Curve() + [e1, e3] + self.assertTrue(isinstance(l, Compound)) + l += e2 # fills the hole and makes a single edge + self.assertTrue(isinstance(l, Edge)) + self.assertAlmostEqual(l.length, 3, 5) + + l2 = e1 + e3 + self.assertTrue(isinstance(l2, list)) + + def test_curve_plus_nothing(self): + e1 = Edge.make_line((0, 1), (1, 1)) + l = e1 + Curve() + self.assertTrue(isinstance(l, Edge)) + self.assertAlmostEqual(l.length, 1, 5) + + def test_nothing_plus_curve(self): + e1 = Edge.make_line((0, 1), (1, 1)) + l = Curve() + e1 + self.assertTrue(isinstance(l, Edge)) + self.assertAlmostEqual(l.length, 1, 5) + + def test_bad_dims(self): + e1 = Edge.make_line((0, 1), (1, 1)) + f1 = Face.make_rect(1, 1) + with self.assertRaises(ValueError): + _ = e1 + f1 + def test_curve_minus(self): l1 = Line((0, 0), (1, 1)) l2 = Line((0.25, 0.25), (0.75, 0.75)) @@ -570,7 +625,8 @@ class AlgebraTests(unittest.TestCase): def test_part_minus_empty(self): b = Box(1, 2, 3) r = b - Part() - self.assertEqual(b.wrapped, r.wrapped) + self.assertAlmostEqual(b.volume, r.volume, 5) + self.assertEqual(r._dim, 3) def test_empty_and_part(self): b = Box(1, 2, 3) @@ -604,7 +660,8 @@ class AlgebraTests(unittest.TestCase): def test_sketch_minus_empty(self): b = Rectangle(1, 2) r = b - Sketch() - self.assertEqual(b.wrapped, r.wrapped) + self.assertAlmostEqual(b.area, r.area, 5) + self.assertEqual(r._dim, 2) def test_empty_and_sketch(self): b = Rectangle(1, 3) @@ -767,6 +824,7 @@ class LocationTests(unittest.TestCase): # on plane, located to grid position, and finally rotated c_plane = plane * outer_loc * rotations[i] s += c_plane * Circle(1) + s = Sketch(s.faces()) for loc in PolarLocations(0.8, (i + 3) * 2): # Use polar locations on c_plane diff --git a/tests/test_align.py b/tests/test_align.py new file mode 100644 index 0000000..423a982 --- /dev/null +++ b/tests/test_align.py @@ -0,0 +1,79 @@ +import pytest + +from build123d.build_enums import Align +from build123d.geometry import Vector, to_align_offset + + +@pytest.mark.parametrize( + "x_align,x_expect", + [ + (Align.MAX, -0.5), + (Align.CENTER, 0.25), + (Align.MIN, 1), + (Align.NONE, 0), + ], +) +@pytest.mark.parametrize( + "y_align,y_expect", + [ + (Align.MAX, -1), + (Align.CENTER, 0.25), + (Align.MIN, 1.5), + (Align.NONE, 0), + ], +) +@pytest.mark.parametrize( + "z_align,z_expect", + [ + (Align.MAX, -1), + (Align.CENTER, -0.75), + (Align.MIN, -0.5), + (Align.NONE, 0), + ], +) +def test_align( + x_align, + x_expect, + y_align, + y_expect, + z_align, + z_expect, +): + offset = to_align_offset( + min_point=(-1, -1.5, 0.5), + max_point=(0.5, 1.0, 1.0), + align=(x_align, y_align, z_align), + ) + assert offset.X == x_expect + assert offset.Y == y_expect + assert offset.Z == z_expect + + +@pytest.mark.parametrize("alignment", Align) +def test_align_single(alignment): + min_point = (-1, -1.5, 0.5) + max_point = (0.5, 1, 1) + expected = to_align_offset( + min_point=min_point, + max_point=max_point, + align=(alignment, alignment, alignment), + ) + offset = to_align_offset( + min_point=min_point, + max_point=max_point, + align=alignment, + ) + assert expected == offset + + +def test_align_center(): + min_point = (-1, -1.5, 0.5) + max_point = (0.5, 1, 1) + center = (4, 2, 6) + offset = to_align_offset( + min_point=min_point, + max_point=max_point, + center=center, + align=Align.CENTER, + ) + assert offset == -Vector(center) diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100644 index 0000000..23eac7c --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,112 @@ +import pytest +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") + + +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(): + exec(_read_docs_ttt_code("ppp0101")) + benchmark(model) + + +def test_ppp_0102(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0102")) + benchmark(model) + + +def test_ppp_0103(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0103")) + benchmark(model) + + +def test_ppp_0104(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0104")) + benchmark(model) + + +def test_ppp_0105(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0105")) + benchmark(model) + + +def test_ppp_0106(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0106")) + benchmark(model) + + +def test_ppp_0107(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0107")) + benchmark(model) + + +def test_ppp_0108(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0108")) + benchmark(model) + + +def test_ppp_0109(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0109")) + benchmark(model) + + +def test_ppp_0110(benchmark): + def model(): + exec(_read_docs_ttt_code("ppp0110")) + benchmark(model) + + +def test_ttt_23_02_02(benchmark): + def model(): + exec(_read_docs_ttt_code("23-02-02-sm_hanger")) + benchmark(model) + +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(): + 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 + # but usually less than 1 second + def test_create_3mf_mesh(i): + vertices = [(float(i), 0.0, 0.0) for i in range(i)] + triangles = [[i, i + 1, i + 2] for i in range(0, i - 3, 3)] + mesher = Mesher()._create_3mf_mesh(vertices, triangles) + assert len(mesher[0]) == i + assert len(mesher[1]) == int(i / 3) + + benchmark(test_create_3mf_mesh, test_input) 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 b4f8fbe..c00a504 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -27,7 +27,10 @@ license: """ import unittest -from math import pi, sqrt, atan2, degrees +from math import atan2, degrees, pi, sqrt + +import pytest + from build123d import * @@ -89,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: @@ -221,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) @@ -260,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): @@ -274,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: @@ -300,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): @@ -322,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) @@ -331,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) @@ -358,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) @@ -366,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) @@ -409,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) @@ -477,8 +503,6 @@ class TestBuildSketchObjects(unittest.TestCase): line = Polyline((0, 0), (10, 10), (20, 10)) test = trace(line, 4) - self.assertEqual(len(test.faces()), 3) - test = trace(line, 4).clean() self.assertEqual(len(test.faces()), 1) def test_full_round(self): @@ -493,19 +517,46 @@ 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, (9, 10)), + (SlotCenterToCenter, (-1, 10)), + (SlotCenterPoint, ((0, 0, 0), (0, 0, 0), 10)), + ], +) +def test_invalid_slots(slot, args): + with pytest.raises(ValueError): + slot(*args) if __name__ == "__main__": diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py deleted file mode 100644 index 8f5e62d..0000000 --- a/tests/test_direct_api.py +++ /dev/null @@ -1,4442 +0,0 @@ -# system modules -import copy -import io -import itertools -import json -import math -import os -import platform -import random -import re -from typing import Optional -import unittest -from random import uniform -from IPython.lib import pretty - -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge -from OCP.BRepGProp import BRepGProp -from OCP.gp import ( - gp, - gp_Ax1, - gp_Ax2, - gp_Circ, - gp_Dir, - gp_Elips, - gp_EulerSequence, - gp_Pnt, - gp_Quaternion, - gp_Trsf, - gp_Vec, - gp_XYZ, -) -from OCP.GProp import GProp_GProps - -from build123d.build_common import GridLocations, Locations, PolarLocations -from build123d.build_enums import ( - Align, - AngularDirection, - CenterOf, - Extrinsic, - GeomType, - Intrinsic, - Keep, - Kind, - Mode, - PositionMode, - Side, - SortBy, - Until, -) - -from build123d.build_part import BuildPart -from build123d.exporters3d import export_brep, export_step, export_stl -from build123d.operations_part import extrude -from build123d.operations_sketch import make_face -from build123d.operations_generic import fillet, add, sweep -from build123d.objects_part import Box, Cylinder -from build123d.objects_curve import CenterArc, EllipticalCenterArc, JernArc, Polyline -from build123d.build_sketch import BuildSketch -from build123d.build_line import BuildLine -from build123d.objects_curve import Spline -from build123d.objects_sketch import Circle, Rectangle, RegularPolygon -from build123d.geometry import ( - Axis, - BoundBox, - Color, - Location, - LocationEncoder, - Matrix, - Pos, - Rot, - Rotation, - Vector, - VectorLike, -) -from build123d.importers import import_brep, import_step, import_stl -from build123d.mesher import Mesher -from build123d.topology import ( - Compound, - Edge, - Face, - Plane, - Shape, - ShapeList, - Shell, - Solid, - Sketch, - Vertex, - Wire, - edges_to_wires, - polar, - new_edges, - delta, -) -from build123d.jupyter_tools import display - -DEG2RAD = math.pi / 180 -RAD2DEG = 180 / math.pi - - -# Always equal to any other object, to test that __eq__ cooperation is working -class AlwaysEqual: - def __eq__(self, other): - return True - - -class DirectApiTestCase(unittest.TestCase): - def assertTupleAlmostEquals( - self, - first: tuple[float, ...], - second: tuple[float, ...], - places: int, - msg: Optional[str] = None, - ): - """Check Tuples""" - self.assertEqual(len(second), len(first)) - for i, j in zip(second, first): - self.assertAlmostEqual(i, j, places, msg=msg) - - def assertVectorAlmostEquals( - self, first: Vector, second: VectorLike, places: int, msg: Optional[str] = None - ): - second_vector = Vector(second) - self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg) - self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg) - self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg) - - -class TestAssembly(unittest.TestCase): - @staticmethod - def create_test_assembly() -> Compound: - box = Solid.make_box(1, 1, 1) - box.orientation = (45, 45, 0) - box.label = "box" - sphere = Solid.make_sphere(1) - sphere.label = "sphere" - sphere.position = (1, 2, 3) - assembly = Compound(label="assembly", children=[box]) - sphere.parent = assembly - return assembly - - def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]): - 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) - self.assertTrue(actual_line.startswith(start)) - self.assertTrue(actual_line.endswith(end)) - - def test_attributes(self): - box = Solid.make_box(1, 1, 1) - box.label = "box" - sphere = Solid.make_sphere(1) - sphere.label = "sphere" - assembly = Compound(label="assembly", children=[box]) - sphere.parent = assembly - - self.assertEqual(len(box.children), 0) - self.assertEqual(box.label, "box") - self.assertEqual(box.parent, assembly) - self.assertEqual(sphere.parent, assembly) - self.assertEqual(len(assembly.children), 2) - - def test_show_topology_compound(self): - assembly = TestAssembly.create_test_assembly() - expected = [ - "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))", - "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))", - "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))", - ] - self.assertTopoEqual(assembly.show_topology("Solid"), expected) - - def test_show_topology_shape_location(self): - assembly = TestAssembly.create_test_assembly() - expected = [ - "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)", - "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)", - " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)", - ] - self.assertTopoEqual( - assembly.children[1].show_topology("Face", show_center=False), expected - ) - - def test_show_topology_shape(self): - assembly = TestAssembly.create_test_assembly() - expected = [ - "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)", - "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)", - " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)", - ] - self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected) - - def test_remove_child(self): - assembly = TestAssembly.create_test_assembly() - self.assertEqual(len(assembly.children), 2) - assembly.children = list(assembly.children)[1:] - self.assertEqual(len(assembly.children), 1) - - def test_do_children_intersect(self): - ( - overlap, - pair, - distance, - ) = TestAssembly.create_test_assembly().do_children_intersect() - self.assertFalse(overlap) - box = Solid.make_box(1, 1, 1) - box.orientation = (45, 45, 0) - box.label = "box" - sphere = Solid.make_sphere(1) - sphere.label = "sphere" - sphere.position = (0, 0, 0) - assembly = Compound(label="assembly", children=[box]) - sphere.parent = assembly - overlap, pair, distance = assembly.do_children_intersect() - self.assertTrue(overlap) - - -class TestAxis(DirectApiTestCase): - """Test the Axis class""" - - def test_axis_init(self): - test_axis = Axis((1, 2, 3), (0, 0, 1)) - self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) - - test_axis = Axis((1, 2, 3), direction=(0, 0, 1)) - self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) - - test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1)) - self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) - - test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4))) - self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) - - test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4))) - self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) - - with self.assertRaises(ValueError): - Axis("one", "up") - with self.assertRaises(ValueError): - Axis(one="up") - - def test_axis_from_occt(self): - occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0)) - test_axis = Axis(occt_axis) - self.assertVectorAlmostEquals(test_axis.position, (1, 1, 1), 5) - self.assertVectorAlmostEquals(test_axis.direction, (0, 1, 0), 5) - - def test_axis_repr_and_str(self): - self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))") - self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))") - - def test_axis_copy(self): - x_copy = copy.copy(Axis.X) - self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5) - self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5) - x_copy = copy.deepcopy(Axis.X) - self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5) - self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5) - - def test_axis_to_location(self): - # TODO: Verify this is correct - x_location = Axis.X.location - self.assertTrue(isinstance(x_location, Location)) - self.assertVectorAlmostEquals(x_location.position, (0, 0, 0), 5) - self.assertVectorAlmostEquals(x_location.orientation, (0, 90, 180), 5) - - def test_axis_located(self): - y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0))) - self.assertVectorAlmostEquals(y_axis.position, (0, 0, 1), 5) - self.assertVectorAlmostEquals(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.assertVectorAlmostEquals(x_plane.origin, (0, 0, 0), 5) - self.assertVectorAlmostEquals(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)))) - self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0)))) - self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0)))) - - def test_axis_is_normal(self): - self.assertTrue(Axis.X.is_normal(Axis.Y)) - self.assertFalse(Axis.X.is_normal(Axis.X)) - - def test_axis_is_opposite(self): - self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0)))) - self.assertFalse(Axis.X.is_opposite(Axis.X)) - - def test_axis_is_parallel(self): - self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0)))) - self.assertFalse(Axis.X.is_parallel(Axis.Y)) - - def test_axis_angle_between(self): - self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5) - self.assertAlmostEqual( - Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5 - ) - - def test_axis_reverse(self): - self.assertVectorAlmostEquals(Axis.X.reverse().direction, (-1, 0, 0), 5) - - def test_axis_reverse_op(self): - axis = -Axis.X - self.assertVectorAlmostEquals(axis.direction, (-1, 0, 0), 5) - - def test_axis_as_edge(self): - edge = Edge(Axis.X) - self.assertTrue(isinstance(edge, Edge)) - common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge() - self.assertAlmostEqual(common.length, 1, 5) - - def test_axis_intersect(self): - common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge() - self.assertAlmostEqual(common.length, 1, 5) - - common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge() - self.assertAlmostEqual(common.length, 1, 5) - - intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0)) - self.assertVectorAlmostEquals(intersection, (1, 0, 0), 5) - - i = Axis.X & Axis((1, 0, 0), (1, 0, 0)) - self.assertEqual(i, Axis.X) - - intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY - self.assertTupleAlmostEquals(intersection.to_tuple(), (1, 2, 0), 5) - - arc = Edge.make_circle(20, start_angle=0, end_angle=180) - ax0 = Axis((-20, 30, 0), (4, -3, 0)) - intersections = arc.intersect(ax0).vertices().sort_by(Axis.X) - self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5) - self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5) - - intersections = ax0.intersect(arc).vertices().sort_by(Axis.X) - self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5) - self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5) - - i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5) - self.assertTrue(isinstance(i, Vector)) - self.assertVectorAlmostEquals(i, (0.5, 0.5, 1.5), 5) - self.assertIsNone(Axis.Y & Vector(2, 0, 0)) - - l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1 - i: Location = Axis.Z & l - self.assertTrue(isinstance(i, Location)) - self.assertVectorAlmostEquals(i.position, l.position, 5) - self.assertVectorAlmostEquals(i.orientation, l.orientation, 5) - - self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1)) - self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1)) - - # TODO: uncomment when generalized edge to surface intersections are complete - # non_planar = ( - # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True) - # ) - # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar - - # self.assertTrue(len(intersections.vertices(), 2)) - # self.assertTupleAlmostEquals( - # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5 - # ) - # self.assertTupleAlmostEquals( - # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5 - # ) - - def test_axis_equal(self): - self.assertEqual(Axis.X, Axis.X) - self.assertEqual(Axis.Y, Axis.Y) - self.assertEqual(Axis.Z, Axis.Z) - self.assertEqual(Axis.X, AlwaysEqual()) - - def test_axis_not_equal(self): - self.assertNotEqual(Axis.X, Axis.Y) - random_obj = object() - self.assertNotEqual(Axis.X, random_obj) - - -class TestBoundBox(DirectApiTestCase): - def test_basic_bounding_box(self): - v = Vertex(1, 1, 1) - v2 = Vertex(2, 2, 2) - self.assertEqual(BoundBox, type(v.bounding_box())) - self.assertEqual(BoundBox, type(v2.bounding_box())) - - bb1 = v.bounding_box().add(v2.bounding_box()) - - # OCC uses some approximations - self.assertAlmostEqual(bb1.size.X, 1.0, 1) - - # Test adding to an existing bounding box - v0 = Vertex(0, 0, 0) - bb2 = v0.bounding_box().add(v.bounding_box()) - - bb3 = bb1.add(bb2) - self.assertVectorAlmostEquals(bb3.size, (2, 2, 2), 7) - - bb3 = bb2.add((3, 3, 3)) - self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7) - - bb3 = bb2.add(Vector(3, 3, 3)) - self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7) - - # Test 2D bounding boxes - 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()) - # 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)) - # Test that neither bounding box contains the other - self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3)) - - # Test creation of a bounding box from a shape - note the low accuracy comparison - # as the box is a little larger than the shape - bb1 = BoundBox._from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False) - self.assertVectorAlmostEquals(bb1.size, (2, 2, 1), 1) - - bb2 = BoundBox._from_topo_ds( - Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False - ) - self.assertTrue(bb2.is_inside(bb1)) - - def test_bounding_box_repr(self): - bb = Solid.make_box(1, 1, 1).bounding_box() - self.assertEqual( - repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0" - ) - - def test_center_of_boundbox(self): - self.assertVectorAlmostEquals( - Solid.make_box(1, 1, 1).bounding_box().center(), - (0.5, 0.5, 0.5), - 5, - ) - - def test_combined_center_of_boundbox(self): - pass - - def test_clean_boundbox(self): - s = Solid.make_sphere(3) - self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5) - s.mesh(1e-3) - self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5) - - # def test_to_solid(self): - # bbox = Solid.make_sphere(1).bounding_box() - # self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5) - # self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5) - # self.assertAlmostEqual(bbox.to_solid().volume, 2**3, 5) - - -class TestCadObjects(DirectApiTestCase): - def _make_circle(self): - circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0) - return Shape.cast(BRepBuilderAPI_MakeEdge(circle).Edge()) - - def _make_ellipse(self): - ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0) - return Shape.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge()) - - def test_edge_wrapper_center(self): - e = self._make_circle() - - self.assertVectorAlmostEquals(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3) - - def test_edge_wrapper_ellipse_center(self): - e = self._make_ellipse() - w = Wire([e]) - self.assertVectorAlmostEquals(Face(w).center(), (1.0, 2.0, 3.0), 3) - - def test_edge_wrapper_make_circle(self): - halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180) - - # self.assertVectorAlmostEquals((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),3) - self.assertVectorAlmostEquals(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3) - self.assertVectorAlmostEquals(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3) - - def test_edge_wrapper_make_tangent_arc(self): - tangent_arc = Edge.make_tangent_arc( - Vector(1, 1), # starts at 1, 1 - Vector(0, 1), # tangent at start of arc is in the +y direction - Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1 - ) - self.assertVectorAlmostEquals(tangent_arc.start_point(), (1, 1, 0), 3) - self.assertVectorAlmostEquals(tangent_arc.end_point(), (2, 1, 0), 3) - self.assertVectorAlmostEquals(tangent_arc.tangent_at(0), (0, 1, 0), 3) - self.assertVectorAlmostEquals(tangent_arc.tangent_at(0.5), (1, 0, 0), 3) - self.assertVectorAlmostEquals(tangent_arc.tangent_at(1), (0, -1, 0), 3) - - def test_edge_wrapper_make_ellipse1(self): - # Check x_radius > y_radius - x_radius, y_radius = 20, 10 - angle1, angle2 = -75.0, 90.0 - arcEllipseEdge = Edge.make_ellipse( - x_radius=x_radius, - y_radius=y_radius, - plane=Plane.XY, - start_angle=angle1, - end_angle=angle2, - ) - - start = ( - x_radius * math.cos(angle1 * DEG2RAD), - y_radius * math.sin(angle1 * DEG2RAD), - 0.0, - ) - end = ( - x_radius * math.cos(angle2 * DEG2RAD), - y_radius * math.sin(angle2 * DEG2RAD), - 0.0, - ) - self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3) - self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3) - - def test_edge_wrapper_make_ellipse2(self): - # Check x_radius < y_radius - x_radius, y_radius = 10, 20 - angle1, angle2 = 0.0, 45.0 - arcEllipseEdge = Edge.make_ellipse( - x_radius=x_radius, - y_radius=y_radius, - plane=Plane.XY, - start_angle=angle1, - end_angle=angle2, - ) - - start = ( - x_radius * math.cos(angle1 * DEG2RAD), - y_radius * math.sin(angle1 * DEG2RAD), - 0.0, - ) - end = ( - x_radius * math.cos(angle2 * DEG2RAD), - y_radius * math.sin(angle2 * DEG2RAD), - 0.0, - ) - self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3) - self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3) - - def test_edge_wrapper_make_circle_with_ellipse(self): - # Check x_radius == y_radius - x_radius, y_radius = 20, 20 - angle1, angle2 = 15.0, 60.0 - arcEllipseEdge = Edge.make_ellipse( - x_radius=x_radius, - y_radius=y_radius, - plane=Plane.XY, - start_angle=angle1, - end_angle=angle2, - ) - - start = ( - x_radius * math.cos(angle1 * DEG2RAD), - y_radius * math.sin(angle1 * DEG2RAD), - 0.0, - ) - end = ( - x_radius * math.cos(angle2 * DEG2RAD), - y_radius * math.sin(angle2 * DEG2RAD), - 0.0, - ) - self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3) - self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3) - - def test_face_wrapper_make_rect(self): - mplane = Face.make_rect(10, 10) - - self.assertVectorAlmostEquals(mplane.normal_at(), (0.0, 0.0, 1.0), 3) - - # def testCompoundcenter(self): - # """ - # Tests whether or not a proper weighted center can be found for a compound - # """ - - # def cylinders(self, radius, height): - - # c = Solid.make_cylinder(radius, height, Vector()) - - # # Combine all the cylinders into a single compound - # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids() - - # return r - - # Workplane.cyl = cylinders - - # # Now test. here we want weird workplane to see if the objects are transformed right - # s = ( - # Workplane("XY") - # .rect(2.0, 3.0, for_construction=true) - # .vertices() - # .cyl(0.25, 0.5) - # ) - - # self.assertEqual(4, len(s.val().solids())) - # self.assertVectorAlmostEquals((0.0, 0.0, 0.25), s.val().center, 3) - - def test_translate(self): - e = Edge.make_circle(2, Plane((1, 2, 3))) - e2 = e.translate(Vector(0, 0, 1)) - - self.assertVectorAlmostEquals(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3) - - def test_vertices(self): - e = Shape.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge()) - self.assertEqual(2, len(e.vertices())) - - def test_edge_wrapper_radius(self): - # get a radius from a simple circle - e0 = Edge.make_circle(2.4) - self.assertAlmostEqual(e0.radius, 2.4) - - # radius of an arc - e1 = Edge.make_circle( - 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30 - ) - self.assertAlmostEqual(e1.radius, 1.8) - - # test value errors - e2 = Edge.make_ellipse(10, 20) - with self.assertRaises(ValueError): - e2.radius - - # radius from a wire - w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1))) - self.assertAlmostEqual(w0.radius, 10) - - # radius from a wire with multiple edges - rad = 2.3 - plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1)) - w1 = Wire( - [ - Edge.make_circle(rad, plane, 0, 10), - Edge.make_circle(rad, plane, 10, 25), - Edge.make_circle(rad, plane, 25, 230), - ] - ) - self.assertAlmostEqual(w1.radius, rad) - - # test value error from wire - w2 = Wire.make_polygon( - [ - Vector(-1, 0, 0), - Vector(0, 1, 0), - Vector(1, -1, 0), - ] - ) - with self.assertRaises(ValueError): - w2.radius - - # (I think) the radius of a wire is the radius of it's first edge. - # Since this is stated in the docstring better make sure. - no_rad = Wire( - [ - Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)), - Edge.make_circle(1.0, start_angle=90, end_angle=270), - ] - ) - with self.assertRaises(ValueError): - no_rad.radius - yes_rad = Wire( - [ - Edge.make_circle(1.0, start_angle=90, end_angle=270), - Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)), - ] - ) - self.assertAlmostEqual(yes_rad.radius, 1.0) - many_rad = Wire( - [ - Edge.make_circle(1.0, start_angle=0, end_angle=180), - Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359), - ] - ) - self.assertAlmostEqual(many_rad.radius, 1.0) - - -class TestColor(DirectApiTestCase): - def test_name1(self): - c = Color("blue") - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5) - - def test_name2(self): - c = Color("blue", alpha=0.5) - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5) - - def test_name3(self): - c = Color("blue", 0.5) - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5) - - def test_rgb0(self): - c = Color(0.0, 1.0, 0.0) - self.assertTupleAlmostEquals(tuple(c), (0, 1, 0, 1), 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) - self.assertTupleAlmostEquals(tuple(c), (1, 1, 0, 0.5), 5) - - def test_rgba3(self): - c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5) - self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.5), 5) - - def test_bad_color_name(self): - with self.assertRaises(ValueError): - Color("build123d") - - def test_to_tuple(self): - c = Color("blue", alpha=0.5) - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5) - - def test_hex(self): - c = Color(0x996692) - self.assertTupleAlmostEquals( - tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5 - ) - - c = Color(0x006692, 0x80) - self.assertTupleAlmostEquals( - tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5 - ) - - c = Color(0x006692, alpha=0x80) - self.assertTupleAlmostEquals(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 5) - - c = Color(color_code=0x996692, alpha=0xCC) - self.assertTupleAlmostEquals( - tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5 - ) - - c = Color(0.0, 0.0, 1.0, 1.0) - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5) - - c = Color(0, 0, 1, 1) - self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5) - - def test_copy(self): - c = Color(0.1, 0.2, 0.3, alpha=0.4) - c_copy = copy.copy(c) - self.assertTupleAlmostEquals(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 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,)) - self.assertTupleAlmostEquals(tuple(c), (0.1, 1.0, 1.0, 1.0), 5) - c = Color((0.1, 0.2)) - self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 1.0, 1.0), 5) - c = Color((0.1, 0.2, 0.3)) - self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 1.0), 5) - c = Color((0.1, 0.2, 0.3, 0.4)) - self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5) - c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4)) - self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5) - - -class TestCompound(DirectApiTestCase): - def test_make_text(self): - arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0)) - text = Compound.make_text("test", 10, text_path=arc) - self.assertEqual(len(text.faces()), 4) - text = Compound.make_text( - "test", 10, align=(Align.MAX, Align.MAX), text_path=arc - ) - self.assertEqual(len(text.faces()), 4) - - def test_fuse(self): - 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.assertAlmostEqual(combined.volume, 2, 5) - fuzzy = Compound([box1]).fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) - self.assertAlmostEqual(fuzzy.volume, 2, 5) - - def test_remove(self): - box1 = Solid.make_box(1, 1, 1) - box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0))) - combined = Compound([box1, box2]) - self.assertTrue(len(combined._remove(box2).solids()), 1) - - def test_repr(self): - simple = Compound([Solid.make_box(1, 1, 1)]) - simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1] - self.assertEqual(simple_str, "Compound at label()") - - assembly = Compound([Solid.make_box(1, 1, 1)]) - assembly.children = [Solid.make_box(1, 1, 1)] - assembly.label = "test" - assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1] - self.assertEqual(assembly_str, "Compound at abel(test), #children(1)") - - def test_center(self): - test_compound = Compound( - [ - Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))), - Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))), - ] - ) - self.assertVectorAlmostEquals(test_compound.center(CenterOf.MASS), (1, 0, 0), 5) - self.assertVectorAlmostEquals( - test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5 - ) - with self.assertRaises(ValueError): - test_compound.center(CenterOf.GEOMETRY) - - def test_triad(self): - triad = Compound.make_triad(10) - bbox = triad.bounding_box() - self.assertGreater(bbox.min.X, -10 / 8) - self.assertLess(bbox.min.X, 0) - self.assertGreater(bbox.min.Y, -10 / 8) - self.assertLess(bbox.min.Y, 0) - self.assertGreater(bbox.min.Y, -10 / 8) - self.assertAlmostEqual(bbox.min.Z, 0, 4) - self.assertLess(bbox.size.Z, 12.5) - self.assertEqual(triad.volume, 0) - - def test_volume(self): - e = Edge.make_line((0, 0), (1, 1)) - self.assertAlmostEqual(e.volume, 0, 5) - - f = Face.make_rect(1, 1) - self.assertAlmostEqual(f.volume, 0, 5) - - b = Solid.make_box(1, 1, 1) - self.assertAlmostEqual(b.volume, 1, 5) - - bb = Box(1, 1, 1) - self.assertAlmostEqual(bb.volume, 1, 5) - - c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))]) - self.assertAlmostEqual(c.volume, 3, 5) - # N.B. b and bb overlap but still add to Compound volume - - def test_constructor(self): - with self.assertRaises(ValueError): - Compound(bob="fred") - - def test_len(self): - self.assertEqual(len(Compound()), 0) - skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) - self.assertEqual(len(skt), 4) - - def test_iteration(self): - skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) - for c1, c2 in itertools.combinations(skt, 2): - self.assertGreaterEqual((c1.position - c2.position).length, 10) - - def test_unwrap(self): - skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) - skt2 = Compound(children=[skt]) - self.assertEqual(len(skt2), 1) - skt3 = skt2.unwrap(fully=False) - self.assertEqual(len(skt3), 4) - - comp1 = Compound().unwrap() - self.assertEqual(len(comp1), 0) - comp2 = Compound(children=[Face.make_rect(1, 1)]) - comp3 = Compound(children=[comp2]) - self.assertEqual(len(comp3), 1) - self.assertTrue(isinstance(next(iter(comp3)), Compound)) - comp4 = comp3.unwrap(fully=True) - self.assertTrue(isinstance(comp4, Face)) - - def test_first_level_shapes(self): - base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4)) - fls = base_shapes.first_level_shapes() - self.assertTrue(isinstance(fls, ShapeList)) - self.assertEqual(len(fls), 20) - self.assertTrue(all(isinstance(s, Solid) for s in fls)) - - -class TestEdge(DirectApiTestCase): - def test_close(self): - self.assertAlmostEqual( - Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5 - ) - self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5) - - def test_make_half_circle(self): - half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180) - self.assertVectorAlmostEquals(half_circle.start_point(), (1, 0, 0), 3) - self.assertVectorAlmostEquals(half_circle.end_point(), (-1, 0, 0), 3) - - def test_make_half_circle2(self): - half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90) - self.assertVectorAlmostEquals(half_circle.start_point(), (0, -1, 0), 3) - self.assertVectorAlmostEquals(half_circle.end_point(), (0, 1, 0), 3) - - def test_make_clockwise_half_circle(self): - half_circle = Edge.make_circle( - radius=1, - start_angle=180, - end_angle=0, - angular_direction=AngularDirection.CLOCKWISE, - ) - self.assertVectorAlmostEquals(half_circle.end_point(), (1, 0, 0), 3) - self.assertVectorAlmostEquals(half_circle.start_point(), (-1, 0, 0), 3) - - def test_make_clockwise_half_circle2(self): - half_circle = Edge.make_circle( - radius=1, - start_angle=90, - end_angle=-90, - angular_direction=AngularDirection.CLOCKWISE, - ) - self.assertVectorAlmostEquals(half_circle.start_point(), (0, 1, 0), 3) - self.assertVectorAlmostEquals(half_circle.end_point(), (0, -1, 0), 3) - - def test_arc_center(self): - self.assertVectorAlmostEquals(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5) - with self.assertRaises(ValueError): - Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center - - def test_spline_with_parameters(self): - spline = Edge.make_spline( - points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0] - ) - self.assertVectorAlmostEquals(spline.end_point(), (2, 0, 0), 5) - with self.assertRaises(ValueError): - Edge.make_spline( - points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0] - ) - with self.assertRaises(ValueError): - Edge.make_spline( - points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)] - ) - - def test_spline_approx(self): - spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)]) - self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5) - spline = Edge.make_spline_approx( - [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0) - ) - self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5) - - def test_distribute_locations(self): - line = Edge.make_line((0, 0, 0), (10, 0, 0)) - locs = line.distribute_locations(3) - for i, x in enumerate([0, 5, 10]): - self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5) - self.assertVectorAlmostEquals(locs[0].orientation, (0, 90, 180), 5) - - locs = line.distribute_locations(3, positions_only=True) - for i, x in enumerate([0, 5, 10]): - self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5) - self.assertVectorAlmostEquals(locs[0].orientation, (0, 0, 0), 5) - - def test_to_wire(self): - edge = Edge.make_line((0, 0, 0), (1, 1, 1)) - for end in [0, 1]: - self.assertVectorAlmostEquals( - edge.position_at(end), - edge.to_wire().position_at(end), - 5, - ) - - def test_arc_center2(self): - edges = [ - Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30), - Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30), - ] - for edge in edges: - self.assertVectorAlmostEquals(edge.arc_center, (1, 2, 3), 5) - with self.assertRaises(ValueError): - Edge.make_line((0, 0), (1, 1)).arc_center - - def test_find_intersection_points(self): - circle = Edge.make_circle(1) - line = Edge.make_line((0, -2), (0, 2)) - crosses = circle.find_intersection_points(line) - for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses): - self.assertVectorAlmostEquals(actual, target, 5) - - with self.assertRaises(ValueError): - circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1))) - with self.assertRaises(ValueError): - circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1))) - - self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)]) - self.assertVectorAlmostEquals( - self_intersect.find_intersection_points()[0], - (-2.6861636507066047, 0, 0), - 5, - ) - line = Edge.make_line((1, -2), (1, 2)) - crosses = line.find_intersection_points(Axis.X) - self.assertVectorAlmostEquals(crosses[0], (1, 0, 0), 5) - - with self.assertRaises(ValueError): - line.find_intersection_points(Plane.YZ) - - # def test_intersections_tolerance(self): - - # Multiple operands not currently supported - - # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1))) - # l1 = Edge.make_line((1, 0), (2, 0)) - # i1 = l1.intersect(*r1) - - # r2 = Rectangle(2, 2).edges() - # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0)) - # i2 = l2.intersect(*r2) - - # self.assertEqual(len(i1.vertices()), len(i2.vertices())) - - def test_trim(self): - line = Edge.make_line((-2, 0), (2, 0)) - self.assertVectorAlmostEquals( - line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5 - ) - self.assertVectorAlmostEquals( - line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5 - ) - with self.assertRaises(ValueError): - line.trim(0.75, 0.25) - - def test_trim_to_length(self): - - e1 = Edge.make_line((0, 0), (10, 10)) - e1_trim = e1.trim_to_length(0.0, 10) - self.assertAlmostEqual(e1_trim.length, 10, 5) - - e2 = Edge.make_circle(10, start_angle=0, end_angle=90) - e2_trim = e2.trim_to_length(0.5, 1) - self.assertAlmostEqual(e2_trim.length, 1, 5) - self.assertVectorAlmostEquals( - e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5 - ) - - e3 = Edge.make_spline( - [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)] - ) - e3_trim = e3.trim_to_length(0, 7) - self.assertAlmostEqual(e3_trim.length, 7, 5) - - a4 = Axis((0, 0, 0), (1, 1, 1)) - e4_trim = Edge(a4).trim_to_length(0.5, 2) - self.assertAlmostEqual(e4_trim.length, 2, 5) - - def test_bezier(self): - with self.assertRaises(ValueError): - Edge.make_bezier((1, 1)) - cntl_pnts = [(1, 2, 3)] * 30 - with self.assertRaises(ValueError): - Edge.make_bezier(*cntl_pnts) - with self.assertRaises(ValueError): - Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0]) - - bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0)) - bbox = bezier.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (0, 0, 0), 5) - self.assertVectorAlmostEquals(bbox.max, (1, 0.75, 0), 5) - - def test_mid_way(self): - mid = Edge.make_mid_way( - Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25 - ) - self.assertVectorAlmostEquals(mid.position_at(0), (0.25, 0, 0), 5) - self.assertVectorAlmostEquals(mid.position_at(1), (0.25, 1, 0), 5) - - def test_distribute_locations2(self): - with self.assertRaises(ValueError): - Edge.make_circle(1).distribute_locations(1) - - locs = Edge.make_circle(1).distribute_locations(5, positions_only=True) - for i, loc in enumerate(locs): - self.assertVectorAlmostEquals( - loc.position, - Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(), - 5, - ) - self.assertVectorAlmostEquals(loc.orientation, (0, 0, 0), 5) - - def test_find_tangent(self): - circle = Edge.make_circle(1) - parm = circle.find_tangent(135)[0] - self.assertVectorAlmostEquals( - circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 - ) - line = Edge.make_line((0, 0), (1, 1)) - parm = line.find_tangent(45)[0] - self.assertAlmostEqual(parm, 0, 5) - parm = line.find_tangent(0) - self.assertEqual(len(parm), 0) - - def test_param_at_point(self): - u = Edge.make_circle(1).param_at_point((0, 1)) - self.assertAlmostEqual(u, 0.25, 5) - - u = 0.3 - edge = Edge.make_line((0, 0), (34, 56)) - pnt = edge.position_at(u) - self.assertAlmostEqual(edge.param_at_point(pnt), u, 5) - - ca = CenterArc((0, 0), 1, -200, 220).edge() - for u in [0.3, 1.0]: - pnt = ca.position_at(u) - self.assertAlmostEqual(ca.param_at_point(pnt), u, 5) - - ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge() - for u in [0.3, 0.9]: - pnt = ea.position_at(u) - self.assertAlmostEqual(ea.param_at_point(pnt), u, 5) - - with self.assertRaises(ValueError): - edge.param_at_point((-1, 1)) - - 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) - - def test_reverse(self): - e1 = Edge.make_line((0, 0), (1, 1)) - self.assertVectorAlmostEquals(e1 @ 0.1, (0.1, 0.1, 0), 5) - self.assertVectorAlmostEquals(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5) - - e2 = Edge.make_circle(1, start_angle=0, end_angle=180) - e2r = e2.reversed() - self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5) - - def test_init(self): - with self.assertRaises(ValueError): - Edge(direction=(1, 0, 0)) - - -class TestFace(DirectApiTestCase): - def test_make_surface_from_curves(self): - 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.assertAlmostEqual(curved.area, math.pi / 2, 5) - self.assertVectorAlmostEquals( - curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 - ) - - 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.assertAlmostEqual(curved.area, 2 * math.pi, 5) - - def test_center(self): - test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)])) - self.assertVectorAlmostEquals( - test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1 - ) - self.assertVectorAlmostEquals( - test_face.center(CenterOf.BOUNDING_BOX), - (0.5, 0.5, 0), - 5, - ) - - def test_face_volume(self): - rect = Face.make_rect(1, 1) - self.assertAlmostEqual(rect.volume, 0, 5) - - def test_chamfer_2d(self): - test_face = Face.make_rect(10, 10) - test_face = test_face.chamfer_2d( - distance=1, distance2=2, vertices=test_face.vertices() - ) - self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2) - - def test_chamfer_2d_reference(self): - test_face = Face.make_rect(10, 10) - edge = test_face.edges().sort_by(Axis.Y)[0] - vertex = edge.vertices().sort_by(Axis.X)[0] - test_face = test_face.chamfer_2d( - distance=1, distance2=2, vertices=[vertex], edge=edge - ) - self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2) - self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9) - self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8) - - def test_chamfer_2d_reference_inverted(self): - test_face = Face.make_rect(10, 10) - edge = test_face.edges().sort_by(Axis.Y)[0] - vertex = edge.vertices().sort_by(Axis.X)[0] - test_face = test_face.chamfer_2d( - distance=2, distance2=1, vertices=[vertex], edge=edge - ) - self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2) - self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8) - self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9) - - def test_chamfer_2d_error_checking(self): - with self.assertRaises(ValueError): - test_face = Face.make_rect(10, 10) - edge = test_face.edges().sort_by(Axis.Y)[0] - vertex = edge.vertices().sort_by(Axis.X)[0] - other_edge = test_face.edges().sort_by(Axis.Y)[-1] - test_face = test_face.chamfer_2d( - distance=1, distance2=2, vertices=[vertex], edge=other_edge - ) - - def test_make_rect(self): - test_face = Face.make_plane() - self.assertVectorAlmostEquals(test_face.normal_at(), (0, 0, 1), 5) - - def test_length_width(self): - test_face = Face.make_rect(8, 10, Plane.XZ) - self.assertAlmostEqual(test_face.length, 8, 5) - self.assertAlmostEqual(test_face.width, 10, 5) - - def test_geometry(self): - box = Solid.make_box(1, 1, 2) - self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE") - self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE") - with BuildPart() as test: - with BuildSketch(): - RegularPolygon(1, 3) - extrude(amount=1) - self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON") - - def test_is_planar(self): - self.assertTrue(Face.make_rect(1, 1).is_planar) - self.assertFalse( - Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar - ) - # Some of these faces have geom_type BSPLINE but are planar - mount = Solid.make_loft( - [ - Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(), - Pos(1, 0, 4) - * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(), - ], - ) - self.assertTrue(all(f.is_planar for f in mount.faces())) - - def test_negate(self): - square = Face.make_rect(1, 1) - self.assertVectorAlmostEquals(square.normal_at(), (0, 0, 1), 5) - flipped_square = -square - self.assertVectorAlmostEquals(flipped_square.normal_at(), (0, 0, -1), 5) - - def test_offset(self): - bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-1, -1, 5), 5) - self.assertVectorAlmostEquals(bbox.max, (1, 1, 5), 5) - - def test_make_from_wires(self): - outer = Wire.make_circle(10) - inners = [ - Wire.make_circle(1).locate(Location((-2, 2, 0))), - Wire.make_circle(1).locate(Location((2, 2, 0))), - ] - happy = Face(outer, inners) - self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5) - - outer = Edge.make_circle(10, end_angle=180).to_wire() - with self.assertRaises(ValueError): - Face(outer, inners) - with self.assertRaises(ValueError): - Face(Wire.make_circle(10, Plane.XZ), inners) - - 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))), - ] - with self.assertRaises(ValueError): - Face(outer, inners) - - def test_sew_faces(self): - patches = [ - Face.make_rect(1, 1, Plane((x, y, z))) - for x in range(2) - for y in range(2) - for z in range(3) - ] - random.shuffle(patches) - sheets = Face.sew_faces(patches) - self.assertEqual(len(sheets), 3) - self.assertEqual(len(sheets[0]), 4) - self.assertTrue(isinstance(sheets[0][0], Face)) - - def test_surface_from_array_of_points(self): - pnts = [ - [ - Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10)) - for x in range(11) - ] - for y in range(11) - ] - surface = Face.make_surface_from_array_of_points(pnts) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (0, 0, -1), 3) - self.assertVectorAlmostEquals(bbox.max, (10, 10, 2), 2) - - def test_bezier_surface(self): - points = [ - [ - (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0) - for x in range(-1, 2) - ] - for y in range(-1, 2) - ] - surface = Face.make_bezier_surface(points) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3) - self.assertVectorAlmostEquals(bbox.max, (+1, +1, +1), 1) - self.assertLess(bbox.max.Z, 1.0) - - weights = [ - [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2) - ] - surface = Face.make_bezier_surface(points, weights) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3) - self.assertGreater(bbox.max.Z, 1.0) - - too_many_points = [ - [ - (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0) - for x in range(-1, 27) - ] - for y in range(-1, 27) - ] - - with self.assertRaises(ValueError): - Face.make_bezier_surface([[(0, 0)]]) - with self.assertRaises(ValueError): - Face.make_bezier_surface(points, [[1, 1], [1, 1]]) - with self.assertRaises(ValueError): - Face.make_bezier_surface(too_many_points) - - def test_thicken(self): - pnts = [ - [ - Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10)) - for x in range(11) - ] - for y in range(11) - ] - surface = Face.make_surface_from_array_of_points(pnts) - solid = surface.thicken(1) - self.assertAlmostEqual(solid.volume, 101.59, 2) - - square = Face.make_rect(10, 10) - bbox = square.thicken(1, normal_override=(0, 0, -1)).bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-5, -5, -1), 5) - self.assertVectorAlmostEquals(bbox.max, (5, 5, 0), 5) - - def test_make_holes(self): - radius = 10 - circumference = 2 * math.pi * radius - hex_diagonal = 4 * (circumference / 10) / 3 - cylinder = Solid.make_cylinder(radius, hex_diagonal * 5) - cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[ - 0 - ] - with BuildSketch(Plane.XZ.offset(radius)) as hex: - with Locations((0, hex_diagonal)): - RegularPolygon( - hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER) - ) - hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire() - - projected_wire: Wire = hex_wire_vertical.project_to_shape( - target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z) - )[0] - projected_wires = [ - projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate( - (0, 0, (j + (i % 2) / 2) * hex_diagonal) - ) - for i in range(5) - 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.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area) - - def test_is_inside(self): - square = Face.make_rect(10, 10) - self.assertTrue(square.is_inside((1, 1))) - self.assertFalse(square.is_inside((20, 1))) - - def test_import_stl(self): - torus = Solid.make_torus(10, 1) - # exporter = Mesher() - # exporter.add_shape(torus) - # exporter.write("test_torus.stl") - export_stl(torus, "test_torus.stl") - imported_torus = import_stl("test_torus.stl") - # The torus from stl is tessellated therefore the areas will only be close - self.assertAlmostEqual(imported_torus.area, torus.area, 0) - os.remove("test_torus.stl") - - def test_is_coplanar(self): - square = Face.make_rect(1, 1, plane=Plane.XZ) - self.assertTrue(square.is_coplanar(Plane.XZ)) - self.assertTrue((-square).is_coplanar(Plane.XZ)) - self.assertFalse(square.is_coplanar(Plane.XY)) - surface: Face = Solid.make_sphere(1).faces()[0] - self.assertFalse(surface.is_coplanar(Plane.XY)) - - def test_center_location(self): - square = Face.make_rect(1, 1, plane=Plane.XZ) - cl = square.center_location - self.assertVectorAlmostEquals(cl.position, (0, 0, 0), 5) - self.assertVectorAlmostEquals(Plane(cl).z_dir, Plane.XZ.z_dir, 5) - - def test_position_at(self): - square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1)) - p = square.position_at(0.25, 0.75) - self.assertVectorAlmostEquals(p, (-0.5, -1.0, 0.5), 5) - - def test_location_at(self): - bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0] - loc = bottom.location_at(0.5, 0.5) - self.assertVectorAlmostEquals(loc.position, (0.5, 1, 0), 5) - self.assertVectorAlmostEquals(loc.orientation, (-180, 0, -180), 5) - - front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0] - loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1)) - self.assertVectorAlmostEquals(loc.position, (0.0, 1.0, 1.5), 5) - self.assertVectorAlmostEquals(loc.orientation, (0, -90, 0), 5) - - 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( - [ - Edge.make_line(corners[3], corners[1]), - Edge.make_line(corners[1], corners[0]), - Edge.make_line(corners[0], corners[2]), - Edge.make_three_point_arc( - corners[2], - (corners[2] + corners[3]) / 2 - Vector(0, 0, 3), - corners[3], - ), - ] - ) - surface = Face.make_surface( - net_exterior, - surface_points=[Vector(0, 0, -5)], - ) - hole_flat = Wire.make_circle(10) - hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0] - surface = Face.make_surface( - exterior=net_exterior, - surface_points=[Vector(0, 0, -5)], - interior_wires=[hole], - ) - self.assertTrue(surface.is_valid()) - self.assertEqual(surface.geom_type, GeomType.BSPLINE) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -5.113393280136395), 5) - self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5) - - # With no surface point - surface = Face.make_surface(net_exterior) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -3), 5) - self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5) - - # Exterior Edge - surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)]) - bbox = surface.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-50, -50, -5), 5) - self.assertVectorAlmostEquals(bbox.max, (50, 50, 0), 5) - - def test_make_surface_error_checking(self): - with self.assertRaises(ValueError): - Face.make_surface(Edge.make_line((0, 0), (1, 0))) - - with self.assertRaises(RuntimeError): - Face.make_surface([Edge.make_line((0, 0), (1, 0))]) - - if platform.system() != "Darwin": - with self.assertRaises(RuntimeError): - Face.make_surface( - [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)] - ) - - with self.assertRaises(RuntimeError): - Face.make_surface( - [Edge.make_circle(50)], - interior_wires=[Wire.make_circle(5, Plane.XZ)], - ) - - def test_sweep(self): - edge = Edge.make_line((1, 0), (2, 0)) - path = Wire.make_circle(1) - circle_with_hole = Face.sweep(edge, path) - self.assertTrue(isinstance(circle_with_hole, Face)) - self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5) - 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_outer_wire(self): - face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face() - self.assertAlmostEqual(face.outer_wire().length, 4, 5) - - def test_wire(self): - face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face() - with self.assertWarns(UserWarning): - outer = face.wire() - self.assertAlmostEqual(outer.length, 4, 5) - - def test_constructor(self): - with self.assertRaises(ValueError): - Face(bob="fred") - - def test_normal_at(self): - face = Face.make_rect(1, 1) - self.assertVectorAlmostEquals(face.normal_at(0, 0), (0, 0, 1), 5) - self.assertVectorAlmostEquals( - face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5 - ) - with self.assertRaises(ValueError): - face.normal_at(0) - with self.assertRaises(ValueError): - face.normal_at(center=(0, 0)) - face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0] - self.assertVectorAlmostEquals(face.normal_at(0, 1), (1, 0, 0), 5) - - -class TestFunctions(unittest.TestCase): - def test_edges_to_wires(self): - square_edges = Face.make_rect(1, 1).edges() - rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges() - wires = edges_to_wires(square_edges + rectangle_edges) - self.assertEqual(len(wires), 2) - self.assertAlmostEqual(wires[0].length, 4, 5) - self.assertAlmostEqual(wires[1].length, 6, 5) - - def test_polar(self): - pnt = polar(1, 30) - self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5) - self.assertAlmostEqual(pnt[1], 0.5, 5) - - def test_new_edges(self): - c = Solid.make_cylinder(1, 5) - s = Solid.make_sphere(2) - s_minus_c = s - c - seams = new_edges(c, s, combined=s_minus_c) - self.assertEqual(len(seams), 1) - self.assertAlmostEqual(seams[0].radius, 1, 5) - - def test_delta(self): - cyl = Solid.make_cylinder(1, 5) - sph = Solid.make_sphere(2) - con = Solid.make_cone(2, 1, 2) - plug = delta([cyl, sph, con], [sph, con]) - self.assertEqual(len(plug), 1) - self.assertEqual(plug[0], cyl) - - def test_parse_intersect_args(self): - - with self.assertRaises(TypeError): - Vector(1, 1, 1) & ("x", "y", "z") - - -class TestImportExport(DirectApiTestCase): - def test_import_export(self): - 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.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.assertAlmostEqual(brep_box.volume, 1, 5) - os.remove("test_box.step") - os.remove("test_box.brep") - with self.assertRaises(FileNotFoundError): - step_box = import_step("test_box.step") - - def test_import_stl(self): - # export solid - original_box = Solid.make_box(1, 2, 3) - exporter = Mesher() - exporter.add_shape(original_box) - exporter.write("test.stl") - - # import as face - stl_box = import_stl("test.stl") - self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5) - - -class TestJupyter(DirectApiTestCase): - def test_repr_javascript(self): - shape = Solid.make_box(1, 1, 1) - - # Test no exception on rendering to js - js1 = shape._repr_javascript_() - - assert "function render" in js1 - - def test_display_error(self): - with self.assertRaises(AttributeError): - display(Vector()) - - -class TestLocation(DirectApiTestCase): - def test_location(self): - loc0 = Location() - T = loc0.wrapped.Transformation().TranslationPart() - self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 0), 6) - angle = loc0.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG - self.assertAlmostEqual(0, angle) - - # Tuple - loc0 = Location((0, 0, 1)) - - T = loc0.wrapped.Transformation().TranslationPart() - self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6) - - # List - loc0 = Location([0, 0, 1]) - - T = loc0.wrapped.Transformation().TranslationPart() - self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6) - - # Vector - loc1 = Location(Vector(0, 0, 1)) - - T = loc1.wrapped.Transformation().TranslationPart() - self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6) - - # rotation + translation - loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45) - - angle = loc2.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG - self.assertAlmostEqual(45, angle) - - # gp_Trsf - T = gp_Trsf() - T.SetTranslation(gp_Vec(0, 0, 1)) - loc3 = Location(T) - - self.assertEqual( - loc1.wrapped.Transformation().TranslationPart().Z(), - loc3.wrapped.Transformation().TranslationPart().Z(), - ) - - # Test creation from the OCP.gp.gp_Trsf object - loc4 = Location(gp_Trsf()) - self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 0), 7) - self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7) - - # Test creation from Plane and Vector - loc4 = Location(Plane.XY, (0, 0, 1)) - self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 1), 7) - self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7) - - # Test composition - loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15) - - loc5 = loc1 * loc4 - loc6 = loc4 * loc4 - loc7 = loc4**2 - - T = loc5.wrapped.Transformation().TranslationPart() - self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6) - - angle5 = ( - loc5.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG - ) - self.assertAlmostEqual(15, angle5) - - angle6 = ( - loc6.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG - ) - self.assertAlmostEqual(30, angle6) - - angle7 = ( - loc7.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG - ) - self.assertAlmostEqual(30, angle7) - - # Test error handling on creation - with self.assertRaises(TypeError): - Location("xy_plane") - - # Test that the computed rotation matrix and intrinsic euler angles return the same - - about_x = uniform(-2 * math.pi, 2 * math.pi) - about_y = uniform(-2 * math.pi, 2 * math.pi) - about_z = uniform(-2 * math.pi, 2 * math.pi) - - rot_x = gp_Trsf() - rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x) - rot_y = gp_Trsf() - rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y) - rot_z = gp_Trsf() - rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z) - loc1 = Location(rot_x * rot_y * rot_z) - - q = gp_Quaternion() - q.SetEulerAngles( - gp_EulerSequence.gp_Intrinsic_XYZ, - about_x, - about_y, - about_z, - ) - t = gp_Trsf() - t.SetRotationPart(q) - loc2 = Location(t) - - self.assertTupleAlmostEquals(loc1.to_tuple()[0], loc2.to_tuple()[0], 6) - self.assertTupleAlmostEquals(loc1.to_tuple()[1], loc2.to_tuple()[1], 6) - - loc1 = Location((1, 2), 34) - self.assertTupleAlmostEquals(loc1.to_tuple()[0], (1, 2, 0), 6) - self.assertTupleAlmostEquals(loc1.to_tuple()[1], (0, 0, 34), 6) - - rot_angles = (-115.00, 35.00, -135.00) - loc2 = Location((1, 2, 3), rot_angles) - self.assertTupleAlmostEquals(loc2.to_tuple()[0], (1, 2, 3), 6) - self.assertTupleAlmostEquals(loc2.to_tuple()[1], rot_angles, 6) - - loc3 = Location(loc2) - self.assertTupleAlmostEquals(loc3.to_tuple()[0], (1, 2, 3), 6) - self.assertTupleAlmostEquals(loc3.to_tuple()[1], rot_angles, 6) - - def test_location_parameters(self): - loc = Location((10, 20, 30)) - self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5) - - loc = Location((10, 20, 30), (10, 20, 30)) - self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5) - self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5) - - loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ) - self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5) - self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5) - - loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX) - self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5) - self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5) - - with self.assertRaises(TypeError): - Location(x=10) - - with self.assertRaises(TypeError): - Location((10, 20, 30), (30, 20, 10), (10, 20, 30)) - - with self.assertRaises(TypeError): - Location(Intrinsic.XYZ) - - def test_location_repr_and_str(self): - self.assertEqual( - repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))" - ) - self.assertEqual( - str(Location()), - "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))", - ) - loc = Location((1, 2, 3), (33, 45, 67)) - self.assertEqual( - str(loc), - "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))", - ) - - def test_location_inverted(self): - loc = Location(Plane.XZ) - self.assertVectorAlmostEquals(loc.inverse().orientation, (-90, 0, 0), 6) - - def test_set_position(self): - loc = Location(Plane.XZ) - loc.position = (1, 2, 3) - self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6) - self.assertVectorAlmostEquals(loc.orientation, (90, 0, 0), 6) - - def test_set_orientation(self): - loc = Location((1, 2, 3), (90, 0, 0)) - loc.orientation = (-90, 0, 0) - self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6) - self.assertVectorAlmostEquals(loc.orientation, (-90, 0, 0), 6) - - def test_copy(self): - loc1 = Location((1, 2, 3), (90, 45, 22.5)) - loc2 = copy.copy(loc1) - loc3 = copy.deepcopy(loc1) - self.assertVectorAlmostEquals(loc1.position, loc2.position.to_tuple(), 6) - self.assertVectorAlmostEquals(loc1.orientation, loc2.orientation.to_tuple(), 6) - self.assertVectorAlmostEquals(loc1.position, loc3.position.to_tuple(), 6) - self.assertVectorAlmostEquals(loc1.orientation, loc3.orientation.to_tuple(), 6) - - def test_to_axis(self): - axis = Location((1, 2, 3), (-90, 0, 0)).to_axis() - self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 6) - self.assertVectorAlmostEquals(axis.direction, (0, 1, 0), 6) - - def test_equal(self): - loc = Location((1, 2, 3), (4, 5, 6)) - same = Location((1, 2, 3), (4, 5, 6)) - - self.assertEqual(loc, same) - self.assertEqual(loc, AlwaysEqual()) - - def test_not_equal(self): - loc = Location((1, 2, 3), (40, 50, 60)) - diff_position = Location((3, 2, 1), (40, 50, 60)) - diff_orientation = Location((1, 2, 3), (60, 50, 40)) - - self.assertNotEqual(loc, diff_position) - self.assertNotEqual(loc, diff_orientation) - self.assertNotEqual(loc, object()) - - def test_neg(self): - loc = Location((1, 2, 3), (0, 35, 127)) - n_loc = -loc - self.assertVectorAlmostEquals(n_loc.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(n_loc.orientation, (180, -35, -127), 5) - - def test_mult_iterable(self): - locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1) - self.assertVectorAlmostEquals(locs[0].position, (-1, 2, 0), 5) - self.assertVectorAlmostEquals(locs[1].position, (3, 2, 0), 5) - - def test_as_json(self): - data_dict = { - "part1": { - "joint_one": Location((1, 2, 3), (4, 5, 6)), - "joint_two": Location((7, 8, 9), (10, 11, 12)), - }, - "part2": { - "joint_one": Location((13, 14, 15), (16, 17, 18)), - "joint_two": Location((19, 20, 21), (22, 23, 24)), - }, - } - - # Serializing json with custom Location encoder - json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder) - - # Writing to sample.json - with open("sample.json", "w") as outfile: - outfile.write(json_object) - - # Reading from sample.json - with open("sample.json", "r") as infile: - read_json = json.load(infile, object_hook=LocationEncoder.location_hook) - - # Validate locations - for key, value in read_json.items(): - for k, v in value.items(): - if key == "part1" and k == "joint_one": - self.assertVectorAlmostEquals(v.position, (1, 2, 3), 5) - elif key == "part1" and k == "joint_two": - self.assertVectorAlmostEquals(v.position, (7, 8, 9), 5) - elif key == "part2" and k == "joint_one": - self.assertVectorAlmostEquals(v.position, (13, 14, 15), 5) - elif key == "part2" and k == "joint_two": - self.assertVectorAlmostEquals(v.position, (19, 20, 21), 5) - else: - self.assertTrue(False) - os.remove("sample.json") - - def test_intersection(self): - e = Edge.make_line((0, 0, 0), (1, 1, 1)) - l0 = e.location_at(0) - l1 = e.location_at(1) - self.assertIsNone(l0 & l1) - self.assertEqual(l1 & l1, l1) - - i = l1 & Vector(1, 1, 1) - self.assertTrue(isinstance(i, Vector)) - self.assertVectorAlmostEquals(i, (1, 1, 1), 5) - - i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1)) - self.assertTrue(isinstance(i, Location)) - self.assertEqual(i, l1) - - p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0)) - l = Location((1, 0, 0), (1, 0, 0), 45) - i = l & p - self.assertTrue(isinstance(i, Location)) - self.assertVectorAlmostEquals(i.position, (1, 0, 0), 5) - self.assertVectorAlmostEquals(i.orientation, l.orientation, 5) - - b = Solid.make_box(1, 1, 1) - l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45) - i = (l & b).vertex() - self.assertTrue(isinstance(i, Vertex)) - self.assertVectorAlmostEquals(Vector(i), (0.5, 0.5, 0.5), 5) - - -class TestMatrix(DirectApiTestCase): - def test_matrix_creation_and_access(self): - def matrix_vals(m): - return [[m[r, c] for c in range(4)] for r in range(4)] - - # default constructor creates a 4x4 identity matrix - m = Matrix() - identity = [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - self.assertEqual(identity, matrix_vals(m)) - - vals4x4 = [ - [1.0, 0.0, 0.0, 1.0], - [0.0, 1.0, 0.0, 2.0], - [0.0, 0.0, 1.0, 3.0], - [0.0, 0.0, 0.0, 1.0], - ] - vals4x4_tuple = tuple(tuple(r) for r in vals4x4) - - # test constructor with 16-value input - m = Matrix(vals4x4) - self.assertEqual(vals4x4, matrix_vals(m)) - m = Matrix(vals4x4_tuple) - self.assertEqual(vals4x4, matrix_vals(m)) - - # test constructor with 12-value input (the last 4 are an implied - # [0,0,0,1]) - m = Matrix(vals4x4[:3]) - self.assertEqual(vals4x4, matrix_vals(m)) - m = Matrix(vals4x4_tuple[:3]) - self.assertEqual(vals4x4, matrix_vals(m)) - - # Test 16-value input with invalid values for the last 4 - invalid = [ - [1.0, 0.0, 0.0, 1.0], - [0.0, 1.0, 0.0, 2.0], - [0.0, 0.0, 1.0, 3.0], - [1.0, 2.0, 3.0, 4.0], - ] - with self.assertRaises(ValueError): - Matrix(invalid) - # Test input with invalid type - with self.assertRaises(TypeError): - Matrix("invalid") - # Test input with invalid size / nested types - with self.assertRaises(TypeError): - Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]]) - with self.assertRaises(TypeError): - Matrix([1, 2, 3]) - - # Invalid sub-type - with self.assertRaises(TypeError): - Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]]) - - # test out-of-bounds access - m = Matrix() - with self.assertRaises(IndexError): - m[0, 4] - with self.assertRaises(IndexError): - m[4, 0] - with self.assertRaises(IndexError): - m["ab"] - - # test __repr__ methods - m = Matrix(vals4x4) - mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])" - self.assertEqual(repr(m), mRepr) - self.assertEqual(str(eval(repr(m))), mRepr) - - def test_matrix_functionality(self): - # Test rotate methods - def matrix_almost_equal(m, target_matrix): - for r, row in enumerate(target_matrix): - for c, target_value in enumerate(row): - self.assertAlmostEqual(m[r, c], target_value) - - root_3_over_2 = math.sqrt(3) / 2 - m_rotate_x_30 = [ - [1, 0, 0, 0], - [0, root_3_over_2, -1 / 2, 0], - [0, 1 / 2, root_3_over_2, 0], - [0, 0, 0, 1], - ] - mx = Matrix() - mx.rotate(Axis.X, 30 * DEG2RAD) - matrix_almost_equal(mx, m_rotate_x_30) - - m_rotate_y_30 = [ - [root_3_over_2, 0, 1 / 2, 0], - [0, 1, 0, 0], - [-1 / 2, 0, root_3_over_2, 0], - [0, 0, 0, 1], - ] - my = Matrix() - my.rotate(Axis.Y, 30 * DEG2RAD) - matrix_almost_equal(my, m_rotate_y_30) - - m_rotate_z_30 = [ - [root_3_over_2, -1 / 2, 0, 0], - [1 / 2, root_3_over_2, 0, 0], - [0, 0, 1, 0], - [0, 0, 0, 1], - ] - mz = Matrix() - mz.rotate(Axis.Z, 30 * DEG2RAD) - matrix_almost_equal(mz, m_rotate_z_30) - - # Test matrix multiply vector - v = Vector(1, 0, 0) - self.assertVectorAlmostEquals(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7) - - # Test matrix multiply matrix - m_rotate_xy_30 = [ - [root_3_over_2, 0, 1 / 2, 0], - [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0], - [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0], - [0, 0, 0, 1], - ] - mxy = mx.multiply(my) - matrix_almost_equal(mxy, m_rotate_xy_30) - - # Test matrix inverse - vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]] - vals4x4_invert = [ - [-53 / 144, 25 / 144, 1 / 16, -53 / 144], - [43 / 144, -23 / 144, 1 / 16, -101 / 144], - [37 / 144, 7 / 144, -1 / 16, -107 / 144], - [0, 0, 0, 1], - ] - m = Matrix(vals4x4).inverse() - matrix_almost_equal(m, vals4x4_invert) - - # Test matrix created from transfer function - rot_x = gp_Trsf() - θ = math.pi - rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ) - m = Matrix(rot_x) - rot_x_matrix = [ - [1, 0, 0, 0], - [0, math.cos(θ), -math.sin(θ), 0], - [0, math.sin(θ), math.cos(θ), 0], - [0, 0, 0, 1], - ] - matrix_almost_equal(m, rot_x_matrix) - - # Test copy - m2 = copy.copy(m) - matrix_almost_equal(m2, rot_x_matrix) - m3 = copy.deepcopy(m) - matrix_almost_equal(m3, rot_x_matrix) - - -class TestMixin1D(DirectApiTestCase): - """Test the add in methods""" - - def test_position_at(self): - self.assertVectorAlmostEquals( - Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5), - (0.5, 0.5, 0.5), - 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() - ) - self.assertTrue(all([0.0 < v < 1.0 for v in point])) - - wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))]) - self.assertVectorAlmostEquals(wire.position_at(0.3), (3, 0, 0), 5) - self.assertVectorAlmostEquals( - wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5 - ) - self.assertVectorAlmostEquals(wire.edge().position_at(0.3), (3, 0, 0), 5) - self.assertVectorAlmostEquals( - wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5 - ) - - circle_wire = Wire( - [ - Edge.make_circle(1, start_angle=0, end_angle=180), - Edge.make_circle(1, start_angle=180, end_angle=360), - ] - ) - p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH) - p2 = circle_wire.position_at(math.pi / circle_wire.length) - self.assertVectorAlmostEquals(p1, (-1, 0, 0), 14) - self.assertVectorAlmostEquals(p2, (-1, 0, 0), 14) - self.assertVectorAlmostEquals(p1, p2, 14) - - circle_edge = Edge.make_circle(1) - p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH) - p4 = circle_edge.position_at(math.pi / circle_edge.length) - self.assertVectorAlmostEquals(p3, (-1, 0, 0), 14) - self.assertVectorAlmostEquals(p4, (-1, 0, 0), 14) - self.assertVectorAlmostEquals(p3, p4, 14) - - circle = Wire( - [ - Edge.make_circle(2, start_angle=0, end_angle=180), - Edge.make_circle(2, start_angle=180, end_angle=360), - ] - ) - self.assertVectorAlmostEquals( - circle.position_at(0.5), - (-2, 0, 0), - 5, - ) - self.assertVectorAlmostEquals( - circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH), - (-2, 0, 0), - 5, - ) - - def test_positions(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.assertVectorAlmostEquals(position, (i / 4, i / 4, i / 4), 5) - - def test_tangent_at(self): - self.assertVectorAlmostEquals( - 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() - ) - self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent])) - - self.assertVectorAlmostEquals( - Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at( - math.pi / 2, position_mode=PositionMode.LENGTH - ), - (-1, 0, 0), - 5, - ) - - def test_tangent_at_point(self): - circle = Wire( - [ - Edge.make_circle(1, start_angle=0, end_angle=180), - Edge.make_circle(1, start_angle=180, end_angle=360), - ] - ) - pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4)) - tan = circle.tangent_at(pnt_on_circle) - self.assertVectorAlmostEquals(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5) - - def test_tangent_at_by_length(self): - circle = Edge.make_circle(1) - tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH) - self.assertVectorAlmostEquals(tan, (0, -1), 5) - - def test_tangent_at_error(self): - with self.assertRaises(ValueError): - Edge.make_circle(1).tangent_at("start") - - def test_normal(self): - self.assertVectorAlmostEquals( - Edge.make_circle( - 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60 - ).normal(), - (1, 0, 0), - 5, - ) - self.assertVectorAlmostEquals( - Edge.make_ellipse( - 1, - 0.5, - Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)), - start_angle=0, - end_angle=90, - ).normal(), - (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), - 5, - ) - self.assertVectorAlmostEquals( - Edge.make_spline( - [ - (1, 0), - (math.sqrt(2) / 2, math.sqrt(2) / 2), - (0, 1), - ], - tangents=((0, 1, 0), (-1, 0, 0)), - ).normal(), - (0, 0, 1), - 5, - ) - with self.assertRaises(ValueError): - Edge.make_line((0, 0, 0), (1, 1, 1)).normal() - - def test_center(self): - c = Edge.make_circle(1, start_angle=0, end_angle=180) - self.assertVectorAlmostEquals(c.center(), (0, 1, 0), 5) - self.assertVectorAlmostEquals( - c.center(CenterOf.MASS), - (0, 0.6366197723675814, 0), - 5, - ) - self.assertVectorAlmostEquals(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5) - - def test_location_at(self): - loc = Edge.make_circle(1).location_at(0.25) - self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5) - self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5) - - loc = Edge.make_circle(1).location_at( - math.pi / 2, position_mode=PositionMode.LENGTH - ) - self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5) - self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5) - - def test_locations(self): - locs = Edge.make_circle(1).locations([i / 4 for i in range(4)]) - self.assertVectorAlmostEquals(locs[0].position, (1, 0, 0), 5) - self.assertVectorAlmostEquals(locs[0].orientation, (-90, 0, -180), 5) - self.assertVectorAlmostEquals(locs[1].position, (0, 1, 0), 5) - self.assertVectorAlmostEquals(locs[1].orientation, (0, -90, -90), 5) - self.assertVectorAlmostEquals(locs[2].position, (-1, 0, 0), 5) - self.assertVectorAlmostEquals(locs[2].orientation, (90, 0, 0), 5) - self.assertVectorAlmostEquals(locs[3].position, (0, -1, 0), 5) - self.assertVectorAlmostEquals(locs[3].orientation, (0, 90, 90), 5) - - 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))) - ellipse: Wire = circle.project(target, (0, 0, -1)) - bbox = ellipse.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5) - self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5) - - def test_project2(self): - target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] - square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0))) - projections: list[Wire] = square.project( - target, direction=(-1, 0, 0), closest=False - ) - self.assertEqual(len(projections), 2) - - def test_is_forward(self): - plate = Box(10, 10, 1) - Cylinder(1, 1) - 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) - - def test_offset_2d(self): - base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False) - corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1] - base_wire = base_wire.fillet_2d(0.4, [corner]) - offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT) - self.assertTrue(offset_wire.is_closed) - self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6) - self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2) - offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT) - self.assertAlmostEqual( - offset_wire_right.edges() - .filter_by(GeomType.CIRCLE) - .sort_by(SortBy.RADIUS)[-1] - .radius, - 0.5, - 4, - ) - h_perimeter = Compound.make_text("h", font_size=10).wire() - with self.assertRaises(RuntimeError): - h_perimeter.offset_2d(-1) - - # Test for returned Edge - can't find a way to do this - # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50) - # self.assertTrue(isinstance(offset_edge, Edge)) - # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) - # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE) - # self.assertAlmostEqual(offset_edge.radius, 12, 5) - # base_edge = Edge.make_line((0, 1), (1, 10)) - # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) - # self.assertTrue(isinstance(offset_edge, Edge)) - # self.assertTrue(offset_edge.geom_type == GeomType.LINE) - # self.assertAlmostEqual(offset_edge.position_at(0).X, 3) - - def test_common_plane(self): - # Straight and circular lines - l = Edge.make_line((0, 0, 0), (5, 0, 0)) - c = Edge.make_circle(2, Plane.XZ, -90, 90) - common = l.common_plane(c) - self.assertAlmostEqual(common.z_dir.X, 0, 5) - self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known - self.assertAlmostEqual(common.z_dir.Z, 0, 5) - - # Co-axial straight lines - l1 = Edge.make_line((0, 0), (1, 1)) - l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75)) - common = l1.common_plane(l2) - # the z_dir isn't know - self.assertAlmostEqual(common.x_dir.Z, 0, 5) - - # Parallel lines - l1 = Edge.make_line((0, 0), (1, 0)) - l2 = Edge.make_line((0, 1), (1, 1)) - common = l1.common_plane(l2) - self.assertAlmostEqual(common.z_dir.X, 0, 5) - self.assertAlmostEqual(common.z_dir.Y, 0, 5) - self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known - - # Many lines - common = Edge.common_plane(*Wire.make_rect(10, 10).edges()) - self.assertAlmostEqual(common.z_dir.X, 0, 5) - self.assertAlmostEqual(common.z_dir.Y, 0, 5) - self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known - - # Wire and Edges - c = Wire.make_circle(1, Plane.YZ) - lines = Wire.make_rect(2, 2, Plane.YZ).edges() - common = c.common_plane(*lines) - self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known - self.assertAlmostEqual(common.z_dir.Y, 0, 5) - self.assertAlmostEqual(common.z_dir.Z, 0, 5) - - def test_edge_volume(self): - edge = Edge.make_line((0, 0), (1, 1)) - self.assertAlmostEqual(edge.volume, 0, 5) - - def test_wire_volume(self): - wire = Wire.make_rect(1, 1) - self.assertAlmostEqual(wire.volume, 0, 5) - - -class TestMixin3D(DirectApiTestCase): - """Test that 3D add ins""" - - def test_chamfer(self): - box = Solid.make_box(1, 1, 1) - chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:]) - self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5) - - def test_chamfer_asym_length(self): - box = Solid.make_box(1, 1, 1) - chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:]) - self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5) - - def test_chamfer_asym_length_with_face(self): - box = Solid.make_box(1, 1, 1) - face = box.faces().sort_by(Axis.Z)[0] - edge = [face.edges().sort_by(Axis.Y)[0]] - chamfer_box = box.chamfer(0.1, 0.2, edge, face=face) - self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5) - - def test_chamfer_too_high_length(self): - box = Solid.make_box(1, 1, 1) - face = box.faces - self.assertRaises( - ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:] - ) - - def test_chamfer_edge_not_part_of_face(self): - box = Solid.make_box(1, 1, 1) - edge = box.edges().sort_by(Axis.Z)[-1:] - face = box.faces().sort_by(Axis.Z)[0] - self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face) - - def test_hollow(self): - shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1) - self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5) - with self.assertRaises(ValueError): - Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT) - - def test_is_inside(self): - self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5))) - - def test_dprism(self): - # face - f = Face.make_rect(0.5, 0.5) - d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( - None, [f], additive=False - ) - self.assertTrue(d.is_valid()) - self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) - - # face with depth - f = Face.make_rect(0.5, 0.5) - 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.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) - - # face until - f = Face.make_rect(0.5, 0.5) - limit = Face.make_rect(1, 1, Plane((0, 0, 0.5))) - 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.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) - - # wire - w = Face.make_rect(0.5, 0.5).outer_wire() - d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( - None, [w], additive=False - ) - self.assertTrue(d.is_valid()) - self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) - - def test_center(self): - with self.assertRaises(ValueError): - Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY) - - self.assertVectorAlmostEquals( - Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX), - (0.5, 0.5, 0.5), - 5, - ) - - -class TestPlane(DirectApiTestCase): - """Plane with class properties""" - - def test_class_properties(self): - """Validate - Name x_dir y_dir z_dir - ======= ====== ====== ====== - XY +x +y +z - YZ +y +z +x - ZX +z +x +y - XZ +x +z -y - YX +y +x -z - ZY +z +y -x - front +x +z -y - back -x +z +y - left -y +z -x - right +y +z +x - top +x +y +z - bottom +x -y -z - isometric +x+y -x+y+z +x+y-z - """ - planes = [ - (Plane.XY, (1, 0, 0), (0, 0, 1)), - (Plane.YZ, (0, 1, 0), (1, 0, 0)), - (Plane.ZX, (0, 0, 1), (0, 1, 0)), - (Plane.XZ, (1, 0, 0), (0, -1, 0)), - (Plane.YX, (0, 1, 0), (0, 0, -1)), - (Plane.ZY, (0, 0, 1), (-1, 0, 0)), - (Plane.front, (1, 0, 0), (0, -1, 0)), - (Plane.back, (-1, 0, 0), (0, 1, 0)), - (Plane.left, (0, -1, 0), (-1, 0, 0)), - (Plane.right, (0, 1, 0), (1, 0, 0)), - (Plane.top, (1, 0, 0), (0, 0, 1)), - (Plane.bottom, (1, 0, 0), (0, 0, -1)), - ( - Plane.isometric, - (1 / 2**0.5, 1 / 2**0.5, 0), - (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5), - ), - ] - for plane, x_dir, z_dir in planes: - self.assertVectorAlmostEquals(plane.x_dir, x_dir, 5) - self.assertVectorAlmostEquals(plane.z_dir, z_dir, 5) - - def test_plane_init(self): - # from origin - o = (0, 0, 0) - x = (1, 0, 0) - y = (0, 1, 0) - z = (0, 0, 1) - planes = [ - Plane(o), - Plane(o, x), - Plane(o, x, z), - Plane(o, x, z_dir=z), - Plane(o, x_dir=x, z_dir=z), - Plane(o, x_dir=x), - Plane(o, z_dir=z), - Plane(origin=o, x_dir=x, z_dir=z), - Plane(origin=o, x_dir=x), - Plane(origin=o, z_dir=z), - ] - for p in planes: - self.assertVectorAlmostEquals(p.origin, o, 6) - self.assertVectorAlmostEquals(p.x_dir, x, 6) - self.assertVectorAlmostEquals(p.y_dir, y, 6) - self.assertVectorAlmostEquals(p.z_dir, z, 6) - with self.assertRaises(TypeError): - Plane() - with self.assertRaises(TypeError): - Plane(o, z_dir="up") - - # rotated location around z - loc = Location((0, 0, 0), (0, 0, 45)) - p_from_loc = Plane(loc) - p_from_named_loc = Plane(location=loc) - for p in [p_from_loc, p_from_named_loc]: - self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6) - self.assertVectorAlmostEquals( - p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6 - ) - self.assertVectorAlmostEquals( - p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6 - ) - self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6) - self.assertVectorAlmostEquals(loc.position, p.location.position, 6) - self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6) - - # rotated location around x and origin <> (0,0,0) - loc = Location((0, 2, -1), (45, 0, 0)) - p = Plane(loc) - self.assertVectorAlmostEquals(p.origin, (0, 2, -1), 6) - self.assertVectorAlmostEquals(p.x_dir, (1, 0, 0), 6) - self.assertVectorAlmostEquals( - p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6 - ) - self.assertVectorAlmostEquals( - p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6 - ) - self.assertVectorAlmostEquals(loc.position, p.location.position, 6) - self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6) - - # from a face - f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45))) - p_from_face = Plane(f) - p_from_named_face = Plane(face=f) - plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped) - p_deep_copy = copy.deepcopy(p_from_face) - for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]: - self.assertVectorAlmostEquals(p.origin, (1, 2, 3), 6) - self.assertVectorAlmostEquals(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6) - self.assertVectorAlmostEquals(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6) - self.assertVectorAlmostEquals( - p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6 - ) - self.assertVectorAlmostEquals(f.location.position, p.location.position, 6) - self.assertVectorAlmostEquals( - f.location.orientation, p.location.orientation, 6 - ) - - # from a face with x_dir - f = Face.make_rect(1, 2) - x = (1, 1) - y = (-1, 1) - planes = [ - Plane(f, x), - Plane(f, x_dir=x), - Plane(face=f, x_dir=x), - ] - for p in planes: - self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6) - self.assertVectorAlmostEquals(p.x_dir, Vector(x).normalized(), 6) - self.assertVectorAlmostEquals(p.y_dir, Vector(y).normalized(), 6) - self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6) - - with self.assertRaises(TypeError): - Plane(Edge.make_line((0, 0), (0, 1))) - - # can be instantiated from planar faces of surface types other than Geom_Plane - # this loft creates the trapezoid faces of type Geom_BSplineSurface - lofted_solid = Solid.make_loft( - [ - Rectangle(3, 1).wire(), - Pos(0, 0, 1) * Rectangle(1, 1).wire(), - ] - ) - - expected = [ - # Trapezoid face, negative y coordinate - ( - Axis.X.direction, # plane x_dir - Axis.Z.direction, # plane y_dir - -Axis.Y.direction, # plane z_dir - ), - # Trapezoid face, positive y coordinate - ( - -Axis.X.direction, - Axis.Z.direction, - Axis.Y.direction, - ), - ] - # assert properties of the trapezoid faces - for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y): - p = Plane(f) - f_props = GProp_GProps() - BRepGProp.SurfaceProperties_s(f.wrapped, f_props) - self.assertVectorAlmostEquals(p.origin, f_props.CentreOfMass(), 6) - self.assertVectorAlmostEquals(p.x_dir, expected[i][0], 6) - self.assertVectorAlmostEquals(p.y_dir, expected[i][1], 6) - self.assertVectorAlmostEquals(p.z_dir, expected[i][2], 6) - - def test_plane_neg(self): - p = Plane( - origin=(1, 2, 3), - x_dir=Vector(1, 2, 3).normalized(), - z_dir=Vector(4, 5, 6).normalized(), - ) - p2 = -p - self.assertVectorAlmostEquals(p2.origin, p.origin, 6) - self.assertVectorAlmostEquals(p2.x_dir, p.x_dir, 6) - self.assertVectorAlmostEquals(p2.z_dir, -p.z_dir, 6) - self.assertVectorAlmostEquals( - p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6 - ) - p3 = p.reverse() - self.assertVectorAlmostEquals(p3.origin, p.origin, 6) - self.assertVectorAlmostEquals(p3.x_dir, p.x_dir, 6) - self.assertVectorAlmostEquals(p3.z_dir, -p.z_dir, 6) - self.assertVectorAlmostEquals( - p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6 - ) - - def test_plane_mul(self): - p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1)) - p2 = p * Location((1, 2, -1), (0, 0, 45)) - self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6) - self.assertVectorAlmostEquals( - p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6 - ) - self.assertVectorAlmostEquals( - p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6 - ) - self.assertVectorAlmostEquals(p2.z_dir, (0, 0, 1), 6) - - p2 = p * Location((1, 2, -1), (0, 45, 0)) - self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6) - self.assertVectorAlmostEquals( - p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6 - ) - self.assertVectorAlmostEquals(p2.y_dir, (0, 1, 0), 6) - self.assertVectorAlmostEquals( - p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6 - ) - - p2 = p * Location((1, 2, -1), (45, 0, 0)) - self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6) - self.assertVectorAlmostEquals(p2.x_dir, (1, 0, 0), 6) - self.assertVectorAlmostEquals( - p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6 - ) - self.assertVectorAlmostEquals( - p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6 - ) - with self.assertRaises(TypeError): - p2 * Vector(1, 1, 1) - - def test_plane_methods(self): - # Test error checking - p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0)) - with self.assertRaises(ValueError): - p.to_local_coords("box") - - # Test translation to local coordinates - local_box = p.to_local_coords(Solid.make_box(1, 1, 1)) - local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()] - target_vertices = [ - (0, -1, 0), - (0, 0, 0), - (0, -1, 1), - (0, 0, 1), - (1, -1, 0), - (1, 0, 0), - (1, -1, 1), - (1, 0, 1), - ] - for i, target_point in enumerate(target_vertices): - self.assertTupleAlmostEquals(target_point, local_box_vertices[i], 7) - - def test_localize_vertex(self): - vertex = Vertex(random.random(), random.random(), random.random()) - self.assertTupleAlmostEquals( - Plane.YZ.to_local_coords(vertex).to_tuple(), - Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(), - 5, - ) - - def test_repr(self): - self.assertEqual( - repr(Plane.XY), - "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))", - ) - - def test_shift_origin_axis(self): - cyl = Cylinder(1, 2, align=Align.MIN) - top = cyl.faces().sort_by(Axis.Z)[-1] - pln = Plane(top).shift_origin(Axis.Z) - with BuildPart() as p: - add(cyl) - with BuildSketch(pln): - with Locations((1, 1)): - Circle(0.5) - extrude(amount=-2, mode=Mode.SUBTRACT) - self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5) - - def test_shift_origin_vertex(self): - box = Box(1, 1, 1, align=Align.MIN) - front = box.faces().sort_by(Axis.X)[-1] - pln = Plane(front).shift_origin( - front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1] - ) - with BuildPart() as p: - add(box) - with BuildSketch(pln): - with Locations((-0.5, 0.5)): - Circle(0.5) - extrude(amount=-1, mode=Mode.SUBTRACT) - self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5) - - def test_shift_origin_vector(self): - with BuildPart() as p: - Box(4, 4, 2) - b = fillet(p.edges().filter_by(Axis.Z), 0.5) - top = p.faces().sort_by(Axis.Z)[-1] - ref = ( - top.edges() - .filter_by(GeomType.CIRCLE) - .group_by(Axis.X)[-1] - .sort_by(Axis.Y)[0] - .arc_center - ) - pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref) - with BuildSketch(pln): - with Locations((0.5, 0.5)): - Rectangle(2, 2, align=Align.MIN) - extrude(amount=-1, mode=Mode.SUBTRACT) - self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5) - - def test_shift_origin_error(self): - with self.assertRaises(ValueError): - Plane.XY.shift_origin(Vertex(1, 1, 1)) - - with self.assertRaises(ValueError): - Plane.XY.shift_origin((1, 1, 1)) - - with self.assertRaises(ValueError): - Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0))) - - with self.assertRaises(TypeError): - Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1))) - - def test_move(self): - pln = Plane.XY.move(Location((1, 2, 3))) - self.assertVectorAlmostEquals(pln.origin, (1, 2, 3), 5) - - def test_rotated(self): - rotated_plane = Plane.XY.rotated((45, 0, 0)) - self.assertVectorAlmostEquals(rotated_plane.x_dir, (1, 0, 0), 5) - self.assertVectorAlmostEquals( - rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5 - ) - - def test_invalid_plane(self): - # Test plane creation error handling - with self.assertRaises(ValueError): - Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1)) - with self.assertRaises(ValueError): - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0)) - - def test_plane_equal(self): - # default orientation - self.assertEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - ) - # moved origin - self.assertEqual( - Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - ) - # moved x-axis - self.assertEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), - Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), - ) - # moved z-axis - self.assertEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), - ) - # __eq__ cooperation - self.assertEqual(Plane.XY, AlwaysEqual()) - - def test_plane_not_equal(self): - # type difference - for value in [None, 0, 1, "abc"]: - self.assertNotEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value - ) - # origin difference - self.assertNotEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - ) - # x-axis difference - self.assertNotEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), - ) - # z-axis difference - self.assertNotEqual( - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), - Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), - ) - - def test_to_location(self): - loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location - self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(loc.orientation, (0, 0, 90), 5) - - def test_intersect(self): - self.assertVectorAlmostEquals( - Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5 - ) - self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0)))) - - self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X) - - self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1))) - - with self.assertRaises(ValueError): - Plane.XY.intersect("Plane.XZ") - - with self.assertRaises(ValueError): - Plane.XY.intersect(pln=Plane.XZ) - - def test_from_non_planar_face(self): - flat = Face.make_rect(1, 1) - pln = Plane(flat) - self.assertTrue(isinstance(pln, Plane)) - cyl = ( - Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0] - ) - with self.assertRaises(ValueError): - pln = Plane(cyl) - - def test_plane_intersect(self): - section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))) - self.assertEqual(len(section.solids()), 0) - self.assertEqual(len(section.faces()), 1) - self.assertAlmostEqual(section.face().area, 2) - - section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)) - self.assertEqual(len(section.solids()), 0) - self.assertEqual(len(section.faces()), 1) - self.assertAlmostEqual(section.face().area, 2) - - self.assertEqual(Plane.XY & Plane.XZ, Axis.X) - # x_axis_as_edge = Plane.XY & Plane.XZ - # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge() - # self.assertAlmostEqual(common.length, 1, 5) - - i = Plane.XY & Vector(1, 2) - self.assertTrue(isinstance(i, Vector)) - self.assertVectorAlmostEquals(i, (1, 2, 0), 5) - - a = Axis((0, 0, 0), (1, 1, 0)) - i = Plane.XY & a - self.assertTrue(isinstance(i, Axis)) - self.assertEqual(i, a) - - a = Axis((1, 2, -1), (0, 0, 1)) - i = Plane.XY & a - self.assertTrue(isinstance(i, Vector)) - self.assertVectorAlmostEquals(i, Vector(1, 2, 0), 5) - - def test_plane_origin_setter(self): - pln = Plane.XY - pln.origin = (1, 2, 3) - ocp_origin = Vector(pln.wrapped.Location()) - self.assertVectorAlmostEquals(ocp_origin, (1, 2, 3), 5) - - -class TestProjection(DirectApiTestCase): - def test_flat_projection(self): - sphere = Solid.make_sphere(50) - projection_direction = Vector(0, -1, 0) - planar_text_faces = ( - Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER)) - .rotate(Axis.X, 90) - .faces() - ) - projected_text_faces = [ - f.project_to_shape(sphere, projection_direction)[0] - for f in planar_text_faces - ] - self.assertEqual(len(projected_text_faces), 4) - - def test_multiple_output_wires(self): - target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2) - circle = Wire.make_circle(3, Plane.XY.offset(10)) - projection = circle.project_to_shape(target, (0, 0, -1)) - bbox = projection[0].bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-3, -3, 1), 2) - self.assertVectorAlmostEquals(bbox.max, (3, 3, 2), 2) - bbox = projection[1].bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-3, -3, -2), 2) - self.assertVectorAlmostEquals(bbox.max, (3, 3, -2), 2) - - def test_text_projection(self): - sphere = Solid.make_sphere(50) - arch_path = ( - sphere.cut( - Solid.make_cylinder( - 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0)) - ) - ) - .edges() - .sort_by(Axis.Z)[0] - ) - - projected_text = sphere.project_faces( - faces=Compound.make_text("dog", font_size=14), - path=arch_path, - ) - self.assertEqual(len(projected_text.solids()), 0) - self.assertEqual(len(projected_text.faces()), 3) - - def test_error_handling(self): - sphere = Solid.make_sphere(50) - circle = Wire.make_circle(1) - with self.assertRaises(ValueError): - circle.project_to_shape(sphere, center=None, direction=None)[0] - - def test_project_edge(self): - projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape( - Solid.make_box(1, 1, 1), (0, 0, 1) - ) - self.assertVectorAlmostEquals(projection[0].position_at(1), (1, 0, 0), 5) - self.assertVectorAlmostEquals(projection[0].position_at(0), (0, 1, 0), 5) - self.assertVectorAlmostEquals(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() - - -class TestRotation(DirectApiTestCase): - def test_rotation_parameters(self): - r = Rotation(10, 20, 30) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation(10, 20, Z=30) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation(10, Y=20, Z=30) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation((10, 20, 30)) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation(10, 20, 30, Intrinsic.XYZ) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation((30, 20, 10), Extrinsic.ZYX) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX) - self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5) - with self.assertRaises(TypeError): - Rotation(x=10) - - -class TestShape(DirectApiTestCase): - """Misc Shape tests""" - - def test_mirror(self): - box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box() - self.assertAlmostEqual(box_bb.min.X, 0, 5) - self.assertAlmostEqual(box_bb.max.X, 1, 5) - self.assertAlmostEqual(box_bb.min.Y, -1, 5) - self.assertAlmostEqual(box_bb.max.Y, 0, 5) - - box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box() - self.assertAlmostEqual(box_bb.min.Z, -1, 5) - self.assertAlmostEqual(box_bb.max.Z, 0, 5) - - def test_compute_mass(self): - with self.assertRaises(NotImplementedError): - Shape.compute_mass(Vertex()) - - def test_combined_center(self): - objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]] - self.assertVectorAlmostEquals( - Shape.combined_center(objs, center_of=CenterOf.MASS), - (0, 0.5, 0.5), - 5, - ) - - objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]] - self.assertVectorAlmostEquals( - Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX), - (-0.5, 0, 0), - 5, - ) - with self.assertRaises(ValueError): - Shape.combined_center(objs, center_of=CenterOf.GEOMETRY) - - def test_shape_type(self): - self.assertEqual(Vertex().shape_type(), "Vertex") - - def test_scale(self): - self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5) - - def test_fuse(self): - 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.assertAlmostEqual(combined.volume, 2, 5) - fuzzy = box1.fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) - self.assertAlmostEqual(fuzzy.volume, 2, 5) - - def test_faces_intersected_by_axis(self): - box = Solid.make_box(1, 1, 1, Plane((0, 0, 1))) - intersected_faces = box.faces_intersected_by_axis(Axis.Z) - self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces) - self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces) - - def test_split(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.BOTTOM) - self.assertEqual(len(split_shape.solids()), 2) - self.assertAlmostEqual(split_shape.volume, 0.25, 5) - self.assertTrue(isinstance(split_shape, Compound)) - split_shape = shape.split(Plane.XY, keep=Keep.TOP) - self.assertEqual(len(split_shape.solids()), 1) - self.assertTrue(isinstance(split_shape, Solid)) - self.assertAlmostEqual(split_shape.volume, 0.5, 5) - - s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10)) - tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face() - s2 = s.split(tool, keep=Keep.TOP) - self.assertLess(s2.volume, s.volume) - self.assertGreater(s2.volume, 0.0) - - def test_split_by_non_planar_face(self): - box = Solid.make_box(1, 1, 1) - tool = Circle(1).wire() - tool_shell: Shell = Shape.extrude(tool, Vector(0, 0, 1)) - split = box.split(tool_shell, keep=Keep.BOTH) - - self.assertEqual(len(split.solids()), 2) - self.assertGreater(split.solids()[0].volume, split.solids()[1].volume) - - def test_split_by_shell(self): - box = Solid.make_box(5, 5, 1) - tool = Wire.make_rect(4, 4) - tool_shell: Shell = Shape.extrude(tool, Vector(0, 0, 1)) - split = box.split(tool_shell, keep=Keep.TOP) - inner_vol = 2 * 2 - outer_vol = 5 * 5 - self.assertAlmostEqual(split.volume, outer_vol - inner_vol) - - def test_split_by_perimeter(self): - # Test 0 - extract a spherical cap - target0 = Solid.make_sphere(10).rotate(Axis.Z, 90) - circle = Plane.YZ.offset(15) * Circle(5).face() - circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0] - circle_outerwire = circle_projected.edge() - inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH) - self.assertLess(inside0.area, outside0.area) - - # Test 1 - extract ring of a sphere - ring = Pos(Z=15) * (Circle(5) - Circle(3)).face() - ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0] - ring_outerwire = ring_projected.outer_wire() - inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH) - self.assertLess(inside1.area, outside1.area) - self.assertEqual(len(outside1.faces()), 2) - - # Test 2 - extract multiple faces - with BuildPart() as cross: - with BuildSketch(Pos(Z=-5) * Rot(Z=-45)) as skt: - Rectangle(5, 1, align=Align.MIN) - Rectangle(1, 5, align=Align.MIN) - fillet(skt.vertices(), 0.3) - extrude(amount=10) - target2 = cross.part - square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0))) - square_projected = square.project_to_shape(cross.part, (-1, 0, 0))[0] - projected_edges = square_projected.edges().sort_by(SortBy.DISTANCE)[2:] - projected_perimeter = Wire(projected_edges) - inside2 = target2.split_by_perimeter(projected_perimeter, Keep.INSIDE) - self.assertTrue(isinstance(inside2, Shell)) - - # Test 3 - Invalid, wire on shape edge - target3 = Solid.make_cylinder(5, 10, Plane((0, 0, -5))) - square_projected = square.project_to_shape(target3, (-1, 0, 0))[0].unwrap( - fully=True - ) - project_perimeter = square_projected.outer_wire() - inside3 = target3.split_by_perimeter(project_perimeter, Keep.INSIDE) - self.assertIsNone(inside3) - outside3 = target3.split_by_perimeter(project_perimeter, Keep.OUTSIDE) - self.assertAlmostEqual(outside3.area, target3.shell().area, 5) - - # Test 4 - invalid inputs - with self.assertRaises(ValueError): - _, _ = target2.split_by_perimeter(projected_perimeter.edges()[0], Keep.BOTH) - - with self.assertRaises(ValueError): - _, _ = target3.split_by_perimeter(projected_perimeter, Keep.TOP) - - def test_distance(self): - sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) - sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) - self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5) - - def test_distances(self): - sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) - sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) - sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5))) - distances = [8, 3] - for i, distance in enumerate(sphere1.distances(sphere2, sphere3)): - self.assertAlmostEqual(distances[i], distance, 5) - - def test_max_fillet(self): - test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)] - max_values = [0.96, 3.84] - for i, test_object in enumerate(test_solids): - with self.subTest("solids" + str(i)): - max = test_object.max_fillet(test_object.edges()) - self.assertAlmostEqual(max, max_values[i], 2) - with self.assertRaises(RuntimeError): - test_solids[0].max_fillet( - test_solids[0].edges(), tolerance=1e-6, max_iterations=1 - ) - with self.assertRaises(ValueError): - box = Solid.make_box(1, 1, 1) - box.fillet(0.75, box.edges()) - # invalid_object = box.fillet(0.75, box.edges()) - # invalid_object.max_fillet(invalid_object.edges()) - - def test_locate_bb(self): - bounding_box = Solid.make_cone(1, 2, 1).bounding_box() - relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box) - self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5) - self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5) - self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5) - self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5) - self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5) - self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5) - - def test_is_equal(self): - box = Solid.make_box(1, 1, 1) - self.assertTrue(box.is_equal(box)) - - def test_equal(self): - box = Solid.make_box(1, 1, 1) - self.assertEqual(box, box) - self.assertEqual(box, AlwaysEqual()) - - def test_not_equal(self): - box = Solid.make_box(1, 1, 1) - diff = Solid.make_box(1, 2, 3) - self.assertNotEqual(box, diff) - self.assertNotEqual(box, object()) - - def test_tessellate(self): - box123 = Solid.make_box(1, 2, 3) - verts, triangles = box123.tessellate(1e-6) - self.assertEqual(len(verts), 24) - self.assertEqual(len(triangles), 12) - - def test_transformed(self): - """Validate that transformed works the same as changing location""" - rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360)) - offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50)) - shape = Solid.make_box(1, 1, 1).transformed(rotation, offset) - 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) - - def test_position_and_orientation(self): - box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30))) - self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5) - - def test_copy(self): - with self.assertWarns(DeprecationWarning): - Solid.make_box(1, 1, 1).copy() - - def test_distance_to_with_closest_points(self): - s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0))) - s1 = Solid.make_sphere(1) - distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1) - self.assertAlmostEqual(distance, 0.1, 5) - self.assertVectorAlmostEquals(pnt0, (0, 1.1, 0), 5) - self.assertVectorAlmostEquals(pnt1, (0, 1, 0), 5) - - def test_closest_points(self): - c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) - c1 = Edge.make_circle(1) - closest = c0.closest_points(c1) - self.assertVectorAlmostEquals(closest[0], c0.position_at(0.75).to_tuple(), 5) - self.assertVectorAlmostEquals(closest[1], c1.position_at(0.25).to_tuple(), 5) - - def test_distance_to(self): - c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) - c1 = Edge.make_circle(1) - distance = c0.distance_to(c1) - self.assertAlmostEqual(distance, 0.1, 5) - - def test_intersection(self): - box = Solid.make_box(1, 1, 1) - intersections = ( - box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z) - ) - self.assertVectorAlmostEquals(intersections[0], (0.5, 0.5, 0), 5) - self.assertVectorAlmostEquals(intersections[1], (0.5, 0.5, 1), 5) - - def test_clean_error(self): - """Note that this test is here to alert build123d to changes in bad OCCT clean behavior - with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this. - """ - sphere = Solid.make_sphere(1) - divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5))) - positive_half, negative_half = [s.clean() for s in sphere.cut(divider).solids()] - self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1) - - 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) - - self.assertEqual(box.location, box_with_hole.location) - - bbox1 = box.bounding_box() - bbox2 = box_with_hole.bounding_box() - self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5) - self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5) - - def test_project_to_viewport(self): - # Basic test - box = Solid.make_box(10, 10, 10) - visible, hidden = box.project_to_viewport((-20, 20, 20)) - self.assertEqual(len(visible), 9) - self.assertEqual(len(hidden), 3) - - # Contour edges - cyl = Solid.make_cylinder(2, 10) - visible, hidden = cyl.project_to_viewport((-20, 20, 20)) - # Note that some edges are broken into two - self.assertEqual(len(visible), 6) - self.assertEqual(len(hidden), 2) - - # Hidden contour edges - hole = box - cyl - visible, hidden = hole.project_to_viewport((-20, 20, 20)) - self.assertEqual(len(visible), 13) - self.assertEqual(len(hidden), 6) - - # Outline edges - sphere = Solid.make_sphere(5) - visible, hidden = sphere.project_to_viewport((-20, 20, 20)) - self.assertEqual(len(visible), 1) - self.assertEqual(len(hidden), 0) - - def test_vertex(self): - v = Edge.make_circle(1).vertex() - self.assertTrue(isinstance(v, Vertex)) - with self.assertWarns(UserWarning): - Wire.make_rect(1, 1).vertex() - - def test_edge(self): - e = Edge.make_circle(1).edge() - self.assertTrue(isinstance(e, Edge)) - with self.assertWarns(UserWarning): - Wire.make_rect(1, 1).edge() - - def test_wire(self): - w = Wire.make_circle(1).wire() - self.assertTrue(isinstance(w, Wire)) - with self.assertWarns(UserWarning): - Solid.make_box(1, 1, 1).wire() - - def test_compound(self): - c = Compound.make_text("hello", 10) - self.assertTrue(isinstance(c, Compound)) - c2 = Compound.make_text("world", 10) - with self.assertWarns(UserWarning): - Compound(children=[c, c2]).compound() - - def test_face(self): - f = Face.make_rect(1, 1) - self.assertTrue(isinstance(f, Face)) - with self.assertWarns(UserWarning): - Solid.make_box(1, 1, 1).face() - - def test_shell(self): - s = Solid.make_sphere(1).shell() - self.assertTrue(isinstance(s, Shell)) - with self.assertWarns(UserWarning): - extrude(Compound.make_text("two", 10), amount=5).shell() - - def test_solid(self): - s = Solid.make_sphere(1).solid() - self.assertTrue(isinstance(s, Solid)) - with self.assertWarns(UserWarning): - Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH).solid() - - def test_manifold(self): - self.assertTrue(Solid.make_box(1, 1, 1).is_manifold) - self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold) - self.assertFalse( - Solid.make_box(1, 1, 1) - .shell() - .cut(Solid.make_box(0.5, 0.5, 0.5)) - .is_manifold - ) - self.assertTrue( - Compound( - children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)] - ).is_manifold - ) - - def test_inherit_color(self): - # Create some objects and assign colors to them - b = Box(1, 1, 1).locate(Pos(2, 2, 0)) - b.color = Color("blue") # Blue - c = Cylinder(1, 1).locate(Pos(-2, 2, 0)) - a = Compound(children=[b, c]) - a.color = Color(0, 1, 0) - # Check that assigned colors stay and iheritance works - self.assertTupleAlmostEquals(tuple(a.color), (0, 1, 0, 1), 5) - self.assertTupleAlmostEquals(tuple(b.color), (0, 0, 1, 1), 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, []) - - verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4))) - self.assertListEqual(verts, []) # ? - self.assertListEqual(edges, []) - - verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5)) - self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5) - self.assertListEqual(edges, []) - - verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY)) - self.assertTupleAlmostEquals(tuple(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 - - # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln)) - # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln)) - # print(vertices1, edges1) - - # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln)) - # print(vertices2, edges2) - - # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln)) - # print(vertices3, edges3) - - # # vertices4, edges4 = cylinder2.ocp_section(cylinder) - - # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln)) - # print(vertices5, edges5) - - # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1]) - - def test_copy_attributes_to(self): - box = Box(1, 1, 1) - box2 = Box(10, 10, 10) - box.label = "box" - box.color = Color("Red") - box.children = [Box(1, 1, 1), Box(2, 2, 2)] - box.topo_parent = box2 - - blank = Compound() - box.copy_attributes_to(blank) - self.assertEqual(blank.label, "box") - self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red")))) - self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children))) - self.assertEqual(blank.topo_parent, box2) - - -class TestShapeList(DirectApiTestCase): - """Test ShapeList functionality""" - - def assertDunderStrEqual(self, actual: str, expected_lines: list[str]): - 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) - 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) - for actual_split, expected_split in zip(actual_split_list, expected_split_list): - self.assertEqual(actual_split, expected_split) - - def test_sort_by(self): - faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA - self.assertAlmostEqual(faces[-1].area, 2, 5) - - def test_filter_by_geomtype(self): - non_planar_faces = ( - Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True) - ) - self.assertEqual(len(non_planar_faces), 1) - self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5) - - with self.assertRaises(ValueError): - Solid.make_box(1, 1, 1).faces().filter_by("True") - - def test_filter_by_axis(self): - box = Solid.make_box(1, 1, 1) - self.assertEqual(len(box.faces().filter_by(Axis.X)), 2) - self.assertEqual(len(box.edges().filter_by(Axis.X)), 4) - self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0) - - def test_filter_by_callable_predicate(self): - boxes = [Solid.make_box(1, 1, 1) for _ in range(3)] - boxes[0].label = "A" - boxes[1].label = "A" - boxes[2].label = "B" - shapelist = ShapeList(boxes) - - self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2) - self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1) - - def test_first_last(self): - vertices = ( - Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1))) - ) - self.assertVectorAlmostEquals(vertices.last, (1, 1, 1), 5) - self.assertVectorAlmostEquals(vertices.first, (0, 0, 0), 5) - - def test_group_by(self): - vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z) - self.assertEqual(len(vertices[0]), 4) - - edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH) - self.assertEqual(len(edges[0]), 12) - - edges = ( - Solid.make_cone(2, 1, 2) - .edges() - .filter_by(GeomType.CIRCLE) - .group_by(SortBy.RADIUS) - ) - self.assertEqual(len(edges[0]), 1) - - edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS - self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5) - - vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE) - self.assertVectorAlmostEquals(vertices[-1][0], (1, 1, 1), 5) - - box = Solid.make_box(1, 1, 2) - self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2) - self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4) - - with BuildPart() as boxes: - with GridLocations(10, 10, 3, 3): - Box(1, 1, 1) - with PolarLocations(100, 10): - Box(1, 1, 2) - self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10) - self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9) - - with self.assertRaises(ValueError): - boxes.solids().group_by("AREA") - - def test_group_by_callable_predicate(self): - boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] - boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] - for box in boxesA: - box.label = "A" - for box in boxesB: - box.label = "B" - boxNoLabel = Solid.make_box(1, 1, 1) - - shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) - result = shapelist.group_by(lambda shape: shape.label) - - self.assertEqual([len(group) for group in result], [1, 3, 2]) - - def test_group_by_retrieve_groups(self): - boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] - boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] - for box in boxesA: - box.label = "A" - for box in boxesB: - box.label = "B" - boxNoLabel = Solid.make_box(1, 1, 1) - - shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) - result = shapelist.group_by(lambda shape: shape.label) - - self.assertEqual(len(result.group("")), 1) - self.assertEqual(len(result.group("A")), 3) - self.assertEqual(len(result.group("B")), 2) - self.assertEqual(result.group(""), result[0]) - self.assertEqual(result.group("A"), result[1]) - self.assertEqual(result.group("B"), result[2]) - self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0])) - self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0])) - with self.assertRaises(KeyError): - result.group("C") - - def test_group_by_str_repr(self): - nonagon = RegularPolygon(5, 9) - - expected = [ - "[[],", - " [,", - " ],", - " [,", - " ],", - " [,", - " ],", - " [,", - " ]]", - ] - - self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected) - - expected_repr = ( - "[[]," - " [," - " ]," - " [," - " ]," - " [," - " ]," - " [," - " ]]" - ) - self.assertDunderReprEqual( - repr(nonagon.edges().group_by(Axis.X)), expected_repr - ) - - f = io.StringIO() - p = pretty.PrettyPrinter(f) - nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True) - self.assertEqual(f.getvalue(), "(...)") - - def test_distance(self): - with BuildPart() as box: - Box(1, 2, 3) - obj = (-0.2, 0.1, 0.5) - edges = box.edges().sort_by_distance(obj) - distances = [Vertex(*obj).distance_to(edge) for edge in edges] - self.assertTrue( - all([distances[i] >= distances[i - 1] for i in range(1, len(edges))]) - ) - - def test_distance_reverse(self): - with BuildPart() as box: - Box(1, 2, 3) - obj = (-0.2, 0.1, 0.5) - edges = box.edges().sort_by_distance(obj, reverse=True) - distances = [Vertex(*obj).distance_to(edge) for edge in edges] - self.assertTrue( - all([distances[i] <= distances[i - 1] for i in range(1, len(edges))]) - ) - - def test_distance_equal(self): - with BuildPart() as box: - Box(1, 1, 1) - self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12) - - def test_vertices(self): - sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) - self.assertEqual(len(sl.vertices()), 8) - - def test_vertex(self): - sl = ShapeList([Edge.make_circle(1)]) - self.assertTupleAlmostEquals(sl.vertex().to_tuple(), (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() - - def test_edges(self): - sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) - self.assertEqual(len(sl.edges()), 8) - - def test_edge(self): - sl = ShapeList([Edge.make_circle(1)]) - self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5) - sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) - with self.assertWarns(UserWarning): - sl.edge() - - def test_wires(self): - sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) - self.assertEqual(len(sl.wires()), 2) - - def test_wire(self): - sl = ShapeList([Wire.make_circle(1)]) - self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5) - sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) - with self.assertWarns(UserWarning): - sl.wire() - - def test_faces(self): - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - self.assertEqual(len(sl.faces()), 9) - - def test_face(self): - sl = ShapeList( - [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)] - ) - self.assertAlmostEqual(sl.face().area, 2 * 1, 5) - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - with self.assertWarns(UserWarning): - sl.face() - - def test_shells(self): - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - self.assertEqual(len(sl.shells()), 2) - - def test_shell(self): - sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)]) - self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5) - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - with self.assertWarns(UserWarning): - sl.shell() - - def test_solids(self): - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - self.assertEqual(len(sl.solids()), 2) - - def test_solid(self): - sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) - with self.assertWarns(UserWarning): - sl.solid() - sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)]) - self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5) - - def test_compounds(self): - sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) - self.assertEqual(len(sl.compounds()), 2) - - def test_compound(self): - sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) - with self.assertWarns(UserWarning): - sl.compound() - sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)]) - self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5) - - def test_equal(self): - box = Box(1, 1, 1) - cyl = Cylinder(1, 1) - sl = ShapeList([box, cyl]) - same = ShapeList([cyl, box]) - self.assertEqual(sl, same) - self.assertEqual(sl, AlwaysEqual()) - - def test_not_equal(self): - sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) - diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)]) - self.assertNotEqual(sl, diff) - self.assertNotEqual(sl, object()) - - -class TestShells(DirectApiTestCase): - 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()) - - def test_center(self): - box_faces = Solid.make_box(1, 1, 1).faces() - box_shell = Shell(box_faces) - self.assertVectorAlmostEquals(box_shell.center(), (0.5, 0.5, 0.5), 5) - - def test_manifold_shell_volume(self): - box_faces = Solid.make_box(1, 1, 1).faces() - box_shell = Shell(box_faces) - self.assertAlmostEqual(box_shell.volume, 1, 5) - - def test_nonmanifold_shell_volume(self): - box_faces = Solid.make_box(1, 1, 1).faces() - nm_shell = Shell(box_faces) - nm_shell -= nm_shell.faces()[0] - self.assertAlmostEqual(nm_shell.volume, 0, 5) - - def test_constructor(self): - with self.assertRaises(ValueError): - Shell(bob="fred") - - 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()) - single_face = Shell(surface.faces()) - self.assertTrue(single_face.is_valid()) - - def test_sweep(self): - path_c1 = JernArc((0, 0), (-1, 0), 1, 180) - path_e = path_c1.edge() - path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90) - path_w = path_c2.wire() - section_e = Circle(0.5).edge() - section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1)) - section_w = section_c2.wire() - - sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w) - sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e) - sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w) - sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1) - sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2) - - self.assertEqual(len(sweep_e_w.faces()), 2) - self.assertEqual(len(sweep_w_e.faces()), 2) - self.assertEqual(len(sweep_c2_c1.faces()), 2) - self.assertEqual(len(sweep_w_w.faces()), 4) - self.assertEqual(len(sweep_c2_c2.faces()), 4) - - def test_make_loft(self): - r = 3 - h = 2 - loft = Shell.make_loft( - [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)] - ) - self.assertEqual(loft.volume, 0, "A shell has no volume") - cylinder_area = 2 * math.pi * r * h - self.assertAlmostEqual(loft.area, cylinder_area) - - def test_thicken(self): - rect = Wire.make_rect(10, 5) - shell: Shell = Shape.extrude(rect, Vector(0, 0, 3)) - thick = shell.thicken(1) - - self.assertEqual(isinstance(thick, Solid), True) - inner_vol = 3 * 10 * 5 - outer_vol = 3 * 12 * 7 - self.assertAlmostEqual(thick.volume, outer_vol - inner_vol) - - -class TestSolid(DirectApiTestCase): - def test_make_solid(self): - box_faces = Solid.make_box(1, 1, 1).faces() - box_shell = Shell(box_faces) - box = Solid(box_shell) - self.assertAlmostEqual(box.area, 6, 5) - self.assertAlmostEqual(box.volume, 1, 5) - self.assertTrue(box.is_valid()) - - def test_extrude(self): - v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1)) - self.assertAlmostEqual(v.length, 1, 5) - - e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1)) - self.assertAlmostEqual(e.area, 1, 5) - - w = Shell.extrude( - Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]), - (0, 0, 1), - ) - self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5) - - f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1)) - self.assertAlmostEqual(f.volume, 1, 5) - - s = Compound.extrude( - Shell( - Solid.make_box(1, 1, 1) - .locate(Location((-2, 1, 0))) - .faces() - .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:] - ), - (0.1, 0.1, 0.1), - ) - self.assertAlmostEqual(s.volume, 0.2, 5) - - with self.assertRaises(ValueError): - Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1)) - - def test_extrude_taper(self): - a = 1 - rect = Face.make_rect(a, a) - flipped = -rect - for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]: - for taper in [10, -10]: - offset_amt = -direction.length * math.tan(math.radians(taper)) - for face in [rect, flipped]: - with self.subTest( - f"{direction=}, {taper=}, flipped={face==flipped}" - ): - taper_solid = Solid.extrude_taper(face, direction, taper) - # V = 1/3 × h × (a² + b² + ab) - h = Vector(direction).length - b = a + 2 * offset_amt - v = h * (a**2 + b**2 + a * b) / 3 - self.assertAlmostEqual(taper_solid.volume, v, 5) - bbox = taper_solid.bounding_box() - size = max(1, b) / 2 - if direction.Z > 0: - self.assertVectorAlmostEquals( - bbox.min, (-size, -size, 0), 1 - ) - self.assertVectorAlmostEquals(bbox.max, (size, size, h), 1) - else: - self.assertVectorAlmostEquals( - bbox.min, (-size, -size, -h), 1 - ) - self.assertVectorAlmostEquals(bbox.max, (size, size, 0), 1) - - def test_extrude_taper_with_hole(self): - rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) - direction = Vector(0, 0, 0.5) - taper = 10 - taper_solid = Solid.extrude_taper(rect_hole, direction, taper) - offset_amt = -direction.length * math.tan(math.radians(taper)) - hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] - self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5) - - def test_extrude_taper_with_hole_flipped(self): - rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) - direction = Vector(0, 0, 1) - taper = 10 - taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True) - taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False) - hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] - hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] - self.assertGreater(hole_t.radius, hole_f.radius) - - def test_extrude_taper_oblique(self): - rect = Face.make_rect(2, 1) - rect_hole = rect.make_holes([Wire.make_circle(0.25)]) - o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77)) - taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5) - taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5) - self.assertAlmostEqual(taper0.volume, taper1.volume, 5) - - def test_extrude_linear_with_rotation(self): - # Face - base = Face.make_rect(1, 1) - twist = Solid.extrude_linear_with_rotation( - base, center=(0, 0, 0), normal=(0, 0, 1), angle=45 - ) - 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) - # Wire - base = Wire.make_rect(1, 1) - twist = Solid.extrude_linear_with_rotation( - base, center=(0, 0, 0), normal=(0, 0, 1), angle=45 - ) - 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) - - def test_make_loft(self): - loft = Solid.make_loft( - [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))] - ) - self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1) - - with self.assertRaises(ValueError): - Solid.make_loft([Wire.make_rect(1, 1)]) - - def test_make_loft_with_vertices(self): - loft = Solid.make_loft( - [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True - ) - self.assertAlmostEqual(loft.volume, 1, 5) - - with self.assertRaises(ValueError): - Solid.make_loft( - [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)] - ) - - with self.assertRaises(ValueError): - Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)]) - - with self.assertRaises(ValueError): - Solid.make_loft( - [ - Vertex(0, 0, 1), - Wire.make_rect(1, 1), - Vertex(0, 0, 2), - Vertex(0, 0, 3), - ] - ) - - def test_extrude_until(self): - square = Face.make_rect(1, 1) - box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3))) - extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST) - self.assertAlmostEqual(extrusion.volume, 4, 5) - - def test_sweep(self): - path = Edge.make_spline([(0, 0), (3, 5), (7, -2)]) - section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0)) - area = Face(section).area - swept = Solid.sweep(section, path) - self.assertAlmostEqual(swept.volume, path.length * area, 0) - - def test_hollow_sweep(self): - path = Edge.make_line((0, 0, 0), (0, 0, 5)) - section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0] - swept = Solid.sweep(section, path) - self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5) - - def test_constructor(self): - with self.assertRaises(ValueError): - Solid(bob="fred") - - -class TestVector(DirectApiTestCase): - """Test the Vector methods""" - - def test_vector_constructors(self): - v1 = Vector(1, 2, 3) - v2 = Vector((1, 2, 3)) - v3 = Vector(gp_Vec(1, 2, 3)) - v4 = Vector([1, 2, 3]) - v5 = Vector(gp_XYZ(1, 2, 3)) - v5b = Vector(X=1, Y=2, Z=3) - v5c = Vector(v=gp_XYZ(1, 2, 3)) - - for v in [v1, v2, v3, v4, v5, v5b, v5c]: - self.assertVectorAlmostEquals(v, (1, 2, 3), 4) - - v6 = Vector((1, 2)) - v7 = Vector([1, 2]) - v8 = Vector(1, 2) - v8b = Vector(X=1, Y=2) - - for v in [v6, v7, v8, v8b]: - self.assertVectorAlmostEquals(v, (1, 2, 0), 4) - - v9 = Vector() - self.assertVectorAlmostEquals(v9, (0, 0, 0), 4) - - v9.X = 1.0 - v9.Y = 2.0 - v9.Z = 3.0 - self.assertVectorAlmostEquals(v9, (1, 2, 3), 4) - self.assertVectorAlmostEquals(Vector(1, 2, 3, 4), (1, 2, 3), 4) - - v10 = Vector(1) - v11 = Vector((1,)) - v12 = Vector([1]) - v13 = Vector(X=1) - for v in [v10, v11, v12, v13]: - self.assertVectorAlmostEquals(v, (1, 0, 0), 4) - - vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10)) - self.assertVectorAlmostEquals(Vector(vertex), (0, 0, 10), 4) - - with self.assertRaises(TypeError): - Vector("vector") - with self.assertRaises(ValueError): - Vector(x=1) - - def test_vector_rotate(self): - """Validate vector rotate methods""" - vector_x = Vector(1, 0, 1).rotate(Axis.X, 45) - vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45) - vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45) - self.assertVectorAlmostEquals( - vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7 - ) - self.assertVectorAlmostEquals(vector_y, (math.sqrt(2), 2, 0), 7) - self.assertVectorAlmostEquals(vector_z, (0, -math.sqrt(2), 3), 7) - - def test_get_signed_angle(self): - """Verify getSignedAngle calculations with and without a provided normal""" - a = math.pi / 3 - v1 = Vector(1, 0, 0) - v2 = Vector(math.cos(a), -math.sin(a), 0) - d1 = v1.get_signed_angle(v2) - d2 = v1.get_signed_angle(v2, Vector(0, 0, 1)) - self.assertAlmostEqual(d1, a * 180 / math.pi) - self.assertAlmostEqual(d2, -a * 180 / math.pi) - - def test_center(self): - v = Vector(1, 1, 1) - self.assertAlmostEqual(v, v.center()) - - def test_dot(self): - v1 = Vector(2, 2, 2) - v2 = Vector(1, -1, 1) - self.assertEqual(2.0, v1.dot(v2)) - - def test_vector_add(self): - result = Vector(1, 2, 0) + Vector(0, 0, 3) - self.assertVectorAlmostEquals(result, (1.0, 2.0, 3.0), 3) - - def test_vector_operators(self): - result = Vector(1, 1, 1) + Vector(2, 2, 2) - self.assertEqual(Vector(3, 3, 3), result) - - result = Vector(1, 2, 3) - Vector(3, 2, 1) - self.assertEqual(Vector(-2, 0, 2), result) - - result = Vector(1, 2, 3) * 2 - self.assertEqual(Vector(2, 4, 6), result) - - result = 3 * Vector(1, 2, 3) - self.assertEqual(Vector(3, 6, 9), result) - - result = Vector(2, 4, 6) / 2 - self.assertEqual(Vector(1, 2, 3), result) - - self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1)) - - self.assertEqual(0, abs(Vector(0, 0, 0))) - self.assertEqual(1, abs(Vector(1, 0, 0))) - self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3))) - - def test_vector_equals(self): - a = Vector(1, 2, 3) - b = Vector(1, 2, 3) - c = Vector(1, 2, 3.000001) - self.assertEqual(a, b) - self.assertEqual(a, c) - self.assertEqual(a, AlwaysEqual()) - - def test_vector_not_equal(self): - a = Vector(1, 2, 3) - b = Vector(3, 2, 1) - self.assertNotEqual(a, b) - self.assertNotEqual(a, object()) - - def test_vector_distance(self): - """ - Test line distance from plane. - """ - v = Vector(1, 2, 3) - - self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ)) - self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX)) - self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY)) - self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY)) - self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ)) - self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX)) - - self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ)) - self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX)) - self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY)) - self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY)) - self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ)) - self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX)) - - def test_vector_project(self): - """ - Test line projection and plane projection methods of Vector - """ - decimal_places = 9 - - z_dir = Vector(1, 2, 3) - base = Vector(5, 7, 9) - x_dir = Vector(1, 0, 0) - - # test passing Plane object - point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir)) - self.assertVectorAlmostEquals(point, (59 / 7, 55 / 7, 51 / 7), decimal_places) - - # test line projection - vec = Vector(10, 10, 10) - line = Vector(3, 4, 5) - angle = vec.get_angle(line) * DEG2RAD - - vecLineProjection = vec.project_to_line(line) - - self.assertVectorAlmostEquals( - vecLineProjection.normalized(), - line.normalized(), - decimal_places, - ) - self.assertAlmostEqual( - vec.length * math.cos(angle), vecLineProjection.length, decimal_places - ) - - def test_vector_not_implemented(self): - pass - - def test_vector_special_methods(self): - self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)") - self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)") - self.assertEqual( - str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)), - "Vector(10, -23.65, 0)", - ) - - def test_vector_iter(self): - self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6) - - def test_reverse(self): - self.assertVectorAlmostEquals(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7) - - def test_copy(self): - v2 = copy.copy(Vector(1, 2, 3)) - v3 = copy.deepcopy(Vector(1, 2, 3)) - self.assertVectorAlmostEquals(v2, (1, 2, 3), 7) - self.assertVectorAlmostEquals(v3, (1, 2, 3), 7) - - def test_radd(self): - vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)] - vector_sum = sum(vectors) - self.assertVectorAlmostEquals(vector_sum, (12, 15, 18), 5) - - def test_hash(self): - vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)] - unique_vectors = list(set(vectors)) - self.assertEqual(len(vectors), 4) - self.assertEqual(len(unique_vectors), 3) - - def test_vector_transform(self): - a = Vector(1, 2, 3) - pxy = Plane.XY - pxy_o1 = Plane.XY.offset(1) - self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a) - self.assertEqual( - a.transform(pxy.forward_transform, is_direction=True), a.normalized() - ) - self.assertEqual( - a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2) - ) - self.assertEqual( - a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized() - ) - self.assertEqual( - a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4) - ) - self.assertEqual( - a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized() - ) - - def test_intersect(self): - v1 = Vector(1, 2, 3) - self.assertVectorAlmostEquals(v1 & Vector(1, 2, 3), (1, 2, 3), 5) - self.assertIsNone(v1 & Vector(0, 0, 0)) - - self.assertVectorAlmostEquals(v1 & Location((1, 2, 3)), (1, 2, 3), 5) - self.assertIsNone(v1 & Location()) - - self.assertVectorAlmostEquals(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5) - self.assertIsNone(v1 & Axis.X) - - self.assertVectorAlmostEquals(v1 & Plane((1, 2, 3)), (1, 2, 3), 5) - self.assertIsNone(v1 & Plane.XY) - - self.assertVectorAlmostEquals( - (v1 & Solid.make_box(2, 4, 5)).vertex(), (1, 2, 3), 5 - ) - self.assertTrue( - len(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)).vertices()) == 0 - ) - - -class TestVectorLike(DirectApiTestCase): - """Test typedef""" - - def test_axis_from_vertex(self): - axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1)) - self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5) - - def test_axis_from_vector(self): - axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1)) - self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5) - - def test_axis_from_tuple(self): - axis = Axis((1, 2, 3), (0, 0, 1)) - self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5) - self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5) - - -class TestVertex(DirectApiTestCase): - """Test the extensions to the cadquery Vertex class""" - - def test_basic_vertex(self): - v = Vertex() - self.assertEqual(0, v.X) - - v = Vertex(1, 1, 1) - self.assertEqual(1, v.X) - self.assertEqual(Vector, type(v.center())) - - self.assertVectorAlmostEquals(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7) - self.assertVectorAlmostEquals(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7) - self.assertVectorAlmostEquals(Vector(Vertex((7,))), (7, 0, 0), 7) - self.assertVectorAlmostEquals(Vector(Vertex((8, 9))), (8, 9, 0), 7) - - def test_vertex_volume(self): - v = Vertex(1, 1, 1) - self.assertAlmostEqual(v.volume, 0, 5) - - def test_vertex_add(self): - test_vertex = Vertex(0, 0, 0) - self.assertVectorAlmostEquals( - Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7 - ) - self.assertVectorAlmostEquals( - Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7 - ) - self.assertVectorAlmostEquals( - Vector(test_vertex + Vertex(100, -40, 10)), - (100, -40, 10), - 7, - ) - with self.assertRaises(TypeError): - test_vertex + [1, 2, 3] - - def test_vertex_sub(self): - test_vertex = Vertex(0, 0, 0) - self.assertVectorAlmostEquals( - Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7 - ) - self.assertVectorAlmostEquals( - Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7 - ) - self.assertVectorAlmostEquals( - Vector(test_vertex - Vertex(100, -40, 10)), - (-100, 40, -10), - 7, - ) - with self.assertRaises(TypeError): - test_vertex - [1, 2, 3] - - def test_vertex_str(self): - self.assertEqual(str(Vertex(0, 0, 0)), "Vertex: (0.0, 0.0, 0.0)") - - def test_vertex_to_vector(self): - self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector) - self.assertVectorAlmostEquals(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7) - - def test_vertex_init_error(self): - with self.assertRaises(TypeError): - Vertex(Axis.Z) - with self.assertRaises(ValueError): - Vertex(x=1) - with self.assertRaises(TypeError): - Vertex((Axis.X, Axis.Y, Axis.Z)) - - def test_no_intersect(self): - with self.assertRaises(NotImplementedError): - Vertex(1, 2, 3) & Vertex(5, 6, 7) - - -class TestWire(DirectApiTestCase): - def test_ellipse_arc(self): - full_ellipse = Wire.make_ellipse(2, 1) - half_ellipse = Wire.make_ellipse( - 2, 1, start_angle=0, end_angle=180, closed=True - ) - self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5) - - def test_stitch(self): - half_ellipse1 = Wire.make_ellipse( - 2, 1, start_angle=0, end_angle=180, closed=False - ) - half_ellipse2 = Wire.make_ellipse( - 2, 1, start_angle=180, end_angle=360, closed=False - ) - ellipse = half_ellipse1.stitch(half_ellipse2) - self.assertEqual(len(ellipse.wires()), 1) - - def test_fillet_2d(self): - square = Wire.make_rect(1, 1) - squaroid = square.fillet_2d(0.1, square.vertices()) - self.assertAlmostEqual( - squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5 - ) - - def test_chamfer_2d(self): - square = Wire.make_rect(1, 1) - squaroid = square.chamfer_2d(0.1, 0.1, square.vertices()) - self.assertAlmostEqual( - squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5 - ) - - def test_chamfer_2d_edge(self): - square = Wire.make_rect(1, 1) - edge = square.edges().sort_by(Axis.Y)[0] - vertex = edge.vertices().sort_by(Axis.X)[0] - square = square.chamfer_2d( - distance=0.1, distance2=0.2, vertices=[vertex], edge=edge - ) - self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9) - - def test_make_convex_hull(self): - # overlapping_edges = [ - # Edge.make_circle(10, end_angle=60), - # Edge.make_circle(10, start_angle=30, end_angle=90), - # Edge.make_line((-10, 10), (10, -10)), - # ] - # with self.assertRaises(ValueError): - # Wire.make_convex_hull(overlapping_edges) - - adjoining_edges = [ - Edge.make_circle(10, end_angle=45), - Edge.make_circle(10, start_angle=315, end_angle=360), - Edge.make_line((-10, 10), (-10, -10)), - ] - hull_wire = Wire.make_convex_hull(adjoining_edges) - self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4) - - # def test_fix_degenerate_edges(self): - # # 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)) - # edge1a = edge1.trim(0, 1e-7) - # edge1b = edge1.trim(1e-7, 1.0) - # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0)) - # wire = Wire([edge0, edge1a, edge1b, edge2]) - # fixed_wire = wire.fix_degenerate_edges(1e-6) - # self.assertEqual(len(fixed_wire.edges()), 2) - - def test_trim(self): - e0 = Edge.make_line((0, 0), (1, 0)) - e1 = Edge.make_line((2, 0), (1, 0)) - e2 = Edge.make_line((2, 0), (3, 0)) - w1 = Wire([e0, e1, e2]) - t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0))) - self.assertAlmostEqual(t1.length, 2.1, 5) - - e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) - # Three edges are created 0->0.5->0.75->1.0 - o = e.offset_2d(10, side=Side.RIGHT, closed=False) - t2 = o.trim(0.1, 0.9) - self.assertAlmostEqual(t2.length, o.length * 0.8, 5) - - t3 = o.trim(0.5, 1.0) - self.assertAlmostEqual(t3.length, o.length * 0.5, 5) - - 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) - spline = Spline( - (0, 0, 0), - (0, 10, 0), - tangents=((0, 0, 1), (0, 0, -1)), - tangent_scalars=(2, 2), - ) - half = spline.trim(0.5, 1) - self.assertVectorAlmostEquals(spline @ 0.5, half @ 0, 4) - self.assertVectorAlmostEquals(spline @ 1, half @ 1, 4) - - w = Rectangle(3, 1).wire() - t5 = w.trim(0, 0.5) - self.assertAlmostEqual(t5.length, 4, 5) - t6 = w.trim(0.5, 1) - self.assertAlmostEqual(t6.length, 4, 5) - - p = RegularPolygon(10, 20).wire() - t7 = p.trim(0.1, 0.2) - self.assertAlmostEqual(p.length * 0.1, t7.length, 5) - - c = Circle(10).wire() - t8 = c.trim(0.4, 0.9) - self.assertAlmostEqual(c.length * 0.5, t8.length, 5) - - def test_param_at_point(self): - e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) - # Three edges are created 0->0.5->0.75->1.0 - o = e.offset_2d(10, side=Side.RIGHT, closed=False) - - e0 = Edge.make_line((0, 0), (1, 0)) - e1 = Edge.make_line((2, 0), (1, 0)) - e2 = Edge.make_line((2, 0), (3, 0)) - w1 = Wire([e0, e1, e2]) - for wire in [o, w1]: - u_value = random.random() - position = wire.position_at(u_value) - self.assertAlmostEqual(wire.param_at_point(position), u_value, 4) - - with self.assertRaises(ValueError): - o.param_at_point((-1, 1)) - - with self.assertRaises(ValueError): - w1.param_at_point((20, 20, 20)) - - def test_order_edges(self): - w1 = Wire( - [ - Edge.make_line((0, 0), (1, 0)), - Edge.make_line((1, 1), (1, 0)), - Edge.make_line((0, 1), (1, 1)), - ] - ) - 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.assertVectorAlmostEquals(ordered_edges[0] @ 0, (0, 0, 0), 5) - self.assertVectorAlmostEquals(ordered_edges[1] @ 0, (1, 0, 0), 5) - self.assertVectorAlmostEquals(ordered_edges[2] @ 0, (1, 1, 0), 5) - - 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()) - w2 = Wire([e0]) - self.assertAlmostEqual(w2.length, 1, 5) - self.assertTrue(w2.is_valid()) - w3 = Wire([e0, e1]) - self.assertTrue(w3.is_valid()) - self.assertAlmostEqual(w3.length, 2, 5) - w4 = Wire(w0.wrapped) - self.assertTrue(w4.is_valid()) - w5 = Wire(obj=w0.wrapped) - self.assertTrue(w5.is_valid()) - w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red")) - self.assertTrue(w6.is_valid()) - self.assertEqual(w6.label, "w6") - self.assertTupleAlmostEquals(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 5) - w7 = Wire(w6) - self.assertTrue(w7.is_valid()) - c0 = Polyline((0, 0), (1, 0), (1, 1)) - w8 = Wire(c0) - self.assertTrue(w8.is_valid()) - with self.assertRaises(ValueError): - Wire(bob="fred") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_direct_api/test_always_equal.py b/tests/test_direct_api/test_always_equal.py new file mode 100644 index 0000000..ded5c9b --- /dev/null +++ b/tests/test_direct_api/test_always_equal.py @@ -0,0 +1,38 @@ +""" +build123d imports + +name: test_always_equal.py +by: Gumyr +date: January 22, 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 unittest + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py new file mode 100644 index 0000000..71e862b --- /dev/null +++ b/tests/test_direct_api/test_assembly.py @@ -0,0 +1,125 @@ +""" +build123d imports + +name: test_assembly.py +by: Gumyr +date: January 22, 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 re +import unittest + +from build123d.topology import Compound, Solid + + +class TestAssembly(unittest.TestCase): + @staticmethod + def create_test_assembly() -> Compound: + box = Solid.make_box(1, 1, 1) + box.orientation = (45, 45, 0) + box.label = "box" + sphere = Solid.make_sphere(1) + sphere.label = "sphere" + sphere.position = (1, 2, 3) + assembly = Compound(label="assembly", children=[box]) + sphere.parent = assembly + return assembly + + def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]): + 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, maxsplit=2, flags=re.I) + self.assertTrue(actual_line.startswith(start)) + self.assertTrue(actual_line.endswith(end)) + + def test_attributes(self): + box = Solid.make_box(1, 1, 1) + box.label = "box" + sphere = Solid.make_sphere(1) + sphere.label = "sphere" + assembly = Compound(label="assembly", children=[box]) + sphere.parent = assembly + + self.assertEqual(len(box.children), 0) + self.assertEqual(box.label, "box") + self.assertEqual(box.parent, assembly) + self.assertEqual(sphere.parent, assembly) + self.assertEqual(len(assembly.children), 2) + + def test_show_topology_compound(self): + assembly = TestAssembly.create_test_assembly() + expected = [ + "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))", + "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))", + "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))", + ] + self.assertTopoEqual(assembly.show_topology("Solid"), expected) + + def test_show_topology_shape_location(self): + assembly = TestAssembly.create_test_assembly() + expected = [ + "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)", + "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)", + " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)", + ] + self.assertTopoEqual( + assembly.children[1].show_topology("Face", show_center=False), expected + ) + + def test_show_topology_shape(self): + assembly = TestAssembly.create_test_assembly() + expected = [ + "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)", + "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)", + " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)", + ] + self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected) + + def test_remove_child(self): + assembly = TestAssembly.create_test_assembly() + self.assertEqual(len(assembly.children), 2) + assembly.children = list(assembly.children)[1:] + self.assertEqual(len(assembly.children), 1) + + def test_do_children_intersect(self): + ( + overlap, + pair, + distance, + ) = TestAssembly.create_test_assembly().do_children_intersect() + self.assertFalse(overlap) + box = Solid.make_box(1, 1, 1) + box.orientation = (45, 45, 0) + box.label = "box" + sphere = Solid.make_sphere(1) + sphere.label = "sphere" + sphere.position = (0, 0, 0) + assembly = Compound(label="assembly", children=[box]) + sphere.parent = assembly + overlap, pair, distance = assembly.do_children_intersect() + self.assertTrue(overlap) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py new file mode 100644 index 0000000..c0bbd46 --- /dev/null +++ b/tests/test_direct_api/test_axis.py @@ -0,0 +1,282 @@ +""" +build123d imports + +name: test_axis.py +by: Gumyr +date: January 22, 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. + +""" + +# Always equal to any other object, to test that __eq__ cooperation is working +import copy +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, Vertex + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +class TestAxis(unittest.TestCase): + """Test the Axis class""" + + def test_axis_init(self): + test_axis = Axis((1, 2, 3), (0, 0, 1)) + self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis((1, 2, 3), direction=(0, 0, 1)) + self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1)) + self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4))) + self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4))) + 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)) + test_axis = Axis(occt_axis) + self.assertAlmostEqual(test_axis.position, (1, 1, 1), 5) + self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5) + + def test_axis_repr_and_str(self): + self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))") + self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))") + + def test_axis_copy(self): + x_copy = copy.copy(Axis.X) + self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5) + self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5) + x_copy = copy.deepcopy(Axis.X) + self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5) + self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5) + + def test_axis_to_location(self): + # TODO: Verify this is correct + x_location = Axis.X.location + self.assertTrue(isinstance(x_location, Location)) + self.assertAlmostEqual(x_location.position, (0, 0, 0), 5) + self.assertAlmostEqual(x_location.orientation, (0, 90, 180), 5) + + def test_axis_located(self): + y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0))) + self.assertAlmostEqual(y_axis.position, (0, 0, 1), 5) + self.assertAlmostEqual(y_axis.direction, (0, 1, 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)))) + self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0)))) + self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0)))) + + def test_axis_is_normal(self): + self.assertTrue(Axis.X.is_normal(Axis.Y)) + self.assertFalse(Axis.X.is_normal(Axis.X)) + + def test_axis_is_opposite(self): + self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0)))) + self.assertFalse(Axis.X.is_opposite(Axis.X)) + + def test_axis_is_parallel(self): + self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0)))) + self.assertFalse(Axis.X.is_parallel(Axis.Y)) + + def test_axis_is_skew(self): + self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1)))) + self.assertFalse(Axis.X.is_skew(Axis.Y)) + + def test_axis_is_skew(self): + # Skew Axes + self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1)))) + + # Perpendicular but intersecting + self.assertFalse(Axis.X.is_skew(Axis.Y)) + + # Parallel coincident axes + self.assertFalse(Axis.X.is_skew(Axis.X)) + + # Parallel but distinct axes + self.assertTrue(Axis.X.is_skew(Axis((0, 1, 0), (1, 0, 0)))) + + # Coplanar but not intersecting + self.assertTrue(Axis((0, 0, 0), (1, 1, 0)).is_skew(Axis((0, 1, 0), (1, 1, 0)))) + + def test_axis_angle_between(self): + self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5) + self.assertAlmostEqual( + Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5 + ) + + def test_axis_reverse(self): + self.assertAlmostEqual(Axis.X.reverse().direction, (-1, 0, 0), 5) + + def test_axis_reverse_op(self): + axis = -Axis.X + self.assertAlmostEqual(axis.direction, (-1, 0, 0), 5) + + def test_axis_as_edge(self): + edge = Edge(Axis.X) + self.assertTrue(isinstance(edge, Edge)) + common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge() + self.assertAlmostEqual(common.length, 1, 5) + + def test_axis_intersect(self): + common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge() + self.assertAlmostEqual(common.length, 1, 5) + + common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge() + self.assertAlmostEqual(common.length, 1, 5) + + intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0)) + self.assertAlmostEqual(intersection, (1, 0, 0), 5) + + i = Axis.X & Axis((1, 0, 0), (1, 0, 0)) + self.assertEqual(i, Axis.X) + + # Skew case + 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, (1, 2, 0), 5) + + arc = Edge.make_circle(20, start_angle=0, end_angle=180) + ax0 = Axis((-20, 30, 0), (4, -3, 0)) + intersections = arc.intersect(ax0).vertices().sort_by(Axis.X) + np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5) + np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5) + + intersections = ax0.intersect(arc).vertices().sort_by(Axis.X) + np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5) + np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5) + + i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5) + self.assertTrue(isinstance(i, Vector)) + self.assertAlmostEqual(i, (0.5, 0.5, 1.5), 5) + self.assertIsNone(Axis.Y & Vector(2, 0, 0)) + + l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1 + i: Location = Axis.Z & l + self.assertTrue(isinstance(i, Location)) + self.assertAlmostEqual(i.position, l.position, 5) + self.assertAlmostEqual(i.orientation, l.orientation, 5) + + self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1)) + self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1)) + + # TODO: uncomment when generalized edge to surface intersections are complete + # non_planar = ( + # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True) + # ) + # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar + + # self.assertTrue(len(intersections.vertices(), 2)) + # np.testing.assert_allclose( + # intersection.vertices()[0], (-1, 0, 5), 5 + # ) + # np.testing.assert_allclose( + # intersection.vertices()[1], (1, 0, 5), 5 + # ) + + def test_axis_equal(self): + self.assertEqual(Axis.X, Axis.X) + self.assertEqual(Axis.Y, Axis.Y) + self.assertEqual(Axis.Z, Axis.Z) + self.assertEqual(Axis.X, AlwaysEqual()) + + def test_axis_not_equal(self): + self.assertNotEqual(Axis.X, Axis.Y) + 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 new file mode 100644 index 0000000..26e4ddf --- /dev/null +++ b/tests/test_direct_api/test_bound_box.py @@ -0,0 +1,107 @@ +""" +build123d imports + +name: test_bound_box.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.geometry import BoundBox, Vector +from build123d.topology import Solid, Vertex + + +class TestBoundBox(unittest.TestCase): + def test_basic_bounding_box(self): + v = Vertex(1, 1, 1) + v2 = Vertex(2, 2, 2) + self.assertEqual(BoundBox, type(v.bounding_box())) + self.assertEqual(BoundBox, type(v2.bounding_box())) + + bb1 = v.bounding_box().add(v2.bounding_box()) + + # 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) + bb2 = v0.bounding_box().add(v.bounding_box()) + + 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) + + bb3 = bb2.add(Vector(3, 3, 3)) + self.assertAlmostEqual(bb3.size, (3, 3, 3), 7) + + # Test 2D bounding boxes + 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)) + # Test that neither bounding box contains the other + self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3)) + + # Test creation of a bounding box from a shape - note the low accuracy comparison + # as the box is a little larger than the shape + bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False) + self.assertAlmostEqual(bb1.size, (2, 2, 1), 1) + + bb2 = BoundBox.from_topo_ds( + Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False + ) + self.assertTrue(bb2.is_inside(bb1)) + + def test_bounding_box_repr(self): + bb = Solid.make_box(1, 1, 1).bounding_box() + self.assertEqual( + repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0" + ) + + def test_center_of_boundbox(self): + self.assertAlmostEqual( + Solid.make_box(1, 1, 1).bounding_box().center(), + (0.5, 0.5, 0.5), + 5, + ) + + def test_combined_center_of_boundbox(self): + pass + + def test_clean_boundbox(self): + s = Solid.make_sphere(3) + self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5) + s.mesh(1e-3) + self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_cad_objects.py b/tests/test_direct_api/test_cad_objects.py new file mode 100644 index 0000000..ce36057 --- /dev/null +++ b/tests/test_direct_api/test_cad_objects.py @@ -0,0 +1,264 @@ +""" +build123d imports + +name: test_cad_objects.py +by: Gumyr +date: January 22, 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 unittest + +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge +from OCP.gp import gp, gp_Ax2, gp_Circ, gp_Elips, gp_Pnt +from build123d.build_enums import CenterOf +from build123d.geometry import Plane, Vector +from build123d.topology import Edge, Face, Wire + + +class TestCadObjects(unittest.TestCase): + def _make_circle(self): + circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0) + return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge()) + + def _make_ellipse(self): + ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0) + return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge()) + + def test_edge_wrapper_center(self): + e = self._make_circle() + + self.assertAlmostEqual(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3) + + def test_edge_wrapper_ellipse_center(self): + e = self._make_ellipse() + w = Wire([e]) + self.assertAlmostEqual(Face(w).center(), (1.0, 2.0, 3.0), 3) + + def test_edge_wrapper_make_circle(self): + halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180) + + # np.testing.assert_allclose((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),1e-3) + self.assertAlmostEqual(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3) + self.assertAlmostEqual(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3) + + def test_edge_wrapper_make_tangent_arc(self): + tangent_arc = Edge.make_tangent_arc( + Vector(1, 1), # starts at 1, 1 + Vector(0, 1), # tangent at start of arc is in the +y direction + Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1 + ) + self.assertAlmostEqual(tangent_arc.start_point(), (1, 1, 0), 3) + self.assertAlmostEqual(tangent_arc.end_point(), (2, 1, 0), 3) + self.assertAlmostEqual(tangent_arc.tangent_at(0), (0, 1, 0), 3) + self.assertAlmostEqual(tangent_arc.tangent_at(0.5), (1, 0, 0), 3) + self.assertAlmostEqual(tangent_arc.tangent_at(1), (0, -1, 0), 3) + + def test_edge_wrapper_make_ellipse1(self): + # Check x_radius > y_radius + x_radius, y_radius = 20, 10 + angle1, angle2 = -75.0, 90.0 + arcEllipseEdge = Edge.make_ellipse( + x_radius=x_radius, + y_radius=y_radius, + plane=Plane.XY, + start_angle=angle1, + end_angle=angle2, + ) + + start = ( + x_radius * math.cos(math.radians(angle1)), + y_radius * math.sin(math.radians(angle1)), + 0.0, + ) + end = ( + x_radius * math.cos(math.radians(angle2)), + y_radius * math.sin(math.radians(angle2)), + 0.0, + ) + self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3) + self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3) + + def test_edge_wrapper_make_ellipse2(self): + # Check x_radius < y_radius + x_radius, y_radius = 10, 20 + angle1, angle2 = 0.0, 45.0 + arcEllipseEdge = Edge.make_ellipse( + x_radius=x_radius, + y_radius=y_radius, + plane=Plane.XY, + start_angle=angle1, + end_angle=angle2, + ) + + start = ( + x_radius * math.cos(math.radians(angle1)), + y_radius * math.sin(math.radians(angle1)), + 0.0, + ) + end = ( + x_radius * math.cos(math.radians(angle2)), + y_radius * math.sin(math.radians(angle2)), + 0.0, + ) + self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3) + self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3) + + def test_edge_wrapper_make_circle_with_ellipse(self): + # Check x_radius == y_radius + x_radius, y_radius = 20, 20 + angle1, angle2 = 15.0, 60.0 + arcEllipseEdge = Edge.make_ellipse( + x_radius=x_radius, + y_radius=y_radius, + plane=Plane.XY, + start_angle=angle1, + end_angle=angle2, + ) + + start = ( + x_radius * math.cos(math.radians(angle1)), + y_radius * math.sin(math.radians(angle1)), + 0.0, + ) + end = ( + x_radius * math.cos(math.radians(angle2)), + y_radius * math.sin(math.radians(angle2)), + 0.0, + ) + self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3) + self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3) + + def test_face_wrapper_make_rect(self): + mplane = Face.make_rect(10, 10) + + self.assertAlmostEqual(mplane.normal_at(), (0.0, 0.0, 1.0), 3) + + # def testCompoundcenter(self): + # """ + # Tests whether or not a proper weighted center can be found for a compound + # """ + + # def cylinders(self, radius, height): + + # c = Solid.make_cylinder(radius, height, Vector()) + + # # Combine all the cylinders into a single compound + # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids() + + # return r + + # Workplane.cyl = cylinders + + # # Now test. here we want weird workplane to see if the objects are transformed right + # s = ( + # Workplane("XY") + # .rect(2.0, 3.0, for_construction=true) + # .vertices() + # .cyl(0.25, 0.5) + # ) + + # self.assertEqual(4, len(s.val().solids())) + # np.testing.assert_allclose((0.0, 0.0, 0.25), s.val().center, 1e-3) + + def test_translate(self): + e = Edge.make_circle(2, Plane((1, 2, 3))) + e2 = e.translate(Vector(0, 0, 1)) + + self.assertAlmostEqual(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3) + + def test_vertices(self): + e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge()) + self.assertEqual(2, len(e.vertices())) + + def test_edge_wrapper_radius(self): + # get a radius from a simple circle + e0 = Edge.make_circle(2.4) + self.assertAlmostEqual(e0.radius, 2.4) + + # radius of an arc + e1 = Edge.make_circle( + 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30 + ) + self.assertAlmostEqual(e1.radius, 1.8) + + # test value errors + e2 = Edge.make_ellipse(10, 20) + with self.assertRaises(ValueError): + e2.radius + + # radius from a wire + w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1))) + self.assertAlmostEqual(w0.radius, 10) + + # radius from a wire with multiple edges + rad = 2.3 + plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1)) + w1 = Wire( + [ + Edge.make_circle(rad, plane, 0, 10), + Edge.make_circle(rad, plane, 10, 25), + Edge.make_circle(rad, plane, 25, 230), + ] + ) + self.assertAlmostEqual(w1.radius, rad) + + # test value error from wire + w2 = Wire.make_polygon( + [ + Vector(-1, 0, 0), + Vector(0, 1, 0), + Vector(1, -1, 0), + ] + ) + with self.assertRaises(ValueError): + w2.radius + + # (I think) the radius of a wire is the radius of it's first edge. + # Since this is stated in the docstring better make sure. + no_rad = Wire( + [ + Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)), + Edge.make_circle(1.0, start_angle=90, end_angle=270), + ] + ) + with self.assertRaises(ValueError): + no_rad.radius + yes_rad = Wire( + [ + Edge.make_circle(1.0, start_angle=90, end_angle=270), + Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)), + ] + ) + self.assertAlmostEqual(yes_rad.radius, 1.0) + many_rad = Wire( + [ + Edge.make_circle(1.0, start_angle=0, end_angle=180), + Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359), + ] + ) + self.assertAlmostEqual(many_rad.radius, 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_clean_method.py b/tests/test_direct_api/test_clean_method.py new file mode 100644 index 0000000..04bedcf --- /dev/null +++ b/tests/test_direct_api/test_clean_method.py @@ -0,0 +1,70 @@ +""" +build123d imports + +name: test_clean_method.py +by: Gumyr +date: January 22, 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 unittest +from unittest.mock import patch, MagicMock + +from build123d.topology import Solid + + +class TestCleanMethod(unittest.TestCase): + def setUp(self): + # Create a mock object + self.solid = Solid() + self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object + + @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain") + def test_clean_warning_on_exception(self, mock_shape_upgrade): + # Mock the upgrader + mock_upgrader = mock_shape_upgrade.return_value + mock_upgrader.Build.side_effect = Exception("Mocked Build failure") + + # Capture warnings + with self.assertWarns(Warning) as warn_context: + self.solid.clean() + + # Assert the warning message + self.assertIn("Unable to clean", str(warn_context.warning)) + + # Verify the upgrader was constructed with the correct arguments + mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True) + + # Verify the Build method was called + mock_upgrader.Build.assert_called_once() + + def test_clean_with_none_wrapped(self): + # Set `wrapped` to None to simulate the error condition + self.solid.wrapped = None + + # Call clean and ensure it returns self + result = self.solid.clean() + self.assertIs(result, self.solid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py new file mode 100644 index 0000000..9e50a8a --- /dev/null +++ b/tests/test_direct_api/test_color.py @@ -0,0 +1,321 @@ +""" +build123d imports + +name: test_color.py +by: Gumyr +date: January 22, 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 colorsys +import copy +import math +import numpy as np +import pytest + +from OCP.Quantity import Quantity_ColorRGBA +from build123d.geometry import Color + + +# Overloads +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), + pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), + pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), + ], +) +def test_overload_name(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) + + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), + pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), + pytest.param( + Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha" + ), + pytest.param( + Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), + (0.1, 0.2, 0.3, 0.5), + id="kw rgba", + ), + ], +) +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 new file mode 100644 index 0000000..9f93460 --- /dev/null +++ b/tests/test_direct_api/test_compound.py @@ -0,0 +1,162 @@ +""" +build123d imports + +name: test_compound.py +by: Gumyr +date: January 22, 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 itertools +import unittest + +from build123d.build_common import GridLocations, PolarLocations +from build123d.build_enums import Align, CenterOf +from build123d.geometry import Location, Plane +from build123d.objects_part import Box +from build123d.objects_sketch import Circle +from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch + + +class TestCompound(unittest.TestCase): + def test_make_text(self): + arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0)) + text = Compound.make_text("test", 10, text_path=arc) + self.assertEqual(len(text.faces()), 4) + text = Compound.make_text( + "test", 10, align=(Align.MAX, Align.MAX), text_path=arc + ) + self.assertEqual(len(text.faces()), 4) + + def test_fuse(self): + 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.assertAlmostEqual(combined.volume, 2, 5) + fuzzy = Compound([box1]).fuse(box2, tol=1e-6) + self.assertTrue(fuzzy.is_valid) + self.assertAlmostEqual(fuzzy.volume, 2, 5) + + def test_remove(self): + box1 = Solid.make_box(1, 1, 1) + box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0))) + combined = Compound([box1, box2]) + self.assertTrue(len(combined._remove(box2).solids()), 1) + + def test_repr(self): + simple = Compound([Solid.make_box(1, 1, 1)]) + simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1] + self.assertEqual(simple_str, "Compound at label()") + + assembly = Compound([Solid.make_box(1, 1, 1)]) + assembly.children = [Solid.make_box(1, 1, 1)] + assembly.label = "test" + assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1] + self.assertEqual(assembly_str, "Compound at abel(test), #children(1)") + + def test_center(self): + test_compound = Compound( + [ + Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))), + Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))), + ] + ) + self.assertAlmostEqual(test_compound.center(CenterOf.MASS), (1, 0, 0), 5) + self.assertAlmostEqual( + test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5 + ) + with self.assertRaises(ValueError): + test_compound.center(CenterOf.GEOMETRY) + + def test_triad(self): + triad = Compound.make_triad(10) + bbox = triad.bounding_box() + self.assertGreater(bbox.min.X, -10 / 8) + self.assertLess(bbox.min.X, 0) + self.assertGreater(bbox.min.Y, -10 / 8) + self.assertLess(bbox.min.Y, 0) + self.assertGreater(bbox.min.Y, -10 / 8) + self.assertAlmostEqual(bbox.min.Z, 0, 4) + self.assertLess(bbox.size.Z, 12.5) + self.assertEqual(triad.volume, 0) + + def test_volume(self): + e = Edge.make_line((0, 0), (1, 1)) + self.assertAlmostEqual(e.volume, 0, 5) + + f = Face.make_rect(1, 1) + self.assertAlmostEqual(f.volume, 0, 5) + + b = Solid.make_box(1, 1, 1) + self.assertAlmostEqual(b.volume, 1, 5) + + bb = Box(1, 1, 1) + self.assertAlmostEqual(bb.volume, 1, 5) + + c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))]) + self.assertAlmostEqual(c.volume, 3, 5) + # N.B. b and bb overlap but still add to Compound volume + + def test_constructor(self): + with self.assertRaises(TypeError): + Compound(foo="bar") + + def test_len(self): + self.assertEqual(len(Compound()), 0) + skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) + self.assertEqual(len(skt), 4) + + def test_iteration(self): + skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) + for c1, c2 in itertools.combinations(skt, 2): + self.assertGreaterEqual((c1.position - c2.position).length, 10) + + def test_unwrap(self): + skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1) + skt2 = Compound(children=[skt]) + self.assertEqual(len(skt2), 1) + skt3 = skt2.unwrap(fully=False) + self.assertEqual(len(skt3), 4) + + comp1 = Compound().unwrap() + self.assertEqual(len(comp1), 0) + comp2 = Compound(children=[Face.make_rect(1, 1)]) + comp3 = Compound(children=[comp2]) + self.assertEqual(len(comp3), 1) + self.assertTrue(isinstance(next(iter(comp3)), Compound)) + comp4 = comp3.unwrap(fully=True) + self.assertTrue(isinstance(comp4, Face)) + + def test_get_top_level_shapes(self): + base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4)) + fls = base_shapes.get_top_level_shapes() + self.assertTrue(isinstance(fls, ShapeList)) + self.assertEqual(len(fls), 20) + self.assertTrue(all(isinstance(s, Solid) for s in fls)) + + b1 = Box(1, 1, 1).solid() + self.assertEqual(b1.get_top_level_shapes()[0], b1) + + +if __name__ == "__main__": + unittest.main() 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_direct_api_test_case.py b/tests/test_direct_api/test_direct_api_test_case.py new file mode 100644 index 0000000..a165857 --- /dev/null +++ b/tests/test_direct_api/test_direct_api_test_case.py @@ -0,0 +1,60 @@ +""" +build123d direct api tests + +name: test_direct_api_test_case.py +by: Gumyr +date: January 21, 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 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 unittest +from typing import Optional + +from build123d.geometry import Vector, VectorLike + + +class DirectApiTestCase(unittest.TestCase): + def assertTupleAlmostEquals( + self, + first: tuple[float, ...], + second: tuple[float, ...], + places: int, + msg: str | None = None, + ): + """Check Tuples""" + self.assertEqual(len(second), len(first)) + for i, j in zip(second, first): + self.assertAlmostEqual(i, j, places, msg=msg) + + def assertVectorAlmostEquals( + self, first: Vector, second: VectorLike, places: int, msg: str | None = None + ): + second_vector = Vector(second) + self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg) + self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg) + self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg) + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py new file mode 100644 index 0000000..6f06f68 --- /dev/null +++ b/tests/test_direct_api/test_edge.py @@ -0,0 +1,448 @@ +""" +build123d imports + +name: test_edge.py +by: Gumyr +date: January 22, 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 numpy as np +import unittest + +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, Face, Wire, Vertex +from OCP.GeomProjLib import GeomProjLib + + +class TestEdge(unittest.TestCase): + def test_close(self): + self.assertAlmostEqual( + Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5 + ) + self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5) + + def test_make_half_circle(self): + half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180) + self.assertAlmostEqual(half_circle.start_point(), (1, 0, 0), 3) + self.assertAlmostEqual(half_circle.end_point(), (-1, 0, 0), 3) + + def test_make_half_circle2(self): + half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90) + self.assertAlmostEqual(half_circle.start_point(), (0, -1, 0), 3) + self.assertAlmostEqual(half_circle.end_point(), (0, 1, 0), 3) + + def test_make_clockwise_half_circle(self): + half_circle = Edge.make_circle( + radius=1, + start_angle=180, + end_angle=0, + angular_direction=AngularDirection.CLOCKWISE, + ) + self.assertAlmostEqual(half_circle.end_point(), (1, 0, 0), 3) + self.assertAlmostEqual(half_circle.start_point(), (-1, 0, 0), 3) + + def test_make_clockwise_half_circle2(self): + half_circle = Edge.make_circle( + radius=1, + start_angle=90, + end_angle=-90, + angular_direction=AngularDirection.CLOCKWISE, + ) + self.assertAlmostEqual(half_circle.start_point(), (0, 1, 0), 3) + self.assertAlmostEqual(half_circle.end_point(), (0, -1, 0), 3) + + def test_arc_center(self): + self.assertAlmostEqual(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5) + with self.assertRaises(ValueError): + Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center + + def test_spline_with_parameters(self): + spline = Edge.make_spline( + points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0] + ) + self.assertAlmostEqual(spline.end_point(), (2, 0, 0), 5) + with self.assertRaises(ValueError): + Edge.make_spline( + points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0] + ) + with self.assertRaises(ValueError): + Edge.make_spline( + points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)] + ) + + def test_spline_approx(self): + spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)]) + self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5) + spline = Edge.make_spline_approx( + [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0) + ) + self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5) + + def test_distribute_locations(self): + line = Edge.make_line((0, 0, 0), (10, 0, 0)) + locs = line.distribute_locations(3) + for i, x in enumerate([0, 5, 10]): + self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5) + self.assertAlmostEqual(locs[0].orientation, (0, 90, 180), 5) + + locs = line.distribute_locations(3, positions_only=True) + for i, x in enumerate([0, 5, 10]): + self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5) + self.assertAlmostEqual(locs[0].orientation, (0, 0, 0), 5) + + def test_to_wire(self): + edge = Edge.make_line((0, 0, 0), (1, 1, 1)) + for end in [0, 1]: + self.assertAlmostEqual( + edge.position_at(end), + Wire(edge).position_at(end), + 5, + ) + + def test_arc_center2(self): + edges = [ + Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30), + Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30), + ] + for edge in edges: + self.assertAlmostEqual(edge.arc_center, (1, 2, 3), 5) + with self.assertRaises(ValueError): + Edge.make_line((0, 0), (1, 1)).arc_center + + def test_find_intersection_points(self): + circle = Edge.make_circle(1) + line = Edge.make_line((0, -2), (0, 2)) + crosses = circle.find_intersection_points(line) + for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses): + self.assertAlmostEqual(actual, target, 5) + + with self.assertRaises(ValueError): + circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1))) + with self.assertRaises(ValueError): + circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1))) + + self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)]) + self.assertAlmostEqual( + self_intersect.find_intersection_points()[0], + (-2.6861636507066047, 0, 0), + 5, + ) + line = Edge.make_line((1, -2), (1, 2)) + crosses = line.find_intersection_points(Axis.X) + self.assertAlmostEqual(crosses[0], (1, 0, 0), 5) + + 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 + + # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1))) + # l1 = Edge.make_line((1, 0), (2, 0)) + # i1 = l1.intersect(*r1) + + # r2 = Rectangle(2, 2).edges() + # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0)) + # i2 = l2.intersect(*r2) + + # self.assertEqual(len(i1.vertices()), len(i2.vertices())) + + def test_trim(self): + 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.1, 0.9) + + def test_trim_to_length(self): + + e1 = Edge.make_line((0, 0), (10, 10)) + e1_trim = e1.trim_to_length(0.0, 10) + self.assertAlmostEqual(e1_trim.length, 10, 5) + + e2 = Edge.make_circle(10, start_angle=0, end_angle=90) + e2_trim = e2.trim_to_length(0.5, 1) + self.assertAlmostEqual(e2_trim.length, 1, 5) + self.assertAlmostEqual( + e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5 + ) + + e3 = Edge.make_spline( + [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)] + ) + e3_trim = e3.trim_to_length(0, 7) + self.assertAlmostEqual(e3_trim.length, 7, 5) + + a4 = Axis((0, 0, 0), (1, 1, 1)) + 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)) + cntl_pnts = [(1, 2, 3)] * 30 + with self.assertRaises(ValueError): + Edge.make_bezier(*cntl_pnts) + with self.assertRaises(ValueError): + Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0]) + + bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0)) + bbox = bezier.bounding_box() + self.assertAlmostEqual(bbox.min, (0, 0, 0), 5) + self.assertAlmostEqual(bbox.max, (1, 0.75, 0), 5) + + def test_mid_way(self): + mid = Edge.make_mid_way( + Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25 + ) + self.assertAlmostEqual(mid.position_at(0), (0.25, 0, 0), 5) + self.assertAlmostEqual(mid.position_at(1), (0.25, 1, 0), 5) + + def test_distribute_locations2(self): + with self.assertRaises(ValueError): + Edge.make_circle(1).distribute_locations(1) + + locs = Edge.make_circle(1).distribute_locations(5, positions_only=True) + for i, loc in enumerate(locs): + self.assertAlmostEqual( + loc.position, + Vector(1, 0, 0).rotate(Axis.Z, i * 90), + 5, + ) + self.assertAlmostEqual(loc.orientation, (0, 0, 0), 5) + + def test_find_tangent(self): + circle = Edge.make_circle(1) + parm = circle.find_tangent(135)[0] + self.assertAlmostEqual( + circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 + ) + line = Edge.make_line((0, 0), (1, 1)) + parm = line.find_tangent(45)[0] + self.assertAlmostEqual(parm, 0, 5) + parm = line.find_tangent(0) + self.assertEqual(len(parm), 0) + + def test_param_at_point(self): + u = Edge.make_circle(1).param_at_point((0, 1)) + self.assertAlmostEqual(u, 0.25, 5) + + u = 0.3 + edge = Edge.make_line((0, 0), (34, 56)) + pnt = edge.position_at(u) + self.assertAlmostEqual(edge.param_at_point(pnt), u, 5) + + ca = CenterArc((0, 0), 1, -200, 220).edge() + for u in [0.3, 1.0]: + pnt = ca.position_at(u) + self.assertAlmostEqual(ca.param_at_point(pnt), u, 5) + + ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge() + for u in [0.3, 0.9]: + pnt = ea.position_at(u) + self.assertAlmostEqual(ea.param_at_point(pnt), u, 5) + + 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) + + def test_reverse(self): + e1 = Edge.make_line((0, 0), (1, 1)) + self.assertAlmostEqual(e1 @ 0.1, (0.1, 0.1, 0), 5) + self.assertAlmostEqual(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5) + + e2 = Edge.make_circle(1, start_angle=0, end_angle=180) + 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)) + + def test_is_interior(self): + path = RegularPolygon(5, 5).face().outer_wire() + profile = path.location_at(0) * (Circle(0.6) & Rectangle(2, 1)) + target = sweep(profile, path, transition=Transition.RIGHT) + inside_edges = target.edges().filter_by(lambda e: e.is_interior) + 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 new file mode 100644 index 0000000..2b71763 --- /dev/null +++ b/tests/test_direct_api/test_face.py @@ -0,0 +1,1281 @@ +""" +build123d imports + +name: test_face.py +by: Gumyr +date: January 22, 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 os +import platform +import random +import unittest +from unittest.mock import PropertyMock, patch + +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 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, Shell, Solid, Wire + + +class TestFace(unittest.TestCase): + def test_make_surface_from_curves(self): + 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.assertAlmostEqual(curved.area, math.pi / 2, 5) + self.assertAlmostEqual( + curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 + ) + + 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.assertAlmostEqual(curved.area, 2 * math.pi, 5) + + def test_center(self): + test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)])) + self.assertAlmostEqual(test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1) + self.assertAlmostEqual( + test_face.center(CenterOf.BOUNDING_BOX), + (0.5, 0.5, 0), + 5, + ) + + def test_face_volume(self): + rect = Face.make_rect(1, 1) + self.assertAlmostEqual(rect.volume, 0, 5) + + def test_chamfer_2d(self): + test_face = Face.make_rect(10, 10) + test_face = test_face.chamfer_2d( + distance=1, distance2=2, vertices=test_face.vertices() + ) + self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2) + + def test_chamfer_2d_reference(self): + test_face = Face.make_rect(10, 10) + edge = test_face.edges().sort_by(Axis.Y)[0] + vertex = edge.vertices().sort_by(Axis.X)[0] + test_face = test_face.chamfer_2d( + distance=1, distance2=2, vertices=[vertex], edge=edge + ) + self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2) + self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9) + self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8) + + def test_chamfer_2d_reference_inverted(self): + test_face = Face.make_rect(10, 10) + edge = test_face.edges().sort_by(Axis.Y)[0] + vertex = edge.vertices().sort_by(Axis.X)[0] + test_face = test_face.chamfer_2d( + distance=2, distance2=1, vertices=[vertex], edge=edge + ) + self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2) + self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8) + self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9) + + def test_chamfer_2d_error_checking(self): + with self.assertRaises(ValueError): + test_face = Face.make_rect(10, 10) + edge = test_face.edges().sort_by(Axis.Y)[0] + vertex = edge.vertices().sort_by(Axis.X)[0] + other_edge = test_face.edges().sort_by(Axis.Y)[-1] + test_face = test_face.chamfer_2d( + distance=1, distance2=2, vertices=[vertex], edge=other_edge + ) + + 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): + test_face = Face.make_rect(8, 10, Plane.XZ) + self.assertAlmostEqual(test_face.length, 8, 5) + self.assertAlmostEqual(test_face.width, 10, 5) + + def test_geometry(self): + box = Solid.make_box(1, 1, 2) + self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE") + self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE") + with BuildPart() as test: + with BuildSketch(): + RegularPolygon(1, 3) + extrude(amount=1) + self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON") + + def test_is_planar(self): + self.assertTrue(Face.make_rect(1, 1).is_planar) + self.assertFalse( + Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar + ) + # Some of these faces have geom_type BSPLINE but are planar + mount = Solid.make_loft( + [ + Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(), + Pos(1, 0, 4) + * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(), + ], + ) + self.assertTrue(all(f.is_planar for f in mount.faces())) + + def test_negate(self): + square = Face.make_rect(1, 1) + self.assertAlmostEqual(square.normal_at(), (0, 0, 1), 5) + 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) + self.assertAlmostEqual(bbox.max, (1, 1, 5), 5) + + def test_make_from_wires(self): + outer = Wire.make_circle(10) + inners = [ + Wire.make_circle(1).locate(Location((-2, 2, 0))), + Wire.make_circle(1).locate(Location((2, 2, 0))), + ] + happy = Face(outer, inners) + self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5) + + outer = Wire(Edge.make_circle(10, end_angle=180)) + with self.assertRaises(ValueError): + Face(outer, inners) + with self.assertRaises(ValueError): + Face(Wire.make_circle(10, Plane.XZ), inners) + + outer = Wire.make_circle(10) + inners = [ + Wire.make_circle(1).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) + + def test_sew_faces(self): + patches = [ + Face.make_rect(1, 1, Plane((x, y, z))) + for x in range(2) + for y in range(2) + for z in range(3) + ] + random.shuffle(patches) + sheets = Face.sew_faces(patches) + self.assertEqual(len(sheets), 3) + self.assertEqual(len(sheets[0]), 4) + self.assertTrue(isinstance(sheets[0][0], Face)) + + def test_surface_from_array_of_points(self): + pnts = [ + [ + Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10)) + for x in range(11) + ] + for y in range(11) + ] + surface = Face.make_surface_from_array_of_points(pnts) + bbox = surface.bounding_box() + self.assertAlmostEqual(bbox.min, (0, 0, -1), 3) + self.assertAlmostEqual(bbox.max, (10, 10, 2), 2) + + def test_bezier_surface(self): + points = [ + [ + (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0) + for x in range(-1, 2) + ] + for y in range(-1, 2) + ] + surface = Face.make_bezier_surface(points) + bbox = surface.bounding_box() + self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3) + self.assertAlmostEqual(bbox.max, (+1, +1, +1), 1) + self.assertLess(bbox.max.Z, 1.0) + + weights = [ + [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2) + ] + surface = Face.make_bezier_surface(points, weights) + bbox = surface.bounding_box() + self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3) + self.assertGreater(bbox.max.Z, 1.0) + + too_many_points = [ + [ + (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0) + for x in range(-1, 27) + ] + for y in range(-1, 27) + ] + + with self.assertRaises(ValueError): + Face.make_bezier_surface([[(0, 0)]]) + with self.assertRaises(ValueError): + Face.make_bezier_surface(points, [[1, 1], [1, 1]]) + with self.assertRaises(ValueError): + Face.make_bezier_surface(too_many_points) + + def test_thicken(self): + pnts = [ + [ + Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10)) + for x in range(11) + ] + for y in range(11) + ] + surface = Face.make_surface_from_array_of_points(pnts) + solid = Solid.thicken(surface, 1) + self.assertAlmostEqual(solid.volume, 101.59, 2) + + square = Face.make_rect(10, 10) + bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box() + self.assertAlmostEqual(bbox.min, (-5, -5, -1), 5) + self.assertAlmostEqual(bbox.max, (5, 5, 0), 5) + + def test_make_holes(self): + radius = 10 + circumference = 2 * math.pi * radius + hex_diagonal = 4 * (circumference / 10) / 3 + cylinder = Solid.make_cylinder(radius, hex_diagonal * 5) + cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[ + 0 + ] + with BuildSketch(Plane.XZ.offset(radius)) as hex: + with Locations((0, hex_diagonal)): + RegularPolygon( + hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER) + ) + hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire() + + projected_wire: Wire = hex_wire_vertical.project_to_shape( + target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z) + )[0] + projected_wires = [ + projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate( + (0, 0, (j + (i % 2) / 2) * hex_diagonal) + ) + for i in range(5) + 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.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area) + + def test_is_inside(self): + square = Face.make_rect(10, 10) + self.assertTrue(square.is_inside((1, 1))) + self.assertFalse(square.is_inside((20, 1))) + + def test_import_stl(self): + torus = Solid.make_torus(10, 1) + # exporter = Mesher() + # exporter.add_shape(torus) + # exporter.write("test_torus.stl") + export_stl(torus, "test_torus.stl") + imported_torus = import_stl("test_torus.stl") + # The torus from stl is tessellated therefore the areas will only be close + self.assertAlmostEqual(imported_torus.area, torus.area, 0) + os.remove("test_torus.stl") + + def test_is_coplanar(self): + square = Face.make_rect(1, 1, plane=Plane.XZ) + self.assertTrue(square.is_coplanar(Plane.XZ)) + self.assertTrue((-square).is_coplanar(Plane.XZ)) + self.assertFalse(square.is_coplanar(Plane.XY)) + surface: Face = Solid.make_sphere(1).faces()[0] + self.assertFalse(surface.is_coplanar(Plane.XY)) + + def test_center_location(self): + square = Face.make_rect(1, 1, plane=Plane.XZ) + cl = square.center_location + self.assertAlmostEqual(cl.position, (0, 0, 0), 5) + self.assertAlmostEqual(Plane(cl).z_dir, Plane.XZ.z_dir, 5) + + def test_position_at(self): + square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1)) + p = square.position_at(0.25, 0.75) + self.assertAlmostEqual(p, (-0.5, -1.0, 0.5), 5) + + def test_location_at(self): + bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0] + loc = bottom.location_at(0.5, 0.5) + self.assertAlmostEqual(loc.position, (0.5, 1, 0), 5) + self.assertAlmostEqual(loc.orientation, (-180, 0, -180), 5) + + front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0] + loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1)) + 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( + [ + Edge.make_line(corners[3], corners[1]), + Edge.make_line(corners[1], corners[0]), + Edge.make_line(corners[0], corners[2]), + Edge.make_three_point_arc( + corners[2], + (corners[2] + corners[3]) / 2 - Vector(0, 0, 3), + corners[3], + ), + ] + ) + surface = Face.make_surface( + net_exterior, + surface_points=[Vector(0, 0, -5)], + ) + hole_flat = Wire.make_circle(10) + hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0] + surface = Face.make_surface( + exterior=net_exterior, + surface_points=[Vector(0, 0, -5)], + interior_wires=[hole], + ) + 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) + self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5) + + # With no surface point + surface = Face.make_surface(net_exterior) + bbox = surface.bounding_box() + self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -3), 5) + self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5) + + # Exterior Edge + surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)]) + bbox = surface.bounding_box() + self.assertAlmostEqual(bbox.min, (-50, -50, -5), 5) + self.assertAlmostEqual(bbox.max, (50, 50, 0), 5) + + def test_make_surface_error_checking(self): + with self.assertRaises(ValueError): + Face.make_surface(Edge.make_line((0, 0), (1, 0))) + + with self.assertRaises(RuntimeError): + Face.make_surface([Edge.make_line((0, 0), (1, 0))]) + + if platform.system() != "Darwin": + with self.assertRaises(RuntimeError): + Face.make_surface( + [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)] + ) + + with self.assertRaises(RuntimeError): + Face.make_surface( + [Edge.make_circle(50)], + interior_wires=[Wire.make_circle(5, Plane.XZ)], + ) + + def test_sweep(self): + edge = Edge.make_line((1, 0), (2, 0)) + path = Wire.make_circle(1) + circle_with_hole = Face.sweep(edge, path) + self.assertTrue(isinstance(circle_with_hole, Face)) + self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5) + with self.assertRaises(ValueError): + Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1))) + + 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() + self.assertAlmostEqual(face.outer_wire().length, 4, 5) + + def test_wire(self): + face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face() + with self.assertWarns(UserWarning): + outer = face.wire() + self.assertAlmostEqual(outer.length, 4, 5) + + def test_constructor(self): + with self.assertRaises(ValueError): + Face(bob="fred") + + def test_normal_at(self): + face = Face.make_rect(1, 1) + self.assertAlmostEqual(face.normal_at(0, 0), (0, 0, 1), 5) + self.assertAlmostEqual(face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5) + with self.assertRaises(ValueError): + face.normal_at(0) + with self.assertRaises(ValueError): + face.normal_at(center=(0, 0)) + face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0] + self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5) + + 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.without_holes() + self.assertEqual(len(frame.inner_wires()), 1) + self.assertEqual(len(filled.inner_wires()), 0) + self.assertAlmostEqual(frame.area, 0.75, 5) + self.assertAlmostEqual(filled.area, 1.0, 5) + + # Errors + frame.wrapped = None + with self.assertRaises(ValueError): + frame.without_holes() + + # No holes + rect = Face.make_rect(1, 1) + self.assertEqual(rect, rect.without_holes()) + + # Non-planar test + cyl_face = ( + (Cylinder(1, 3) - Cylinder(0.5, 3, rotation=(90, 0, 0))) + .faces() + .sort_by(Face.area)[-1] + ) + 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.area_without_holes, filled.area, 5) + + def test_area_without_holes(self): + frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face() + frame.wrapped = None + 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__": + unittest.main() diff --git a/tests/test_direct_api/test_functions.py b/tests/test_direct_api/test_functions.py new file mode 100644 index 0000000..a8d7002 --- /dev/null +++ b/tests/test_direct_api/test_functions.py @@ -0,0 +1,105 @@ +""" +build123d imports + +name: test_functions.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.geometry import Plane, Vector +from build123d.objects_part import Box +from build123d.topology import ( + Compound, + Face, + Solid, + edges_to_wires, + polar, + new_edges, + delta, + unwrap_topods_compound, +) + + +class TestFunctions(unittest.TestCase): + def test_edges_to_wires(self): + square_edges = Face.make_rect(1, 1).edges() + rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges() + wires = edges_to_wires(square_edges + rectangle_edges) + self.assertEqual(len(wires), 2) + self.assertAlmostEqual(wires[0].length, 4, 5) + self.assertAlmostEqual(wires[1].length, 6, 5) + + def test_polar(self): + pnt = polar(1, 30) + self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5) + self.assertAlmostEqual(pnt[1], 0.5, 5) + + def test_new_edges(self): + c = Solid.make_cylinder(1, 5) + s = Solid.make_sphere(2) + s_minus_c = s - c + seams = new_edges(c, s, combined=s_minus_c) + self.assertEqual(len(seams), 1) + self.assertAlmostEqual(seams[0].radius, 1, 5) + + def test_delta(self): + cyl = Solid.make_cylinder(1, 5) + sph = Solid.make_sphere(2) + con = Solid.make_cone(2, 1, 2) + plug = delta([cyl, sph, con], [sph, con]) + self.assertEqual(len(plug), 1) + self.assertEqual(plug[0], cyl) + + def test_parse_intersect_args(self): + + with self.assertRaises(TypeError): + Vector(1, 1, 1) & ("x", "y", "z") + + def test_unwrap_topods_compound(self): + # Complex Compound + b1 = Box(1, 1, 1).solid() + b2 = Box(2, 2, 2).solid() + c1 = Compound([b1, b2]) + c2 = Compound([b1, c1]) + c3 = Compound([c2]) + c4 = Compound([c3]) + self.assertEqual(c4.wrapped.NbChildren(), 1) + c5 = Compound(unwrap_topods_compound(c4.wrapped, False)) + self.assertEqual(c5.wrapped.NbChildren(), 2) + + # unwrap fully + c0 = Compound([b1]) + c1 = Compound([c0]) + result = Compound.cast(unwrap_topods_compound(c1.wrapped, True)) + self.assertTrue(isinstance(result, Solid)) + + # unwrap not fully + result = Compound.cast(unwrap_topods_compound(c1.wrapped, False)) + self.assertTrue(isinstance(result, Compound)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_group_by.py b/tests/test_direct_api/test_group_by.py new file mode 100644 index 0000000..2e024e8 --- /dev/null +++ b/tests/test_direct_api/test_group_by.py @@ -0,0 +1,69 @@ +""" +build123d imports + +name: test_group_by.py +by: Gumyr +date: January 22, 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 pprint +import unittest + +from build123d.geometry import Axis +from build123d.topology import Solid + + +class TestGroupBy(unittest.TestCase): + + def setUp(self): + # Ensure the class variable is in its default state before each test + self.v = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z) + + def test_str(self): + self.assertEqual( + str(self.v), + f"""[[Vertex(0.0, 0.0, 0.0), + Vertex(0.0, 1.0, 0.0), + Vertex(1.0, 0.0, 0.0), + Vertex(1.0, 1.0, 0.0)], + [Vertex(0.0, 0.0, 1.0), + Vertex(0.0, 1.0, 1.0), + Vertex(1.0, 0.0, 1.0), + Vertex(1.0, 1.0, 1.0)]]""", + ) + + def test_repr(self): + self.assertEqual( + repr(self.v), + "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]", + ) + + def test_pp(self): + self.assertEqual( + pprint.pformat(self.v), + "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py new file mode 100644 index 0000000..8f9f29e --- /dev/null +++ b/tests/test_direct_api/test_import_export.py @@ -0,0 +1,67 @@ +""" +build123d imports + +name: test_import_export.py +by: Gumyr +date: January 22, 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 os +import unittest + +from build123d.exporters3d import export_brep, export_step +from build123d.importers import import_brep, import_step, import_stl +from build123d.mesher import Mesher +from build123d.topology import Solid + + +class TestImportExport(unittest.TestCase): + def test_import_export(self): + 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.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.assertAlmostEqual(brep_box.volume, 1, 5) + os.remove("test_box.step") + os.remove("test_box.brep") + with self.assertRaises(FileNotFoundError): + step_box = import_step("test_box.step") + + def test_import_stl(self): + # export solid + original_box = Solid.make_box(1, 2, 3) + exporter = Mesher() + exporter.add_shape(original_box) + exporter.write("test.stl") + + # import as face + stl_box = import_stl("test.stl") + self.assertAlmostEqual(stl_box.position, (0, 0, 0), 5) + + +if __name__ == "__main__": + unittest.main() 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_jupyter.py b/tests/test_direct_api/test_jupyter.py new file mode 100644 index 0000000..765159d --- /dev/null +++ b/tests/test_direct_api/test_jupyter.py @@ -0,0 +1,58 @@ +""" +build123d imports + +name: test_jupyter.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.geometry import Vector +from build123d.jupyter_tools import shape_to_html +from build123d.vtk_tools import to_vtkpoly_string +from build123d.topology import Solid + + +class TestJupyter(unittest.TestCase): + def test_repr_html(self): + shape = Solid.make_box(1, 1, 1) + + # Test no exception on rendering to html + html1 = shape._repr_html_() + + assert "function render" in html1 + + def test_display_error(self): + with self.assertRaises(TypeError): + shape_to_html(Vector()) + + with self.assertRaises(ValueError): + to_vtkpoly_string("invalid") + + with self.assertRaises(ValueError): + shape_to_html("invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py new file mode 100644 index 0000000..d22cb6c --- /dev/null +++ b/tests/test_direct_api/test_location.py @@ -0,0 +1,465 @@ +""" +build123d imports + +name: test_location.py +by: Gumyr +date: January 22, 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 copy +import json +import math +import os +import unittest +from random import uniform + +from OCP.gp import ( + gp_Ax1, + gp_Dir, + gp_EulerSequence, + gp_Pnt, + gp_Quaternion, + gp_Trsf, + gp_Vec, +) +from build123d.build_common import GridLocations +from build123d.build_enums import Extrinsic, Intrinsic +from build123d.geometry import Axis, Location, LocationEncoder, Plane, Pos, Vector +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 + + +class TestLocation(unittest.TestCase): + def test_location(self): + loc0 = Location() + T = loc0.wrapped.Transformation().TranslationPart() + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 0), 5) + angle = math.degrees( + loc0.wrapped.Transformation().GetRotation().GetRotationAngle() + ) + self.assertAlmostEqual(0, angle) + + # Tuple + loc0 = Location((0, 0, 1)) + + T = loc0.wrapped.Transformation().TranslationPart() + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) + + # List + loc0 = Location([0, 0, 1]) + + T = loc0.wrapped.Transformation().TranslationPart() + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) + + # Vector + loc1 = Location(Vector(0, 0, 1)) + + T = loc1.wrapped.Transformation().TranslationPart() + 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) + + angle = math.degrees( + loc2.wrapped.Transformation().GetRotation().GetRotationAngle() + ) + self.assertAlmostEqual(45, angle) + + # gp_Trsf + T = gp_Trsf() + T.SetTranslation(gp_Vec(0, 0, 1)) + loc3 = Location(T) + + self.assertEqual( + loc1.wrapped.Transformation().TranslationPart().Z(), + loc3.wrapped.Transformation().TranslationPart().Z(), + ) + + # Test creation from the OCP.gp.gp_Trsf object + loc4 = Location(gp_Trsf()) + 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) + + loc5 = loc1 * loc4 + loc6 = loc4 * loc4 + loc7 = loc4**2 + + T = loc5.wrapped.Transformation().TranslationPart() + self.assertAlmostEqual((T.X(), T.Y(), T.Z()), (0, 0, 1), 5) + + angle5 = math.degrees( + loc5.wrapped.Transformation().GetRotation().GetRotationAngle() + ) + self.assertAlmostEqual(15, angle5) + + angle6 = math.degrees( + loc6.wrapped.Transformation().GetRotation().GetRotationAngle() + ) + self.assertAlmostEqual(30, angle6) + + angle7 = math.degrees( + loc7.wrapped.Transformation().GetRotation().GetRotationAngle() + ) + self.assertAlmostEqual(30, angle7) + + # Test error handling on creation + with self.assertRaises(TypeError): + Location("xy_plane") + + # Test that the computed rotation matrix and intrinsic euler angles return the same + + about_x = uniform(-2 * math.pi, 2 * math.pi) + about_y = uniform(-2 * math.pi, 2 * math.pi) + about_z = uniform(-2 * math.pi, 2 * math.pi) + + rot_x = gp_Trsf() + rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x) + rot_y = gp_Trsf() + rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y) + rot_z = gp_Trsf() + rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z) + loc1 = Location(rot_x * rot_y * rot_z) + + q = gp_Quaternion() + q.SetEulerAngles( + gp_EulerSequence.gp_Intrinsic_XYZ, + about_x, + about_y, + about_z, + ) + t = gp_Trsf() + t.SetRotationPart(q) + loc2 = Location(t) + + self.assertAlmostEqual(tuple(loc1)[0], tuple(loc2)[0], 5) + self.assertAlmostEqual(tuple(loc1)[1], tuple(loc2)[1], 5) + + loc1 = Location((1, 2), 34) + 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) + self.assertAlmostEqual(tuple(loc2)[0], (1, 2, 3), 5) + self.assertAlmostEqual(tuple(loc2)[1], rot_angles, 5) + + loc3 = Location(loc2) + 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)) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + + loc = Location((10, 20, 30), (10, 20, 30)) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5) + + loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5) + + loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX) + self.assertAlmostEqual(loc.position, (10, 20, 30), 5) + self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5) + + with self.assertRaises(TypeError): + Location(x=10) + + with self.assertRaises(TypeError): + Location((10, 20, 30), (30, 20, 10), (10, 20, 30)) + + with self.assertRaises(TypeError): + Location(Intrinsic.XYZ) + + def test_location_repr_and_str(self): + self.assertEqual( + repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))" + ) + self.assertEqual( + str(Location()), + "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))", + ) + loc = Location((1, 2, 3), (33, 45, 67)) + self.assertEqual( + str(loc), + "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))", + ) + + def test_location_inverted(self): + loc = Location(Plane.XZ) + self.assertAlmostEqual(loc.inverse().orientation, (-90, 0, 0), 6) + + def test_set_position(self): + loc = Location(Plane.XZ) + loc.position = (1, 2, 3) + self.assertAlmostEqual(loc.position, (1, 2, 3), 6) + self.assertAlmostEqual(loc.orientation, (90, 0, 0), 6) + + def test_set_orientation(self): + loc = Location((1, 2, 3), (90, 0, 0)) + loc.orientation = (-90, 0, 0) + self.assertAlmostEqual(loc.position, (1, 2, 3), 6) + self.assertAlmostEqual(loc.orientation, (-90, 0, 0), 6) + + def test_copy(self): + loc1 = Location((1, 2, 3), (90, 45, 22.5)) + loc2 = copy.copy(loc1) + loc3 = copy.deepcopy(loc1) + 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) + + # 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)) + same = Location((1, 2, 3), (4, 5, 6)) + + self.assertEqual(loc, same) + self.assertEqual(loc, AlwaysEqual()) + + def test_not_equal(self): + loc = Location((1, 2, 3), (40, 50, 60)) + diff_position = Location((3, 2, 1), (40, 50, 60)) + diff_orientation = Location((1, 2, 3), (60, 50, 40)) + + self.assertNotEqual(loc, diff_position) + 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 + self.assertAlmostEqual(n_loc.position, (1, 2, 3), 5) + self.assertAlmostEqual(n_loc.orientation, (180, -35, -127), 5) + + def test_mult_iterable(self): + locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1) + self.assertAlmostEqual(locs[0].position, (-1, 2, 0), 5) + self.assertAlmostEqual(locs[1].position, (3, 2, 0), 5) + + def test_as_json(self): + data_dict = { + "part1": { + "joint_one": Location((1, 2, 3), (4, 5, 6)), + "joint_two": Location((7, 8, 9), (10, 11, 12)), + }, + "part2": { + "joint_one": Location((13, 14, 15), (16, 17, 18)), + "joint_two": Location((19, 20, 21), (22, 23, 24)), + }, + } + + # Serializing json with custom Location encoder + 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: + outfile.write(json_object) + + # Reading from sample.json + with open("sample.json") as infile: + 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(): + for k, v in value.items(): + if key == "part1" and k == "joint_one": + self.assertAlmostEqual(v.position, (1, 2, 3), 5) + elif key == "part1" and k == "joint_two": + self.assertAlmostEqual(v.position, (7, 8, 9), 5) + elif key == "part2" and k == "joint_one": + self.assertAlmostEqual(v.position, (13, 14, 15), 5) + elif key == "part2" and k == "joint_two": + self.assertAlmostEqual(v.position, (19, 20, 21), 5) + else: + self.assertTrue(False) + os.remove("sample.json") + + def test_intersection(self): + e = Edge.make_line((0, 0, 0), (1, 1, 1)) + l0 = e.location_at(0) + l1 = e.location_at(1) + self.assertIsNone(l0 & l1) + self.assertEqual(l1 & l1, l1) + + i = l1 & Vector(1, 1, 1) + self.assertTrue(isinstance(i, Vector)) + self.assertAlmostEqual(i, (1, 1, 1), 5) + + i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1)) + self.assertTrue(isinstance(i, Location)) + self.assertEqual(i, l1) + + p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0)) + l = Location((1, 0, 0), (1, 0, 0), 45) + i = l & p + self.assertTrue(isinstance(i, Location)) + self.assertAlmostEqual(i.position, (1, 0, 0), 5) + self.assertAlmostEqual(i.orientation, l.orientation, 5) + + b = Solid.make_box(1, 1, 1) + l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45) + i = (l & b).vertex() + self.assertTrue(isinstance(i, Vertex)) + self.assertAlmostEqual(Vector(i), (0.5, 0.5, 0.5), 5) + + e1 = Edge.make_line((0, -1), (2, 1)) + e2 = Edge.make_line((0, 1), (2, -1)) + e3 = Edge.make_line((0, 0), (2, 0)) + + i = e1.intersect(e2, e3) + 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)) + i = e3.intersect(e4, e5) + self.assertIsNone(i) + + self.assertIsNone(b.intersect(b.moved(Pos(X=10)))) + + # Look for common vertices + e1 = Edge.make_line((0, 0), (1, 0)) + e2 = Edge.make_line((1, 0), (1, 1)) + e3 = Edge.make_line((1, 0), (2, 0)) + i = e1.intersect(e2) + self.assertEqual(len(i.vertices()), 1) + self.assertEqual(tuple(i.vertex()), (1, 0, 0)) + i = e1.intersect(e3) + self.assertEqual(len(i.vertices()), 1) + self.assertEqual(tuple(i.vertex()), (1, 0, 0)) + + # Intersect with plane + e1 = Edge.make_line((0, 0), (2, 0)) + p1 = Plane.YZ.offset(1) + i = e1.intersect(p1) + self.assertEqual(len(i.vertices()), 1) + self.assertEqual(tuple(i.vertex()), (1, 0, 0)) + + e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir) + i = e2.intersect(p1) + self.assertEqual(len(i.vertices()), 2) + self.assertEqual(len(i.edges()), 1) + self.assertAlmostEqual(i.edge().length, 2, 5) + + with self.assertRaises(ValueError): + e1.intersect("line") + + def test_pos(self): + with self.assertRaises(TypeError): + Pos(0, "foo") + self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3)) + self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3)) + self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3)) + self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3)) + 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_mass_properties.py b/tests/test_direct_api/test_mass_properties.py new file mode 100644 index 0000000..df7d80c --- /dev/null +++ b/tests/test_direct_api/test_mass_properties.py @@ -0,0 +1,130 @@ +""" +build123d tests + +name: test_mass_properties.py +by: Gumyr +date: January 28, 2025 + +desc: + This python module contains tests for shape properties. + +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 unittest +from build123d.objects_part import Box, Cylinder, Sphere +from build123d.geometry import Align, Axis +from build123d import Sphere, Align, Axis +from math import pi + + +class TestMassProperties(unittest.TestCase): + + def test_sphere(self): + r = 2 # Sphere radius + sphere = Sphere(r) + + # Expected mass properties + volume = (4 / 3) * pi * r**3 + expected_static_moments = (0, 0, 0) # COM at (0,0,0) + expected_inertia = (2 / 5) * volume * r**2 # Ixx = Iyy = Izz + + # Test static moments (should be zero if centered at origin) + self.assertAlmostEqual( + sphere.static_moments[0], expected_static_moments[0], places=5 + ) + self.assertAlmostEqual( + sphere.static_moments[1], expected_static_moments[1], places=5 + ) + self.assertAlmostEqual( + sphere.static_moments[2], expected_static_moments[2], places=5 + ) + + # Test matrix of inertia (diagonal and equal for a sphere) + inertia_matrix = sphere.matrix_of_inertia + self.assertAlmostEqual(inertia_matrix[0][0], expected_inertia, places=5) + self.assertAlmostEqual(inertia_matrix[1][1], expected_inertia, places=5) + self.assertAlmostEqual(inertia_matrix[2][2], expected_inertia, places=5) + + # Test principal properties (should match matrix of inertia) + principal_axes, principal_moments = zip(*sphere.principal_properties) + self.assertAlmostEqual(principal_moments[0], expected_inertia, places=5) + self.assertAlmostEqual(principal_moments[1], expected_inertia, places=5) + self.assertAlmostEqual(principal_moments[2], expected_inertia, places=5) + + # Test radius of gyration (should be sqrt(2/5) * r) + expected_radius_of_gyration = (2 / 5) ** 0.5 * r + self.assertAlmostEqual( + sphere.radius_of_gyration(Axis.X), expected_radius_of_gyration, places=5 + ) + + def test_cube(self): + side = 2 + cube = Box(side, side, side, align=Align.CENTER) + + # Expected values + volume = side**3 + expected_static_moments = (0, 0, 0) # Centered + expected_inertia = (1 / 6) * volume * side**2 # Ixx = Iyy = Izz + + # Test inertia matrix (should be diagonal) + inertia_matrix = cube.matrix_of_inertia + self.assertAlmostEqual(inertia_matrix[0][0], expected_inertia, places=5) + self.assertAlmostEqual(inertia_matrix[1][1], expected_inertia, places=5) + self.assertAlmostEqual(inertia_matrix[2][2], expected_inertia, places=5) + + # Test principal moments (should be equal) + principal_axes, principal_moments = zip(*cube.principal_properties) + self.assertAlmostEqual(principal_moments[0], expected_inertia, places=5) + self.assertAlmostEqual(principal_moments[1], expected_inertia, places=5) + self.assertAlmostEqual(principal_moments[2], expected_inertia, places=5) + + # Test radius of gyration (should be sqrt(1/6) * side) + expected_radius_of_gyration = (1 / 6) ** 0.5 * side + self.assertAlmostEqual( + cube.radius_of_gyration(Axis.X), expected_radius_of_gyration, places=5 + ) + + def test_cylinder(self): + r, h = 2, 5 + cylinder = Cylinder(r, h, align=Align.CENTER) + + # Expected values + volume = pi * r**2 * h + expected_inertia_xx = (1 / 12) * volume * (3 * r**2 + h**2) # Ixx = Iyy + expected_inertia_zz = (1 / 2) * volume * r**2 # Iz about Z-axis + + # Test principal moments (should align with Z) + principal_axes, principal_moments = zip(*cylinder.principal_properties) + self.assertAlmostEqual(principal_moments[0], expected_inertia_xx, places=5) + self.assertAlmostEqual(principal_moments[1], expected_inertia_xx, places=5) + self.assertAlmostEqual(principal_moments[2], expected_inertia_zz, places=5) + + # Test radius of gyration (should be sqrt(I/m)) + expected_radius_x = (expected_inertia_xx / volume) ** 0.5 + expected_radius_z = (expected_inertia_zz / volume) ** 0.5 + self.assertAlmostEqual( + cylinder.radius_of_gyration(Axis.X), expected_radius_x, places=5 + ) + self.assertAlmostEqual( + cylinder.radius_of_gyration(Axis.Z), expected_radius_z, places=5 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_matrix.py b/tests/test_direct_api/test_matrix.py new file mode 100644 index 0000000..09501b5 --- /dev/null +++ b/tests/test_direct_api/test_matrix.py @@ -0,0 +1,194 @@ +""" +build123d imports + +name: test_matrix.py +by: Gumyr +date: January 22, 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 copy +import math +import unittest + +from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf +from build123d.geometry import Axis, Matrix, Vector + + +class TestMatrix(unittest.TestCase): + def test_matrix_creation_and_access(self): + def matrix_vals(m): + return [[m[r, c] for c in range(4)] for r in range(4)] + + # default constructor creates a 4x4 identity matrix + m = Matrix() + identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + self.assertEqual(identity, matrix_vals(m)) + + vals4x4 = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ] + vals4x4_tuple = tuple(tuple(r) for r in vals4x4) + + # test constructor with 16-value input + m = Matrix(vals4x4) + self.assertEqual(vals4x4, matrix_vals(m)) + m = Matrix(vals4x4_tuple) + self.assertEqual(vals4x4, matrix_vals(m)) + + # test constructor with 12-value input (the last 4 are an implied + # [0,0,0,1]) + m = Matrix(vals4x4[:3]) + self.assertEqual(vals4x4, matrix_vals(m)) + m = Matrix(vals4x4_tuple[:3]) + self.assertEqual(vals4x4, matrix_vals(m)) + + # Test 16-value input with invalid values for the last 4 + invalid = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [1.0, 2.0, 3.0, 4.0], + ] + with self.assertRaises(ValueError): + Matrix(invalid) + # Test input with invalid type + with self.assertRaises(TypeError): + Matrix("invalid") + # Test input with invalid size / nested types + with self.assertRaises(TypeError): + Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]]) + with self.assertRaises(TypeError): + Matrix([1, 2, 3]) + + # Invalid sub-type + with self.assertRaises(TypeError): + Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]]) + + # test out-of-bounds access + m = Matrix() + with self.assertRaises(IndexError): + m[0, 4] + with self.assertRaises(IndexError): + m[4, 0] + with self.assertRaises(IndexError): + m["ab"] + + # test __repr__ methods + m = Matrix(vals4x4) + mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])" + self.assertEqual(repr(m), mRepr) + self.assertEqual(str(eval(repr(m))), mRepr) + + def test_matrix_functionality(self): + # Test rotate methods + def matrix_almost_equal(m, target_matrix): + for r, row in enumerate(target_matrix): + for c, target_value in enumerate(row): + self.assertAlmostEqual(m[r, c], target_value) + + root_3_over_2 = math.sqrt(3) / 2 + m_rotate_x_30 = [ + [1, 0, 0, 0], + [0, root_3_over_2, -1 / 2, 0], + [0, 1 / 2, root_3_over_2, 0], + [0, 0, 0, 1], + ] + mx = Matrix() + mx.rotate(Axis.X, math.radians(30)) + matrix_almost_equal(mx, m_rotate_x_30) + + m_rotate_y_30 = [ + [root_3_over_2, 0, 1 / 2, 0], + [0, 1, 0, 0], + [-1 / 2, 0, root_3_over_2, 0], + [0, 0, 0, 1], + ] + my = Matrix() + my.rotate(Axis.Y, math.radians(30)) + matrix_almost_equal(my, m_rotate_y_30) + + m_rotate_z_30 = [ + [root_3_over_2, -1 / 2, 0, 0], + [1 / 2, root_3_over_2, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + mz = Matrix() + mz.rotate(Axis.Z, math.radians(30)) + matrix_almost_equal(mz, m_rotate_z_30) + + # Test matrix multiply vector + v = Vector(1, 0, 0) + self.assertAlmostEqual(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7) + + # Test matrix multiply matrix + m_rotate_xy_30 = [ + [root_3_over_2, 0, 1 / 2, 0], + [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0], + [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0], + [0, 0, 0, 1], + ] + mxy = mx.multiply(my) + matrix_almost_equal(mxy, m_rotate_xy_30) + + # Test matrix inverse + vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]] + vals4x4_invert = [ + [-53 / 144, 25 / 144, 1 / 16, -53 / 144], + [43 / 144, -23 / 144, 1 / 16, -101 / 144], + [37 / 144, 7 / 144, -1 / 16, -107 / 144], + [0, 0, 0, 1], + ] + m = Matrix(vals4x4).inverse() + matrix_almost_equal(m, vals4x4_invert) + + # Test matrix created from transfer function + rot_x = gp_Trsf() + θ = math.pi + rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ) + m = Matrix(rot_x) + rot_x_matrix = [ + [1, 0, 0, 0], + [0, math.cos(θ), -math.sin(θ), 0], + [0, math.sin(θ), math.cos(θ), 0], + [0, 0, 0, 1], + ] + matrix_almost_equal(m, rot_x_matrix) + + # Test copy + m2 = copy.copy(m) + matrix_almost_equal(m2, rot_x_matrix) + m3 = copy.deepcopy(m) + matrix_almost_equal(m3, rot_x_matrix) + + +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 new file mode 100644 index 0000000..864711b --- /dev/null +++ b/tests/test_direct_api/test_mixin1_d.py @@ -0,0 +1,562 @@ +""" +build123d imports + +name: test_mixin1_d.py +by: Gumyr +date: January 22, 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 unittest +from unittest.mock import patch + +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.operations_part import extrude +from build123d.operations_generic import fillet +from build123d.topology import Compound, Edge, Face, Solid, Vertex, Wire + + +class TestMixin1D(unittest.TestCase): + """Test the add in methods""" + + def test_position_at(self): + self.assertAlmostEqual( + Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5), + (0.5, 0.5, 0.5), + 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 + ) + self.assertTrue(all([0.0 < v < 1.0 for v in point])) + + wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))]) + self.assertAlmostEqual(wire.position_at(0.3), (3, 0, 0), 5) + self.assertAlmostEqual( + wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5 + ) + self.assertAlmostEqual(wire.edge().position_at(0.3), (3, 0, 0), 5) + self.assertAlmostEqual( + wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5 + ) + + circle_wire = Wire( + [ + Edge.make_circle(1, start_angle=0, end_angle=180), + Edge.make_circle(1, start_angle=180, end_angle=360), + ] + ) + p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH) + p2 = circle_wire.position_at(math.pi / circle_wire.length) + self.assertAlmostEqual(p1, (-1, 0, 0), 14) + self.assertAlmostEqual(p2, (-1, 0, 0), 14) + self.assertAlmostEqual(p1, p2, 14) + + circle_edge = Edge.make_circle(1) + p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH) + p4 = circle_edge.position_at(math.pi / circle_edge.length) + self.assertAlmostEqual(p3, (-1, 0, 0), 14) + self.assertAlmostEqual(p4, (-1, 0, 0), 14) + self.assertAlmostEqual(p3, p4, 14) + + circle = Wire( + [ + Edge.make_circle(2, start_angle=0, end_angle=180), + Edge.make_circle(2, start_angle=180, end_angle=360), + ] + ) + self.assertAlmostEqual( + circle.position_at(0.5), + (-2, 0, 0), + 5, + ) + self.assertAlmostEqual( + circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH), + (-2, 0, 0), + 5, + ) + + 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 + ) + self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent])) + + self.assertAlmostEqual( + Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at( + math.pi / 2, position_mode=PositionMode.LENGTH + ), + (-1, 0, 0), + 5, + ) + + def test_tangent_at_point(self): + circle = Wire( + [ + Edge.make_circle(1, start_angle=0, end_angle=180), + Edge.make_circle(1, start_angle=180, end_angle=360), + ] + ) + pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4)) + tan = circle.tangent_at(pnt_on_circle) + self.assertAlmostEqual(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5) + + def test_tangent_at_by_length(self): + circle = Edge.make_circle(1) + tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH) + self.assertAlmostEqual(tan, (0, -1), 5) + + def test_tangent_at_error(self): + with self.assertRaises(ValueError): + Edge.make_circle(1).tangent_at("start") + + def test_normal(self): + self.assertAlmostEqual( + Edge.make_circle( + 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60 + ).normal(), + (1, 0, 0), + 5, + ) + self.assertAlmostEqual( + Edge.make_ellipse( + 1, + 0.5, + Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)), + start_angle=0, + end_angle=90, + ).normal(), + (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), + 5, + ) + self.assertAlmostEqual( + Edge.make_spline( + [ + (1, 0), + (math.sqrt(2) / 2, math.sqrt(2) / 2), + (0, 1), + ], + tangents=((0, 1, 0), (-1, 0, 0)), + ).normal(), + (0, 0, 1), + 5, + ) + line = Edge.make_line((0, 0, 0), (1, 1, 1)) + with self.assertRaises(ValueError): + 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) + self.assertAlmostEqual(c.center(), (0, 1, 0), 5) + self.assertAlmostEqual( + c.center(CenterOf.MASS), + (0, 0.6366197723675814, 0), + 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) + self.assertAlmostEqual(loc.position, (0, 1, 0), 5) + self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5) + + loc = Edge.make_circle(1).location_at( + math.pi / 2, position_mode=PositionMode.LENGTH + ) + 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) + self.assertAlmostEqual(locs[0].orientation, (-90, 0, -180), 5) + self.assertAlmostEqual(locs[1].position, (0, 1, 0), 5) + self.assertAlmostEqual(locs[1].orientation, (0, -90, -90), 5) + self.assertAlmostEqual(locs[2].position, (-1, 0, 0), 5) + self.assertAlmostEqual(locs[2].orientation, (90, 0, 0), 5) + 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))) + ellipse: Wire = circle.project(target, (0, 0, -1)) + 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] + square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0))) + projections: list[Wire] = square.project( + target, direction=(-1, 0, 0), closest=False + ) + self.assertEqual(len(projections), 2) + + def test_is_forward(self): + plate = Box(10, 10, 1) - Cylinder(1, 1) + 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) + corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1] + base_wire = base_wire.fillet_2d(0.4, [corner]) + offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT) + self.assertTrue(offset_wire.is_closed) + self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6) + self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2) + offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT) + self.assertAlmostEqual( + offset_wire_right.edges() + .filter_by(GeomType.CIRCLE) + .sort_by(SortBy.RADIUS)[-1] + .radius, + 0.5, + 4, + ) + h_perimeter = Compound.make_text("h", font_size=10).wire() + with self.assertRaises(RuntimeError): + h_perimeter.offset_2d(-1) + + # Test for returned Edge - can't find a way to do this + # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50) + # self.assertTrue(isinstance(offset_edge, Edge)) + # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) + # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE) + # self.assertAlmostEqual(offset_edge.radius, 12, 5) + # base_edge = Edge.make_line((0, 1), (1, 10)) + # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) + # self.assertTrue(isinstance(offset_edge, Edge)) + # self.assertTrue(offset_edge.geom_type == GeomType.LINE) + # self.assertAlmostEqual(offset_edge.position_at(0).X, 3) + + def test_common_plane(self): + # Straight and circular lines + l = Edge.make_line((0, 0, 0), (5, 0, 0)) + c = Edge.make_circle(2, Plane.XZ, -90, 90) + common = l.common_plane(c) + self.assertAlmostEqual(common.z_dir.X, 0, 5) + self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known + self.assertAlmostEqual(common.z_dir.Z, 0, 5) + + # Co-axial straight lines + l1 = Edge.make_line((0, 0), (1, 1)) + l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75)) + common = l1.common_plane(l2) + # the z_dir isn't know + self.assertAlmostEqual(common.x_dir.Z, 0, 5) + + # Parallel lines + l1 = Edge.make_line((0, 0), (1, 0)) + l2 = Edge.make_line((0, 1), (1, 1)) + common = l1.common_plane(l2) + self.assertAlmostEqual(common.z_dir.X, 0, 5) + self.assertAlmostEqual(common.z_dir.Y, 0, 5) + self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known + + # Many lines + common = Edge.common_plane(*Wire.make_rect(10, 10).edges()) + self.assertAlmostEqual(common.z_dir.X, 0, 5) + self.assertAlmostEqual(common.z_dir.Y, 0, 5) + self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known + + # Wire and Edges + c = Wire.make_circle(1, Plane.YZ) + lines = Wire.make_rect(2, 2, Plane.YZ).edges() + common = c.common_plane(*lines) + self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known + self.assertAlmostEqual(common.z_dir.Y, 0, 5) + self.assertAlmostEqual(common.z_dir.Z, 0, 5) + + def test_edge_volume(self): + edge = Edge.make_line((0, 0), (1, 1)) + self.assertAlmostEqual(edge.volume, 0, 5) + + def test_wire_volume(self): + 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 new file mode 100644 index 0000000..1bee8fc --- /dev/null +++ b/tests/test_direct_api/test_mixin3_d.py @@ -0,0 +1,154 @@ +""" +build123d imports + +name: test_mixin3_d.py +by: Gumyr +date: January 22, 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 unittest +from unittest.mock import patch, PropertyMock + +from build123d.build_enums import CenterOf, Kind +from build123d.geometry import Axis, Plane +from build123d.topology import Face, Shape, Solid + + +class TestMixin3D(unittest.TestCase): + """Test that 3D add ins""" + + def test_chamfer(self): + box = Solid.make_box(1, 1, 1) + chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:]) + self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5) + + def test_chamfer_asym_length(self): + box = Solid.make_box(1, 1, 1) + chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:]) + self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5) + + def test_chamfer_asym_length_with_face(self): + box = Solid.make_box(1, 1, 1) + face = box.faces().sort_by(Axis.Z)[0] + edge = [face.edges().sort_by(Axis.Y)[0]] + chamfer_box = box.chamfer(0.1, 0.2, edge, face=face) + self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5) + + def test_chamfer_too_high_length(self): + box = Solid.make_box(1, 1, 1) + face = box.faces + self.assertRaises( + ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:] + ) + + def test_chamfer_edge_not_part_of_face(self): + box = Solid.make_box(1, 1, 1) + edge = box.edges().sort_by(Axis.Z)[-1:] + face = box.faces().sort_by(Axis.Z)[0] + self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face) + + @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) + + # Assert that ValueError is raised + with self.assertRaises(ValueError) as chamfer_context: + max = box.chamfer(0.1, None, box.edges()) + + # Check the error message + self.assertEqual( + str(chamfer_context.exception), + "Failed creating a chamfer, try a smaller length value(s)", + ) + + # Verify is_valid was called + mock_is_valid.assert_called_once() + + def test_hollow(self): + shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1) + self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5) + + shell_box = Solid.make_box(1, 1, 1) + shell_box = shell_box.hollow( + shell_box.faces().filter_by(Axis.Z), thickness=0.1, kind=Kind.INTERSECTION + ) + self.assertAlmostEqual(shell_box.volume, 1 * 1.2**2 - 1**3, 5) + + shell_box = Solid.make_box(1, 1, 1).hollow( + [], thickness=0.1, kind=Kind.INTERSECTION + ) + self.assertAlmostEqual(shell_box.volume, 1.2**3 - 1**3, 5) + + with self.assertRaises(ValueError): + Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT) + + def test_is_inside(self): + self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5))) + + def test_dprism(self): + # face + f = Face.make_rect(0.5, 0.5) + d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( + None, [f], additive=False + ) + self.assertTrue(d.is_valid) + self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) + + # face with depth + f = Face.make_rect(0.5, 0.5) + 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.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) + + # face until + f = Face.make_rect(0.5, 0.5) + limit = Face.make_rect(1, 1, Plane((0, 0, 0.5))) + 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.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) + + # wire + w = Face.make_rect(0.5, 0.5).outer_wire() + d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( + None, [w], additive=False + ) + self.assertTrue(d.is_valid) + self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) + + def test_center(self): + with self.assertRaises(ValueError): + Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY) + + self.assertAlmostEqual( + Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX), + (0.5, 0.5, 0.5), + 5, + ) + + +if __name__ == "__main__": + unittest.main() 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 new file mode 100644 index 0000000..e9e7faa --- /dev/null +++ b/tests/test_direct_api/test_plane.py @@ -0,0 +1,547 @@ +""" +build123d imports + +name: test_plane.py +by: Gumyr +date: January 22, 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. + +""" + +# Always equal to any other object, to test that __eq__ cooperation is working +import copy +import math +import random +import unittest + +import numpy as np +from OCP.BRepGProp import BRepGProp +from OCP.GProp import GProp_GProps +from build123d.build_common import Locations +from build123d.build_enums import Align, GeomType, Mode +from build123d.build_part import BuildPart +from build123d.build_sketch import BuildSketch +from build123d.geometry import Axis, Location, Plane, Pos, Vector +from build123d.objects_part import Box, Cylinder +from build123d.objects_sketch import Circle, Rectangle +from build123d.operations_generic import fillet, add +from build123d.operations_part import extrude +from build123d.topology import Edge, Face, Solid, Vertex + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +class TestPlane(unittest.TestCase): + """Plane with class properties""" + + def test_class_properties(self): + """Validate + Name x_dir y_dir z_dir + ======= ====== ====== ====== + XY +x +y +z + YZ +y +z +x + ZX +z +x +y + XZ +x +z -y + YX +y +x -z + ZY +z +y -x + front +x +z -y + back -x +z +y + left -y +z -x + right +y +z +x + top +x +y +z + bottom +x -y -z + isometric +x+y -x+y+z +x+y-z + """ + planes = [ + (Plane.XY, (1, 0, 0), (0, 0, 1)), + (Plane.YZ, (0, 1, 0), (1, 0, 0)), + (Plane.ZX, (0, 0, 1), (0, 1, 0)), + (Plane.XZ, (1, 0, 0), (0, -1, 0)), + (Plane.YX, (0, 1, 0), (0, 0, -1)), + (Plane.ZY, (0, 0, 1), (-1, 0, 0)), + (Plane.front, (1, 0, 0), (0, -1, 0)), + (Plane.back, (-1, 0, 0), (0, 1, 0)), + (Plane.left, (0, -1, 0), (-1, 0, 0)), + (Plane.right, (0, 1, 0), (1, 0, 0)), + (Plane.top, (1, 0, 0), (0, 0, 1)), + (Plane.bottom, (1, 0, 0), (0, 0, -1)), + ( + Plane.isometric, + (1 / 2**0.5, 1 / 2**0.5, 0), + (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5), + ), + ] + for plane, x_dir, z_dir in planes: + self.assertAlmostEqual(plane.x_dir, x_dir, 5) + self.assertAlmostEqual(plane.z_dir, z_dir, 5) + + def test_plane_init(self): + # from origin + o = (0, 0, 0) + x = (1, 0, 0) + y = (0, 1, 0) + z = (0, 0, 1) + planes = [ + Plane(o), + Plane(o, x), + Plane(o, x, z), + Plane(o, x, z_dir=z), + Plane(o, x_dir=x, z_dir=z), + Plane(o, x_dir=x), + Plane(o, z_dir=z), + Plane(origin=o, x_dir=x, z_dir=z), + Plane(origin=o, x_dir=x), + Plane(origin=o, z_dir=z), + ] + for p in planes: + self.assertAlmostEqual(p.origin, o, 6) + self.assertAlmostEqual(p.x_dir, x, 6) + self.assertAlmostEqual(p.y_dir, y, 6) + self.assertAlmostEqual(p.z_dir, z, 6) + with self.assertRaises(TypeError): + 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)) + p_from_loc = Plane(loc) + p_from_named_loc = Plane(location=loc) + for p in [p_from_loc, p_from_named_loc]: + self.assertAlmostEqual(p.origin, (0, 0, 0), 6) + self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6) + self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6) + self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6) + self.assertAlmostEqual(loc.position, p.location.position, 6) + self.assertAlmostEqual(loc.orientation, p.location.orientation, 6) + + # rotated location around x and origin <> (0,0,0) + loc = Location((0, 2, -1), (45, 0, 0)) + p = Plane(loc) + self.assertAlmostEqual(p.origin, (0, 2, -1), 6) + self.assertAlmostEqual(p.x_dir, (1, 0, 0), 6) + self.assertAlmostEqual(p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6) + self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6) + self.assertAlmostEqual(loc.position, p.location.position, 6) + self.assertAlmostEqual(loc.orientation, p.location.orientation, 6) + + # from a face + f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45))) + p_from_face = Plane(f) + p_from_named_face = Plane(face=f) + plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped) + p_deep_copy = copy.deepcopy(p_from_face) + for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]: + self.assertAlmostEqual(p.origin, (1, 2, 3), 6) + self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6) + self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6) + self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6) + self.assertAlmostEqual(f.location.position, p.location.position, 6) + self.assertAlmostEqual(f.location.orientation, p.location.orientation, 6) + + # from a face with x_dir + f = Face.make_rect(1, 2) + x = (1, 1) + y = (-1, 1) + planes = [ + Plane(f, x), + Plane(f, x_dir=x), + Plane(face=f, x_dir=x), + ] + for p in planes: + self.assertAlmostEqual(p.origin, (0, 0, 0), 6) + self.assertAlmostEqual(p.x_dir, Vector(x).normalized(), 6) + self.assertAlmostEqual(p.y_dir, Vector(y).normalized(), 6) + self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6) + + with self.assertRaises(TypeError): + Plane(Edge.make_line((0, 0), (0, 1))) + + # can be instantiated from planar faces of surface types other than Geom_Plane + # this loft creates the trapezoid faces of type Geom_BSplineSurface + lofted_solid = Solid.make_loft( + [ + Rectangle(3, 1).wire(), + Pos(0, 0, 1) * Rectangle(1, 1).wire(), + ] + ) + + expected = [ + # Trapezoid face, negative y coordinate + ( + Axis.X.direction, # plane x_dir + Axis.Z.direction, # plane y_dir + -Axis.Y.direction, # plane z_dir + ), + # Trapezoid face, positive y coordinate + ( + -Axis.X.direction, + Axis.Z.direction, + Axis.Y.direction, + ), + ] + # assert properties of the trapezoid faces + for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y): + p = Plane(f) + f_props = GProp_GProps() + BRepGProp.SurfaceProperties_s(f.wrapped, f_props) + self.assertAlmostEqual(p.origin, Vector(f_props.CentreOfMass()), 6) + self.assertAlmostEqual(p.x_dir, expected[i][0], 6) + 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), + x_dir=Vector(1, 2, 3).normalized(), + z_dir=Vector(4, 5, 6).normalized(), + ) + p2 = -p + self.assertAlmostEqual(p2.origin, p.origin, 6) + self.assertAlmostEqual(p2.x_dir, p.x_dir, 6) + self.assertAlmostEqual(p2.z_dir, -p.z_dir, 6) + self.assertAlmostEqual(p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6) + p3 = p.reverse() + self.assertAlmostEqual(p3.origin, p.origin, 6) + self.assertAlmostEqual(p3.x_dir, p.x_dir, 6) + self.assertAlmostEqual(p3.z_dir, -p.z_dir, 6) + self.assertAlmostEqual(p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6) + + def test_plane_mul(self): + p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1)) + p2 = p * Location((1, 2, -1), (0, 0, 45)) + self.assertAlmostEqual(p2.origin, (2, 4, 2), 6) + self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6) + self.assertAlmostEqual(p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6) + self.assertAlmostEqual(p2.z_dir, (0, 0, 1), 6) + + p2 = p * Location((1, 2, -1), (0, 45, 0)) + self.assertAlmostEqual(p2.origin, (2, 4, 2), 6) + self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6) + self.assertAlmostEqual(p2.y_dir, (0, 1, 0), 6) + self.assertAlmostEqual(p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6) + + p2 = p * Location((1, 2, -1), (45, 0, 0)) + self.assertAlmostEqual(p2.origin, (2, 4, 2), 6) + self.assertAlmostEqual(p2.x_dir, (1, 0, 0), 6) + self.assertAlmostEqual(p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6) + self.assertAlmostEqual(p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6) + with self.assertRaises(TypeError): + p2 * Vector(1, 1, 1) + + def test_plane_methods(self): + # Test error checking + p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0)) + with self.assertRaises(ValueError): + p.to_local_coords("box") + + # Test translation to local coordinates + local_box = p.to_local_coords(Solid.make_box(1, 1, 1)) + local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()] + target_vertices = [ + (0, -1, 0), + (0, 0, 0), + (0, -1, 1), + (0, 0, 1), + (1, -1, 0), + (1, 0, 0), + (1, -1, 1), + (1, 0, 1), + ] + for i, target_point in enumerate(target_vertices): + np.testing.assert_allclose(target_point, local_box_vertices[i], 1e-7) + + def test_localize_vertex(self): + 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): + self.assertEqual( + repr(Plane.XY), + "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))", + ) + + def test_shift_origin_axis(self): + cyl = Cylinder(1, 2, align=Align.MIN) + top = cyl.faces().sort_by(Axis.Z)[-1] + pln = Plane(top).shift_origin(Axis.Z) + with BuildPart() as p: + add(cyl) + with BuildSketch(pln): + with Locations((1, 1)): + Circle(0.5) + extrude(amount=-2, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5) + + def test_shift_origin_vertex(self): + box = Box(1, 1, 1, align=Align.MIN) + front = box.faces().sort_by(Axis.X)[-1] + pln = Plane(front).shift_origin( + front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1] + ) + with BuildPart() as p: + add(box) + with BuildSketch(pln): + with Locations((-0.5, 0.5)): + Circle(0.5) + extrude(amount=-1, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5) + + def test_shift_origin_vector(self): + with BuildPart() as p: + Box(4, 4, 2) + b = fillet(p.edges().filter_by(Axis.Z), 0.5) + top = p.faces().sort_by(Axis.Z)[-1] + ref = ( + top.edges() + .filter_by(GeomType.CIRCLE) + .group_by(Axis.X)[-1] + .sort_by(Axis.Y)[0] + .arc_center + ) + pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref) + with BuildSketch(pln): + with Locations((0.5, 0.5)): + Rectangle(2, 2, align=Align.MIN) + extrude(amount=-1, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5) + + def test_shift_origin_error(self): + with self.assertRaises(ValueError): + Plane.XY.shift_origin(Vertex(1, 1, 1)) + + with self.assertRaises(ValueError): + Plane.XY.shift_origin((1, 1, 1)) + + with self.assertRaises(ValueError): + Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0))) + + with self.assertRaises(TypeError): + Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1))) + + def test_move(self): + pln = Plane.XY.move(Location((1, 2, 3))) + self.assertAlmostEqual(pln.origin, (1, 2, 3), 5) + + def test_rotated(self): + rotated_plane = Plane.XY.rotated((45, 0, 0)) + self.assertAlmostEqual(rotated_plane.x_dir, (1, 0, 0), 5) + self.assertAlmostEqual( + rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5 + ) + + def test_invalid_plane(self): + # Test plane creation error handling + with self.assertRaises(ValueError): + Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1)) + with self.assertRaises(ValueError): + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0)) + + def test_plane_equal(self): + # default orientation + self.assertEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + ) + # moved origin + self.assertEqual( + Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + ) + # moved x-axis + self.assertEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), + Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), + ) + # moved z-axis + self.assertEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)), + ) + # __eq__ cooperation + self.assertEqual(Plane.XY, AlwaysEqual()) + + def test_plane_not_equal(self): + # type difference + for value in [None, 0, 1, "abc"]: + self.assertNotEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value + ) + # origin difference + self.assertNotEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + ) + # x-axis difference + self.assertNotEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)), + ) + # z-axis difference + self.assertNotEqual( + Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), + 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) + self.assertAlmostEqual(loc.orientation, (0, 0, 90), 5) + + def test_intersect(self): + self.assertAlmostEqual( + Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5 + ) + self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0)))) + + self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X) + + self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1))) + + with self.assertRaises(ValueError): + Plane.XY.intersect("Plane.XZ") + + with self.assertRaises(ValueError): + Plane.XY.intersect(pln=Plane.XZ) + + def test_from_non_planar_face(self): + flat = Face.make_rect(1, 1) + pln = Plane(flat) + self.assertTrue(isinstance(pln, Plane)) + cyl = ( + Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0] + ) + with self.assertRaises(ValueError): + pln = Plane(cyl) + + def test_plane_intersect(self): + section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))) + self.assertEqual(len(section.solids()), 0) + self.assertEqual(len(section.faces()), 1) + self.assertAlmostEqual(section.face().area, 2) + + section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)) + self.assertEqual(len(section.solids()), 0) + self.assertEqual(len(section.faces()), 1) + self.assertAlmostEqual(section.face().area, 2) + + self.assertEqual(Plane.XY & Plane.XZ, Axis.X) + # x_axis_as_edge = Plane.XY & Plane.XZ + # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge() + # self.assertAlmostEqual(common.length, 1, 5) + + i = Plane.XY & Vector(1, 2) + self.assertTrue(isinstance(i, Vector)) + self.assertAlmostEqual(i, (1, 2, 0), 5) + + a = Axis((0, 0, 0), (1, 1, 0)) + i = Plane.XY & a + self.assertTrue(isinstance(i, Axis)) + self.assertEqual(i, a) + + a = Axis((1, 2, -1), (0, 0, 1)) + i = Plane.XY & a + self.assertTrue(isinstance(i, Vector)) + self.assertAlmostEqual(i, Vector(1, 2, 0), 5) + + def test_plane_origin_setter(self): + pln = Plane.XY + pln.origin = (1, 2, 3) + ocp_origin = Vector(pln.wrapped.Location()) + self.assertAlmostEqual(ocp_origin, (1, 2, 3), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py new file mode 100644 index 0000000..8b0da03 --- /dev/null +++ b/tests/test_direct_api/test_projection.py @@ -0,0 +1,99 @@ +""" +build123d imports + +name: test_projection.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.build_enums import Align +from build123d.geometry import Axis, Plane, Pos, Vector +from build123d.objects_part import Box +from build123d.topology import Compound, Edge, Solid, Wire + + +class TestProjection(unittest.TestCase): + def test_flat_projection(self): + sphere = Solid.make_sphere(50) + projection_direction = Vector(0, -1, 0) + planar_text_faces = ( + Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER)) + .rotate(Axis.X, 90) + .faces() + ) + projected_text_faces = [ + f.project_to_shape(sphere, projection_direction)[0] + for f in planar_text_faces + ] + self.assertEqual(len(projected_text_faces), 4) + + def test_multiple_output_wires(self): + target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2) + circle = Wire.make_circle(3, Plane.XY.offset(10)) + projection = circle.project_to_shape(target, (0, 0, -1)) + bbox = projection[0].bounding_box() + self.assertAlmostEqual(bbox.min, (-3, -3, 1), 2) + self.assertAlmostEqual(bbox.max, (3, 3, 2), 2) + bbox = projection[1].bounding_box() + self.assertAlmostEqual(bbox.min, (-3, -3, -2), 2) + self.assertAlmostEqual(bbox.max, (3, 3, -2), 2) + + def test_text_projection(self): + sphere = Solid.make_sphere(50) + arch_path = ( + sphere.cut( + Solid.make_cylinder( + 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0)) + ) + ) + .edges() + .sort_by(Axis.Z)[0] + ) + + projected_text = sphere.project_faces( + faces=Compound.make_text("dog", font_size=14), + path=arch_path, + start=0.01, # avoid a character spanning the sphere edge + ) + self.assertEqual(len(projected_text.solids()), 0) + self.assertEqual(len(projected_text.faces()), 3) + + def test_error_handling(self): + sphere = Solid.make_sphere(50) + circle = Wire.make_circle(1) + with self.assertRaises(ValueError): + circle.project_to_shape(sphere, center=None, direction=None)[0] + + def test_project_edge(self): + projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape( + Solid.make_box(1, 1, 1), (0, 0, 1) + ) + self.assertAlmostEqual(projection[0].position_at(1), (1, 0, 0), 5) + self.assertAlmostEqual(projection[0].position_at(0), (0, 1, 0), 5) + self.assertAlmostEqual(projection[0].arc_center, (0, 0, 0), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_rotation.py b/tests/test_direct_api/test_rotation.py new file mode 100644 index 0000000..aae49c8 --- /dev/null +++ b/tests/test_direct_api/test_rotation.py @@ -0,0 +1,58 @@ +""" +build123d imports + +name: test_rotation.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.build_enums import Extrinsic, Intrinsic +from build123d.geometry import Rotation + + +class TestRotation(unittest.TestCase): + def test_rotation_parameters(self): + r = Rotation(10, 20, 30) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation(10, 20, Z=30) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation(10, Y=20, Z=30) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation((10, 20, 30)) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation(10, 20, 30, Intrinsic.XYZ) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation((30, 20, 10), Extrinsic.ZYX) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX) + self.assertAlmostEqual(r.orientation, (10, 20, 30), 5) + with self.assertRaises(TypeError): + Rotation(x=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py new file mode 100644 index 0000000..a261f8f --- /dev/null +++ b/tests/test_direct_api/test_shape.py @@ -0,0 +1,689 @@ +""" +build123d imports + +name: test_shape.py +by: Gumyr +date: January 22, 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. + +""" + +# Always equal to any other object, to test that __eq__ cooperation is working +import unittest +from random import uniform +from unittest.mock import PropertyMock, patch + +import numpy as np +from anytree import PreOrderIter +from build123d.build_enums import CenterOf, GeomType, Keep +from build123d.geometry import ( + Axis, + Color, + Location, + Matrix, + Plane, + Pos, + Rotation, + Vector, +) +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 ( + Compound, + Edge, + Face, + Shape, + ShapeList, + Shell, + Solid, + Vertex, + Wire, +) + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +class TestShape(unittest.TestCase): + """Misc Shape tests""" + + def test_mirror(self): + box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box() + self.assertAlmostEqual(box_bb.min.X, 0, 5) + self.assertAlmostEqual(box_bb.max.X, 1, 5) + self.assertAlmostEqual(box_bb.min.Y, -1, 5) + self.assertAlmostEqual(box_bb.max.Y, 0, 5) + + box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box() + self.assertAlmostEqual(box_bb.min.Z, -1, 5) + self.assertAlmostEqual(box_bb.max.Z, 0, 5) + + def test_compute_mass(self): + with self.assertRaises(NotImplementedError): + Shape.compute_mass(Vertex()) + + def test_combined_center(self): + objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]] + self.assertAlmostEqual( + Shape.combined_center(objs, center_of=CenterOf.MASS), + (0, 0.5, 0.5), + 5, + ) + + objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]] + self.assertAlmostEqual( + Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX), + (-0.5, 0, 0), + 5, + ) + with self.assertRaises(ValueError): + Shape.combined_center(objs, center_of=CenterOf.GEOMETRY) + + def test_shape_type(self): + self.assertEqual(Vertex().shape_type, "Vertex") + + def test_scale(self): + self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5) + + def test_fuse(self): + 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.assertAlmostEqual(combined.volume, 2, 5) + fuzzy = box1.fuse(box2, tol=1e-6) + self.assertTrue(fuzzy.is_valid) + self.assertAlmostEqual(fuzzy.volume, 2, 5) + + def test_faces_intersected_by_axis(self): + box = Solid.make_box(1, 1, 1, Plane((0, 0, 1))) + intersected_faces = box.faces_intersected_by_axis(Axis.Z) + self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces) + self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces) + + def test_split(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.BOTTOM) + self.assertTrue(isinstance(split_shape, list)) + self.assertEqual(len(split_shape), 2) + self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5) + split_shape = shape.split(Plane.XY, keep=Keep.TOP) + self.assertEqual(len(split_shape.solids()), 1) + self.assertTrue(isinstance(split_shape, Solid)) + self.assertAlmostEqual(split_shape.volume, 0.5, 5) + + s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10)) + tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face() + s2 = s.split(tool, keep=Keep.TOP) + self.assertLess(s2.volume, s.volume) + self.assertGreater(s2.volume, 0.0) + + def test_split_by_non_planar_face(self): + box = Solid.make_box(1, 1, 1) + tool = Circle(1).wire() + tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) + top, bottom = box.split(tool_shell, keep=Keep.BOTH) + + self.assertFalse(top is None) + self.assertFalse(bottom is None) + self.assertGreater(top.volume, bottom.volume) + + def test_split_by_shell(self): + box = Solid.make_box(5, 5, 1) + tool = Wire.make_rect(4, 4) + tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) + split = box.split(tool_shell, keep=Keep.TOP) + inner_vol = 2 * 2 + outer_vol = 5 * 5 + self.assertAlmostEqual(split.volume, outer_vol - inner_vol) + + def test_split_keep_all(self): + shape = Box(1, 1, 1) + split_shape = shape.split(Plane.XY, keep=Keep.ALL) + self.assertTrue(isinstance(split_shape, ShapeList)) + self.assertEqual(len(split_shape), 2) + + def test_split_edge_by_shell(self): + edge = Edge.make_line((-5, 0, 0), (5, 0, 0)) + tool = Wire.make_rect(4, 4) + tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) + top = edge.split(tool_shell, keep=Keep.TOP) + self.assertEqual(len(top), 2) + self.assertAlmostEqual(top[0].length, 3, 5) + + 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 + target0 = Solid.make_sphere(10).rotate(Axis.Z, 90) + circle = Plane.YZ.offset(15) * Circle(5).face() + circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0] + circle_outerwire = circle_projected.edge() + inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH) + self.assertLess(inside0.area, outside0.area) + + # Test 1 - extract ring of a sphere + ring = Pos(Z=15) * (Circle(5) - Circle(3)).face() + ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0] + ring_outerwire = ring_projected.outer_wire() + inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH) + if isinstance(inside1, list): + inside1 = Compound(inside1) + if isinstance(outside1, list): + outside1 = Compound(outside1) + self.assertLess(inside1.area, outside1.area) + self.assertEqual(len(outside1.faces()), 2) + + # Test 2 - extract multiple faces + target2 = Box(1, 10, 10) + square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0))) + square_projected = square.project_to_shape(target2, (-1, 0, 0))[0] + outside2 = target2.split_by_perimeter( + square_projected.outer_wire(), Keep.OUTSIDE + ) + self.assertTrue(isinstance(outside2, Shell)) + inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE) + self.assertTrue(isinstance(inside2, Face)) + + # Test 4 - invalid inputs + with self.assertRaises(ValueError): + _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH) + + with self.assertRaises(ValueError): + _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP) + + def test_distance(self): + sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) + sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) + self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5) + + def test_distances(self): + sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) + sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) + sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5))) + distances = [8, 3] + for i, distance in enumerate(sphere1.distances(sphere2, sphere3)): + self.assertAlmostEqual(distances[i], distance, 5) + + def test_max_fillet(self): + test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)] + max_values = [0.96, 3.84] + for i, test_object in enumerate(test_solids): + with self.subTest("solids" + str(i)): + max = test_object.max_fillet(test_object.edges()) + self.assertAlmostEqual(max, max_values[i], 2) + with self.assertRaises(RuntimeError): + test_solids[0].max_fillet( + test_solids[0].edges(), tolerance=1e-6, max_iterations=1 + ) + with self.assertRaises(ValueError): + box = Solid.make_box(1, 1, 1) + box.fillet(0.75, box.edges()) + # invalid_object = box.fillet(0.75, box.edges()) + # invalid_object.max_fillet(invalid_object.edges()) + + @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) + + # Assert that ValueError is raised + with self.assertRaises(ValueError) as max_fillet_context: + max = box.max_fillet(box.edges()) + + # Check the error message + self.assertEqual(str(max_fillet_context.exception), "Invalid Shape") + + # Verify is_valid was called + mock_is_valid.assert_called_once() + + def test_locate_bb(self): + bounding_box = Solid.make_cone(1, 2, 1).bounding_box() + relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box) + self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5) + self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5) + self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5) + self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5) + self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5) + self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5) + + def test_is_equal(self): + box = Solid.make_box(1, 1, 1) + self.assertTrue(box.is_equal(box)) + + def test_equal(self): + box = Solid.make_box(1, 1, 1) + self.assertEqual(box, box) + self.assertEqual(box, AlwaysEqual()) + + def test_not_equal(self): + box = Solid.make_box(1, 1, 1) + diff = Solid.make_box(1, 2, 3) + self.assertNotEqual(box, diff) + self.assertNotEqual(box, object()) + + def test_tessellate(self): + box123 = Solid.make_box(1, 2, 3) + verts, triangles = box123.tessellate(1e-6) + self.assertEqual(len(verts), 24) + self.assertEqual(len(triangles), 12) + + def test_transformed(self): + """Validate that transformed works the same as changing location""" + rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360)) + offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50)) + shape = Solid.make_box(1, 1, 1).transformed(rotation, offset) + predicted_location = Location(offset) * Rotation(*rotation) + located_shape = Solid.make_box(1, 1, 1).locate(predicted_location) + intersect = shape.intersect(located_shape) + 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))) + self.assertAlmostEqual(box.position, (1, 2, 3), 5) + self.assertAlmostEqual(box.orientation, (10, 20, 30), 5) + + def test_distance_to_with_closest_points(self): + s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0))) + s1 = Solid.make_sphere(1) + distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1) + self.assertAlmostEqual(distance, 0.1, 5) + self.assertAlmostEqual(pnt0, (0, 1.1, 0), 5) + self.assertAlmostEqual(pnt1, (0, 1, 0), 5) + + def test_closest_points(self): + 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), 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))) + c1 = Edge.make_circle(1) + distance = c0.distance_to(c1) + self.assertAlmostEqual(distance, 0.1, 5) + + def test_intersection(self): + box = Solid.make_box(1, 1, 1) + intersections = ( + box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z) + ) + self.assertAlmostEqual(Vector(intersections[0]), (0.5, 0.5, 0), 5) + self.assertAlmostEqual(Vector(intersections[1]), (0.5, 0.5, 1), 5) + + def test_clean_error(self): + """Note that this test is here to alert build123d to changes in bad OCCT clean behavior + with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this. + """ + sphere = Solid.make_sphere(1) + divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5))) + positive_half, negative_half = (s.clean() for s in sphere.cut(divider).solids()) + self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1) + + def test_clean_empty(self): + 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))) + + # box_with_hole = box.cut(cylinder) + # box_with_hole.relocate(box.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) + + def test_project_to_viewport(self): + # Basic test + box = Solid.make_box(10, 10, 10) + visible, hidden = box.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 9) + self.assertEqual(len(hidden), 3) + + # Contour edges + cyl = Solid.make_cylinder(2, 10) + visible, hidden = cyl.project_to_viewport((-20, 20, 20)) + # Note that some edges are broken into two + self.assertEqual(len(visible), 6) + self.assertEqual(len(hidden), 2) + + # Hidden contour edges + hole = box - cyl + visible, hidden = hole.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 13) + self.assertEqual(len(hidden), 6) + + # Outline edges + sphere = Solid.make_sphere(5) + visible, hidden = sphere.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 1) + self.assertEqual(len(hidden), 0) + + def test_vertex(self): + v = Edge.make_circle(1).vertex() + self.assertTrue(isinstance(v, Vertex)) + with self.assertWarns(UserWarning): + Wire.make_rect(1, 1).vertex() + + def test_edge(self): + e = Edge.make_circle(1).edge() + self.assertTrue(isinstance(e, Edge)) + with self.assertWarns(UserWarning): + Wire.make_rect(1, 1).edge() + + def test_wire(self): + w = Wire.make_circle(1).wire() + self.assertTrue(isinstance(w, Wire)) + with self.assertWarns(UserWarning): + Solid.make_box(1, 1, 1).wire() + + def test_compound(self): + c = Compound.make_text("hello", 10) + self.assertTrue(isinstance(c, Compound)) + c2 = Compound.make_text("world", 10) + with self.assertWarns(UserWarning): + Compound(children=[c, c2]).compound() + + def test_face(self): + f = Face.make_rect(1, 1) + self.assertTrue(isinstance(f, Face)) + with self.assertWarns(UserWarning): + Solid.make_box(1, 1, 1).face() + + def test_shell(self): + s = Solid.make_sphere(1).shell() + self.assertTrue(isinstance(s, Shell)) + with self.assertWarns(UserWarning): + extrude(Compound.make_text("two", 10), amount=5).shell() + + def test_solid(self): + s = Solid.make_sphere(1).solid() + self.assertTrue(isinstance(s, Solid)) + with self.assertWarns(UserWarning): + Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid() + + def test_manifold(self): + self.assertTrue(Solid.make_box(1, 1, 1).is_manifold) + self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold) + self.assertFalse( + Solid.make_box(1, 1, 1) + .shell() + .cut(Solid.make_box(0.5, 0.5, 0.5)) + .is_manifold + ) + self.assertTrue( + Compound( + children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)] + ).is_manifold + ) + + def test_inherit_color(self): + # Create some objects and assign colors to them + 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 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.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.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)) + self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) + self.assertListEqual(edges, []) + + verts, edges = Vertex(1, 2, 0)._ocp_section(Face(Plane.XY)) + self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) + self.assertListEqual(edges, []) + + 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 + + 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) + + 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) + + 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) + self.assertGreaterEqual(len(vertices4), 0) + self.assertGreaterEqual(len(edges4), 2) + self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in edges4)) + + cylinder3 = Cylinder(5, 20).solid() + cylinder4 = Rotation(0, 90, 0) * cylinder3 + + 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) + box2 = Box(10, 10, 10) + box.label = "box" + box.color = Color("Red") + box.children = [Box(1, 1, 1), Box(2, 2, 2)] + box.topo_parent = box2 + + blank = Compound() + box.copy_attributes_to(blank) + self.assertEqual(blank.label, "box") + self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red")))) + self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children))) + self.assertEqual(blank.topo_parent, box2) + + def test_empty_shape(self): + empty = Solid() + box = Solid.make_box(1, 1, 1) + 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 + self.assertIs(empty, empty.fix()) + self.assertEqual(hash(empty), 0) + self.assertFalse(empty.is_same(Solid())) + self.assertFalse(empty.is_equal(Solid())) + 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)) + self.assertEqual(Shape.compute_mass(empty), 0) + self.assertEqual(empty.entities("Face"), []) + self.assertEqual(empty.area, 0) + self.assertIs(empty, empty.rotate(Axis.Z, 90)) + translate_matrix = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ] + self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix))) + self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix))) + with self.assertRaises(ValueError): + empty.locate(Location()) + empty_loc = Location() + empty_loc.wrapped = None + with self.assertRaises(ValueError): + box.locate(empty_loc) + with self.assertRaises(ValueError): + empty.located(Location()) + with self.assertRaises(ValueError): + box.located(empty_loc) + with self.assertRaises(ValueError): + empty.move(Location()) + with self.assertRaises(ValueError): + box.move(empty_loc) + with self.assertRaises(ValueError): + 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.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(AttributeError): + box.intersect(empty_loc) + self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], [])) + self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList()) + with self.assertRaises(ValueError): + empty.split_by_perimeter(Circle(1).wire()) + with self.assertRaises(ValueError): + empty.distance(Vertex(1, 1, 1)) + with self.assertRaises(ValueError): + list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1))) + with self.assertRaises(ValueError): + list(box.distances(empty, Vertex(1, 1, 1))) + with self.assertRaises(ValueError): + empty.mesh(0.001) + with self.assertRaises(ValueError): + empty.tessellate(0.001) + with self.assertRaises(ValueError): + empty.to_splines() + empty_axis = Axis((0, 0, 0), (1, 0, 0)) + empty_axis.wrapped = None + with self.assertRaises(ValueError): + box.vertices().group_by(empty_axis) + empty_wire = Wire() + with self.assertRaises(ValueError): + box.vertices().group_by(empty_wire) + with self.assertRaises(ValueError): + box.vertices().sort_by(empty_axis) + with self.assertRaises(ValueError): + box.vertices().sort_by(empty_wire) + + def test_empty_selectors(self): + self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList()) + self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList()) + self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList()) + self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList()) + self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList()) + self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList()) + self.assertIsNone(Vertex(1, 1, 1).edge()) + self.assertIsNone(Vertex(1, 1, 1).wire()) + self.assertIsNone(Vertex(1, 1, 1).face()) + self.assertIsNone(Vertex(1, 1, 1).shell()) + self.assertIsNone(Vertex(1, 1, 1).solid()) + 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 new file mode 100644 index 0000000..24091b7 --- /dev/null +++ b/tests/test_direct_api/test_shape_list.py @@ -0,0 +1,480 @@ +""" +build123d imports + +name: test_shape_list.py +by: Gumyr +date: January 22, 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. + +""" + +# Always equal to any other object, to test that __eq__ cooperation is working +import io +import math +import re +import unittest + +from IPython.lib import pretty +from build123d.build_common import GridLocations, PolarLocations +from build123d.build_enums import GeomType, SortBy +from build123d.build_part import BuildPart +from build123d.geometry import Axis, Plane, Vector +from build123d.objects_part import Box, Cylinder +from build123d.objects_sketch import RegularPolygon +from build123d.topology import ( + Compound, + Edge, + Face, + ShapeList, + Shell, + Solid, + Vertex, + Wire, +) + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +class TestShapeList(unittest.TestCase): + """Test ShapeList functionality""" + + def assertDunderStrEqual(self, actual: str, expected_lines: list[str]): + 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, 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, 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) + + def test_sort_by(self): + faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA + self.assertAlmostEqual(faces[-1].area, 2, 5) + + def test_sort_by_lambda(self): + c = Solid.make_cone(2, 1, 2) + flat_faces = c.faces().filter_by(GeomType.PLANE) + sorted_flat_faces = flat_faces.sort_by(lambda f: f.area) + smallest = sorted_flat_faces[0] + largest = sorted_flat_faces[-1] + + self.assertAlmostEqual(smallest.area, math.pi * 1**2, 5) + self.assertAlmostEqual(largest.area, math.pi * 2**2, 5) + + def test_sort_by_property(self): + box1 = Box(1, 1, 1) + box2 = Box(2, 2, 2) + box3 = Box(3, 3, 3) + unsorted_boxes = ShapeList([box2, box3, box1]) + assert unsorted_boxes.sort_by(Solid.volume) == [box1, box2, box3] + assert unsorted_boxes.sort_by(Solid.volume, reverse=True) == [box3, box2, box1] + + def test_sort_by_invalid(self): + with self.assertRaises(ValueError): + Solid.make_box(1, 1, 1).faces().sort_by(">Z") + + def test_filter_by_geomtype(self): + non_planar_faces = ( + Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True) + ) + self.assertEqual(len(non_planar_faces), 1) + self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5) + + with self.assertRaises(ValueError): + Solid.make_box(1, 1, 1).faces().filter_by("True") + + def test_filter_by_axis(self): + box = Solid.make_box(1, 1, 1) + self.assertEqual(len(box.faces().filter_by(Axis.X)), 2) + 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" + boxes[1].label = "A" + boxes[2].label = "B" + shapelist = ShapeList(boxes) + + self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2) + self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1) + + def test_filter_by_property(self): + box1 = Box(2, 2, 2) + box2 = Box(2, 2, 2).translate((1, 1, 1)) + assert len((box1 + box2).edges().filter_by(Edge.is_interior)) == 6 + assert len((box1 - box2).edges().filter_by(Edge.is_interior)) == 3 + + def test_first_last(self): + vertices = ( + Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1))) + ) + self.assertAlmostEqual(Vector(vertices.last), (1, 1, 1), 5) + self.assertAlmostEqual(Vector(vertices.first), (0, 0, 0), 5) + + def test_group_by(self): + vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z) + self.assertEqual(len(vertices[0]), 4) + + edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH) + self.assertEqual(len(edges[0]), 12) + + edges = ( + Solid.make_cone(2, 1, 2) + .edges() + .filter_by(GeomType.CIRCLE) + .group_by(SortBy.RADIUS) + ) + self.assertEqual(len(edges[0]), 1) + + edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS + self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5) + + vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE) + self.assertAlmostEqual(Vector(vertices[-1][0]), (1, 1, 1), 5) + + box = Solid.make_box(1, 1, 2) + self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2) + self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4) + + line = Edge.make_line((0, 0, 0), (1, 1, 2)) + vertices_by_line = box.vertices().group_by(line) + self.assertEqual(len(vertices_by_line[0]), 1) + self.assertEqual(len(vertices_by_line[1]), 2) + self.assertEqual(len(vertices_by_line[2]), 1) + self.assertEqual(len(vertices_by_line[3]), 1) + self.assertEqual(len(vertices_by_line[4]), 2) + self.assertEqual(len(vertices_by_line[5]), 1) + self.assertAlmostEqual(Vector(vertices_by_line[0][0]), (0, 0, 0), 5) + self.assertAlmostEqual(Vector(vertices_by_line[-1][0]), (1, 1, 2), 5) + + with BuildPart() as boxes: + with GridLocations(10, 10, 3, 3): + Box(1, 1, 1) + with PolarLocations(100, 10): + Box(1, 1, 2) + self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10) + self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9) + + with self.assertRaises(ValueError): + boxes.solids().group_by("AREA") + + def test_group_by_callable_predicate(self): + boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] + boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] + for box in boxesA: + box.label = "A" + for box in boxesB: + box.label = "B" + boxNoLabel = Solid.make_box(1, 1, 1) + + shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) + result = shapelist.group_by(lambda shape: shape.label) + + self.assertEqual([len(group) for group in result], [1, 3, 2]) + + def test_group_by_property(self): + box1 = Box(2, 2, 2) + box2 = Box(2, 2, 2).translate((1, 1, 1)) + g1 = (box1 + box2).edges().group_by(Edge.is_interior) + assert len(g1.group(True)) == 6 + assert len(g1.group(False)) == 24 + + g2 = (box1 - box2).edges().group_by(Edge.is_interior) + assert len(g2.group(True)) == 3 + assert len(g2.group(False)) == 18 + + def test_group_by_retrieve_groups(self): + boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] + boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] + for box in boxesA: + box.label = "A" + for box in boxesB: + box.label = "B" + boxNoLabel = Solid.make_box(1, 1, 1) + + shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) + result = shapelist.group_by(lambda shape: shape.label) + + self.assertEqual(len(result.group("")), 1) + self.assertEqual(len(result.group("A")), 3) + self.assertEqual(len(result.group("B")), 2) + self.assertEqual(result.group(""), result[0]) + self.assertEqual(result.group("A"), result[1]) + self.assertEqual(result.group("B"), result[2]) + self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0])) + self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0])) + with self.assertRaises(KeyError): + result.group("C") + + def test_group_by_str_repr(self): + nonagon = RegularPolygon(5, 9) + + expected = [ + "[[],", + " [,", + " ],", + " [,", + " ],", + " [,", + " ],", + " [,", + " ]]", + ] + self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected) + + expected_repr = ( + "[[]," + " [," + " ]," + " [," + " ]," + " [," + " ]," + " [," + " ]]" + ) + self.assertDunderReprEqual( + repr(nonagon.edges().group_by(Axis.X)), expected_repr + ) + + f = io.StringIO() + p = pretty.PrettyPrinter(f) + nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True) + self.assertEqual(f.getvalue(), "(...)") + + def test_distance(self): + with BuildPart() as box: + Box(1, 2, 3) + obj = (-0.2, 0.1, 0.5) + edges = box.edges().sort_by_distance(obj) + distances = [Vertex(*obj).distance_to(edge) for edge in edges] + self.assertTrue( + all([distances[i] >= distances[i - 1] for i in range(1, len(edges))]) + ) + + def test_distance_reverse(self): + with BuildPart() as box: + Box(1, 2, 3) + obj = (-0.2, 0.1, 0.5) + edges = box.edges().sort_by_distance(obj, reverse=True) + distances = [Vertex(*obj).distance_to(edge) for edge in edges] + self.assertTrue( + all([distances[i] <= distances[i - 1] for i in range(1, len(edges))]) + ) + + def test_distance_equal(self): + with BuildPart() as box: + Box(1, 1, 1) + self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12) + + def test_vertices(self): + sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) + self.assertEqual(len(sl.vertices()), 8) + + def test_vertex(self): + sl = ShapeList([Edge.make_circle(1)]) + 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() + self.assertEqual(len(Edge().vertices()), 0) + + def test_edges(self): + sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) + self.assertEqual(len(sl.edges()), 8) + self.assertEqual(len(Edge().edges()), 0) + + def test_edge(self): + sl = ShapeList([Edge.make_circle(1)]) + self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5) + sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) + with self.assertWarns(UserWarning): + sl.edge() + + def test_wires(self): + sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) + self.assertEqual(len(sl.wires()), 2) + self.assertEqual(len(Wire().wires()), 0) + + def test_wire(self): + sl = ShapeList([Wire.make_circle(1)]) + self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5) + sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))]) + with self.assertWarns(UserWarning): + sl.wire() + + def test_faces(self): + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + self.assertEqual(len(sl.faces()), 9) + self.assertEqual(len(Face().faces()), 0) + + def test_face(self): + sl = ShapeList( + [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)] + ) + self.assertAlmostEqual(sl.face().area, 2 * 1, 5) + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + with self.assertWarns(UserWarning): + sl.face() + + def test_shells(self): + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + self.assertEqual(len(sl.shells()), 2) + self.assertEqual(len(Shell().shells()), 0) + + def test_shell(self): + sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)]) + self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5) + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + with self.assertWarns(UserWarning): + sl.shell() + + def test_solids(self): + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + self.assertEqual(len(sl.solids()), 2) + self.assertEqual(len(Solid().solids()), 0) + + def test_solid(self): + sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]) + with self.assertWarns(UserWarning): + sl.solid() + sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)]) + self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5) + + def test_compounds(self): + sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) + self.assertEqual(len(sl.compounds()), 2) + self.assertEqual(len(Compound().compounds()), 0) + + def test_compound(self): + sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) + with self.assertWarns(UserWarning): + sl.compound() + sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)]) + self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5) + + def test_equal(self): + box = Box(1, 1, 1) + cyl = Cylinder(1, 1) + sl = ShapeList([box, cyl]) + same = ShapeList([cyl, box]) + self.assertEqual(sl, same) + self.assertEqual(sl, AlwaysEqual()) + + def test_not_equal(self): + sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)]) + diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)]) + self.assertNotEqual(sl, diff) + self.assertNotEqual(sl, object()) + + def test_center(self): + self.assertEqual(tuple(ShapeList().center()), (0, 0, 0)) + self.assertEqual( + tuple(ShapeList(Vertex(i, 0, 0) for i in range(3)).center()), (1, 0, 0) + ) + + +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 new file mode 100644 index 0000000..bd81945 --- /dev/null +++ b/tests/test_direct_api/test_shells.py @@ -0,0 +1,128 @@ +""" +build123d imports + +name: test_shells.py +by: Gumyr +date: January 22, 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 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 +from build123d.operations_generic import sweep +from build123d.topology import Shell, Solid, Wire + + +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) + + 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() + box_shell = Shell(box_faces) + self.assertAlmostEqual(box_shell.center(), (0.5, 0.5, 0.5), 5) + + def test_manifold_shell_volume(self): + box_faces = Solid.make_box(1, 1, 1).faces() + box_shell = Shell(box_faces) + self.assertAlmostEqual(box_shell.volume, 1, 5) + + def test_nonmanifold_shell_volume(self): + box_faces = Solid.make_box(1, 1, 1).faces() + nm_shell = Shell(box_faces) + nm_shell -= nm_shell.faces()[0] + self.assertAlmostEqual(nm_shell.volume, 0, 5) + + def test_constructor(self): + with self.assertRaises(TypeError): + Shell(foo="bar") + + 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) + single_face = Shell(surface.faces()) + self.assertTrue(single_face.is_valid) + + def test_sweep(self): + path_c1 = JernArc((0, 0), (-1, 0), 1, 180) + path_e = path_c1.edge() + path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90) + path_w = path_c2.wire() + section_e = Circle(0.5).edge() + section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1)) + section_w = section_c2.wire() + + sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w) + sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e) + sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w) + sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1) + sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2) + + self.assertEqual(len(sweep_e_w.faces()), 2) + self.assertEqual(len(sweep_w_e.faces()), 2) + self.assertEqual(len(sweep_c2_c1.faces()), 2) + self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without + self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without + + def test_make_loft(self): + r = 3 + h = 2 + loft = Shell.make_loft( + [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)] + ) + self.assertEqual(loft.volume, 0, "A shell has no volume") + cylinder_area = 2 * math.pi * r * h + self.assertAlmostEqual(loft.area, cylinder_area) + + def test_thicken(self): + rect = Wire.make_rect(10, 5) + shell: Shell = Shell.extrude(rect, Vector(0, 0, 3)) + thick = Solid.thicken(shell, 1) + + self.assertEqual(isinstance(thick, Solid), True) + inner_vol = 3 * 10 * 5 + 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_skip_clean.py b/tests/test_direct_api/test_skip_clean.py new file mode 100644 index 0000000..4c26916 --- /dev/null +++ b/tests/test_direct_api/test_skip_clean.py @@ -0,0 +1,68 @@ +""" +build123d imports + +name: test_skip_clean.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.topology import SkipClean + + +class TestSkipClean(unittest.TestCase): + def setUp(self): + # Ensure the class variable is in its default state before each test + SkipClean.clean = True + + def test_context_manager_sets_clean_false(self): + # Verify `clean` is initially True + self.assertTrue(SkipClean.clean) + + # Use the context manager + with SkipClean(): + # Within the context, `clean` should be False + self.assertFalse(SkipClean.clean) + + # After exiting the context, `clean` should revert to True + self.assertTrue(SkipClean.clean) + + def test_exception_handling_does_not_affect_clean(self): + # Verify `clean` is initially True + self.assertTrue(SkipClean.clean) + + # Use the context manager and raise an exception + try: + with SkipClean(): + self.assertFalse(SkipClean.clean) + raise ValueError("Test exception") + except ValueError: + pass + + # Ensure `clean` is restored to True after an exception + self.assertTrue(SkipClean.clean) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py new file mode 100644 index 0000000..75fad74 --- /dev/null +++ b/tests/test_direct_api/test_solid.py @@ -0,0 +1,331 @@ +""" +build123d imports + +name: test_solid.py +by: Gumyr +date: January 22, 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 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, + 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): + def test_make_solid(self): + box_faces = Solid.make_box(1, 1, 1).faces() + box_shell = Shell(box_faces) + box = Solid(box_shell) + self.assertAlmostEqual(box.area, 6, 5) + self.assertAlmostEqual(box.volume, 1, 5) + self.assertTrue(box.is_valid) + + def test_extrude(self): + v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1)) + self.assertAlmostEqual(v.length, 1, 5) + + e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1)) + self.assertAlmostEqual(e.area, 1, 5) + + w = Shell.extrude( + Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]), + (0, 0, 1), + ) + self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5) + + f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1)) + self.assertAlmostEqual(f.volume, 1, 5) + + s = Compound.extrude( + Shell( + Solid.make_box(1, 1, 1) + .locate(Location((-2, 1, 0))) + .faces() + .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:] + ), + (0.1, 0.1, 0.1), + ) + self.assertAlmostEqual(s.volume, 0.2, 5) + + with self.assertRaises(ValueError): + Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1)) + + def test_extrude_taper(self): + a = 1 + rect = Face.make_rect(a, a) + flipped = -rect + for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]: + for taper in [10, -10]: + offset_amt = -direction.length * math.tan(math.radians(taper)) + for face in [rect, flipped]: + with self.subTest( + f"{direction=}, {taper=}, flipped={face==flipped}" + ): + taper_solid = Solid.extrude_taper(face, direction, taper) + # V = 1/3 × h × (a² + b² + ab) + h = Vector(direction).length + b = a + 2 * offset_amt + v = h * (a**2 + b**2 + a * b) / 3 + self.assertAlmostEqual(taper_solid.volume, v, 5) + bbox = taper_solid.bounding_box() + size = max(1, b) / 2 + if direction.Z > 0: + self.assertAlmostEqual(bbox.min, (-size, -size, 0), 1) + self.assertAlmostEqual(bbox.max, (size, size, h), 1) + else: + self.assertAlmostEqual(bbox.min, (-size, -size, -h), 1) + self.assertAlmostEqual(bbox.max, (size, size, 0), 1) + + def test_extrude_taper_with_hole(self): + rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) + direction = Vector(0, 0, 0.5) + taper = 10 + taper_solid = Solid.extrude_taper(rect_hole, direction, taper) + offset_amt = -direction.length * math.tan(math.radians(taper)) + hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5) + + def test_extrude_taper_with_hole_flipped(self): + rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) + direction = Vector(0, 0, 1) + taper = 10 + taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True) + taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False) + hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + self.assertGreater(hole_t.radius, hole_f.radius) + + def test_extrude_taper_oblique(self): + rect = Face.make_rect(2, 1) + rect_hole = rect.make_holes([Wire.make_circle(0.25)]) + o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77)) + taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5) + taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5) + self.assertAlmostEqual(taper0.volume, taper1.volume, 5) + + def test_extrude_linear_with_rotation(self): + # Face + base = Face.make_rect(1, 1) + twist = Solid.extrude_linear_with_rotation( + base, center=(0, 0, 0), normal=(0, 0, 1), angle=45 + ) + 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] + 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( + base, center=(0, 0, 0), normal=(0, 0, 1), angle=45 + ) + 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] + 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( + [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))] + ) + self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1) + + with self.assertRaises(ValueError): + Solid.make_loft([Wire.make_rect(1, 1)]) + + def test_make_loft_with_vertices(self): + loft = Solid.make_loft( + [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True + ) + self.assertAlmostEqual(loft.volume, 1, 5) + + with self.assertRaises(ValueError): + Solid.make_loft( + [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)] + ) + + with self.assertRaises(ValueError): + Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)]) + + with self.assertRaises(ValueError): + Solid.make_loft( + [ + Vertex(0, 0, 1), + Wire.make_rect(1, 1), + Vertex(0, 0, 2), + Vertex(0, 0, 3), + ] + ) + + def test_extrude_until(self): + square = Face.make_rect(1, 1) + box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3))) + extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST) + self.assertAlmostEqual(extrusion.volume, 4, 5) + + square = Face.make_rect(1, 1) + box = Solid.make_box(4, 4, 1, Plane((-2, -2, -3))) + extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.PREVIOUS) + self.assertAlmostEqual(extrusion.volume, 2, 5) + + def test_sweep(self): + path = Edge.make_spline([(0, 0), (3, 5), (7, -2)]) + section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0)) + area = Face(section).area + swept = Solid.sweep(section, path) + self.assertAlmostEqual(swept.volume, path.length * area, 0) + + def test_hollow_sweep(self): + path = Edge.make_line((0, 0, 0), (0, 0, 5)) + section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0] + swept = Solid.sweep(section, path) + self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5) + + def test_sweep_multi(self): + f0 = Face.make_rect(1, 1) + f1 = Pos(X=10) * Circle(1).face() + path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (0, 0, -1))) + binormal = Edge.make_line((0, 1), (10, 1)) + swept = Solid.sweep_multi([f0, f1], path, is_frenet=True, binormal=binormal) + self.assertAlmostEqual(swept.volume, 23.78, 2) + + path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (1, 0, 0))) + swept = Solid.sweep_multi( + [f0, f1], path, is_frenet=True, binormal=Vector(5, 0, 1) + ) + self.assertAlmostEqual(swept.volume, 20.75, 2) + + def test_constructor(self): + with self.assertRaises(TypeError): + Solid(foo="bar") + + def test_offset_3d(self): + with self.assertRaises(ValueError): + Solid.make_box(1, 1, 1).offset_3d(None, 0.1, kind=Kind.TANGENT) + + def test_revolve(self): + r = Solid.revolve( + Face.make_rect(1, 1, Plane((10, 0, 0))).wire(), 180, axis=Axis.Y + ) + 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 new file mode 100644 index 0000000..521c47c --- /dev/null +++ b/tests/test_direct_api/test_vector.py @@ -0,0 +1,298 @@ +""" +build123d imports + +name: test_vector.py +by: Gumyr +date: January 22, 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. + +""" + +# Always equal to any other object, to test that __eq__ cooperation is working +import copy +import math +import unittest + +from OCP.gp import gp_Vec, gp_XYZ +from build123d.geometry import Axis, Location, Plane, Pos, Vector +from build123d.topology import Solid, Vertex + + +class AlwaysEqual: + def __eq__(self, other): + return True + + +class TestVector(unittest.TestCase): + """Test the Vector methods""" + + def test_vector_constructors(self): + v1 = Vector(1, 2, 3) + v2 = Vector((1, 2, 3)) + v3 = Vector(gp_Vec(1, 2, 3)) + v4 = Vector([1, 2, 3]) + v5 = Vector(gp_XYZ(1, 2, 3)) + v5b = Vector(X=1, Y=2, Z=3) + v5c = Vector(v=gp_XYZ(1, 2, 3)) + + for v in [v1, v2, v3, v4, v5, v5b, v5c]: + self.assertAlmostEqual(v, (1, 2, 3), 4) + + v6 = Vector((1, 2)) + v7 = Vector([1, 2]) + v8 = Vector(1, 2) + v8b = Vector(X=1, Y=2) + + for v in [v6, v7, v8, v8b]: + self.assertAlmostEqual(v, (1, 2, 0), 4) + + v9 = Vector() + self.assertAlmostEqual(v9, (0, 0, 0), 4) + + v9.X = 1.0 + v9.Y = 2.0 + v9.Z = 3.0 + self.assertAlmostEqual(v9, (1, 2, 3), 4) + self.assertAlmostEqual(Vector(1, 2, 3, 4), (1, 2, 3), 4) + + v10 = Vector(1) + v11 = Vector((1,)) + v12 = Vector([1]) + v13 = Vector(X=1) + for v in [v10, v11, v12, v13]: + self.assertAlmostEqual(v, (1, 0, 0), 4) + + vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10)) + self.assertAlmostEqual(Vector(vertex), (0, 0, 10), 4) + + with self.assertRaises(TypeError): + Vector("vector") + with self.assertRaises(ValueError): + Vector(x=1) + + def test_vector_rotate(self): + """Validate vector rotate methods""" + vector_x = Vector(1, 0, 1).rotate(Axis.X, 45) + vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45) + vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45) + self.assertAlmostEqual(vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7) + self.assertAlmostEqual(vector_y, (math.sqrt(2), 2, 0), 7) + self.assertAlmostEqual(vector_z, (0, -math.sqrt(2), 3), 7) + + def test_get_signed_angle(self): + """Verify getSignedAngle calculations with and without a provided normal""" + a = math.pi / 3 + v1 = Vector(1, 0, 0) + v2 = Vector(math.cos(a), -math.sin(a), 0) + d1 = v1.get_signed_angle(v2) + d2 = v1.get_signed_angle(v2, Vector(0, 0, 1)) + self.assertAlmostEqual(d1, a * 180 / math.pi) + self.assertAlmostEqual(d2, -a * 180 / math.pi) + + def test_center(self): + v = Vector(1, 1, 1) + self.assertAlmostEqual(v, v.center()) + + def test_dot(self): + v1 = Vector(2, 2, 2) + v2 = Vector(1, -1, 1) + self.assertEqual(2.0, v1.dot(v2)) + + def test_vector_add(self): + result = Vector(1, 2, 0) + Vector(0, 0, 3) + self.assertAlmostEqual(result, (1.0, 2.0, 3.0), 3) + + def test_vector_operators(self): + result = Vector(1, 1, 1) + Vector(2, 2, 2) + self.assertEqual(Vector(3, 3, 3), result) + + result = Vector(1, 2, 3) - Vector(3, 2, 1) + self.assertEqual(Vector(-2, 0, 2), result) + + result = Vector(1, 2, 3) * 2 + self.assertEqual(Vector(2, 4, 6), result) + + result = 3 * Vector(1, 2, 3) + self.assertEqual(Vector(3, 6, 9), result) + + result = Vector(2, 4, 6) / 2 + self.assertEqual(Vector(1, 2, 3), result) + + self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1)) + + self.assertEqual(0, abs(Vector(0, 0, 0))) + self.assertEqual(1, abs(Vector(1, 0, 0))) + self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3))) + + def test_vector_equals(self): + a = Vector(1, 2, 3) + b = Vector(1, 2, 3) + c = Vector(1, 2, 3.000001) + self.assertEqual(a, b) + self.assertEqual(a, c) + self.assertEqual(a, AlwaysEqual()) + + def test_vector_not_equal(self): + a = Vector(1, 2, 3) + b = Vector(3, 2, 1) + 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. + """ + v = Vector(1, 2, 3) + + self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ)) + self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX)) + self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY)) + self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY)) + self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ)) + self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX)) + + self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ)) + self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX)) + self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY)) + self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY)) + self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ)) + self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX)) + + def test_vector_project(self): + """ + Test line projection and plane projection methods of Vector + """ + decimal_places = 9 + + z_dir = Vector(1, 2, 3) + base = Vector(5, 7, 9) + x_dir = Vector(1, 0, 0) + + # test passing Plane object + point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir)) + self.assertAlmostEqual(point, (59 / 7, 55 / 7, 51 / 7), decimal_places) + + # test line projection + vec = Vector(10, 10, 10) + line = Vector(3, 4, 5) + angle = math.radians(vec.get_angle(line)) + + vecLineProjection = vec.project_to_line(line) + + self.assertAlmostEqual( + vecLineProjection.normalized(), + line.normalized(), + decimal_places, + ) + self.assertAlmostEqual( + vec.length * math.cos(angle), vecLineProjection.length, decimal_places + ) + + def test_vector_not_implemented(self): + pass + + def test_vector_special_methods(self): + self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)") + self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)") + self.assertEqual( + str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)), + "Vector(10, -23.65, 0)", + ) + + def test_vector_iter(self): + self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6) + + def test_reverse(self): + self.assertAlmostEqual(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7) + + def test_copy(self): + v2 = copy.copy(Vector(1, 2, 3)) + v3 = copy.deepcopy(Vector(1, 2, 3)) + self.assertAlmostEqual(v2, (1, 2, 3), 7) + self.assertAlmostEqual(v3, (1, 2, 3), 7) + + def test_radd(self): + vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)] + vector_sum = sum(vectors) + self.assertAlmostEqual(vector_sum, (12, 15, 18), 5) + + def test_hash(self): + vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)] + unique_vectors = list(set(vectors)) + self.assertEqual(len(vectors), 4) + self.assertEqual(len(unique_vectors), 3) + + def test_vector_transform(self): + a = Vector(1, 2, 3) + pxy = Plane.XY + pxy_o1 = Plane.XY.offset(1) + self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a) + self.assertEqual( + a.transform(pxy.forward_transform, is_direction=True), a.normalized() + ) + self.assertEqual( + a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2) + ) + self.assertEqual( + a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized() + ) + self.assertEqual( + a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4) + ) + self.assertEqual( + a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized() + ) + + def test_intersect(self): + v1 = Vector(1, 2, 3) + self.assertAlmostEqual(v1 & Vector(1, 2, 3), (1, 2, 3), 5) + self.assertIsNone(v1 & Vector(0, 0, 0)) + + self.assertAlmostEqual(v1 & Location((1, 2, 3)), (1, 2, 3), 5) + self.assertIsNone(v1 & Location()) + + self.assertAlmostEqual(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5) + self.assertIsNone(v1 & Axis.X) + + self.assertAlmostEqual(v1 & Plane((1, 2, 3)), (1, 2, 3), 5) + self.assertIsNone(v1 & Plane.XY) + + self.assertAlmostEqual( + Vector((v1 & Solid.make_box(2, 4, 5)).vertex()), (1, 2, 3), 5 + ) + self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5))) + self.assertIsNone( + Vertex(-10, -10, -10).intersect(Solid.make_box(0.5, 0.5, 0.5)) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_vector_like.py b/tests/test_direct_api/test_vector_like.py new file mode 100644 index 0000000..4926331 --- /dev/null +++ b/tests/test_direct_api/test_vector_like.py @@ -0,0 +1,55 @@ +""" +build123d imports + +name: test_vector_like.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.geometry import Axis, Vector +from build123d.topology import Vertex + + +class TestVectorLike(unittest.TestCase): + """Test typedef""" + + def test_axis_from_vertex(self): + axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1)) + self.assertAlmostEqual(axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(axis.direction, (0, 0, 1), 5) + + def test_axis_from_vector(self): + axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1)) + self.assertAlmostEqual(axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(axis.direction, (0, 0, 1), 5) + + def test_axis_from_tuple(self): + axis = Axis((1, 2, 3), (0, 0, 1)) + self.assertAlmostEqual(axis.position, (1, 2, 3), 5) + self.assertAlmostEqual(axis.direction, (0, 0, 1), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_vertex.py b/tests/test_direct_api/test_vertex.py new file mode 100644 index 0000000..434135c --- /dev/null +++ b/tests/test_direct_api/test_vertex.py @@ -0,0 +1,104 @@ +""" +build123d imports + +name: test_vertex.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.geometry import Axis, Vector +from build123d.topology import Vertex + + +class TestVertex(unittest.TestCase): + """Test the extensions to the cadquery Vertex class""" + + def test_basic_vertex(self): + v = Vertex() + self.assertEqual(0, v.X) + + v = Vertex(1, 1, 1) + self.assertEqual(1, v.X) + self.assertEqual(Vector, type(v.center())) + + self.assertAlmostEqual(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7) + self.assertAlmostEqual(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7) + self.assertAlmostEqual(Vector(Vertex((7,))), (7, 0, 0), 7) + self.assertAlmostEqual(Vector(Vertex((8, 9))), (8, 9, 0), 7) + + def test_vertex_volume(self): + v = Vertex(1, 1, 1) + self.assertAlmostEqual(v.volume, 0, 5) + + def test_vertex_add(self): + test_vertex = Vertex(0, 0, 0) + self.assertAlmostEqual(Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7) + self.assertAlmostEqual( + Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7 + ) + self.assertAlmostEqual( + Vector(test_vertex + Vertex(100, -40, 10)), + (100, -40, 10), + 7, + ) + with self.assertRaises(TypeError): + test_vertex + [1, 2, 3] + + def test_vertex_sub(self): + test_vertex = Vertex(0, 0, 0) + self.assertAlmostEqual(Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7) + self.assertAlmostEqual( + Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7 + ) + self.assertAlmostEqual( + Vector(test_vertex - Vertex(100, -40, 10)), + (-100, 40, -10), + 7, + ) + with self.assertRaises(TypeError): + test_vertex - [1, 2, 3] + + def test_vertex_str(self): + self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)") + + def test_vertex_to_vector(self): + self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector) + self.assertAlmostEqual(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7) + + def test_vertex_init_error(self): + with self.assertRaises(TypeError): + Vertex(Axis.Z) + with self.assertRaises(ValueError): + Vertex(x=1) + with self.assertRaises(TypeError): + Vertex((Axis.X, Axis.Y, Axis.Z)) + + def test_no_intersect(self): + with self.assertRaises(NotImplementedError): + Vertex(1, 2, 3) & Vertex(5, 6, 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_vtk_poly_data.py b/tests/test_direct_api/test_vtk_poly_data.py new file mode 100644 index 0000000..b3ba166 --- /dev/null +++ b/tests/test_direct_api/test_vtk_poly_data.py @@ -0,0 +1,88 @@ +""" +build123d imports + +name: test_v_t_k_poly_data.py +by: Gumyr +date: January 22, 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 unittest + +from build123d.topology import Solid +from build123d.vtk_tools import to_vtk_poly_data +from vtkmodules.vtkCommonDataModel import vtkPolyData +from vtkmodules.vtkFiltersCore import vtkTriangleFilter + + +class TestVTKPolyData(unittest.TestCase): + def setUp(self): + # Create a simple test object (e.g., a cylinder) + self.object_under_test = Solid.make_cylinder(1, 2) + + def test_to_vtk_poly_data(self): + # Generate VTK data + vtk_data = to_vtk_poly_data( + self.object_under_test, tolerance=0.1, angular_tolerance=0.2, normals=True + ) + + # Verify the result is of type vtkPolyData + self.assertIsInstance(vtk_data, vtkPolyData) + + # Further verification can include: + # - Checking the number of points, polygons, or cells + self.assertGreater( + vtk_data.GetNumberOfPoints(), 0, "VTK data should have points." + ) + self.assertGreater( + vtk_data.GetNumberOfCells(), 0, "VTK data should have cells." + ) + + # Optionally, compare the output with a known reference object + # (if available) by exporting or analyzing the VTK data + known_filter = vtkTriangleFilter() + known_filter.SetInputData(vtk_data) + known_filter.Update() + known_output = known_filter.GetOutput() + + self.assertEqual( + vtk_data.GetNumberOfPoints(), + known_output.GetNumberOfPoints(), + "Number of points in VTK data does not match the expected output.", + ) + self.assertEqual( + vtk_data.GetNumberOfCells(), + known_output.GetNumberOfCells(), + "Number of cells in VTK data does not match the expected output.", + ) + + def test_empty_shape(self): + # Test handling of empty shape + empty_object = Solid() # Create an empty object + with self.assertRaises(ValueError) as context: + to_vtk_poly_data(empty_object) + + self.assertEqual(str(context.exception), "Cannot convert an empty shape") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py new file mode 100644 index 0000000..bbfb6fc --- /dev/null +++ b/tests/test_direct_api/test_wire.py @@ -0,0 +1,359 @@ +""" +build123d imports + +name: test_wire.py +by: Gumyr +date: January 22, 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 random +import unittest + +import numpy as np +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): + def test_ellipse_arc(self): + full_ellipse = Wire.make_ellipse(2, 1) + half_ellipse = Wire.make_ellipse( + 2, 1, start_angle=0, end_angle=180, closed=True + ) + self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5) + + def test_stitch(self): + half_ellipse1 = Wire.make_ellipse( + 2, 1, start_angle=0, end_angle=180, closed=False + ) + half_ellipse2 = Wire.make_ellipse( + 2, 1, start_angle=180, end_angle=360, closed=False + ) + ellipse = half_ellipse1.stitch(half_ellipse2) + self.assertEqual(len(ellipse.wires()), 1) + + def test_fillet_2d(self): + square = Wire.make_rect(1, 1) + squaroid = square.fillet_2d(0.1, square.vertices()) + 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) + squaroid = square.chamfer_2d(0.1, 0.1, square.vertices()) + 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) + edge = square.edges().sort_by(Axis.Y)[0] + vertex = edge.vertices().sort_by(Axis.X)[0] + square = square.chamfer_2d( + distance=0.1, distance2=0.2, vertices=[vertex], edge=edge + ) + self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9) + + def test_make_convex_hull(self): + # overlapping_edges = [ + # Edge.make_circle(10, end_angle=60), + # Edge.make_circle(10, start_angle=30, end_angle=90), + # Edge.make_line((-10, 10), (10, -10)), + # ] + # with self.assertRaises(ValueError): + # Wire.make_convex_hull(overlapping_edges) + + adjoining_edges = [ + Edge.make_circle(10, end_angle=45), + Edge.make_circle(10, start_angle=315, end_angle=360), + Edge.make_line((-10, 10), (-10, -10)), + ] + hull_wire = Wire.make_convex_hull(adjoining_edges) + self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4) + + 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)) + # edge1a = edge1.trim(0, 1e-7) + # edge1b = edge1.trim(1e-7, 1.0) + # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0)) + # wire = Wire([edge0, edge1a, edge1b, edge2]) + # fixed_wire = wire.fix_degenerate_edges(1e-6) + # self.assertEqual(len(fixed_wire.edges()), 2) + + def test_trim(self): + e0 = Edge.make_line((0, 0), (1, 0)) + e1 = Edge.make_line((2, 0), (1, 0)) + e2 = Edge.make_line((2, 0), (3, 0)) + w1 = Wire([e0, e1, e2]) + t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0))) + self.assertAlmostEqual(t1.length, 2.1, 5) + + e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) + # Three edges are created 0->0.5->0.75->1.0 + o = e.offset_2d(10, side=Side.RIGHT, closed=False) + t2 = o.trim(0.1, 0.9) + self.assertAlmostEqual(t2.length, o.length * 0.8, 5) + + t3 = o.trim(0.5, 1.0) + self.assertAlmostEqual(t3.length, o.length * 0.5, 5) + + t4 = o.trim(0.5, 0.75) + self.assertAlmostEqual(t4.length, o.length * 0.25, 5) + + 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), + tangents=((0, 0, 1), (0, 0, -1)), + tangent_scalars=(2, 2), + ) + half = spline.trim(0.5, 1) + self.assertAlmostEqual(spline @ 0.5, half @ 0, 4) + self.assertAlmostEqual(spline @ 1, half @ 1, 4) + + w = Rectangle(3, 1).wire() + t5 = w.trim(0, 0.5) + self.assertAlmostEqual(t5.length, 4, 5) + t6 = w.trim(0.5, 1) + self.assertAlmostEqual(t6.length, 4, 5) + + p = RegularPolygon(10, 20).wire() + t7 = p.trim(0.1, 0.2) + self.assertAlmostEqual(p.length * 0.1, t7.length, 5) + + c = Circle(10).wire() + t8 = c.trim(0.4, 0.9) + self.assertAlmostEqual(c.length * 0.5, t8.length, 5) + + def test_param_at_point(self): + e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) + # Three edges are created 0->0.5->0.75->1.0 + o = e.offset_2d(10, side=Side.RIGHT, closed=False) + + e0 = Edge.make_line((0, 0), (1, 0)) + e1 = Edge.make_line((2, 0), (1, 0)) + e2 = Edge.make_line((2, 0), (3, 0)) + w1 = Wire([e0, e1, e2]) + for wire in [o, w1]: + u_value = random.random() + position = wire.position_at(u_value) + self.assertAlmostEqual(wire.param_at_point(position), u_value, 4) + + with self.assertRaises(ValueError): + o.param_at_point((-1, 1)) + + 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( + [ + Edge.make_line((0, 0), (1, 0)), + Edge.make_line((1, 1), (1, 0)), + Edge.make_line((0, 1), (1, 1)), + ] + ) + ordered_edges = w1.order_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) + w2 = Wire([e0]) + self.assertAlmostEqual(w2.length, 1, 5) + self.assertTrue(w2.is_valid) + w3 = Wire([e0, e1]) + self.assertTrue(w3.is_valid) + self.assertAlmostEqual(w3.length, 2, 5) + w4 = Wire(w0.wrapped) + self.assertTrue(w4.is_valid) + w5 = Wire(obj=w0.wrapped) + self.assertTrue(w5.is_valid) + w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red")) + 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) + c0 = Polyline((0, 0), (1, 0), (1, 1)) + w8 = Wire(c0) + 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 a038057..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 @@ -29,6 +30,7 @@ from build123d import ( add, mirror, section, + ThreePointArc, ) from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType @@ -173,9 +175,29 @@ class ExportersTestCase(unittest.TestCase): svg.add_shape(sketch) svg.write("test-colors.svg") + def test_svg_small_arc(self): + pnts = ((0, 0), (0, 0.000001), (0.000001, 0)) + small_arc = ThreePointArc(pnts).scale(0.01) + with self.assertWarns(UserWarning): + svg_exporter = ExportSVG() + segments = svg_exporter._circle_segments(small_arc.edges()[0], False) + self.assertEqual(len(segments), 0, "Small arc should produce no segments") + + def test_svg_small_ellipse(self): + pnts = ((0, 0), (0, 0.000001), (0.000002, 0)) + small_ellipse = ThreePointArc(pnts).scale(0.01) + with self.assertWarns(UserWarning): + svg_exporter = ExportSVG() + segments = svg_exporter._ellipse_segments(small_ellipse.edges()[0], False) + self.assertEqual( + len(segments), 0, "Small ellipse should produce no segments" + ) + @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): @@ -186,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 7c7e4a1..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): @@ -71,13 +71,9 @@ class ImportSVG(unittest.TestCase): def test_import_svg(self): svg_file = Path(__file__).parent / "../tests/svg_import_test.svg" - for tag in ["id", "label"]: + for tag in ["id", "inkscape:label"]: # Import the svg object as a ShapeList - svg = import_svg( - svg_file, - label_by=tag, - is_inkscape_label=tag == "label", - ) + svg = import_svg(svg_file, label_by=tag) # Exact the shape of the plate & holes base_faces = svg.filter_by(lambda f: "base" in f.label) @@ -88,6 +84,24 @@ class ImportSVG(unittest.TestCase): self.assertEqual(len(list(hole_faces)), 2) self.assertEqual(len(list(test_wires)), 1) + def test_import_svg_deprecated_param(self): # TODO remove for `1.0` release + svg_file = Path(__file__).parent / "../tests/svg_import_test.svg" + + with self.assertWarns(UserWarning): + svg = import_svg(svg_file, label_by="label", is_inkscape_label=True) + + # Exact the shape of the plate & holes + base_faces = svg.filter_by(lambda f: "base" in f.label) + hole_faces = svg.filter_by(lambda f: "hole" in f.label) + test_wires = svg.filter_by(lambda f: "wire" in f.label) + + self.assertEqual(len(list(base_faces)), 1) + self.assertEqual(len(list(hole_faces)), 2) + self.assertEqual(len(list(test_wires)), 1) + + with self.assertWarns(UserWarning): + svg = import_svg(svg_file, is_inkscape_label=False) + def test_import_svg_colors(self): svg_file = StringIO( '' @@ -102,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_joints.py b/tests/test_joints.py index dc61498..f42187f 100644 --- a/tests/test_joints.py +++ b/tests/test_joints.py @@ -34,7 +34,7 @@ from build123d.build_enums import Align, CenterOf, GeomType from build123d.build_common import Mode from build123d.build_part import BuildPart from build123d.build_sketch import BuildSketch -from build123d.geometry import Axis, Location, Rotation, Vector, VectorLike +from build123d.geometry import Axis, Location, Plane, Rotation, Vector, VectorLike from build123d.joints import ( BallJoint, CylindricalJoint, @@ -45,7 +45,7 @@ from build123d.joints import ( 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 Edge, Plane, Solid +from build123d.topology import Edge, Solid class DirectApiTestCase(unittest.TestCase): diff --git a/tests/test_mesher.py b/tests/test_mesher.py index 77c82fb..ef3a2af 100644 --- a/tests/test_mesher.py +++ b/tests/test_mesher.py @@ -1,7 +1,10 @@ import unittest, uuid +from io import BytesIO from packaging.specifiers import SpecifierSet from pathlib import Path from os import fsdecode, fsencode +import sys +import tempfile import pytest @@ -17,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, @@ -46,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): @@ -72,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") @@ -89,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") @@ -117,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() @@ -140,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) @@ -157,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) @@ -194,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): @@ -207,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) @@ -215,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 736db50..1d733d5 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -8,10 +8,8 @@ date: November 9th 2023 desc: Unit tests for the build123d pack module """ -import operator import random import unittest -from functools import reduce from build123d import * @@ -41,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", @@ -53,15 +51,14 @@ class TestPack(unittest.TestCase): random.seed(123456) # 50 is an arbitrary number that is large enough to exercise # different aspects of the packer while still completing quickly. - inputs = [ - SlotOverall(random.randint(1, 20), random.randint(1, 20)) for _ in range(50) - ] - # Not raising in this call shows successfull non-overlap. + 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 successful non-overlap. packed = pack(inputs, 1) - self.assertEqual( - "bbox: 0.0 <= x <= 124.0, 0.0 <= y <= 105.0, 0.0 <= z <= 0.0", - str((Sketch() + packed).bounding_box()), - ) + bb = (Sketch() + packed).bounding_box() + self.assertEqual(bb.min, Vector(0, 0, 0)) + self.assertEqual(bb.max, Vector(70, 63, 0)) if __name__ == "__main__": diff --git a/tests/test_topo_explore.py b/tests/test_topo_explore.py index 75b4e3a..add8721 100644 --- a/tests/test_topo_explore.py +++ b/tests/test_topo_explore.py @@ -1,7 +1,12 @@ from typing import Optional import unittest -from build123d.build_enums import SortBy +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace +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 ContinuityLevel, GeomType, SortBy from build123d.objects_part import Box from build123d.geometry import ( @@ -12,8 +17,12 @@ from build123d.geometry import ( from build123d.topology import ( Edge, Face, + ShapeList, + Shell, Wire, + offset_topods_face, topo_explore_connected_edges, + topo_explore_connected_faces, topo_explore_common_vertex, ) @@ -78,6 +87,98 @@ class TestTopoExplore(DirectApiTestCase): connected_edges = topo_explore_connected_edges(face.edges()[0]) self.assertEqual(len(connected_edges), 1) + def test_topo_explore_connected_edges_errors(self): + # No parent case + with self.assertRaises(ValueError): + topo_explore_connected_edges(Edge()) + + # Null edge case + null_edge = Wire.make_rect(1, 1).edges()[0] + null_edge.wrapped = None + 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( @@ -98,5 +199,66 @@ class TestTopoExplore(DirectApiTestCase): ) +class TestOffsetTopodsFace(unittest.TestCase): + def setUp(self): + # Create a simple planar face for testing + self.face = Face.make_rect(1, 1).wrapped + + def get_face_center(self, face: TopoDS_Face) -> tuple: + """Calculate the center of a face""" + props = GProp_GProps() + BRepGProp.SurfaceProperties_s(face, props) + center = props.CentreOfMass() + return (center.X(), center.Y(), center.Z()) + + def test_offset_topods_face(self): + # Offset the face by a positive amount + offset_amount = 1.0 + original_center = self.get_face_center(self.face) + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(0, 0, 1), offset_center) + + # Offset the face by a negative amount + offset_amount = -1.0 + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(0, 0, -1), offset_center) + + def test_offset_topods_face_zero(self): + # Offset the face by zero amount + offset_amount = 0.0 + original_center = self.get_face_center(self.face) + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(original_center), offset_center) + + +class TestTopoExploreConnectedFaces(unittest.TestCase): + def setUp(self): + # Create a shell with 4 faces + walls = Shell.extrude(Wire.make_rect(1, 1), (0, 0, 1)) + diagonal = Axis((0, 0, 0), (1, 1, 0)) + + # Extract the edge that is connected to two faces + self.connected_edge = walls.edges().filter_by(Axis.Z).sort_by(diagonal)[-1] + + # Create an edge that is only connected to one face + self.unconnected_edge = Face.make_rect(1, 1).edges()[0] + + def test_topo_explore_connected_faces(self): + # Add the edge to the faces + faces = topo_explore_connected_faces(self.connected_edge) + self.assertEqual(len(faces), 2) + + def test_topo_explore_connected_faces_invalid(self): + # No parent case + with self.assertRaises(ValueError): + topo_explore_connected_faces(Edge()) + + if __name__ == "__main__": unittest.main() 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_test_direct_api.py b/tools/refactor_test_direct_api.py new file mode 100644 index 0000000..be201e9 --- /dev/null +++ b/tools/refactor_test_direct_api.py @@ -0,0 +1,346 @@ +""" + +name: refactor_test_direct_api.py +by: Gumyr +date: January 22, 2025 + +Description: + This script automates the process of splitting a large test file into smaller, + more manageable test files based on class definitions. Each generated test file + includes necessary imports, an optional header with project and license information, + and the appropriate class definitions. Additionally, the script dynamically injects + shared utilities like the `AlwaysEqual` class only into files where they are needed. + +Features: + - Splits a large test file into separate files by test class. + - Adds a standardized header with project details and an Apache 2.0 license. + - Dynamically includes shared utilities like `AlwaysEqual` where required. + - Supports `unittest` compatibility by adding a `unittest.main()` block for direct execution. + - Ensures imports are cleaned and Python syntax is upgraded to modern standards using + `rope` and `pyupgrade`. + +Usage: + Run the script with the input file and output directory as arguments: + python refactor_test_direct_api.py + +Dependencies: + - libcst: For parsing and analyzing the test file structure. + - rope: For organizing and pruning unused imports. + - pyupgrade: For upgrading Python syntax to the latest standards. + +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 pathlib import Path +import libcst as cst +from libcst.metadata import PositionProvider, MetadataWrapper +import os +from rope.base.project import Project +from rope.refactor.importutils import ImportOrganizer +import subprocess +from datetime import datetime + + +class TestFileSplitter(cst.CSTVisitor): + METADATA_DEPENDENCIES = (PositionProvider,) + + def __init__(self, module_content, output_dir): + self.module_content = module_content + self.output_dir = output_dir + self.current_class = None + self.current_class_code = [] + self.global_imports = [] + + def visit_Import(self, node: cst.Import): + # Capture global import statements + self.global_imports.append(self._extract_code(node)) + + def visit_ImportFrom(self, node: cst.ImportFrom): + # Capture global import statements + self.global_imports.append(self._extract_code(node)) + + def visit_ClassDef(self, node: cst.ClassDef): + if self.current_class: + # Write the previous class to a file + self._write_class_file() + + # Start collecting for the new class + self.current_class = node.name.value + + # Get the start and end positions of the node + position = self.get_metadata(PositionProvider, node) + start = self._calculate_offset(position.start.line, position.start.column) + end = self._calculate_offset(position.end.line, position.end.column) + + # Extract the source code for the class + class_code = self.module_content[start:end] + self.current_class_code = [class_code] + + def leave_Module(self, original_node: cst.Module): + # Write the last class to a file + if self.current_class: + self._write_class_file() + + def _write_class_file(self): + """ + Write the current class to a file, including a header, ensuring no redundant 'test_' prefix, + and make the file executable when run directly. + """ + # Determine the file name by converting the class name to snake_case + snake_case_name = self._convert_to_snake_case(self.current_class) + + # Avoid redundant 'test_' prefix if it already exists + if snake_case_name.startswith("test_"): + filename = f"{snake_case_name}.py" + else: + filename = f"test_{snake_case_name}.py" + + filepath = os.path.join(self.output_dir, filename) + + # Generate the header with the current date and year + current_date = datetime.now().strftime("%B %d, %Y") + current_year = datetime.now().year + header = f''' +""" +build123d direct api tests + +name: {filename} +by: Gumyr +date: {current_date} + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright {current_year} 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. + +""" +''' + # Define imports for base class and shared utilities + base_class_import = "from ..base_test import AlwaysEqual" + + # Add the main block to run tests + main_block = """ +if __name__ == "__main__": + unittest.main() +""" + + # Write the header, imports, class definition, and main block + with open(filepath, "w") as f: + # Combine all parts into the file + f.write(header + "\n\n") + f.write("\n".join(self.global_imports) + "\n\n") + f.write(base_class_import + "\n\n") + f.write("\n".join(self.current_class_code) + "\n\n") + f.write(main_block) + + # Prune unused imports and upgrade the code + self._prune_unused_imports(filepath) + + def _write_class_file(self): + """ + Write the current class to a file, including a header, ensuring no redundant 'test_' prefix, + and dynamically inject the AlwaysEqual class if used. + """ + # Determine the file name by converting the class name to snake_case + snake_case_name = self._convert_to_snake_case(self.current_class) + + # Avoid redundant 'test_' prefix if it already exists + if snake_case_name.startswith("test_"): + filename = f"{snake_case_name}.py" + else: + filename = f"test_{snake_case_name}.py" + + filepath = os.path.join(self.output_dir, filename) + + # Check if the current class code references AlwaysEqual + needs_always_equal = ( + any("AlwaysEqual" in line for line in self.current_class_code) + and not filename == "test_always_equal.py" + ) + + # Generate the header with the current date and year + current_date = datetime.now().strftime("%B %d, %Y") + current_year = datetime.now().year + header = f''' +""" +build123d imports + +name: {filename} +by: Gumyr +date: {current_date} + +desc: + This python module contains tests for the build123d project. + +license: + + Copyright {current_year} 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. + +""" +''' + + # Define the AlwaysEqual class if needed + always_equal_definition = ( + """ +# Always equal to any other object, to test that __eq__ cooperation is working +class AlwaysEqual: + def __eq__(self, other): + return True +""" + if needs_always_equal + else "" + ) + + # Add the main block to run tests + main_block = """ +if __name__ == "__main__": + unittest.main() +""" + + # Write the header, AlwaysEqual (if needed), imports, class definition, and main block + with open(filepath, "w") as f: + # Combine all parts into the file + f.write(header + "\n\n") + f.write(always_equal_definition + "\n\n") + f.write("\n".join(self.global_imports) + "\n\n") + f.write("\n".join(self.current_class_code) + "\n\n") + f.write(main_block) + + # Prune unused imports and upgrade the code + self._prune_unused_imports(filepath) + + def _convert_to_snake_case(self, name: str) -> str: + """ + Convert a PascalCase or camelCase name to snake_case. + """ + import re + + name = re.sub(r"(? str: + """ + Extract the source code of a given node using PositionProvider. + """ + position = self.get_metadata(PositionProvider, node) + start = self._calculate_offset(position.start.line, position.start.column) + end = self._calculate_offset(position.end.line, position.end.column) + return self.module_content[start:end] + + def _calculate_offset(self, line: int, column: int) -> int: + """ + Calculate the byte offset in the source content based on line and column numbers. + """ + lines = self.module_content.splitlines(keepends=True) + offset = sum(len(lines[i]) for i in range(line - 1)) + column + return offset + + def _prune_unused_imports(self, filepath): + """ + Wrapper for remove_unused_imports to clean unused imports in a file and upgrade the code. + """ + # Initialize the Rope project + project = Project(self.output_dir) + + # Use the shared function to remove unused imports + remove_unused_imports(Path(filepath), project) + + # Run pyupgrade on the file to modernize the Python syntax + print(f"Upgrading Python syntax in {filepath} with pyupgrade...") + subprocess.run(["pyupgrade", "--py310-plus", str(filepath)]) + + +def remove_unused_imports(file_path: Path, project: Project) -> None: + """Remove unused imports from a Python file using Rope. + + Args: + file_path: Path to the Python file to clean imports + project: Rope project instance to refresh and use for cleaning + """ + # Get the relative file path from the project root + relative_path = file_path.relative_to(project.address) + + # Refresh the project to recognize new files + project.validate() + + # Get the resource (file) to work on + resource = project.get_resource(str(relative_path)) + + # Create import organizer + import_organizer = ImportOrganizer(project) + + # Get and apply the changes + changes = import_organizer.organize_imports(resource) + if changes: + changes.do() + print(f"Cleaned imports in {file_path}") + subprocess.run(["black", str(file_path)]) + else: + print(f"No unused imports found in {file_path}") + + +def split_test_file(input_file, output_dir): + # Ensure the output directory exists + os.makedirs(output_dir, exist_ok=True) + + # Read the input file + with open(input_file, "r") as f: + content = f.read() + + # Parse the file and wrap it with metadata + module = cst.parse_module(content) + wrapper = MetadataWrapper(module) + + # Process the file + splitter = TestFileSplitter(module_content=content, output_dir=output_dir) + wrapper.visit(splitter) + + +# Define paths +script_dir = Path(__file__).parent +test_direct_api_file = script_dir / ".." / "tests" / "test_direct_api.py" +output_dir = script_dir / ".." / "tests" / "test_direct_api" +test_direct_api_file = test_direct_api_file.resolve() +output_dir = output_dir.resolve() + +split_test_file(test_direct_api_file, output_dir) diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py index e1fcb4e..be56a28 100644 --- a/tools/refactor_topo.py +++ b/tools/refactor_topo.py @@ -1,9 +1,418 @@ +""" +refactor topology + +name: refactor_topology.py +by: Gumyr +date: Dec 05, 2024 + +desc: + This python script refactors the very large topology.py module into several + files based on the topological hierarchical order: + + shape_core.py - base classes Shape, ShapeList + + utils.py - utility classes & functions + + zero_d.py - Vertex + + one_d.py - Mixin1D, Edge, Wire + + two_d.py - Mixin2D, Face, Shell + + three_d.py - Mixin3D, Solid + + composite.py - Compound + Each of these modules import lower order modules to avoid import loops. They + also may contain functions used both by end users and higher order modules. + +license: + + Copyright 2024 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 pathlib import Path import libcst as cst -from typing import List, Set, Dict, Union -from pprint import pprint +import libcst.matchers as m +from typing import List, Set, Dict from rope.base.project import Project from rope.refactor.importutils import ImportOrganizer +import subprocess +from datetime import datetime + +module_descriptions = { + "shape_core": """ +This module defines the foundational classes and methods for the build123d CAD library, enabling +detailed geometric operations and 3D modeling capabilities. It provides a hierarchy of classes +representing various geometric entities like vertices, edges, wires, faces, shells, solids, and +compounds. These classes are designed to work seamlessly with the OpenCascade Python bindings, +leveraging its robust CAD kernel. + +Key Features: +- **Shape Base Class:** Implements core functionalities such as transformations (rotation, + translation, scaling), geometric queries, and boolean operations (cut, fuse, intersect). +- **Custom Utilities:** Includes helper classes like `ShapeList` for advanced filtering, sorting, + and grouping of shapes, and `GroupBy` for organizing shapes by specific criteria. +- **Type Safety:** Extensive use of Python typing features ensures clarity and correctness in type + handling. +- **Advanced Geometry:** Supports operations like finding intersections, computing bounding boxes, + projecting faces, and generating triangulated meshes. + +The module is designed for extensibility, enabling developers to build complex 3D assemblies and +perform detailed CAD operations programmatically while maintaining a clean and structured API. +""", + "utils": """ +This module provides utility functions and helper classes for the build123d CAD library, enabling +advanced geometric operations and facilitating the use of the OpenCascade CAD kernel. It complements +the core library by offering reusable and modular tools for manipulating shapes, performing Boolean +operations, and validating geometry. + +Key Features: +- **Geometric Utilities**: + - `polar`: Converts polar coordinates to Cartesian. + - `tuplify`: Normalizes inputs into consistent tuples. + - `find_max_dimension`: Computes the maximum bounding dimension of shapes. + +- **Shape Creation**: + - `_make_loft`: Creates lofted shapes from wires and vertices. + - `_make_topods_compound_from_shapes`: Constructs compounds from multiple shapes. + - `_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**: + - `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance. + +This module is a critical component of build123d, supporting complex CAD workflows and geometric +transformations while maintaining a clean, extensible API. +""", + "zero_d": """ +This module provides the foundational implementation for zero-dimensional geometry in the build123d +CAD system, focusing on the `Vertex` class and its related operations. A `Vertex` represents a +single point in 3D space, serving as the cornerstone for more complex geometric structures such as +edges, wires, and faces. It is directly integrated with the OpenCascade kernel, enabling precise +modeling and manipulation of 3D objects. + +Key Features: +- **Vertex Class**: + - Supports multiple constructors, including Cartesian coordinates, iterable inputs, and + OpenCascade `TopoDS_Vertex` objects. + - Offers robust arithmetic operations such as addition and subtraction with other vertices, + vectors, or tuples. + - Provides utility methods for transforming vertices, converting to tuples, and iterating over + coordinate components. + +- **Intersection Utilities**: + - Includes `topo_explore_common_vertex`, a utility to identify shared vertices between edges, + facilitating advanced topological queries. + +- **Integration with Shape Hierarchy**: + - Extends the `Shape` base class, inheriting essential features such as transformation matrices + and bounding box computations. + +This module plays a critical role in defining precise geometric points and their interactions, +serving as the building block for complex 3D models in the build123d library. +""", + "one_d": """ +This module defines the classes and methods for one-dimensional geometric entities in the build123d +CAD library. It focuses on `Edge` and `Wire`, representing essential topological elements like +curves and connected sequences of curves within a 3D model. These entities are pivotal for +constructing complex shapes, boundaries, and paths in CAD applications. + +Key Features: +- **Edge Class**: + - Represents curves such as lines, arcs, splines, and circles. + - Supports advanced operations like trimming, offsetting, splitting, and projecting onto shapes. + - Includes methods for geometric queries like finding tangent angles, normals, and intersection + points. + +- **Wire Class**: + - Represents a connected sequence of edges forming a continuous path. + - Supports operations such as closure, projection, and edge manipulation. + +- **Mixin1D**: + - Shared functionality for both `Edge` and `Wire` classes, enabling splitting, extrusion, and + 1D-specific operations. + +This module integrates deeply with OpenCascade, leveraging its robust geometric and topological +operations. It provides utility functions to create, manipulate, and query 1D geometric entities, +ensuring precise and efficient workflows in 3D modeling tasks. +""", + "two_d": """ +This module provides classes and methods for two-dimensional geometric entities in the build123d CAD +library, focusing on the `Face` and `Shell` classes. These entities form the building blocks for +creating and manipulating complex 2D surfaces and 3D shells, enabling precise modeling for CAD +applications. + +Key Features: +- **Mixin2D**: + - Adds shared functionality to `Face` and `Shell` classes, such as splitting, extrusion, and + projection operations. + +- **Face Class**: + - Represents a 3D bounded surface with advanced features like trimming, offsetting, and Boolean + operations. + - Provides utilities for creating faces from wires, arrays of points, Bézier surfaces, and ruled + surfaces. + - Enables geometry queries like normal vectors, surface centers, and planarity checks. + +- **Shell Class**: + - Represents a collection of connected faces forming a closed surface. + - Supports operations like lofting and sweeping profiles along paths. + +- **Utilities**: + - Includes methods for sorting wires into buildable faces and creating holes within faces + efficiently. + +The module integrates deeply with OpenCascade to leverage its powerful CAD kernel, offering robust +and extensible tools for surface and shell creation, manipulation, and analysis. +""", + "three_d": """ +This module defines the `Solid` class and associated methods for creating, manipulating, and +querying three-dimensional solid geometries in the build123d CAD system. It provides powerful tools +for constructing complex 3D models, including operations such as extrusion, sweeping, filleting, +chamfering, and Boolean operations. The module integrates with OpenCascade to leverage its robust +geometric kernel for precise 3D modeling. + +Key Features: +- **Solid Class**: + - Represents closed, bounded 3D shapes with methods for volume calculation, bounding box + computation, and validity checks. + - Includes constructors for primitive solids (e.g., box, cylinder, cone, torus) and advanced + operations like lofting, revolving, and sweeping profiles along paths. + +- **Mixin3D**: + - Adds shared methods for operations like filleting, chamfering, splitting, and hollowing solids. + - Supports advanced workflows such as finding maximum fillet radii and extruding with rotation or + taper. + +- **Boolean Operations**: + - Provides utilities for union, subtraction, and intersection of solids. + +- **Thickening and Offsetting**: + - Allows transformation of faces or shells into solids through thickening. + +This module is essential for generating and manipulating complex 3D geometries in the build123d +library, offering a comprehensive API for CAD modeling. +""", + "composite": """ +This module defines advanced composite geometric entities for the build123d CAD system. It +introduces the `Compound` class as a central concept for managing groups of shapes, alongside +specialized subclasses such as `Curve`, `Sketch`, and `Part` for 1D, 2D, and 3D objects, +respectively. These classes streamline the construction and manipulation of complex geometric +assemblies. + +Key Features: +- **Compound Class**: + - Represents a collection of geometric shapes (e.g., vertices, edges, faces, solids) grouped + hierarchically. + - Supports operations like adding, removing, and combining shapes, as well as querying volumes, + centers, and intersections. + - Provides utility methods for unwrapping nested compounds and generating 3D text or coordinate + system triads. + +- **Specialized Subclasses**: + - `Curve`: Handles 1D objects like edges and wires. + - `Sketch`: Focused on 2D objects, such as faces. + - `Part`: Manages 3D solids and assemblies. + +- **Advanced Features**: + - Includes Boolean operations, hierarchy traversal, and bounding box-based intersection detection. + - Supports transformations, child-parent relationships, and dynamic updates. + +This module leverages OpenCascade for robust geometric operations while offering a Pythonic +interface for efficient and extensible CAD modeling workflows. +""", +} + + +def sort_class_methods_by_convention(class_def: cst.ClassDef) -> cst.ClassDef: + """Sort methods and properties in a class according to Python conventions.""" + methods, properties = extract_methods_and_properties(class_def) + sorted_body = order_methods_by_convention(methods, properties) + + other_statements = [ + stmt for stmt in class_def.body.body if not isinstance(stmt, cst.FunctionDef) + ] + final_body = cst.IndentedBlock(body=other_statements + sorted_body) + return class_def.with_changes(body=final_body) + + +def extract_methods_and_properties( + class_def: cst.ClassDef, +) -> tuple[List[cst.FunctionDef], List[List[cst.FunctionDef]]]: + """ + Extract methods and properties (with setters grouped together) from a class. + + Returns: + - methods: Regular methods in the class. + - properties: List of grouped properties, where each group contains a getter + and its associated setter, if present. + """ + methods = [] + properties = {} + + for stmt in class_def.body.body: + if isinstance(stmt, cst.FunctionDef): + for decorator in stmt.decorators: + # Handle @property + if ( + isinstance(decorator.decorator, cst.Name) + and decorator.decorator.value == "property" + ): + properties[stmt.name.value] = [stmt] # Initialize with getter + # Handle @property.setter + elif ( + isinstance(decorator.decorator, cst.Attribute) + and decorator.decorator.attr.value == "setter" + ): + base_name = decorator.decorator.value.value # Extract base name + if base_name in properties: + properties[base_name].append( + stmt + ) # Add setter to the property group + else: + # Setter appears before the getter + properties[base_name] = [None, stmt] + + # Add non-property methods + if not any( + isinstance(decorator.decorator, cst.Name) + and decorator.decorator.value == "property" + or isinstance(decorator.decorator, cst.Attribute) + and decorator.decorator.attr.value == "setter" + for decorator in stmt.decorators + ): + methods.append(stmt) + + # Convert property dictionary into a sorted list of grouped properties + sorted_properties = [group for _, group in sorted(properties.items())] + + return methods, sorted_properties + + +def order_methods_by_convention( + methods: List[cst.FunctionDef], properties: List[List[cst.FunctionDef]] +) -> List[cst.BaseStatement]: + """ + Order methods and properties in a class by Python's conventional order with section headers. + + Sections: + - Constructor + - Properties (grouped by getter and setter) + - Class Methods + - Static Methods + - Public and Private Instance Methods + """ + + def method_key(method: cst.FunctionDef) -> tuple[int, str]: + name = method.name.value + decorators = { + decorator.decorator.value + for decorator in method.decorators + if isinstance(decorator.decorator, cst.Name) + } + + if name == "__init__": + return (0, name) # Constructor always comes first + elif name.startswith("__") and name.endswith("__"): + return (1, name) # Dunder methods follow + elif any( + decorator == "property" or decorator.endswith(".setter") + for decorator in decorators + ): + return (2, name) # Properties and setters follow dunder methods + elif "classmethod" in decorators: + return (3, name) # Class methods follow properties + elif "staticmethod" in decorators: + return (4, name) # Static methods follow class methods + elif not name.startswith("_"): + return (5, name) # Public instance methods + else: + return (6, name) # Private methods last + + # Flatten properties into a single sorted list + flattened_properties = [ + prop for group in properties for prop in group if prop is not None + ] + + # Separate __init__, class methods, static methods, and instance methods + init_methods = [m for m in methods if m.name.value == "__init__"] + class_methods = [ + m + for m in methods + if any(decorator.decorator.value == "classmethod" for decorator in m.decorators) + ] + static_methods = [ + m + for m in methods + if any( + decorator.decorator.value == "staticmethod" for decorator in m.decorators + ) + ] + instance_methods = [ + m + for m in methods + if m.name.value != "__init__" + and not any( + decorator.decorator.value in {"classmethod", "staticmethod"} + for decorator in m.decorators + ) + ] + + # Sort properties and each method group alphabetically + sorted_properties = sorted(flattened_properties, key=lambda prop: prop.name.value) + sorted_class_methods = sorted(class_methods, key=lambda m: m.name.value) + sorted_static_methods = sorted(static_methods, key=lambda m: m.name.value) + sorted_instance_methods = sorted(instance_methods, key=lambda m: method_key(m)) + + # Combine all sections with headers + ordered_sections: List[cst.BaseStatement] = [] + + if init_methods: + ordered_sections.append( + cst.SimpleStatementLine([cst.Expr(cst.Comment("# ---- Constructor ----"))]) + ) + ordered_sections.extend(init_methods) + + if sorted_properties: + ordered_sections.append( + cst.SimpleStatementLine([cst.Expr(cst.Comment("# ---- Properties ----"))]) + ) + ordered_sections.extend(sorted_properties) + + if sorted_class_methods: + ordered_sections.append( + cst.SimpleStatementLine( + [cst.Expr(cst.Comment("# ---- Class Methods ----"))] + ) + ) + ordered_sections.extend(sorted_class_methods) + + if sorted_static_methods: + ordered_sections.append( + cst.SimpleStatementLine( + [cst.Expr(cst.Comment("# ---- Static Methods ----"))] + ) + ) + ordered_sections.extend(sorted_static_methods) + + if sorted_instance_methods: + ordered_sections.append( + cst.SimpleStatementLine( + [cst.Expr(cst.Comment("# ---- Instance Methods ----"))] + ) + ) + ordered_sections.extend(sorted_instance_methods) + + return ordered_sections class ImportCollector(cst.CSTVisitor): @@ -32,6 +441,22 @@ class ClassExtractor(cst.CSTVisitor): self.extracted_classes[node.name.value] = node +class ClassMethodExtractor(cst.CSTVisitor): + def __init__(self): + self.class_methods: Dict[str, List[cst.FunctionDef]] = {} + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + class_name = node.name.value + self.class_methods[class_name] = [] + + for statement in node.body.body: + if isinstance(statement, cst.FunctionDef): + self.class_methods[class_name].append(statement) + + # Sort methods alphabetically by name + self.class_methods[class_name].sort(key=lambda method: method.name.value) + + class MixinClassExtractor(cst.CSTVisitor): def __init__(self): self.extracted_classes: Dict[str, cst.ClassDef] = {} @@ -58,6 +483,9 @@ class StandaloneFunctionAndVariableCollector(cst.CSTVisitor): if self.current_scope_level == 0: self.functions.append(node) + def get_sorted_functions(self) -> List[cst.FunctionDef]: + return sorted(self.functions, key=lambda func: func.name.value) + class GlobalVariableExtractor(cst.CSTVisitor): def __init__(self): @@ -73,135 +501,355 @@ class GlobalVariableExtractor(cst.CSTVisitor): self.global_variables.append(assign) +class ClassMethodExtractor(cst.CSTVisitor): + def __init__(self, methods_to_convert: List[str]): + self.methods_to_convert = methods_to_convert + self.extracted_methods: List[cst.FunctionDef] = [] + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + # Extract the class name to append it to the function name + self.current_class_name = node.name.value + self.generic_visit(node) # Continue to visit child nodes + + def leave_ClassDef(self, original_node: cst.ClassDef) -> None: + # Clear the current class name after leaving the class + self.current_class_name = None + + def visit_FunctionDef(self, node: cst.FunctionDef) -> None: + # Check if the function should be converted + if node.name.value in self.methods_to_convert and self.current_class_name: + # Rename the method by appending the class name to avoid conflicts + new_name = f"{node.name.value}_{self.current_class_name.lower()}" + renamed_node = node.with_changes(name=cst.Name(new_name)) + # Remove `self` from parameters since it's now a standalone function + if renamed_node.params.params: + renamed_node = renamed_node.with_changes( + params=renamed_node.params.with_changes( + params=renamed_node.params.params[1:] + ) + ) + self.extracted_methods.append(renamed_node) + + def write_topo_class_files( + source_tree: cst.Module, extracted_classes: Dict[str, cst.ClassDef], imports: Set[str], output_dir: Path, ) -> None: - """ - Write files for each group of classes: - 1. Separate modules for "Shape", "Compound", "Solid", "Face" + "Shell", "Edge" + "Wire", and "Vertex" - 2. "ShapeList" is extracted into its own module and imported by all modules except "Shape" - """ + """Write files for each group of classes:""" # Create output directory if it doesn't exist output_dir.mkdir(parents=True, exist_ok=True) # Sort imports for consistency imports_code = "\n".join(imports) - # Define class groupings based on layers - class_groups = { - "shape": ["Shape"], - "vertex": ["Vertex"], - "edge_wire": ["Mixin1D", "Edge", "Wire"], - "face_shell": ["Face", "Shell"], - "solid": ["Mixin3D", "Solid"], - "compound": ["Compound"], - "shape_list": ["ShapeList"], + # Describe where the functions should go + function_source = { + "shape_core": [ + "downcast", + "fix", + "get_top_level_topods_shapes", + "_sew_topods_faces", + "shapetype", + "topods_dim", + "_topods_entities", + "_topods_face_normal_at", + "apply_ocp_monkey_patches", + "unwrap_topods_compound", + ], + "utils": [ + "delta", + "_extrude_topods_shape", + "find_max_dimension", + "isclose_b", + "_make_loft", + "_make_topods_compound_from_shapes", + "_make_topods_face_from_wires", + "new_edges", + "polar", + "_topods_bool_op", + "tuplify", + "unwrapped_shapetype", + ], + "zero_d": [ + "topo_explore_common_vertex", + ], + "one_d": [ + "edges_to_wires", + "topo_explore_connected_edges", + ], + "two_d": ["sort_wires_by_build_order"], } - # Write ShapeList class separately - if "ShapeList" in extracted_classes: - class_file = output_dir / "shape_list.py" - shape_list_class = extracted_classes["ShapeList"] - shape_list_module = cst.Module( - body=[*cst.parse_module(imports_code).body, shape_list_class] - ) - class_file.write_text(shape_list_module.code) - print(f"Created {class_file}") + # Define class groupings based on layers + class_groups = { + "shape_core": [ + "Shape", + "Comparable", + "ShapePredicate", + "GroupBy", + "ShapeList", + "Joint", + "SkipClean", + "BoundBox", + ], + "zero_d": ["Vertex"], + "one_d": ["Mixin1D", "Edge", "Wire"], + "two_d": ["Mixin2D", "Face", "Shell"], + "three_d": ["Mixin3D", "Solid"], + "composite": ["Compound", "Curve", "Sketch", "Part"], + "utils": [], + } for group_name, class_names in class_groups.items(): - if group_name == "shape_list": - continue + + module_docstring = f""" +build123d topology + +name: {group_name}.py +by: Gumyr +date: {datetime.now().strftime('%B %d, %Y')} + +desc: +{module_descriptions[group_name]} +license: + + Copyright {datetime.now().strftime('%Y')} 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. + +""" + header = [ + cst.SimpleStatementLine( + [cst.Expr(cst.SimpleString(f'"""{module_docstring}"""'))] + ) + ] + + if group_name in ["utils", "shape_core"]: + function_collector = StandaloneFunctionAndVariableCollector() + source_tree.visit(function_collector) + + variable_collector = GlobalVariableExtractor() + source_tree.visit(variable_collector) group_classes = [ - extracted_classes[name] for name in class_names if name in extracted_classes + sort_class_methods_by_convention(extracted_classes[name]) + for name in class_names + if name in extracted_classes ] - if not group_classes: - continue - # Add imports for base classes based on layer dependencies - additional_imports = ["from .utils import *"] - if group_name != "shape": - additional_imports.append("from .shape import Shape") - additional_imports.append("from .shape_list import ShapeList") - if group_name in ["edge_wire", "face_shell", "solid", "compound"]: - additional_imports.append("from .vertex import Vertex") - if group_name in ["face_shell", "solid", "compound"]: - additional_imports.append("from .edge_wire import Edge, Wire") - if group_name in ["solid", "compound"]: - additional_imports.append("from .face_shell import Face, Shell") - if group_name == "compound": - additional_imports.append("from .solid import Solid") + additional_imports = [] + if group_name != "shape_core": + additional_imports.append( + "from .shape_core import Shape, ShapeList, BoundBox, SkipClean, TrimmingTool, Joint" + ) + if group_name not in ["shape_core", "vertex"]: + for sub_group_name in function_source.keys(): + additional_imports.append( + f"from .{sub_group_name} import " + + ",".join(function_source[sub_group_name]) + ) + if group_name not in ["shape_core", "utils", "vertex"]: + additional_imports.append("from .zero_d import Vertex") + if group_name in ["two_d"]: + additional_imports.append("from .one_d import Mixin1D") - # Create class file (e.g., face_shell.py) + if group_name in ["two_d", "three_d", "composite"]: + additional_imports.append("from .one_d import Edge, Wire") + if group_name in ["three_d", "composite"]: + additional_imports.append("from .one_d import Mixin1D") + + additional_imports.append("from .two_d import Mixin2D, Face, Shell") + if group_name == "composite": + additional_imports.append("from .one_d import Mixin1D") + additional_imports.append("from .three_d import Mixin3D, Solid") + + # Add TYPE_CHECKING imports + if group_name not in ["composite"]: + additional_imports.append("if TYPE_CHECKING: # pragma: no cover") + if group_name in ["shape_core", "utils"]: + additional_imports.append( + " from .zero_d import Vertex # pylint: disable=R0801" + ) + if group_name in ["shape_core", "utils", "zero_d"]: + additional_imports.append( + " from .one_d import Edge, Wire # pylint: disable=R0801" + ) + if group_name in ["shape_core", "utils", "one_d"]: + additional_imports.append( + " from .two_d import Face, Shell # pylint: disable=R0801" + ) + if group_name in ["shape_core", "utils", "one_d", "two_d"]: + additional_imports.append( + " from .three_d import Solid # pylint: disable=R0801" + ) + if group_name in ["shape_core", "utils", "one_d", "two_d", "three_d"]: + additional_imports.append( + " from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801" + ) + # Create class file (e.g., two_d.py) class_file = output_dir / f"{group_name}.py" all_imports_code = "\n".join([imports_code, *additional_imports]) - class_module = cst.Module( - body=[*cst.parse_module(all_imports_code).body, *group_classes] - ) + + # if group_name in ["shape_core", "utils"]: + if group_name in function_source.keys(): + body = [*cst.parse_module(all_imports_code).body] + if group_name == "shape_core": + for var in variable_collector.global_variables: + # Check the name of the assigned variable(s) + for target in var.targets: + if isinstance(target.target, cst.Name): + var_name = target.target.value + # Check if the variable name is in the exclusion list + if var_name not in ["T", "K"]: + body.append(var) + body.append(cst.EmptyLine(indent=False)) + + # Add classes and inject variables after a specific class + for class_def in group_classes: + body.append(class_def) + + # Inject variables after the specified class + if class_def.name.value == "Comparable": + body.append( + cst.Comment( + "# This TypeVar allows IDEs to see the type of objects within the ShapeList" + ) + ) + body.append(cst.EmptyLine(indent=False)) + for var in variable_collector.global_variables: + # Check the name of the assigned variable(s) + for target in var.targets: + if isinstance(target.target, cst.Name): + var_name = target.target.value + # Check if the variable name is in the inclusion list + if var_name in ["T", "K"]: + body.append(var) + body.append(cst.EmptyLine(indent=False)) + + for func in function_collector.get_sorted_functions(): + if func.name.value in function_source[group_name]: + body.append(func) + class_module = cst.Module(body=body, header=header) + else: + class_module = cst.Module( + body=[*cst.parse_module(all_imports_code).body, *group_classes], + header=header, + ) class_file.write_text(class_module.code) + print(f"Created {class_file}") # Create __init__.py to make it a proper package init_file = output_dir / "__init__.py" - init_content = [] - for group_name in class_groups.keys(): - if group_name != "shape_list": - init_content.append(f"from .{group_name} import *") + init_content = f''' +""" +build123d.topology package - init_file.write_text("\n".join(init_content)) +name: __init__.py +by: Gumyr +date: {datetime.now().strftime('%B %d, %Y')} + +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 + manipulate and analyze those objects. + +license: + + Copyright {datetime.now().strftime('%Y')} 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 .shape_core import ( + Shape, + Comparable, + ShapePredicate, + GroupBy, + ShapeList, + Joint, + SkipClean, + BoundBox, + downcast, + fix, + unwrap_topods_compound, +) +from .utils import ( + tuplify, + isclose_b, + polar, + delta, + new_edges, + find_max_dimension, +) +from .zero_d import Vertex, topo_explore_common_vertex +from .one_d import Edge, Wire, edges_to_wires, topo_explore_connected_edges +from .two_d import Face, Shell, sort_wires_by_build_order +from .three_d import Solid +from .composite import Compound, Curve, Sketch, Part + +__all__ = [ + "Shape", + "Comparable", + "ShapePredicate", + "GroupBy", + "ShapeList", + "Joint", + "SkipClean", + "BoundBox", + "downcast", + "fix", + "unwrap_topods_compound", + "tuplify", + "isclose_b", + "polar", + "delta", + "new_edges", + "find_max_dimension", + "Vertex", + "topo_explore_common_vertex", + "Edge", + "Wire", + "edges_to_wires", + "topo_explore_connected_edges", + "Face", + "Shell", + "sort_wires_by_build_order", + "Solid", + "Compound", + "Curve", + "Sketch", + "Part", +] +''' + init_file.write_text(init_content) print(f"Created {init_file}") -def write_utils_file( - source_tree: cst.Module, imports: Set[str], output_dir: Path -) -> None: - """ - Extract and write standalone functions and global variables to a utils.py file. - - Args: - source_tree: The parsed source tree - imports: Set of import statements - output_dir: Directory to write the utils file - """ - # Collect standalone functions and global variables - function_collector = StandaloneFunctionAndVariableCollector() - source_tree.visit(function_collector) - - variable_collector = GlobalVariableExtractor() - source_tree.visit(variable_collector) - - # Create utils file - utils_file = output_dir / "utils.py" - - # Prepare the module body - module_body = [] - - # Add imports - imports_tree = cst.parse_module("\n".join(sorted(imports))) - module_body.extend(imports_tree.body) - - # Add global variables with newlines - for var in variable_collector.global_variables: - module_body.append(var) - module_body.append(cst.EmptyLine(indent=False)) - - # Add a newline between variables and functions - if variable_collector.global_variables and function_collector.functions: - module_body.append(cst.EmptyLine(indent=False)) - - # Add functions - module_body.extend(function_collector.functions) - - # Create the module - utils_module = cst.Module(body=module_body) - - # Write the file - utils_file.write_text(utils_module.code) - print(f"Created {utils_file}") - - def remove_unused_imports(file_path: Path, project: Project) -> None: """Remove unused imports from a Python file using rope. @@ -226,18 +874,60 @@ def remove_unused_imports(file_path: Path, project: Project) -> None: if changes: changes.do() print(f"Cleaned imports in {file_path}") + subprocess.run(["black", file_path]) + else: print(f"No unused imports found in {file_path}") +class UnionToPipeTransformer(cst.CSTTransformer): + def leave_Annotation( + self, original_node: cst.Annotation, updated_node: cst.Annotation + ) -> cst.Annotation: + # Check if the annotation is using a Union + if m.matches(updated_node.annotation, m.Subscript(value=m.Name("Union"))): + subscript = updated_node.annotation + if isinstance(subscript, cst.Subscript): + elements = [elt.slice.value for elt in subscript.slice] + # Build new binary operator nodes using | for each type in the Union + new_annotation = elements[0] + for element in elements[1:]: + new_annotation = cst.BinaryOperation( + left=new_annotation, operator=cst.BitOr(), right=element + ) + return updated_node.with_changes(annotation=new_annotation) + return updated_node + + +class OptionalToPipeTransformer(cst.CSTTransformer): + def leave_Annotation( + self, original_node: cst.Annotation, updated_node: cst.Annotation + ) -> cst.Annotation: + # Match Optional[...] annotations + if m.matches(updated_node.annotation, m.Subscript(value=m.Name("Optional"))): + subscript = updated_node.annotation + if isinstance(subscript, cst.Subscript) and subscript.slice: + # Extract the inner type of Optional + inner_type = subscript.slice[0].slice.value + # Replace Optional[X] with X | None + new_annotation = cst.BinaryOperation( + left=inner_type, operator=cst.BitOr(), right=cst.Name("None") + ) + return updated_node.with_changes(annotation=new_annotation) + return updated_node + + def main(): # Define paths script_dir = Path(__file__).parent - topo_file = script_dir / "topology.py" - output_dir = script_dir / "topology" + topo_file = script_dir / ".." / "src" / "build123d" / "topology_old.py" + output_dir = script_dir / ".." / "src" / "build123d" / "topology" + topo_file = topo_file.resolve() + output_dir = output_dir.resolve() # Define classes to extract class_names = [ + "BoundBox", "Shape", "Compound", "Solid", @@ -246,16 +936,27 @@ def main(): "Wire", "Edge", "Vertex", - "Mixin0D", + "Curve", + "Sketch", + "Part", "Mixin1D", "Mixin2D", "Mixin3D", - "MixinCompound", + "Comparable", + "ShapePredicate", + "SkipClean", "ShapeList", + "GroupBy", + "Joint", ] # Parse source file and collect imports source_tree = cst.parse_module(topo_file.read_text()) + source_tree = source_tree.visit(UnionToPipeTransformer()) + source_tree = source_tree.visit(OptionalToPipeTransformer()) + # transformed_module = source_tree.visit(UnionToPipeTransformer()) + # print(transformed_module.code) + collector = ImportCollector() source_tree.visit(collector) @@ -267,23 +968,28 @@ def main(): mixin_extractor = MixinClassExtractor() source_tree.visit(mixin_extractor) + # Extract functions + function_collector = StandaloneFunctionAndVariableCollector() + source_tree.visit(function_collector) + # for f in function_collector.functions: + # print(f.name.value) + # Write the class files write_topo_class_files( + source_tree=source_tree, extracted_classes=extractor.extracted_classes, imports=collector.imports, output_dir=output_dir, ) - # Write the utils file - write_utils_file( - source_tree=source_tree, imports=collector.imports, output_dir=output_dir - ) - # Create a Rope project instance - project = Project(str(script_dir)) + # project = Project(str(script_dir)) + project = Project(str(output_dir)) # Clean up imports for file in output_dir.glob("*.py"): + if file.name == "__init__.py": + continue remove_unused_imports(file, project)