diff --git a/.gitignore b/.gitignore index ed011f3..a79817d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ venv.bak/ # Profiling debris. prof/ + +# MacOS cruft +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1344c3..78f6540 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,8 +3,8 @@ tests, ensure they build and pass, and ensure that `pylint` and `mypy` are happy with your code. - Install `pip` following their [documentation](https://pip.pypa.io/en/stable/installation/). -- Install development dependencies: `pip install -e .[development]` -- Install docs dependencies: `pip install -e .[docs]` +- Install development dependencies: `pip install -e ".[development]"` +- Install docs dependencies: `pip install -e ".[docs]"` - Install `build123d` in editable mode from current dir: `pip install -e .` - Run tests with: `python -m pytest -n auto` - Build docs with: `cd docs && make html` diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2082252 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +build123d +Copyright (c) 2022–2025 The build123d Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at: + + http://www.apache.org/licenses/LICENSE-2.0 + +------------------------------------------------------------------------------- + +This project was originally derived from portions of the CadQuery codebase +(https://github.com/CadQuery/cadquery) but has since been extensively +refactored and restructured into an independent system. +CadQuery is licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 818210d..cb7c309 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- build123d logo + build123d logo

[![Documentation Status](https://readthedocs.org/projects/build123d/badge/?version=latest)](https://build123d.readthedocs.io/en/latest/?badge=latest) @@ -19,9 +19,17 @@ [![DOI](https://zenodo.org/badge/510925389.svg)](https://doi.org/10.5281/zenodo.14872322) -Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks. +Build123d is a Python-based, parametric [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. Built on the [Open Cascade] geometric kernel, it provides a clean, fully Pythonic interface for creating precise models suitable for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to popular CAD tools such as [FreeCAD] and SolidWorks. -Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc. +Designed for modern, maintainable CAD-as-code, build123d combines clear architecture with expressive, algebraic modeling. It offers: +- Minimal or no internal state depending on mode, +- Explicit 1D, 2D, and 3D geometry classes with well-defined operations, +- Extensibility through subclassing and functional composition—no monkey patching, +- Standards-compliant code (PEP 8, mypy, pylint) with rich pylance type hints, +- Deep Python integration—selectors as lists, locations as iterables, and natural conversions (Solid(shell), tuple(Vector)), +- Operator-driven modeling (obj += sub_obj, Plane.XZ * Pos(X=5) * Rectangle(1, 1)) for algebraic, readable, and composable design logic. + +The result is a framework that feels native to Python while providing the full power of OpenCascade geometry underneath. The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html). @@ -62,6 +70,10 @@ python3 -m pip install -e . Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html). +Attribution: + +Build123d was originally derived from portions of the [CadQuery] codebase but has since been extensively refactored and restructured into an independent system. + [BREP]: https://en.wikipedia.org/wiki/Boundary_representation [CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html [FreeCAD]: https://www.freecad.org/ diff --git a/docs/index.rst b/docs/index.rst index 08f3299..6b23c03 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,58 +29,35 @@ :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:: 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 @@ -92,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 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/pyproject.toml b/pyproject.toml index fdb8bc0..22f6440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ development = [ "black", "mypy", "pylint", - "pytest", + "pytest==8.4.2", # TODO: unpin on resolution of pytest-dev/pytest-xdist/issues/1273 "pytest-benchmark", "pytest-cov", "pytest-xdist", diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index e5edde3..d14b556 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -466,7 +466,7 @@ class Builder(ABC, Generic[ShapeT]): elif mode == Mode.INTERSECT: if self._obj is None: raise RuntimeError("Nothing to intersect with") - combined = self._obj.intersect(*typed[self._shape]) + combined = self._obj.intersect(Compound(typed[self._shape])) elif mode == Mode.REPLACE: combined = self._sub_class(list(typed[self._shape])) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index 07a5193..415d33e 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -453,7 +453,7 @@ class DimensionLine(BaseSketchObject): if self_intersection is None: self_intersection_area = 0.0 else: - self_intersection_area = self_intersection.area + self_intersection_area = sum(f.area for f in self_intersection.faces()) d_line += placed_label bbox_size = d_line.bounding_box().diagonal @@ -467,7 +467,7 @@ class DimensionLine(BaseSketchObject): if line_intersection is None: common_area = 0.0 else: - common_area = line_intersection.area + common_area = sum(f.area for f in line_intersection.faces()) common_area += self_intersection_area score = (d_line.area - 10 * common_area) / bbox_size d_lines[d_line] = score diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 49339ee..a229fa2 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -758,7 +758,7 @@ class ExportDXF(Export2D): ) # need to apply the transform on the geometry level - if edge.wrapped is None or edge.location is None: + if not edge or edge.location is None: raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -1345,7 +1345,7 @@ class ExportSVG(Export2D): u2 = adaptor.LastParameter() # Apply the shape location to the geometry. - if edge.wrapped is None or edge.location is None: + if not edge or edge.location is None: raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -1411,7 +1411,7 @@ class ExportSVG(Export2D): } def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: - if edge.wrapped is None: + if not edge: raise ValueError(f"Edge is empty {edge}.") edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED geom_type = edge.geom_type diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py index 5fb9a54..5433848 100644 --- a/src/build123d/mesher.py +++ b/src/build123d/mesher.py @@ -295,7 +295,7 @@ class Mesher: ocp_mesh_vertices.append(pnt) # Store the triangles from the triangulated faces - if facet.wrapped is None: + if not facet: continue facet_reversed = facet.wrapped.Orientation() == ta.TopAbs_REVERSED order = [1, 3, 2] if facet_reversed else [1, 2, 3] diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 8850cdc..71d788b 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -29,6 +29,7 @@ license: from __future__ import annotations import copy as copy_module +import warnings import numpy as np import sympy # type: ignore from collections.abc import Iterable @@ -792,7 +793,7 @@ class FilletPolyline(BaseLineObject): Args: pts (VectorLike | Iterable[VectorLike]): sequence of two or more points - radius (float): fillet radius + radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices close (bool, optional): close end points with extra Edge and corner fillets. Defaults to False mode (Mode, optional): combination mode. Defaults to Mode.ADD @@ -807,7 +808,7 @@ class FilletPolyline(BaseLineObject): def __init__( self, *pts: VectorLike | Iterable[VectorLike], - radius: float, + radius: float | Iterable[float], close: bool = False, mode: Mode = Mode.ADD, ): @@ -818,8 +819,18 @@ class FilletPolyline(BaseLineObject): if len(points) < 2: raise ValueError("FilletPolyline requires two or more pts") - if radius <= 0: - raise ValueError("radius must be positive") + + if isinstance(radius, (int, float)): + radius_list = [radius] * len(points) # Single radius for all points + else: + radius_list = list(radius) + if len(radius_list) != len(points) - int(not close) * 2: + raise ValueError( + f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})" + ) + for r in radius_list: + if r <= 0: + raise ValueError(f"radius {r} must be positive") lines_pts = WorkplaneList.localize(*points) @@ -851,12 +862,14 @@ class FilletPolyline(BaseLineObject): # For each corner vertex create a new fillet Edge fillets = [] - for vertex, edges in vertex_to_edges.items(): + for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: continue other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} third_edge = Edge.make_line(*[v for v in other_vertices]) - fillet_face = Face(Wire(edges + [third_edge])).fillet_2d(radius, [vertex]) + fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( + radius_list[i - int(not close)], [vertex] + ) fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) # Create the Edges that join the fillets @@ -1362,6 +1375,12 @@ class PointArcTangentLine(BaseEdgeObject): mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( + "The 'PointArcTangentLine' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( @@ -1441,6 +1460,12 @@ class PointArcTangentArc(BaseEdgeObject): RuntimeError: No tangent arc found """ + warnings.warn( + "The 'PointArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( @@ -1585,6 +1610,12 @@ class ArcArcTangentLine(BaseEdgeObject): 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__( @@ -1685,6 +1716,12 @@ class ArcArcTangentArc(BaseEdgeObject): mode (Mode, optional): combination mode. Defaults to Mode.ADD """ + warnings.warn( + "The 'ArcArcTangentArc' object is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + _applies_to = [BuildLine._tag] def __init__( diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 69d75cc..2a2f007 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -365,7 +365,7 @@ def chamfer( if target._dim == 1: if isinstance(target, BaseLineObject): - if target.wrapped is None: + if not target: target = Wire([]) # empty wire else: target = Wire(target.wrapped) @@ -465,7 +465,7 @@ def fillet( if target._dim == 1: if isinstance(target, BaseLineObject): - if target.wrapped is None: + if not target: target = Wire([]) # empty wire else: target = Wire(target.wrapped) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 823eece..14c67b4 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -58,13 +58,12 @@ import copy import os import sys import warnings -from itertools import combinations -from typing import Type, Union - from collections.abc import Iterable, Iterator, Sequence +from itertools import combinations +from typing_extensions import Self import OCP.TopAbs as ta -from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse, BRepAlgoAPI_Section from OCP.Font import ( Font_FA_Bold, Font_FA_BoldItalic, @@ -107,7 +106,6 @@ from build123d.geometry import ( VectorLike, logger, ) -from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D from .shape_core import ( @@ -130,7 +128,7 @@ from .utils import ( from .zero_d import Vertex -class Compound(Mixin3D, Shape[TopoDS_Compound]): +class Compound(Mixin3D[TopoDS_Compound]): """A Compound in build123d is a topological entity representing a collection of geometric shapes grouped together within a single structure. It serves as a container for organizing diverse shapes like edges, faces, or solids. This @@ -455,7 +453,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): will be a Wire, otherwise a Shape. """ if self._dim == 1: - curve = Curve() if self.wrapped is None else Curve(self.wrapped) + curve = Curve() if self._wrapped is None else Curve(self.wrapped) sum1d: Edge | Wire | ShapeList[Edge] = curve + other if isinstance(sum1d, ShapeList): result1d: Curve | Wire = Curve(sum1d) @@ -517,7 +515,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): Check if empty. """ - return TopoDS_Iterator(self.wrapped).More() + return self._wrapped is not None and TopoDS_Iterator(self.wrapped).More() def __iter__(self) -> Iterator[Shape]: """ @@ -534,7 +532,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): def __len__(self) -> int: """Return the number of subshapes""" count = 0 - if self.wrapped is not None: + if self._wrapped is not None: for _ in self: count += 1 return count @@ -602,7 +600,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): def compounds(self) -> ShapeList[Compound]: """compounds - all the compounds in this Shape""" - if self.wrapped is None: + if self._wrapped is None: return ShapeList() if isinstance(self.wrapped, TopoDS_Compound): # pylint: disable=not-an-iterable @@ -651,11 +649,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): children[child_index_pair[1]] ) if obj_intersection is not None: - common_volume = ( - 0.0 - if isinstance(obj_intersection, list) - else obj_intersection.volume - ) + common_volume = sum(s.volume for s in obj_intersection.solids()) if common_volume > tolerance: return ( True, @@ -711,6 +705,148 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]): return results + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face | Solid]: + """Intersect Compound with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, + faces, and/or solids. + """ + + def to_vector(objs: Iterable) -> ShapeList: + return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + + def to_vertex(objs: Iterable) -> ShapeList: + return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, + ) -> ShapeList: + # 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() + 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 unwrap(self, fully: bool = True) -> Self | Shape: """Strip unnecessary Compound wrappers diff --git a/src/build123d/topology/constrained_lines.py b/src/build123d/topology/constrained_lines.py index 9c316b6..4e53ddb 100644 --- a/src/build123d/topology/constrained_lines.py +++ b/src/build123d/topology/constrained_lines.py @@ -174,7 +174,7 @@ def _as_gcc_arg(obj: Edge | Vector, constaint: Tangency) -> tuple[ - Edge -> (QualifiedCurve, h2d, first, last, True) - Vector -> (CartesianPoint, None, None, None, False) """ - if obj.wrapped is None: + if not obj: raise TypeError("Can't create a qualified curve from empty edge") if isinstance(obj.wrapped, TopoDS_Edge): diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 25b817a..611acb3 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -52,12 +52,11 @@ license: from __future__ import annotations import copy -import numpy as np import warnings -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from itertools import combinations from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians -from typing import TYPE_CHECKING, Literal, TypeAlias, overload +from typing import TYPE_CHECKING, Literal, overload from typing import cast as tcast import numpy as np @@ -217,6 +216,7 @@ from build123d.geometry import ( ) from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, @@ -250,7 +250,7 @@ if TYPE_CHECKING: # pragma: no cover from .two_d import Face, Shell # pylint: disable=R0801 -class Mixin1D(Shape): +class Mixin1D(Shape[TOPODS]): """Methods to add to the Edge and Wire classes""" # ---- Properties ---- @@ -263,14 +263,14 @@ class Mixin1D(Shape): @property def is_closed(self) -> bool: """Are the start and end points equal?""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine if empty Edge or Wire is closed") return BRep_Tool.IsClosed_s(self.wrapped) @property def is_forward(self) -> bool: """Does the Edge/Wire loop forward or reverse""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine direction of empty Edge or Wire") return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD @@ -388,8 +388,7 @@ class Mixin1D(Shape): shape # for o in (other if isinstance(other, (list, tuple)) else [other]) for o in ([other] if isinstance(other, Shape) else other) - if o is not None - for shape in get_top_level_topods_shapes(o.wrapped) + for shape in get_top_level_topods_shapes(o.wrapped if o else None) ] # If there is nothing to add return the original object if not topods_summands: @@ -404,7 +403,7 @@ class Mixin1D(Shape): ) summand_edges = [e for summand in summands for e in summand.edges()] - if self.wrapped is None: # an empty object + if self._wrapped is None: # an empty object if len(summands) == 1: sum_shape: Edge | Wire | ShapeList[Edge] = summands[0] else: @@ -452,7 +451,7 @@ class Mixin1D(Shape): Returns: Vector: center """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find center of empty edge/wire") if center_of == CenterOf.GEOMETRY: @@ -578,7 +577,7 @@ class Mixin1D(Shape): >>> show(my_wire, Curve(comb)) """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't create curvature_comb for empty curve") pln = self.common_plane() if pln is None or not isclose(abs(pln.z_dir.Z), 1.0, abs_tol=TOLERANCE): @@ -729,122 +728,103 @@ class Mixin1D(Shape): def to_vertex(objs: Iterable) -> ShapeList: return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) - common_set: ShapeList[Vertex | Edge] = ShapeList(self.edges()) - target: ShapeList | Shape | Plane + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, + ) -> ShapeList: + # Wrap Shape._bool_op for corrected output + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or ShapeList() + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return ShapeList() + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Wire] = ShapeList([self]) + target: Shape | Plane for other in to_intersect: # Conform target type - # Vertices need to be Vector for set() match other: case Axis(): - target = ShapeList([Edge(other)]) + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) case Plane(): target = other case Vector(): target = Vertex(other) case Location(): target = Vertex(other.position) - case Edge(): - target = ShapeList([other]) - case Wire(): - target = ShapeList(other.edges()) case _ if issubclass(type(other), Shape): target = other case _: raise ValueError(f"Unsupported type to_intersect: {type(other)}") # Find common matches - common: list[Vector | Edge] = [] - result: ShapeList | Shape | None + common: list[Vertex | Edge | Wire] = [] + result: ShapeList | None for obj in common_set: match (obj, target): - case obj, Shape() as target: - # Find Shape with Edge/Wire - if isinstance(target, Vertex): - result = Shape.intersect(obj, target) - else: - result = target.intersect(obj) + case (_, Plane()): + target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) + operation = BRepAlgoAPI_Section() + result = bool_op((obj,), (target,), operation) + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) - if result: - if not isinstance(result, list): - result = ShapeList([result]) - common.extend(to_vector(result)) + case (_, Vertex() | Edge() | Wire()): + operation = BRepAlgoAPI_Section() + section = bool_op((obj,), (target,), operation) + result = section + if not section: + operation = BRepAlgoAPI_Common() + result.extend(bool_op((obj,), (target,), operation)) - case Vertex() as obj, target: - if not isinstance(target, ShapeList): - target = ShapeList([target]) + case _ if issubclass(type(target), Shape): + result = target.intersect(obj) - for tar in target: - if isinstance(tar, Edge): - result = Shape.intersect(obj, tar) - else: - result = obj.intersect(tar) - - if result: - if not isinstance(result, list): - result = ShapeList([result]) - common.extend(to_vector(result)) - - case Edge() as obj, ShapeList() as targets: - # Find any edge / edge intersection points - for tar in targets: - # Find crossing points - try: - intersection_points = obj.find_intersection_points(tar) - common.extend(intersection_points) - except ValueError: - pass - - # Find common end points - obj_end_points = set(Vector(v) for v in obj.vertices()) - tar_end_points = set(Vector(v) for v in tar.vertices()) - points = set.intersection(obj_end_points, tar_end_points) - common.extend(points) - - # Find Edge/Edge overlaps - result = obj._bool_op( - (obj,), targets, BRepAlgoAPI_Common() - ).edges() - common.extend(result if isinstance(result, list) else [result]) - - case Edge() as obj, Plane() as plane: - # Find any edge / plane intersection points & edges - # Find point intersections - if obj.wrapped is None: - continue - geom_line = BRep_Tool.Curve_s( - obj.wrapped, obj.param_at(0), obj.param_at(1) - ) - geom_plane = Geom_Plane(plane.local_coord_system) - intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) - plane_intersection_points: list[Vector] = [] - if intersection_calculator.IsDone(): - plane_intersection_points = [ - Vector(intersection_calculator.Point(i + 1)) - for i in range(intersection_calculator.NbPoints()) - ] - common.extend(plane_intersection_points) - - # Find edge intersections - if all( - plane.contains(v) - for v in obj.positions(i / 7 for i in range(8)) - ): # is a 2D edge - common.append(obj) + if result: + common.extend(result) if common: - common_set = to_vertex(set(common)) - # Remove Vertex intersections coincident to Edge intersections - vts = common_set.vertices() - eds = common_set.edges() - if vts and eds: - filtered_vts = ShapeList( - [ - v - for v in vts - if all(v.distance_to(e) > TOLERANCE for e in eds) - ] - ) - common_set = filtered_vts + eds + common_set = ShapeList() + for shape in common: + if isinstance(shape, Wire): + common_set.extend(shape.edges()) + else: + common_set.append(shape) + common_set = to_vertex(set(to_vector(common_set))) + common_set = filter_shapes_by_order(common_set, [Vertex, Edge]) else: return None @@ -991,7 +971,7 @@ class Mixin1D(Shape): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find normal of empty edge/wire") curve = self.geom_adaptor() @@ -1225,7 +1205,7 @@ class Mixin1D(Shape): Returns: """ - if self.wrapped is None or face.wrapped is None: + if self._wrapped is None or not face: raise ValueError("Can't project an empty Edge or Wire onto empty Face") bldr = BRepProj_Projection( @@ -1297,7 +1277,7 @@ class Mixin1D(Shape): return edges - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't project empty edge/wire") # Setup the projector @@ -1400,7 +1380,7 @@ class Mixin1D(Shape): - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is either a `Self` or `list[Self]`, or `None` if no corresponding part is found. """ - if self.wrapped is None or tool.wrapped is None: + if self._wrapped is None or not tool: raise ValueError("Can't split an empty edge/wire/tool") shape_list = TopTools_ListOfShape() @@ -1566,7 +1546,7 @@ class Mixin1D(Shape): return Shape.get_shape_list(self, "Wire") -class Edge(Mixin1D, Shape[TopoDS_Edge]): +class Edge(Mixin1D[TopoDS_Edge]): """An Edge in build123d is a fundamental element in the topological data structure representing a one-dimensional geometric entity within a 3D model. It encapsulates information about a curve, which could be a line, arc, or other parametrically @@ -1647,7 +1627,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: extruded shape """ - if obj.wrapped is None: + if not obj: raise ValueError("Can't extrude empty vertex") return Edge(TopoDS.Edge_s(_extrude_topods_shape(obj.wrapped, direction))) @@ -2538,7 +2518,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): extension_factor: float = 0.1, ): """Helper method to slightly extend an edge that is bound to a surface""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't extend empty spline") if self.geom_type != GeomType.BSPLINE: raise TypeError("_extend_spline only works with splines") @@ -2595,7 +2575,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: ShapeList[Vector]: list of intersection points """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find intersections of empty edge") # Convert an Axis into an edge at least as large as self and Axis start point @@ -2723,7 +2703,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): def geom_adaptor(self) -> BRepAdaptor_Curve: """Return the Geom Curve from this Edge""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find adaptor for empty edge") return BRepAdaptor_Curve(self.wrapped) @@ -2811,7 +2791,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): float: Normalized parameter in [0.0, 1.0] corresponding to the point's closest location on the edge. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find param on empty edge") pnt = Vector(point) @@ -2945,7 +2925,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: reversed """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("An empty edge can't be reversed") assert isinstance(self.wrapped, TopoDS_Edge) @@ -3025,7 +3005,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): # if start_u >= end_u: # raise ValueError(f"start ({start_u}) must be less than end ({end_u})") - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't trim empty edge") self_copy = copy.deepcopy(self) @@ -3060,7 +3040,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): Returns: Edge: trimmed edge """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't trim empty edge") start_u = Mixin1D._to_param(self, start, "start") @@ -3089,7 +3069,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]): return Edge(new_edge) -class Wire(Mixin1D, Shape[TopoDS_Wire]): +class Wire(Mixin1D[TopoDS_Wire]): """A Wire in build123d is a topological entity representing a connected sequence of edges forming a continuous curve or path in 3D space. Wires are essential components in modeling complex objects, defining boundaries for surfaces or @@ -3623,7 +3603,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: chamfered wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't chamfer empty wire") reference_edge = edge @@ -3638,7 +3618,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): ) for v in vertices: - if v.wrapped is None: + if not v: continue edge_list = vertex_edge_map.FindFromKey(v.wrapped) @@ -3695,7 +3675,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: filleted wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't fillet an empty wire") # Create a face to fillet @@ -3723,7 +3703,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: fixed wire """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't fix an empty edge") sf_w = ShapeFix_Wireframe(self.wrapped) @@ -3735,7 +3715,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): def geom_adaptor(self) -> BRepAdaptor_CompCurve: """Return the Geom Comp Curve for this Wire""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't get geom adaptor of empty wire") return BRepAdaptor_CompCurve(self.wrapped) @@ -3779,7 +3759,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): float: Normalized parameter in [0.0, 1.0] representing the relative position of the projected point along the wire. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find point on empty wire") point_on_curve = Vector(point) @@ -3932,7 +3912,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): """ # pylint: disable=too-many-branches - if self.wrapped is None or target_object.wrapped is None: + if self._wrapped is None or not target_object: raise ValueError("Can't project empty Wires or to empty Shapes") if direction is not None and center is None: @@ -4021,7 +4001,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): Returns: Wire: stitched wires """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: raise ValueError("Can't stitch empty wires") wire_builder = BRepBuilderAPI_MakeWire() @@ -4065,7 +4045,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]): """ # Build a single Geom_BSplineCurve from the wire, in *topological order* builder = GeomConvert_CompCurveToBSplineCurve() - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't convert an empty wire") wire_explorer = BRepTools_WireExplorer(self.wrapped) @@ -4217,9 +4197,9 @@ def topo_explore_connected_edges( parent = parent if parent is not None else edge.topo_parent if parent is None: raise ValueError("edge has no valid parent") - given_topods_edge = edge.wrapped - if given_topods_edge is None: + if not edge: raise ValueError("edge is empty") + given_topods_edge = edge.wrapped connected_edges = set() # Find all the TopoDS_Edges for this Shape @@ -4262,11 +4242,11 @@ def topo_explore_connected_faces( ) -> list[TopoDS_Face]: """Given an edge extracted from a Shape, return the topods_faces connected to it""" - if edge.wrapped is None: + if not edge: raise ValueError("Can't explore from an empty edge") parent = parent if parent is not None else edge.topo_parent - if parent is None or parent.wrapped is None: + if not parent: raise ValueError("edge has no valid parent") # make a edge --> faces mapping diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 6402c3e..1a3d4a8 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -287,7 +287,7 @@ class Shape(NodeMixin, Generic[TOPODS]): color: ColorLike | None = None, parent: Compound | None = None, ): - self.wrapped: TOPODS | None = ( + self._wrapped: TOPODS | None = ( tcast(Optional[TOPODS], downcast(obj)) if obj is not None else None ) self.for_construction = False @@ -304,6 +304,18 @@ class Shape(NodeMixin, Generic[TOPODS]): # pylint: disable=too-many-instance-attributes, too-many-public-methods + @property + def wrapped(self): + assert self._wrapped + return self._wrapped + + @wrapped.setter + def wrapped(self, shape: TOPODS): + self._wrapped = shape + + def __bool__(self): + return self._wrapped is not None + @property @abstractmethod def _dim(self) -> int | None: @@ -312,7 +324,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def area(self) -> float: """area -the surface area of all faces in this Shape""" - if self.wrapped is None: + if self._wrapped is None: return 0.0 properties = GProp_GProps() BRepGProp.SurfaceProperties_s(self.wrapped, properties) @@ -351,7 +363,7 @@ class Shape(NodeMixin, Generic[TOPODS]): GeomType: The geometry type of the shape """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot determine geometry type of an empty shape") shape: TopAbs_ShapeEnum = shapetype(self.wrapped) @@ -380,7 +392,7 @@ class Shape(NodeMixin, Generic[TOPODS]): bool: is the shape manifold or water tight """ # Extract one or more (if a Compound) shape from self - if self.wrapped is None: + if self._wrapped is None: return False shape_stack = get_top_level_topods_shapes(self.wrapped) @@ -431,12 +443,12 @@ class Shape(NodeMixin, Generic[TOPODS]): underlying shape with the potential to be given a location and an orientation. """ - return self.wrapped is None or self.wrapped.IsNull() + return self._wrapped is None or self.wrapped.IsNull() @property def is_planar_face(self) -> bool: """Is the shape a planar face even though its geom_type may not be PLANE""" - if self.wrapped is None or not isinstance(self.wrapped, TopoDS_Face): + if self._wrapped is None or not isinstance(self.wrapped, TopoDS_Face): return False surface = BRep_Tool.Surface_s(self.wrapped) is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE) @@ -448,7 +460,7 @@ class Shape(NodeMixin, Generic[TOPODS]): subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full description of what is checked. """ - if self.wrapped is None: + if self._wrapped is None: return True chk = BRepCheck_Analyzer(self.wrapped) chk.SetParallel(True) @@ -474,7 +486,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def location(self) -> Location: """Get this Shape's Location""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't find the location of an empty shape") return Location(self.wrapped.Location()) @@ -518,7 +530,7 @@ class Shape(NodeMixin, Generic[TOPODS]): - It is commonly used in structural analysis, mechanical simulations, and physics-based motion calculations. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate matrix for empty shape") properties = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, properties) @@ -546,7 +558,7 @@ class Shape(NodeMixin, Generic[TOPODS]): @property def position(self) -> Vector: """Get the position component of this Shape's Location""" - if self.wrapped is None or self.location is None: + if self._wrapped is None or self.location is None: raise ValueError("Can't find the position of an empty shape") return self.location.position @@ -575,7 +587,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (Vector(0, 1, 0), 1000.0), (Vector(0, 0, 1), 300.0)] """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate properties for empty shape") properties = GProp_GProps() @@ -615,7 +627,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (150.0, 200.0, 50.0) """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate moments for empty shape") properties = GProp_GProps() @@ -785,7 +797,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if obj.wrapped is None: + if not obj: return 0.0 properties = GProp_GProps() @@ -805,7 +817,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ], ) -> ShapeList: """Helper to extract entities of a specific type from a shape.""" - if shape.wrapped is None: + if not shape: return ShapeList() shape_list = ShapeList( [shape.__class__.cast(i) for i in shape.entities(entity_type)] @@ -859,7 +871,7 @@ class Shape(NodeMixin, Generic[TOPODS]): if not all(summand._dim == addend_dim for summand in summands): raise ValueError("Only shapes with the same dimension can be added") - if self.wrapped is None: # an empty object + if self._wrapped is None: # an empty object if len(summands) == 1: sum_shape = summands[0] else: @@ -876,7 +888,7 @@ class Shape(NodeMixin, Generic[TOPODS]): """intersect shape with self operator &""" others = other if isinstance(other, (list, tuple)) else [other] - if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None): + if not self or (isinstance(other, Shape) and not other): raise ValueError("Cannot intersect shape with empty compound") new_shape = self.intersect(*others) @@ -948,7 +960,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def __hash__(self) -> int: """Return hash code""" - if self.wrapped is None: + if self._wrapped is None: return 0 return hash(self.wrapped) @@ -966,7 +978,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def __sub__(self, other: None | Shape | Iterable[Shape]) -> Self | ShapeList[Self]: """cut shape from self operator -""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot subtract shape from empty compound") # Convert `other` to list of base objects and filter out None values @@ -1014,7 +1026,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: BoundBox: A box sized to contain this Shape """ - if self.wrapped is None: + if self._wrapped is None: return BoundBox(Bnd_Box()) tolerance = TOLERANCE if tolerance is None else tolerance return BoundBox.from_topo_ds(self.wrapped, tolerance=tolerance, optimal=optimal) @@ -1033,7 +1045,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: Original object with extraneous internal edges removed """ - if self.wrapped is None: + if self._wrapped is None: return self upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True) upgrader.AllowInternalEdges(False) @@ -1112,7 +1124,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: raise ValueError("Cannot calculate distance to or from an empty shape") return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value() @@ -1125,7 +1137,7 @@ class Shape(NodeMixin, Generic[TOPODS]): self, other: Shape | VectorLike ) -> tuple[float, Vector, Vector]: """Minimal distance between two shapes and the points on each shape""" - if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None): + if self._wrapped is None or (isinstance(other, Shape) and not other): raise ValueError("Cannot calculate distance to or from an empty shape") if isinstance(other, Shape): @@ -1155,14 +1167,14 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot calculate distance to or from an empty shape") dist_calc = BRepExtrema_DistShapeShape() dist_calc.LoadS1(self.wrapped) for other_shape in others: - if other_shape.wrapped is None: + if not other_shape: raise ValueError("Cannot calculate distance to or from an empty shape") dist_calc.LoadS2(other_shape.wrapped) dist_calc.Perform() @@ -1181,7 +1193,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def entities(self, topo_type: Shapes) -> list[TopoDS_Shape]: """Return all of the TopoDS sub entities of the given type""" - if self.wrapped is None: + if self._wrapped is None: return [] return _topods_entities(self.wrapped, topo_type) @@ -1209,7 +1221,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: list[Face]: A list of intersected faces sorted by distance from axis.position """ - if self.wrapped is None: + if self._wrapped is None: return ShapeList() line = gce_MakeLin(axis.wrapped).Value() @@ -1239,7 +1251,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def fix(self) -> Self: """fix - try to fix shape if not valid""" - if self.wrapped is None: + if self._wrapped is None: return self if not self.is_valid: shape_copy: Shape = copy.deepcopy(self, None) @@ -1281,7 +1293,7 @@ class Shape(NodeMixin, Generic[TOPODS]): # self, child_type: Shapes, parent_type: Shapes # ) -> Dict[Shape, list[Shape]]: # """This function is very slow on M1 macs and is currently unused""" - # if self.wrapped is None: + # if self._wrapped is None: # return {} # res = TopTools_IndexedDataMapOfShapeListOfShape() @@ -1319,7 +1331,7 @@ class Shape(NodeMixin, Generic[TOPODS]): (e.g., edges, vertices) and other compounds, the method returns a list of only the simple shapes directly contained at the top level. """ - if self.wrapped is None: + if self._wrapped is None: return ShapeList() return ShapeList( self.__class__.cast(s) for s in get_top_level_topods_shapes(self.wrapped) @@ -1327,7 +1339,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def intersect( self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | Self | ShapeList[Self]: + ) -> None | ShapeList[Self]: """Intersection of the arguments and this shape Args: @@ -1335,8 +1347,8 @@ class Shape(NodeMixin, Generic[TOPODS]): intersect with Returns: - Self | ShapeList[Self]: Resulting object may be of a different class than self - or a ShapeList if multiple non-Compound object created + None | ShapeList[Self]: Resulting ShapeList may contain different class + than self """ def _to_vertex(vec: Vector) -> Vertex: @@ -1380,15 +1392,12 @@ class Shape(NodeMixin, Generic[TOPODS]): # Find the shape intersections intersect_op = BRepAlgoAPI_Common() - shape_intersections = self._bool_op((self,), objs, intersect_op) - if isinstance(shape_intersections, ShapeList) and not shape_intersections: - return None - if ( - not isinstance(shape_intersections, ShapeList) - and shape_intersections.is_null - ): - return None - return shape_intersections + intersections = self._bool_op((self,), objs, intersect_op) + if isinstance(intersections, ShapeList): + return intersections or None + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return None def is_equal(self, other: Shape) -> bool: """Returns True if two shapes are equal, i.e. if they share the same @@ -1401,7 +1410,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return False return self.wrapped.IsEqual(other.wrapped) @@ -1416,7 +1425,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return False return self.wrapped.IsSame(other.wrapped) @@ -1429,7 +1438,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot locate an empty shape") if loc.wrapped is None: raise ValueError("Cannot locate a shape at an empty location") @@ -1448,7 +1457,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of Shape at location """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot locate an empty shape") if loc.wrapped is None: raise ValueError("Cannot locate a shape at an empty location") @@ -1466,7 +1475,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot mesh an empty shape") if not BRepTools.Triangulation_s(self.wrapped, tolerance): @@ -1487,7 +1496,7 @@ class Shape(NodeMixin, Generic[TOPODS]): if not mirror_plane: mirror_plane = Plane.XY - if self.wrapped is None: + if self._wrapped is None: return self transformation = gp_Trsf() transformation.SetMirror( @@ -1505,7 +1514,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot move an empty shape") if loc.wrapped is None: raise ValueError("Cannot move a shape at an empty location") @@ -1525,7 +1534,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of Shape moved to relative location """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot move an empty shape") if loc.wrapped is None: raise ValueError("Cannot move a shape at an empty location") @@ -1539,7 +1548,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: OrientedBoundBox: A box oriented and sized to contain this Shape """ - if self.wrapped is None: + if self._wrapped is None: return OrientedBoundBox(Bnd_OBB()) return OrientedBoundBox(self) @@ -1641,7 +1650,7 @@ class Shape(NodeMixin, Generic[TOPODS]): - The radius of gyration is computed based on the shape’s mass properties. - It is useful for evaluating structural stability and rotational behavior. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't calculate radius of gyration for empty shape") properties = GProp_GProps() @@ -1660,7 +1669,7 @@ class Shape(NodeMixin, Generic[TOPODS]): DeprecationWarning, stacklevel=2, ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot relocate an empty shape") if loc.wrapped is None: raise ValueError("Cannot relocate a shape at an empty location") @@ -1855,7 +1864,7 @@ class Shape(NodeMixin, Generic[TOPODS]): "keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH" ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot split an empty shape") # Process the perimeter @@ -1863,7 +1872,7 @@ class Shape(NodeMixin, Generic[TOPODS]): raise ValueError("perimeter must be a closed Wire or Edge") perimeter_edges = TopTools_SequenceOfShape() for perimeter_edge in perimeter.edges(): - if perimeter_edge.wrapped is None: + if not perimeter_edge: continue perimeter_edges.Append(perimeter_edge.wrapped) @@ -1871,7 +1880,7 @@ class Shape(NodeMixin, Generic[TOPODS]): lefts: list[Shell] = [] rights: list[Shell] = [] for target_shell in self.shells(): - if target_shell.wrapped is None: + if not target_shell: continue constructor = BRepFeat_SplitShape(target_shell.wrapped) constructor.Add(perimeter_edges) @@ -1900,7 +1909,7 @@ class Shape(NodeMixin, Generic[TOPODS]): self, tolerance: float, angular_tolerance: float = 0.1 ) -> tuple[list[Vector], list[tuple[int, int, int]]]: """General triangulated approximation""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot tessellate an empty shape") self.mesh(tolerance, angular_tolerance) @@ -1962,7 +1971,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Self: Approximated shape """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot approximate an empty shape") params = ShapeCustom_RestrictionParameters() @@ -1999,7 +2008,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: a copy of the object, but with geometry transformed """ - if self.wrapped is None: + if self._wrapped is None: return self new_shape = copy.deepcopy(self, None) transformed = downcast( @@ -2022,7 +2031,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of transformed shape with all objects keeping their type """ - if self.wrapped is None: + if self._wrapped is None: return self new_shape = copy.deepcopy(self, None) transformed = downcast( @@ -2095,7 +2104,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: Shape: copy of transformed Shape """ - if self.wrapped is None: + if self._wrapped is None: return self shape_copy: Shape = copy.deepcopy(self, None) transformed_shape = BRepBuilderAPI_Transform( @@ -2126,7 +2135,11 @@ class Shape(NodeMixin, Generic[TOPODS]): args = list(args) tools = list(tools) # Find the highest order class from all the inputs Solid > Vertex - order_dict = {type(s): type(s).order for s in [self] + args + tools} + order_dict = { + type(s): type(s).order + for s in [self] + args + tools + if hasattr(type(s), "order") + } highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1] # The base of the operation @@ -2200,7 +2213,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Returns: tuple[ShapeList[Vertex], ShapeList[Edge]]: section results """ - if self.wrapped is None or other.wrapped is None: + if self._wrapped is None or not other: return (ShapeList(), ShapeList()) section = BRepAlgoAPI_Section(self.wrapped, other.wrapped) @@ -2701,15 +2714,16 @@ class ShapeList(list[T]): tol_digits, ) - elif hasattr(group_by, "wrapped"): - if group_by.wrapped is None: - raise ValueError("Cannot group by an empty object") + elif not group_by: + raise ValueError("Cannot group by an empty object") - if isinstance(group_by.wrapped, (TopoDS_Edge, TopoDS_Wire)): + elif hasattr(group_by, "wrapped") and isinstance( + group_by.wrapped, (TopoDS_Edge, TopoDS_Wire) + ): - def key_f(obj): - pnt1, _pnt2 = group_by.closest_points(obj.center()) - return round(group_by.param_at_point(pnt1), tol_digits) + def key_f(obj): + pnt1, _pnt2 = group_by.closest_points(obj.center()) + return round(group_by.param_at_point(pnt1), tol_digits) elif isinstance(group_by, SortBy): if group_by == SortBy.LENGTH: @@ -2815,22 +2829,22 @@ class ShapeList(list[T]): ).position.Z, reverse=reverse, ) - elif hasattr(sort_by, "wrapped"): - if sort_by.wrapped is None: - raise ValueError("Cannot sort by an empty object") + elif not sort_by: + raise ValueError("Cannot sort by an empty object") + elif hasattr(sort_by, "wrapped") and isinstance( + sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire) + ): - if isinstance(sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire)): + def u_of_closest_center(obj) -> float: + """u-value of closest point between object center and sort_by""" + assert not isinstance(sort_by, SortBy) + pnt1, _pnt2 = sort_by.closest_points(obj.center()) + return sort_by.param_at_point(pnt1) - def u_of_closest_center(obj) -> float: - """u-value of closest point between object center and sort_by""" - assert not isinstance(sort_by, SortBy) - pnt1, _pnt2 = sort_by.closest_points(obj.center()) - return sort_by.param_at_point(pnt1) - - # pylint: disable=unnecessary-lambda - objects = sorted( - self, key=lambda o: u_of_closest_center(o), reverse=reverse - ) + # pylint: disable=unnecessary-lambda + objects = sorted( + self, key=lambda o: u_of_closest_center(o), reverse=reverse + ) elif isinstance(sort_by, SortBy): if sort_by == SortBy.LENGTH: diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index e4131ce..279f46f 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -56,13 +56,13 @@ from __future__ import annotations import platform import warnings +from collections.abc import Iterable, Sequence from math import radians, cos, tan -from typing import Union, TYPE_CHECKING - -from collections.abc import Iterable +from typing import TYPE_CHECKING +from typing_extensions import Self import OCP.TopAbs as ta -from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Section from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.BRepFeat import BRepFeat_MakeDPrism @@ -95,6 +95,7 @@ from OCP.gp import gp_Ax2, gp_Pnt from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, + TOLERANCE, Axis, BoundBox, Color, @@ -104,10 +105,9 @@ from build123d.geometry import ( Vector, VectorLike, ) -from typing_extensions import Self from .one_d import Edge, Wire, Mixin1D -from .shape_core import Shape, ShapeList, Joint, downcast, shapetype +from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell from .utils import ( _extrude_topods_shape, @@ -122,7 +122,7 @@ if TYPE_CHECKING: # pragma: no cover from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801 -class Mixin3D(Shape): +class Mixin3D(Shape[TOPODS]): """Additional methods to add to 3D Shape classes""" project_to_viewport = Mixin1D.project_to_viewport @@ -420,6 +420,130 @@ class Mixin3D(Shape): return return_value + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face | Solid]: + """Intersect Solid with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, + faces, and/or solids. + """ + + def to_vector(objs: Iterable) -> ShapeList: + return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + + def to_vertex(objs: Iterable) -> ShapeList: + return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, + ) -> ShapeList: + # Wrap Shape._bool_op for corrected output + intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) + if isinstance(intersections, ShapeList): + return intersections or ShapeList() + if isinstance(intersections, Shape) and not intersections.is_null: + return ShapeList([intersections]) + return ShapeList() + + def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: + # Remove lower order shapes from list which *appear* to be part of + # a higher order shape using a lazy distance check + # (sufficient for vertices, may be an issue for higher orders) + order_groups = [] + for order in orders: + order_groups.append( + ShapeList([s for s in shapes if isinstance(s, order)]) + ) + + filtered_shapes = order_groups[-1] + for i in range(len(order_groups) - 1): + los = order_groups[i] + his: list = sum(order_groups[i + 1 :], []) + filtered_shapes.extend( + ShapeList( + lo + for lo in los + if all(lo.distance_to(hi) > TOLERANCE for hi in his) + ) + ) + + return filtered_shapes + + common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList([self]) + target: Shape + for other in to_intersect: + # Conform target type + match other: + case Axis(): + # BRepAlgoAPI_Section seems happier if Edge isnt infinite + bbox = self.bounding_box() + dist = self.distance_to(other.position) + dist = dist if dist >= 1 else 1 + target = Edge.make_line( + other.position - other.direction * bbox.diagonal * dist, + other.position + other.direction * bbox.diagonal * dist, + ) + case Plane(): + target = Face(other) + case Vector(): + target = Vertex(other) + case Location(): + target = Vertex(other.position) + case _ if issubclass(type(other), Shape): + target = other + case _: + raise ValueError(f"Unsupported type to_intersect: {type(other)}") + + # Find common matches + common: list[Vertex | Edge | Wire | Face | Shell | Solid] = [] + result: ShapeList | None + for obj in common_set: + match (obj, target): + case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): + operation = BRepAlgoAPI_Section() + 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. @@ -590,7 +714,7 @@ class Mixin3D(Shape): return Shape.get_shape_list(self, "Solid") -class Solid(Mixin3D, Shape[TopoDS_Solid]): +class Solid(Mixin3D[TopoDS_Solid]): """A Solid in build123d represents a three-dimensional solid geometry in a topological structure. A solid is a closed and bounded volume, enclosing a region in 3D space. It comprises faces, edges, and vertices connected in a @@ -1269,7 +1393,7 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]): outer_wire = section inner_wires = inner_wires if inner_wires else [] - shapes = [] + shapes: list[Mixin3D[TopoDS_Shape]] = [] for wire in [outer_wire] + inner_wires: builder = BRepOffsetAPI_MakePipeShell(Wire(path).wrapped) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8b8f264..450fa10 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -66,7 +66,7 @@ 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 +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, @@ -139,6 +139,7 @@ from build123d.geometry import ( from .one_d import Edge, Mixin1D, Wire from .shape_core import ( + TOPODS, Shape, ShapeList, SkipClean, @@ -165,7 +166,7 @@ if TYPE_CHECKING: # pragma: no cover T = TypeVar("T", Edge, Wire, "Face") -class Mixin2D(ABC, Shape): +class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" project_to_viewport = Mixin1D.project_to_viewport @@ -213,7 +214,7 @@ class Mixin2D(ABC, Shape): def __neg__(self) -> Self: """Reverse normal operator -""" - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Invalid Shape") new_surface = copy.deepcopy(self) new_surface.wrapped = downcast(self.wrapped.Complemented()) @@ -244,7 +245,7 @@ class Mixin2D(ABC, Shape): Returns: list[tuple[Vector, Vector]]: Point and normal of intersection """ - if self.wrapped is None: + if self._wrapped is None: return [] intersection_line = gce_MakeLin(other.wrapped).Value() @@ -278,6 +279,126 @@ class Mixin2D(ABC, Shape): return result + def intersect( + self, *to_intersect: Shape | Vector | Location | Axis | Plane + ) -> None | ShapeList[Vertex | Edge | Face]: + """Intersect Face with Shape or geometry object + + Args: + to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + + Returns: + ShapeList[Vertex | Edge | Face] | None: ShapeList of vertices, edges, and/or + faces. + """ + + def to_vector(objs: Iterable) -> ShapeList: + return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + + def to_vertex(objs: Iterable) -> ShapeList: + return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + + def bool_op( + args: Sequence, + tools: Sequence, + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, + ) -> ShapeList: + # 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() + 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""" @@ -350,7 +471,7 @@ class Mixin2D(ABC, Shape): world_point, world_point - target_object_center ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't wrap around an empty face") # Initial setup @@ -411,7 +532,7 @@ class Mixin2D(ABC, Shape): raise RuntimeError( f"Length error of {length_error:.6f} exceeds tolerance {tolerance}" ) - if wrapped_edge.wrapped is None or not wrapped_edge.is_valid: + if not wrapped_edge or not wrapped_edge.is_valid: raise RuntimeError("Wrapped edge is invalid") if not snap_to_face: @@ -434,7 +555,7 @@ class Mixin2D(ABC, Shape): return projected_edge -class Face(Mixin2D, Shape[TopoDS_Face]): +class Face(Mixin2D[TopoDS_Face]): """A Face in build123d represents a 3D bounded surface within the topological data structure. It encapsulates geometric information, defining a face of a 3D shape. These faces are integral components of complex structures, such as solids and @@ -449,7 +570,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): @overload def __init__( self, - obj: TopoDS_Face, + obj: TopoDS_Face | Plane, label: str = "", color: Color | None = None, parent: Compound | None = None, @@ -457,7 +578,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): """Build a Face from an OCCT TopoDS_Shape/TopoDS_Face Args: - obj (TopoDS_Shape, optional): OCCT Face. + obj (TopoDS_Shape | Plane, optional): OCCT Face or Plane. label (str, optional): Defaults to ''. color (Color, optional): Defaults to None. parent (Compound, optional): assembly parent. Defaults to None. @@ -487,7 +608,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if args: l_a = len(args) - if isinstance(args[0], TopoDS_Shape): + if isinstance(args[0], Plane): + obj = args[0] + elif isinstance(args[0], TopoDS_Shape): obj, label, color, parent = args[:4] + (None,) * (4 - l_a) elif isinstance(args[0], Wire): outer_wire, inner_wires, label, color, parent = args[:5] + (None,) * ( @@ -516,6 +639,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]): color = kwargs.get("color", color) parent = kwargs.get("parent", parent) + if isinstance(obj, Plane): + obj = BRepBuilderAPI_MakeFace(obj.wrapped).Face() + if outer_wire is not None: inner_topods_wires = ( [w.wrapped for w in inner_wires] if inner_wires is not None else [] @@ -545,7 +671,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): float: The total surface area, including the area of holes. Returns 0.0 if the face is empty. """ - if self.wrapped is None: + if self._wrapped is None: return 0.0 return self.without_holes().area @@ -605,7 +731,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ValueError: If the face or its underlying representation is empty. ValueError: If the face is not planar. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Can't determine axes_of_symmetry of empty face") if not self.is_planar_face: @@ -671,15 +797,13 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ).sort_by(Axis(cog, cross_dir)) bottom_area = sum(f.area for f in bottom_list) - intersect_area = 0.0 for flipped_face, bottom_face in zip(top_flipped_list, bottom_list): intersection = flipped_face.intersect(bottom_face) - if intersection is None or isinstance(intersection, list): + if intersection is None: intersect_area = -1.0 break else: - assert isinstance(intersection, Face) - intersect_area += intersection.area + intersect_area = sum(f.area for f in intersection.faces()) if intersect_area == -1.0: continue @@ -871,7 +995,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): Returns: Face: extruded shape """ - if obj.wrapped is None: + if not obj: raise ValueError("Can't extrude empty object") return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction))) @@ -981,7 +1105,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) return single_point_curve - if shape.wrapped is None: + if not shape: raise ValueError("input Edge cannot be empty") adaptor = BRepAdaptor_Curve(shape.wrapped) @@ -1015,6 +1139,12 @@ class Face(Mixin2D, Shape[TopoDS_Face]): 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) @@ -1104,7 +1234,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): raise ValueError("exterior must be a Wire or list of Edges") for edge in outside_edges: - if edge.wrapped is None: + if not edge: raise ValueError("exterior contains empty edges") surface.Add(edge.wrapped, GeomAbs_C0) @@ -1135,7 +1265,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): if interior_wires: makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped) for wire in interior_wires: - if wire.wrapped is None: + if not wire: raise ValueError("interior_wires contain an empty wire") makeface_object.Add(wire.wrapped) try: @@ -1329,7 +1459,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err result = result.fix() - if not result.is_valid or result.wrapped is None: + if not result.is_valid or not result: raise RuntimeError("Non planar face is invalid") return result @@ -1940,7 +2070,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): DeprecationWarning, stacklevel=2, ) - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot approximate an empty shape") return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance)) @@ -1953,7 +2083,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): Returns: Face: A new Face instance identical to the original but without any holes. """ - if self.wrapped is None: + if self._wrapped is None: raise ValueError("Cannot remove holes from an empty face") if not (inner_wires := self.inner_wires()): @@ -2327,7 +2457,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): return wrapped_wire -class Shell(Mixin2D, Shape[TopoDS_Shell]): +class Shell(Mixin2D[TopoDS_Shell]): """A Shell is a fundamental component in build123d's topological data structure representing a connected set of faces forming a closed surface in 3D space. As part of a geometric model, it defines a watertight enclosure, commonly encountered @@ -2359,7 +2489,7 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]): obj = obj_list[0] if isinstance(obj, Face): - if obj.wrapped is None: + if not obj: raise ValueError(f"Can't create a Shell from empty Face") builder = BRep_Builder() shell = TopoDS_Shell() diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index c1bbb1e..dbccc80 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -263,7 +263,10 @@ def _make_topods_face_from_wires( for inner_wire in inner_wires: if not BRep_Tool.IsClosed_s(inner_wire): raise ValueError("Cannot build face(s): inner wire is not closed") - face_builder.Add(inner_wire) + sf_s = ShapeFix_Shape(inner_wire) + sf_s.Perform() + fixed_inner_wire = TopoDS.Wire_s(sf_s.Shape()) + face_builder.Add(fixed_inner_wire) face_builder.Build() diff --git a/src/build123d/vtk_tools.py b/src/build123d/vtk_tools.py index 9d22185..a4af54a 100644 --- a/src/build123d/vtk_tools.py +++ b/src/build123d/vtk_tools.py @@ -80,7 +80,7 @@ def to_vtk_poly_data( if not HAS_VTK: warnings.warn("VTK not supported", stacklevel=2) - if obj.wrapped is None: + if not obj: raise ValueError("Cannot convert an empty shape") vtk_shape = IVtkOCC_Shape(obj.wrapped) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 01c2fbe..be4cd8d 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -183,6 +183,59 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 2) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 2, 3, 0), + close=True, + ) + + with self.assertRaises(ValueError): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 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 diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index f8619c5..2b71763 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -130,8 +130,8 @@ class TestFace(unittest.TestCase): distance=1, distance2=2, vertices=[vertex], edge=other_edge ) - def test_make_rect(self): - test_face = Face.make_plane() + def test_plane_as_face(self): + test_face = Face(Plane.XY) self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5) def test_length_width(self): diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 6eebc41..758fd6f 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -152,6 +152,7 @@ def test_shape_0d(obj, target, expected): run_test(obj, target, expected) +# 1d Shapes ed1 = Line((0, 0), (5, 0)).edge() ed2 = Line((0, -1), (5, 1)).edge() ed3 = Line((0, 0, 5), (5, 0, 5)).edge() @@ -220,6 +221,195 @@ 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() @@ -240,7 +430,7 @@ freecad_matrix = [ Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None), Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None), - Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", "Should return 2 Vertices"), + Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", None), Case(c1, horz, [Vertex], "circle, horiz, tangent", None), Case(c2, horz, [Vertex], "circle, horiz, tangent", None), Case(c1, vert, [Vertex], "circle, vert, tangent", None), @@ -263,11 +453,11 @@ w1 = Wire.make_circle(0.5) f1 = Face(Wire.make_circle(0.5)) issues_matrix = [ - Case(t, t, [Face, Face], "issue #1015", "Returns Compound"), - Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"), - Case(a, b, [Edge], "issue #918", "Returns empty Compound"), - Case(e1, w1, [Vertex, Vertex], "issue #697"), - Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"), + Case(t, t, [Face, Face], "issue #1015", None), + Case(l, s, [Edge], "issue #945", None), + Case(a, b, [Edge], "issue #918", None), + Case(e1, w1, [Vertex, Vertex], "issue #697", None), + Case(e1, f1, [Edge], "issue #697", None), ] @pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) @@ -279,6 +469,9 @@ def test_issues(obj, target, expected): exception_matrix = [ Case(vt1, Color(), None, "Unsupported type", None), Case(ed1, Color(), None, "Unsupported type", None), + Case(fc1, Color(), None, "Unsupported type", None), + Case(sl1, Color(), None, "Unsupported type", None), + Case(cp1, Color(), None, "Unsupported type", None), ] @pytest.mark.skip diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py index bcdb566..a083f7b 100644 --- a/tests/test_direct_api/test_oriented_bound_box.py +++ b/tests/test_direct_api/test_oriented_bound_box.py @@ -229,13 +229,15 @@ class TestOrientedBoundBox(unittest.TestCase): obb = OrientedBoundBox(rect) corners = obb.corners poly = Polygon(*corners, align=None) - self.assertAlmostEqual(rect.intersect(poly).area, rect.area, 5) + area = sum(f.area for f in rect.intersect(poly).faces()) + self.assertAlmostEqual(area, rect.area, 5) for face in Box(1, 2, 3).faces(): obb = OrientedBoundBox(face) corners = obb.corners poly = Polygon(*corners, align=None) - self.assertAlmostEqual(face.intersect(poly).area, face.area, 5) + area = sum(f.area for f in face.intersect(poly).faces()) + self.assertAlmostEqual(area, face.area, 5) def test_line_corners(self): """ diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 2c0bb3c..bb290e7 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -299,7 +299,8 @@ class TestShape(unittest.TestCase): predicted_location = Location(offset) * Rotation(*rotation) located_shape = Solid.make_box(1, 1, 1).locate(predicted_location) intersect = shape.intersect(located_shape) - self.assertAlmostEqual(intersect.volume, 1, 5) + volume = sum(s.volume for s in intersect.solids()) + self.assertAlmostEqual(volume, 1, 5) def test_position_and_orientation(self): box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30))) @@ -475,7 +476,7 @@ class TestShape(unittest.TestCase): self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) - verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY)) + verts, edges = Vertex(1, 2, 0)._ocp_section(Face(Plane.XY)) self.assertAlmostEqual(Vector(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) @@ -493,7 +494,7 @@ class TestShape(unittest.TestCase): self.assertEqual(len(edges1), 1) self.assertAlmostEqual(edges1[0].length, 20, 5) - vertices2, edges2 = cylinder._ocp_section(Face.make_plane(pln)) + vertices2, edges2 = cylinder._ocp_section(Face(pln)) self.assertEqual(len(vertices2), 1) self.assertEqual(len(edges2), 1) self.assertAlmostEqual(Vector(vertices2[0]), (5, 0, 0), 5) @@ -588,7 +589,7 @@ class TestShape(unittest.TestCase): empty.distance_to_with_closest_points(Vector(1, 1, 1)) with self.assertRaises(ValueError): empty.distance_to(Vector(1, 1, 1)) - with self.assertRaises(ValueError): + with self.assertRaises(AttributeError): box.intersect(empty_loc) self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], [])) self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList()) diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py index a0fa0f3..75fad74 100644 --- a/tests/test_direct_api/test_solid.py +++ b/tests/test_direct_api/test_solid.py @@ -153,7 +153,9 @@ class TestSolid(unittest.TestCase): self.assertAlmostEqual(twist.volume, 1, 5) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) bottom = twist.faces().sort_by(Axis.Z)[0] - self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) + intersect = top.translate((0, 0, -1)).intersect(bottom) + area = sum(f.area for f in intersect.faces()) + self.assertAlmostEqual(area, 1, 5) # Wire base = Wire.make_rect(1, 1) twist = Solid.extrude_linear_with_rotation( @@ -162,7 +164,9 @@ class TestSolid(unittest.TestCase): self.assertAlmostEqual(twist.volume, 1, 5) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) bottom = twist.faces().sort_by(Axis.Z)[0] - self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) + intersect = top.translate((0, 0, -1)).intersect(bottom) + area = sum(f.area for f in intersect.faces()) + self.assertAlmostEqual(area, 1, 5) def test_make_loft(self): loft = Solid.make_loft( diff --git a/tests/test_drafting.py b/tests/test_drafting.py index 1bb97ab..2f1b301 100644 --- a/tests/test_drafting.py +++ b/tests/test_drafting.py @@ -231,7 +231,8 @@ class DimensionLineTestCase(unittest.TestCase): ], draft=metric, ) - self.assertGreater(hole.intersect(d_line).area, 0) + area = sum(f.area for f in hole.intersect(d_line).faces()) + self.assertGreater(area, 0) def test_outside_arrows(self): d_line = DimensionLine([(0, 0, 0), (15, 0, 0)], draft=metric)