"""
build123d topology
name: composite.py
by: Gumyr
date: January 07, 2025
desc:
This module defines advanced composite geometric entities for the build123d CAD system. It
introduces the `Compound` class as a central concept for managing groups of shapes, alongside
specialized subclasses such as `Curve`, `Sketch`, and `Part` for 1D, 2D, and 3D objects,
respectively. These classes streamline the construction and manipulation of complex geometric
assemblies.
Key Features:
- **Compound Class**:
- Represents a collection of geometric shapes (e.g., vertices, edges, faces, solids) grouped
hierarchically.
- Supports operations like adding, removing, and combining shapes, as well as querying volumes,
centers, and intersections.
- Provides utility methods for unwrapping nested compounds and generating 3D text or coordinate
system triads.
- **Specialized Subclasses**:
- `Curve`: Handles 1D objects like edges and wires.
- `Sketch`: Focused on 2D objects, such as faces.
- `Part`: Manages 3D solids and assemblies.
- **Advanced Features**:
- Includes Boolean operations, hierarchy traversal, and bounding box-based intersection detection.
- Supports transformations, child-parent relationships, and dynamic updates.
This module leverages OpenCascade for robust geometric operations while offering a Pythonic
interface for efficient and extensible CAD modeling workflows.
license:
Copyright 2025 Gumyr
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from __future__ import annotations
import copy
import os
import sys
import warnings
from itertools import combinations
from typing import Type, Union
from collections.abc import Iterable, Iterator, Sequence
import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.Font import (
Font_FA_Bold,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
Font_SystemFont,
)
from OCP.GProp import GProp_GProps
from OCP.NCollection import NCollection_Utf8String
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder, StdPrs_BRepFont
from OCP.TCollection import TCollection_AsciiString
from OCP.TopAbs import TopAbs_ShapeEnum
from OCP.TopoDS import (
TopoDS,
TopoDS_Builder,
TopoDS_Compound,
TopoDS_Iterator,
TopoDS_Shape,
)
from anytree import PreOrderIter
from build123d.build_enums import Align, CenterOf, FontStyle
from build123d.geometry import (
TOLERANCE,
Axis,
Color,
Location,
Plane,
Vector,
VectorLike,
logger,
)
from typing_extensions import Self
from .one_d import Edge, Wire, Mixin1D
from .shape_core import (
Shape,
ShapeList,
SkipClean,
Joint,
downcast,
shapetype,
topods_dim,
)
from .three_d import Mixin3D, Solid
from .two_d import Face, Shell
from .utils import (
_extrude_topods_shape,
_make_topods_compound_from_shapes,
tuplify,
unwrapped_shapetype,
)
from .zero_d import Vertex
[docs]
class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""A Compound in build123d is a topological entity representing a collection of
geometric shapes grouped together within a single structure. It serves as a
container for organizing diverse shapes like edges, faces, or solids. This
hierarchical arrangement facilitates the construction of complex models by
combining simpler shapes. Compound plays a pivotal role in managing the
composition and structure of intricate 3D models in computer-aided design
(CAD) applications, allowing engineers and designers to work with assemblies
of shapes as unified entities for efficient modeling and analysis."""
order = 4.0
project_to_viewport = Mixin1D.project_to_viewport
# ---- Constructor ----
def __init__(
self,
obj: TopoDS_Compound | Iterable[Shape] | None = None,
label: str = "",
color: Color | None = None,
material: str = "",
joints: dict[str, Joint] | None = None,
parent: Compound | None = None,
children: Sequence[Shape] | None = None,
):
"""Build a Compound from Shapes
Args:
obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
material (str, optional): tag for external tools. Defaults to ''.
joints (dict[str, Joint], optional): names joints. Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
children (Sequence[Shape], optional): assembly children. Defaults to None.
"""
if isinstance(obj, Iterable):
topods_compound = _make_topods_compound_from_shapes(
[s.wrapped for s in obj]
)
else:
topods_compound = obj
super().__init__(
obj=topods_compound,
label=label,
color=color,
parent=parent,
)
self.material = "" if material is None else material
self.joints = {} if joints is None else joints
self.children = [] if children is None else children
# ---- Properties ----
@property
def _dim(self) -> int | None:
"""The dimension of the shapes within the Compound - None if inconsistent"""
return topods_dim(self.wrapped)
@property
def volume(self) -> float:
"""volume - the volume of this Compound"""
# when density == 1, mass == volume
return sum(i.volume for i in [*self.get_type(Solid), *self.get_type(Shell)])
# ---- Class Methods ----
[docs]
@classmethod
def cast(
cls, obj: TopoDS_Shape
) -> Vertex | Edge | Wire | Face | Shell | Solid | Compound:
"Returns the right type of wrapper, given a OCCT object"
# define the shape lookup table for casting
constructor_lut = {
ta.TopAbs_VERTEX: Vertex,
ta.TopAbs_EDGE: Edge,
ta.TopAbs_WIRE: Wire,
ta.TopAbs_FACE: Face,
ta.TopAbs_SHELL: Shell,
ta.TopAbs_SOLID: Solid,
ta.TopAbs_COMPOUND: Compound,
}
shape_type = shapetype(obj)
# NB downcast is needed to handle TopoDS_Shape types
return constructor_lut[shape_type](downcast(obj))
[docs]
@classmethod
def extrude(cls, obj: Shell, direction: VectorLike) -> Compound:
"""extrude
Extrude a Shell into a Compound.
Args:
direction (VectorLike): direction and magnitude of extrusion
Raises:
ValueError: Unsupported class
RuntimeError: Generated invalid result
Returns:
Edge: extruded shape
"""
return Compound(
TopoDS.Compound_s(_extrude_topods_shape(obj.wrapped, direction))
)
[docs]
@classmethod
def make_text(
cls,
txt: str,
font_size: float,
font: str = "Arial",
font_path: str | None = None,
font_style: FontStyle = FontStyle.REGULAR,
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
position_on_path: float = 0.0,
text_path: Edge | Wire | None = None,
) -> Compound:
"""2D Text that optionally follows a path.
The text that is created can be combined as with other sketch features by specifying
a mode or rotated by the given angle. In addition, edges have been previously created
with arc or segment, the text will follow the path defined by these edges. The start
parameter can be used to shift the text along the path to achieve precise positioning.
Args:
txt: text to be rendered
font_size: size of the font in model units
font: font name
font_path: path to font file
font_style: text style. Defaults to FontStyle.REGULAR.
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max
of object. Defaults to (Align.CENTER, Align.CENTER).
position_on_path: the relative location on path to position the text,
between 0.0 and 1.0. Defaults to 0.0.
text_path: a path for the text to follows. Defaults to None - linear text.
Returns:
a Compound object containing multiple Faces representing the text
Examples::
fox = Compound.make_text(
txt="The quick brown fox jumped over the lazy dog",
font_size=10,
position_on_path=0.1,
text_path=jump_edge,
)
"""
# pylint: disable=too-many-locals
def position_face(orig_face: Face) -> Face:
"""
Reposition a face to the provided path
Local coordinates are used to calculate the position of the face
relative to the path. Global coordinates to position the face.
"""
assert text_path is not None
bbox = orig_face.bounding_box()
face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
relative_position_on_wire = (
position_on_path + face_bottom_center.X / path_length
)
wire_tangent = text_path.tangent_at(relative_position_on_wire)
wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent)
wire_position = text_path.position_at(relative_position_on_wire)
return orig_face.translate(wire_position - face_bottom_center).rotate(
Axis(wire_position, (0, 0, 1)),
-wire_angle,
)
if sys.platform.startswith("linux"):
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
font_kind = {
FontStyle.REGULAR: Font_FA_Regular,
FontStyle.BOLD: Font_FA_Bold,
FontStyle.ITALIC: Font_FA_Italic,
}[font_style]
mgr = Font_FontMgr.GetInstance_s()
if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()):
font_t = Font_SystemFont(TCollection_AsciiString(font_path))
font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path))
mgr.RegisterFont(font_t, True)
else:
font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind)
logger.info(
"Creating text with font %s located at %s",
font_t.FontName().ToCString(),
font_t.FontPath(font_kind).ToCString(),
)
builder = Font_BRepTextBuilder()
font_i = StdPrs_BRepFont(
NCollection_Utf8String(font_t.FontName().ToCString()),
font_kind,
float(font_size),
)
text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt)))
# Align the text from the bounding box
align_text = tuplify(align, 2)
text_flat = text_flat.translate(
Vector(*text_flat.bounding_box().to_align_offset(align_text))
)
if text_path is not None:
path_length = text_path.length
text_flat = Compound([position_face(f) for f in text_flat.faces()])
return text_flat
[docs]
@classmethod
def make_triad(cls, axes_scale: float) -> Compound:
"""The coordinate system triad (X, Y, Z axes)"""
x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0))
y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0))
z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale))
arrow_arc = Edge.make_spline(
[(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)],
[(-1, 0, 0), (-1, 1.5, 0)],
)
arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)])
x_label = (
Compound.make_text(
"X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
)
.move(Location(x_axis @ 1))
.edges()
)
y_label = (
Compound.make_text(
"Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
)
.rotate(Axis.Z, 90)
.move(Location(y_axis @ 1))
.edges()
)
z_label = (
Compound.make_text(
"Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN)
)
.rotate(Axis.Y, 90)
.rotate(Axis.X, 90)
.move(Location(z_axis @ 1))
.edges()
)
triad = Curve(
[
x_axis,
y_axis,
z_axis,
arrow.moved(Location(x_axis @ 1)),
arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)),
arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)),
*x_label,
*y_label,
*z_label,
]
)
return triad
# ---- Instance Methods ----
def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound:
"""Combine other to self `+` operator
Note that if all of the objects are connected Edges/Wires the result
will be a Wire, otherwise a Shape.
"""
if self._dim == 1:
curve = Curve() if self.wrapped is None else Curve(self.wrapped)
self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
return curve + other
summands: ShapeList[Shape]
if other is None:
summands = ShapeList()
else:
summands = ShapeList(
shape
for o in ([other] if isinstance(other, Shape) else other)
if o is not None
for shape in o.get_top_level_shapes()
)
# If there is nothing to add return the original object
if not summands:
return self
summands = ShapeList(
s for s in self.get_top_level_shapes() + summands if s is not None
)
# Only fuse the parts if necessary
if len(summands) <= 1:
result: Shape = Compound(summands[0:1])
else:
fuse_op = BRepAlgoAPI_Fuse()
fuse_op.SetFuzzyValue(TOLERANCE)
self.copy_attributes_to(summands[0], ["wrapped", "_NodeMixin__children"])
bool_result = self._bool_op(summands[:1], summands[1:], fuse_op)
if isinstance(bool_result, list):
result = Compound(bool_result)
self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
else:
result = bool_result
if SkipClean.clean:
result = result.clean()
return result
def __and__(self, other: Shape | Iterable[Shape]) -> Compound:
"""Intersect other to self `&` operator"""
intersection = Shape.__and__(self, other)
intersection = Compound(
intersection if isinstance(intersection, list) else [intersection]
)
self.copy_attributes_to(intersection, ["wrapped", "_NodeMixin__children"])
return intersection
def __bool__(self) -> bool:
"""
Check if empty.
"""
return TopoDS_Iterator(self.wrapped).More()
def __iter__(self) -> Iterator[Shape]:
"""
Iterate over subshapes.
"""
iterator = TopoDS_Iterator(self.wrapped)
while iterator.More():
yield Compound.cast(iterator.Value())
iterator.Next()
def __len__(self) -> int:
"""Return the number of subshapes"""
count = 0
if self.wrapped is not None:
for _ in self:
count += 1
return count
def __repr__(self):
"""Return Compound info as string"""
if hasattr(self, "label") and hasattr(self, "children"):
result = (
f"{self.__class__.__name__} at {id(self):#x}, label({self.label}), "
+ f"#children({len(self.children)})"
)
else:
result = f"{self.__class__.__name__} at {id(self):#x}"
return result
def __sub__(self, other: None | Shape | Iterable[Shape]) -> Compound:
"""Cut other to self `-` operator"""
difference = Shape.__sub__(self, other)
difference = Compound(
difference if isinstance(difference, list) else [difference]
)
self.copy_attributes_to(difference, ["wrapped", "_NodeMixin__children"])
return difference
[docs]
def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
"""Return center of object
Find center of object
Args:
center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.
Raises:
ValueError: Center of GEOMETRY is not supported for this object
NotImplementedError: Unable to calculate center of mass of this object
Returns:
Vector: center
"""
if center_of == CenterOf.GEOMETRY:
raise ValueError("Center of GEOMETRY is not supported for this object")
if center_of == CenterOf.MASS:
properties = GProp_GProps()
calc_function = Shape.shape_properties_LUT[unwrapped_shapetype(self)]
if calc_function:
calc_function(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
else:
raise NotImplementedError
elif center_of == CenterOf.BOUNDING_BOX:
middle = self.bounding_box().center()
return middle
[docs]
def compound(self) -> Compound | None:
"""Return the Compound"""
shape_list = self.compounds()
entity_count = len(shape_list)
if entity_count != 1:
warnings.warn(
f"Found {entity_count} compounds, returning first",
stacklevel=2,
)
return shape_list[0] if shape_list else None
[docs]
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
if self.wrapped is None:
return ShapeList()
if isinstance(self.wrapped, TopoDS_Compound):
# pylint: disable=not-an-iterable
sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)]
sub_compounds.append(self)
else:
sub_compounds = []
return ShapeList(sub_compounds)
[docs]
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
) -> tuple[bool, tuple[Shape | None, Shape | None], float]:
"""Do Children Intersect
Determine if any of the child objects within a Compound/assembly intersect by
intersecting each of the shapes with each other and checking for
a common volume.
Args:
include_parent (bool, optional): check parent for intersections. Defaults to False.
tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5.
Returns:
tuple[bool, tuple[Shape, Shape], float]:
do the object intersect, intersecting objects, volume of intersection
"""
children: list[Shape] = list(PreOrderIter(self))
if not include_parent:
children.pop(0) # remove parent
# children_bbox = [child.bounding_box().to_solid() for child in children]
children_bbox = [
Solid.from_bounding_box(child.bounding_box()) for child in children
]
child_index_pairs = [
tuple(map(int, comb))
for comb in combinations(list(range(len(children))), 2)
]
for child_index_pair in child_index_pairs:
# First check for bounding box intersections ..
# .. then confirm with actual object intersections which could be complex
bbox_intersection = children_bbox[child_index_pair[0]].intersect(
children_bbox[child_index_pair[1]]
)
if bbox_intersection is not None:
obj_intersection = children[child_index_pair[0]].intersect(
children[child_index_pair[1]]
)
if obj_intersection is not None:
common_volume = (
0.0
if isinstance(obj_intersection, list)
else obj_intersection.volume
)
if common_volume > tolerance:
return (
True,
(
children[child_index_pair[0]],
children[child_index_pair[1]],
),
common_volume,
)
return (False, (None, None), 0.0)
[docs]
def get_type(
self,
obj_type: (
type[Vertex]
| type[Edge]
| type[Face]
| type[Shell]
| type[Solid]
| type[Wire]
),
) -> list[Vertex | Edge | Face | Shell | Solid | Wire]:
"""get_type
Extract the objects of the given type from a Compound. Note that this
isn't the same as Faces() etc. which will extract Faces from Solids.
Args:
obj_type (Union[Vertex, Edge, Face, Shell, Solid, Wire]): Object types to extract
Returns:
list[Union[Vertex, Edge, Face, Shell, Solid, Wire]]: Extracted objects
"""
type_map = {
Vertex: TopAbs_ShapeEnum.TopAbs_VERTEX,
Edge: TopAbs_ShapeEnum.TopAbs_EDGE,
Face: TopAbs_ShapeEnum.TopAbs_FACE,
Shell: TopAbs_ShapeEnum.TopAbs_SHELL,
Solid: TopAbs_ShapeEnum.TopAbs_SOLID,
Wire: TopAbs_ShapeEnum.TopAbs_WIRE,
Compound: TopAbs_ShapeEnum.TopAbs_COMPOUND,
}
results = []
for comp in self.compounds():
iterator = TopoDS_Iterator()
iterator.Initialize(comp.wrapped)
while iterator.More():
child = iterator.Value()
if child.ShapeType() == type_map[obj_type]:
results.append(obj_type(downcast(child)))
iterator.Next()
return results
[docs]
def unwrap(self, fully: bool = True) -> Self | Shape:
"""Strip unnecessary Compound wrappers
Args:
fully (bool, optional): return base shape without any Compound
wrappers (otherwise one Compound is left). Defaults to True.
Returns:
Union[Self, Shape]: base shape
"""
if len(self) == 1:
single_element = next(iter(self))
self.copy_attributes_to(single_element, ["wrapped", "_NodeMixin__children"])
# If the single element is another Compound, unwrap it recursively
if isinstance(single_element, Compound):
# Unwrap recursively and copy attributes down
unwrapped = single_element.unwrap(fully)
if not fully:
unwrapped = type(self)(unwrapped.wrapped)
self.copy_attributes_to(unwrapped, ["wrapped", "_NodeMixin__children"])
return unwrapped
return single_element if fully else self
# If there are no elements or more than one element, return self
return self
def _post_attach(self, parent: Compound):
"""Method call after attaching to `parent`."""
logger.debug("Updated parent of %s to %s", self.label, parent.label)
parent.wrapped = _make_topods_compound_from_shapes(
[c.wrapped for c in parent.children]
)
def _post_attach_children(self, children: Iterable[Shape]):
"""Method call after attaching `children`."""
if children:
kids = ",".join([child.label for child in children])
logger.debug("Adding children %s to %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
[c.wrapped for c in self.children]
)
# else:
# logger.debug("Adding no children to %s", self.label)
def _post_detach(self, parent: Compound):
"""Method call after detaching from `parent`."""
logger.debug("Removing parent of %s (%s)", self.label, parent.label)
if parent.children:
parent.wrapped = _make_topods_compound_from_shapes(
[c.wrapped for c in parent.children]
)
else:
parent.wrapped = None
def _post_detach_children(self, children):
"""Method call before detaching `children`."""
if children:
kids = ",".join([child.label for child in children])
logger.debug("Removing children %s from %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
[c.wrapped for c in self.children]
)
# else:
# logger.debug("Removing no children from %s", self.label)
def _pre_attach(self, parent: Compound):
"""Method call before attaching to `parent`."""
if not isinstance(parent, Compound):
raise ValueError("`parent` must be of type Compound")
def _pre_attach_children(self, children):
"""Method call before attaching `children`."""
if not all(isinstance(child, Shape) for child in children):
raise ValueError("Each child must be of type Shape")
def _remove(self, shape: Shape) -> Compound:
"""Return self with the specified shape removed.
Args:
shape: Shape:
"""
comp_builder = TopoDS_Builder()
comp_builder.Remove(self.wrapped, shape.wrapped)
return self
[docs]
class Curve(Compound):
"""A Compound containing 1D objects - aka Edges"""
__add__ = Mixin1D.__add__ # type: ignore
# ---- Properties ----
@property
def _dim(self) -> int:
return 1
# ---- Instance Methods ----
[docs]
def __matmul__(self, position: float) -> Vector:
"""Position on curve operator @ - only works if continuous"""
return Wire(self.edges()).position_at(position)
[docs]
def __mod__(self, position: float) -> Vector:
"""Tangent on wire operator % - only works if continuous"""
return Wire(self.edges()).tangent_at(position)
def __xor__(self, position: float) -> Location:
"""Location on wire operator ^ - only works if continuous"""
return Wire(self.edges()).location_at(position)
[docs]
def wires(self) -> ShapeList[Wire]: # type: ignore
"""A list of wires created from the edges"""
return Wire.combine(self.edges())
[docs]
class Sketch(Compound):
"""A Compound containing 2D objects - aka Faces"""
# ---- Properties ----
@property
def _dim(self) -> int:
return 2
[docs]
class Part(Compound):
"""A Compound containing 3D objects - aka Solids"""
# ---- Properties ----
@property
def _dim(self) -> int:
return 3