build123d/src/build123d/direct_api.py
2023-02-15 15:52:18 -05:00

8450 lines
276 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
build123d direct api
name: direct_api.py
by: Gumyr
date: Oct 14, 2022
desc:
This python module is a CAD library based on OpenCascade.
license:
Copyright 2022 Gumyr
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from __future__ import annotations
# pylint has trouble with the OCP imports
# pylint: disable=no-name-in-module, import-error
# other pylint warning to temp remove:
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import copy
import io as StringIO
import logging
import os
import platform
import sys
import warnings
from abc import ABC, abstractmethod
from io import BytesIO
from itertools import combinations
from math import degrees, inf, pi, radians, sqrt
from typing import (
Any,
Dict,
Iterable,
Iterator,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
)
from typing import cast as tcast
from typing import overload
from anytree import NodeMixin, PreOrderIter, RenderTree
from svgpathtools import svg2paths
from typing_extensions import Literal
from vtkmodules.vtkCommonDataModel import vtkPolyData
from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
import OCP.GeomAbs as ga # Geometry type enum
import OCP.IFSelect
import OCP.TopAbs as ta # Topology type enum
from OCP.Aspect import Aspect_TOL_SOLID
from OCP.Bnd import Bnd_Box
from OCP.BOPAlgo import BOPAlgo_GlueEnum
# used for getting underlying geometry -- is this equivalent to brep adaptor?
from OCP.BRep import BRep_Builder, BRep_Tool
from OCP.BRepAdaptor import (
BRepAdaptor_CompCurve,
BRepAdaptor_Curve,
BRepAdaptor_Surface,
)
from OCP.BRepAlgoAPI import (
BRepAlgoAPI_BooleanOperation,
BRepAlgoAPI_Common,
BRepAlgoAPI_Cut,
BRepAlgoAPI_Fuse,
BRepAlgoAPI_Splitter,
)
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_Copy,
BRepBuilderAPI_DisconnectedWire,
BRepBuilderAPI_EmptyWire,
BRepBuilderAPI_GTransform,
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeSolid,
BRepBuilderAPI_MakeVertex,
BRepBuilderAPI_MakeWire,
BRepBuilderAPI_NonManifoldWire,
BRepBuilderAPI_RightCorner,
BRepBuilderAPI_RoundCorner,
BRepBuilderAPI_Sewing,
BRepBuilderAPI_Transform,
BRepBuilderAPI_Transformed,
)
from OCP.BRepCheck import BRepCheck_Analyzer
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFeat import BRepFeat_MakeDPrism
from OCP.BRepFill import BRepFill
from OCP.BRepFilletAPI import (
BRepFilletAPI_MakeChamfer,
BRepFilletAPI_MakeFillet,
BRepFilletAPI_MakeFillet2d,
)
from OCP.BRepGProp import BRepGProp, BRepGProp_Face # used for mass calculation
from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepLib import BRepLib, BRepLib_FindSurface
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin
from OCP.BRepOffsetAPI import (
BRepOffsetAPI_MakeFilling,
BRepOffsetAPI_MakeOffset,
BRepOffsetAPI_MakePipeShell,
BRepOffsetAPI_MakeThickSolid,
BRepOffsetAPI_ThruSections,
)
from OCP.BRepPrimAPI import (
BRepPrimAPI_MakeBox,
BRepPrimAPI_MakeCone,
BRepPrimAPI_MakeCylinder,
BRepPrimAPI_MakePrism,
BRepPrimAPI_MakeRevol,
BRepPrimAPI_MakeSphere,
BRepPrimAPI_MakeTorus,
BRepPrimAPI_MakeWedge,
)
from OCP.BRepProj import BRepProj_Projection
from OCP.BRepTools import BRepTools
from OCP.Font import (
Font_FA_Bold,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
Font_SystemFont,
)
from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse # geometry construction
from OCP.gce import gce_MakeDir, gce_MakeLin
from OCP.GCE2d import GCE2d_MakeSegment
from OCP.GCPnts import GCPnts_AbscissaPoint, GCPnts_QuasiUniformDeflection
from OCP.Geom import (
Geom_BezierCurve,
Geom_ConicalSurface,
Geom_CylindricalSurface,
Geom_Plane,
Geom_Surface,
)
from OCP.Geom2d import Geom2d_Line, Geom2d_Curve
from OCP.GeomAbs import GeomAbs_C0, GeomAbs_Intersection, GeomAbs_JoinType
from OCP.GeomAPI import (
GeomAPI_Interpolate,
GeomAPI_PointsToBSpline,
GeomAPI_PointsToBSplineSurface,
GeomAPI_ProjectPointOnSurf,
)
from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve
from OCP.GeomFill import (
GeomFill_CorrectedFrenet,
GeomFill_Frenet,
GeomFill_TrihedronLaw,
)
from OCP.gp import (
gp_Ax1,
gp_Ax2,
gp_Ax3,
gp_Circ,
gp_Dir,
gp_Dir2d,
gp_Elips,
gp_EulerSequence,
gp_GTrsf,
gp_Lin,
gp_Pln,
gp_Pnt,
gp_Pnt2d,
gp_Quaternion,
gp_Trsf,
gp_Vec,
gp_XYZ,
)
# properties used to store mass calculation result
from OCP.GProp import GProp_GProps
from OCP.HLRAlgo import HLRAlgo_Projector
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
from OCP.IFSelect import IFSelect_ReturnStatus
from OCP.Interface import Interface_Static
from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher
from OCP.IVtkVTK import IVtkVTK_ShapeData
from OCP.LocOpe import LocOpe_DPrism
from OCP.NCollection import NCollection_Utf8String
from OCP.Precision import Precision
from OCP.Prs3d import Prs3d_IsoAspect
from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA
from OCP.RWStl import RWStl
from OCP.ShapeAnalysis import ShapeAnalysis_Edge, ShapeAnalysis_FreeBounds
from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape, ShapeFix_Solid
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
# for catching exceptions
from OCP.Standard import Standard_Failure, Standard_NoSuchObject
from OCP.StdFail import StdFail_NotDone
from OCP.StdPrs import StdPrs_BRepFont
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder
from OCP.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
from OCP.StlAPI import StlAPI_Writer
# Array of vectors (used for B-spline interpolation):
# Array of points (used for B-spline construction):
from OCP.TColgp import (
TColgp_Array1OfPnt,
TColgp_Array1OfVec,
TColgp_HArray1OfPnt,
TColgp_HArray2OfPnt,
)
from OCP.TCollection import TCollection_AsciiString
# Array of floats (used for B-spline interpolation):
# Array of booleans (used for B-spline interpolation):
from OCP.TColStd import (
TColStd_Array1OfReal,
TColStd_HArray1OfBoolean,
TColStd_HArray1OfReal,
)
from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum
from OCP.TopExp import TopExp_Explorer # Topology explorer
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import (
TopoDS,
TopoDS_Builder,
TopoDS_Compound,
TopoDS_Face,
TopoDS_Iterator,
TopoDS_Shape,
TopoDS_Shell,
TopoDS_Solid,
TopoDS_Vertex,
TopoDS_Wire,
)
from OCP.TopTools import (
TopTools_HSequenceOfShape,
TopTools_IndexedDataMapOfShapeListOfShape,
TopTools_ListOfShape,
)
from build123d.build_enums import (
Align,
AngularDirection,
CenterOf,
Direction,
FontStyle,
FrameMethod,
GeomType,
Kind,
PositionMode,
SortBy,
Transition,
Until,
)
# Create a build123d logger to distinguish these logs from application logs.
# If the user doesn't configure logging, all build123d logs will be discarded.
logging.getLogger("build123d").addHandler(logging.NullHandler())
logger = logging.getLogger("build123d")
TOLERANCE = 1e-6
TOL = 1e-2
DEG2RAD = pi / 180.0
RAD2DEG = 180 / pi
HASH_CODE_MAX = 2147483647 # max 32bit signed int, required by OCC.Core.HashCode
shape_LUT = {
ta.TopAbs_VERTEX: "Vertex",
ta.TopAbs_EDGE: "Edge",
ta.TopAbs_WIRE: "Wire",
ta.TopAbs_FACE: "Face",
ta.TopAbs_SHELL: "Shell",
ta.TopAbs_SOLID: "Solid",
ta.TopAbs_COMPOUND: "Compound",
}
shape_properties_LUT = {
ta.TopAbs_VERTEX: None,
ta.TopAbs_EDGE: BRepGProp.LinearProperties_s,
ta.TopAbs_WIRE: BRepGProp.LinearProperties_s,
ta.TopAbs_FACE: BRepGProp.SurfaceProperties_s,
ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s,
ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s,
ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s,
}
inverse_shape_LUT = {v: k for k, v in shape_LUT.items()}
downcast_LUT = {
ta.TopAbs_VERTEX: TopoDS.Vertex_s,
ta.TopAbs_EDGE: TopoDS.Edge_s,
ta.TopAbs_WIRE: TopoDS.Wire_s,
ta.TopAbs_FACE: TopoDS.Face_s,
ta.TopAbs_SHELL: TopoDS.Shell_s,
ta.TopAbs_SOLID: TopoDS.Solid_s,
ta.TopAbs_COMPOUND: TopoDS.Compound_s,
}
geom_LUT = {
ta.TopAbs_VERTEX: "Vertex",
ta.TopAbs_EDGE: BRepAdaptor_Curve,
ta.TopAbs_WIRE: "Wire",
ta.TopAbs_FACE: BRepAdaptor_Surface,
ta.TopAbs_SHELL: "Shell",
ta.TopAbs_SOLID: "Solid",
ta.TopAbs_COMPOUND: "Compound",
}
geom_LUT_FACE = {
ga.GeomAbs_Plane: "PLANE",
ga.GeomAbs_Cylinder: "CYLINDER",
ga.GeomAbs_Cone: "CONE",
ga.GeomAbs_Sphere: "SPHERE",
ga.GeomAbs_Torus: "TORUS",
ga.GeomAbs_BezierSurface: "BEZIER",
ga.GeomAbs_BSplineSurface: "BSPLINE",
ga.GeomAbs_SurfaceOfRevolution: "REVOLUTION",
ga.GeomAbs_SurfaceOfExtrusion: "EXTRUSION",
ga.GeomAbs_OffsetSurface: "OFFSET",
ga.GeomAbs_OtherSurface: "OTHER",
}
geom_LUT_EDGE = {
ga.GeomAbs_Line: "LINE",
ga.GeomAbs_Circle: "CIRCLE",
ga.GeomAbs_Ellipse: "ELLIPSE",
ga.GeomAbs_Hyperbola: "HYPERBOLA",
ga.GeomAbs_Parabola: "PARABOLA",
ga.GeomAbs_BezierCurve: "BEZIER",
ga.GeomAbs_BSplineCurve: "BSPLINE",
ga.GeomAbs_OffsetCurve: "OFFSET",
ga.GeomAbs_OtherCurve: "OTHER",
}
Shapes = Literal["Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"]
Geoms = Literal[
"Vertex",
"Wire",
"Shell",
"Solid",
"Compound",
"PLANE",
"CYLINDER",
"CONE",
"SPHERE",
"TORUS",
"BEZIER",
"BSPLINE",
"REVOLUTION",
"EXTRUSION",
"OFFSET",
"OTHER",
"LINE",
"CIRCLE",
"ELLIPSE",
"HYPERBOLA",
"PARABOLA",
]
class Vector:
"""Create a 3-dimensional vector
Args:
args: a 3D vector, with x-y-z parts.
you can either provide:
* nothing (in which case the null vector is return)
* a gp_Vec
* a vector ( in which case it is copied )
* a 3-tuple
* a 2-tuple (z assumed to be 0)
* three float values: x, y, and z
* two float values: x,y
Returns:
"""
_wrapped: gp_Vec
@overload
def __init__(self, x: float, y: float, z: float): # pragma: no cover
...
@overload
def __init__(self, x: float, y: float): # pragma: no cover
...
@overload
def __init__(self, vec: Vector): # pragma: no cover
...
@overload
def __init__(self, vec: Sequence[float]): # pragma: no cover
...
@overload
def __init__(self, vec: Union[gp_Vec, gp_Pnt, gp_Dir, gp_XYZ]): # pragma: no cover
...
@overload
def __init__(self): # pragma: no cover
...
def __init__(self, *args):
self.vector_index = 0
if len(args) == 3:
f_v = gp_Vec(*args)
elif len(args) == 2:
f_v = gp_Vec(*args, 0)
elif len(args) == 1:
if isinstance(args[0], Vector):
f_v = gp_Vec(args[0].wrapped.XYZ())
elif isinstance(args[0], (tuple, list)):
arg = args[0]
if len(arg) == 3:
f_v = gp_Vec(*arg)
elif len(arg) == 2:
f_v = gp_Vec(*arg, 0)
elif isinstance(args[0], (gp_Vec, gp_Pnt, gp_Dir)):
f_v = gp_Vec(args[0].XYZ())
elif isinstance(args[0], gp_XYZ):
f_v = gp_Vec(args[0])
else:
raise TypeError("Expected three floats, OCC gp_, or 3-tuple")
elif len(args) == 0:
f_v = gp_Vec(0, 0, 0)
else:
raise TypeError("Expected three floats, OCC gp_, or 3-tuple")
self._wrapped = f_v
def __iter__(self):
"""Initialize to beginning"""
self.vector_index = 0
return self
def __next__(self):
"""return the next value"""
if self.vector_index == 0:
self.vector_index += 1
value = self.X
elif self.vector_index == 1:
self.vector_index += 1
value = self.Y
elif self.vector_index == 2:
self.vector_index += 1
value = self.Z
else:
raise StopIteration
return value
@property
def X(self) -> float:
"""Get x value"""
return self.wrapped.X()
@X.setter
def X(self, value: float) -> None:
"""Set x value"""
self.wrapped.SetX(value)
@property
def Y(self) -> float:
"""Get y value"""
return self.wrapped.Y()
@Y.setter
def Y(self, value: float) -> None:
"""Set y value"""
self.wrapped.SetY(value)
@property
def Z(self) -> float:
"""Get z value"""
return self.wrapped.Z()
@Z.setter
def Z(self, value: float) -> None:
"""Set z value"""
self.wrapped.SetZ(value)
@property
def wrapped(self) -> gp_Vec:
"""OCCT object"""
return self._wrapped
def to_tuple(self) -> tuple[float, float, float]:
"""Return tuple equivalent"""
return (self.X, self.Y, self.Z)
@property
def length(self) -> float:
"""Vector length"""
return self.wrapped.Magnitude()
def to_vertex(self) -> Vertex:
"""Convert to Vector to Vertex
Returns:
Vertex equivalent of Vector
"""
return Vertex(*self.to_tuple())
def cross(self, vec: Vector) -> Vector:
"""Mathematical cross function"""
return Vector(self.wrapped.Crossed(vec.wrapped))
def dot(self, vec: Vector) -> float:
"""Mathematical dot function"""
return self.wrapped.Dot(vec.wrapped)
def sub(self, vec: VectorLike) -> Vector:
"""Mathematical subtraction function"""
if isinstance(vec, Vector):
result = Vector(self.wrapped.Subtracted(vec.wrapped))
elif isinstance(vec, tuple):
result = Vector(self.wrapped.Subtracted(Vector(vec).wrapped))
else:
raise ValueError("Only Vectors or tuples can be subtracted from Vectors")
return result
def __sub__(self, vec: Vector) -> Vector:
"""Mathematical subtraction function"""
return self.sub(vec)
def add(self, vec: VectorLike) -> Vector:
"""Mathematical addition function"""
if isinstance(vec, Vector):
result = Vector(self.wrapped.Added(vec.wrapped))
elif isinstance(vec, tuple):
result = Vector(self.wrapped.Added(Vector(vec).wrapped))
else:
raise ValueError("Only Vectors or tuples can be added to Vectors")
return result
def __add__(self, vec: Vector) -> Vector:
"""Mathematical addition function"""
return self.add(vec)
def multiply(self, scale: float) -> Vector:
"""Mathematical multiply function"""
return Vector(self.wrapped.Multiplied(scale))
def __mul__(self, scale: float) -> Vector:
"""Mathematical multiply function"""
return self.multiply(scale)
def __truediv__(self, denom: float) -> Vector:
"""Mathematical division function"""
return self.multiply(1.0 / denom)
def __rmul__(self, scale: float) -> Vector:
"""Mathematical multiply function"""
return self.multiply(scale)
def normalized(self) -> Vector:
"""Scale to length of 1"""
return Vector(self.wrapped.Normalized())
def reverse(self) -> Vector:
"""Return a vector with the same magnitude but pointing in the opposite direction"""
return self * -1.0
def center(self) -> Vector:
"""center
Returns:
The center of myself is myself.
Provided so that vectors, vertices, and other shapes all support a
common interface, when center() is requested for all objects on the
stack.
"""
return self
def get_angle(self, vec: Vector) -> float:
"""Unsigned angle between vectors"""
return self.wrapped.Angle(vec.wrapped) * RAD2DEG
def get_signed_angle(self, vec: Vector, normal: Vector = None) -> float:
"""Signed Angle Between Vectors
Return the signed angle in degrees between two vectors with the given normal
based on this math: angle = atan2((Va × Vb) ⋅ Vn, Va ⋅ Vb)
Args:
v (Vector): Second Vector
normal (Vector, optional): normal direction. Defaults to None.
Returns:
float: Angle between vectors
"""
if normal is None:
gp_normal = gp_Vec(0, 0, -1)
else:
gp_normal = normal.wrapped
return self.wrapped.AngleWithRef(vec.wrapped, gp_normal) * RAD2DEG
def project_to_line(self, line: Vector) -> Vector:
"""Returns a new vector equal to the projection of this Vector onto the line
represented by Vector <line>
Args:
line (Vector): project to this line
Returns:
Vector: Returns the projected vector.
"""
line_length = line.length
return line * (self.dot(line) / (line_length * line_length))
def distance_to_plane(self):
"""Minimum distance between vector and plane"""
raise NotImplementedError("Have not needed this yet, but OCCT supports it!")
def project_to_plane(self, plane: Plane) -> Vector:
"""Vector is projected onto the plane provided as input.
Args:
args: Plane object
Returns the projected vector.
plane: Plane:
Returns:
"""
base = plane.origin
normal = plane.z_dir
return self - normal * (((self - base).dot(normal)) / normal.length**2)
def __neg__(self) -> Vector:
"""Flip direction of vector"""
return self * -1
def __abs__(self) -> float:
"""Vector length"""
return self.length
def __repr__(self) -> str:
"""Display vector"""
return "Vector: " + str((self.X, self.Y, self.Z))
def __str__(self) -> str:
"""Display vector"""
return "Vector: " + str((self.X, self.Y, self.Z))
def __eq__(self, other: Vector) -> bool: # type: ignore[override]
"""Vectors equal"""
return self.wrapped.IsEqual(other.wrapped, 0.00001, 0.00001)
def __copy__(self) -> Vector:
"""Return copy of self"""
return Vector(self.X, self.Y, self.Z)
def __deepcopy__(self, _memo) -> Vector:
"""Return deepcopy of self"""
return Vector(self.X, self.Y, self.Z)
def to_pnt(self) -> gp_Pnt:
"""Convert to OCCT gp_Pnt object"""
return gp_Pnt(self.wrapped.XYZ())
def to_dir(self) -> gp_Dir:
"""Convert to OCCT gp_Dir object"""
return gp_Dir(self.wrapped.XYZ())
def transform(self, affine_transform: Matrix) -> Vector:
"""Apply affine transformation"""
# to gp_Pnt to obey cq transformation convention (in OCP.vectors do not translate)
pnt = self.to_pnt()
pnt_t = pnt.Transformed(affine_transform.wrapped.Trsf())
return Vector(gp_Vec(pnt_t.XYZ()))
def rotate(self, axis: Axis, angle: float) -> Vector:
"""Rotate about axis
Rotate about the given Axis by an angle in degrees
Args:
axis (Axis): Axis of rotation
angle (float): angle in degrees
Returns:
Vector: rotated vector
"""
return Vector(self.wrapped.Rotated(axis.wrapped, pi * angle / 180))
#:TypeVar("VectorLike"): Tuple of float or Vector defining a position in space
VectorLike = Union[Vector, tuple[float, float], tuple[float, float, float]]
class Axis:
"""Axis
Axis defined by point and direction
Args:
origin (VectorLike): start point
direction (VectorLike): direction
"""
@classmethod
@property
def X(cls) -> Axis:
"""X Axis"""
return Axis((0, 0, 0), (1, 0, 0))
@classmethod
@property
def Y(cls) -> Axis:
"""Y Axis"""
return Axis((0, 0, 0), (0, 1, 0))
@classmethod
@property
def Z(cls) -> Axis:
"""Z Axis"""
return Axis((0, 0, 0), (0, 0, 1))
def __init__(self, origin: VectorLike, direction: VectorLike):
self.wrapped = gp_Ax1(
Vector(origin).to_pnt(), gp_Dir(*Vector(direction).normalized().to_tuple())
)
self.position = Vector(
self.wrapped.Location().X(),
self.wrapped.Location().Y(),
self.wrapped.Location().Z(),
)
self.direction = Vector(
self.wrapped.Direction().X(),
self.wrapped.Direction().Y(),
self.wrapped.Direction().Z(),
)
@classmethod
def from_occt(cls, axis: gp_Ax1) -> Axis:
"""Create an Axis instance from the occt object"""
position = (
axis.Location().X(),
axis.Location().Y(),
axis.Location().Z(),
)
direction = (
axis.Direction().X(),
axis.Direction().Y(),
axis.Direction().Z(),
)
return Axis(position, direction)
def __copy__(self) -> Axis:
"""Return copy of self"""
return Axis(self.position, self.direction)
def __deepcopy__(self, _memo) -> Axis:
"""Return deepcopy of self"""
return Axis(self.position, self.direction)
def __repr__(self) -> str:
"""Display self"""
return f"({self.position.to_tuple()},{self.direction.to_tuple()})"
def __str__(self) -> str:
"""Display self"""
return f"Axis: ({self.position.to_tuple()},{self.direction.to_tuple()})"
def located(self, new_location: Location):
"""relocates self to a new location possibly changing position and direction"""
new_gp_ax1 = self.wrapped.Transformed(new_location.wrapped.Transformation())
return Axis.from_occt(new_gp_ax1)
def to_location(self) -> Location:
"""Return self as Location"""
return Location(Plane(origin=self.position, z_dir=self.direction))
def to_plane(self) -> Plane:
"""Return self as Plane"""
return Plane(origin=self.position, z_dir=self.direction)
def is_coaxial(
self,
other: Axis,
angular_tolerance: float = 1e-5,
linear_tolerance: float = 1e-5,
) -> bool:
"""are axes coaxial
True if the angle between self and other is lower or equal to angular_tolerance and
the distance between self and other is lower or equal to linear_tolerance.
Args:
other (Axis): axis to compare to
angular_tolerance (float, optional): max angular deviation. Defaults to 1e-5.
linear_tolerance (float, optional): max linear deviation. Defaults to 1e-5.
Returns:
bool: axes are coaxial
"""
return self.wrapped.IsCoaxial(
other.wrapped, angular_tolerance * (pi / 180), linear_tolerance
)
def is_normal(self, other: Axis, angular_tolerance: float = 1e-5) -> bool:
"""are axes normal
Returns True if the direction of this and another axis are normal to each other. That is,
if the angle between the two axes is equal to 90° within the angular_tolerance.
Args:
other (Axis): axis to compare to
angular_tolerance (float, optional): max angular deviation. Defaults to 1e-5.
Returns:
bool: axes are normal
"""
return self.wrapped.IsNormal(other.wrapped, angular_tolerance * (pi / 180))
def is_opposite(self, other: Axis, angular_tolerance: float = 1e-5) -> bool:
"""are axes opposite
Returns True if the direction of this and another axis are parallel with
opposite orientation. That is, if the angle between the two axes is equal
to 180° within the angular_tolerance.
Args:
other (Axis): axis to compare to
angular_tolerance (float, optional): max angular deviation. Defaults to 1e-5.
Returns:
bool: axes are opposite
"""
return self.wrapped.IsOpposite(other.wrapped, angular_tolerance * (pi / 180))
def is_parallel(self, other: Axis, angular_tolerance: float = 1e-5) -> bool:
"""are axes parallel
Returns True if the direction of this and another axis are parallel with same
orientation or opposite orientation. That is, if the angle between the two axes is
equal to 0° or 180° within the angular_tolerance.
Args:
other (Axis): axis to compare to
angular_tolerance (float, optional): max angular deviation. Defaults to 1e-5.
Returns:
bool: axes are parallel
"""
return self.wrapped.IsParallel(other.wrapped, angular_tolerance * (pi / 180))
def angle_between(self, other: Axis) -> float:
"""calculate angle between axes
Computes the angular value, in degrees, between the direction of self and other
between 0° and 360°.
Args:
other (Axis): axis to compare to
Returns:
float: angle between axes
"""
return self.wrapped.Angle(other.wrapped) * RAD2DEG
def reverse(self) -> Axis:
"""Return a copy of self with the direction reversed"""
return Axis.from_occt(self.wrapped.Reversed())
def __neg__(self) -> Axis:
"""Return a copy of self with the direction reversed"""
return self.reverse()
class BoundBox:
"""A BoundingBox for a Shape"""
def __init__(self, bounding_box: Bnd_Box) -> None:
self.wrapped: Bnd_Box = bounding_box
x_min, y_min, z_min, x_max, y_max, z_max = bounding_box.Get()
self.min = Vector(x_min, y_min, z_min)
self.max = Vector(x_max, y_max, z_max)
self.size = Vector(x_max - x_min, y_max - y_min, z_max - z_min)
@property
def diagonal(self) -> float:
"""body diagonal length (i.e. object maximum size)"""
return self.wrapped.SquareExtent() ** 0.5
def __repr__(self):
"""Display bounding box parameters"""
return (
f"bbox: {self.min.X} <= x <= {self.max.X}, {self.min.Y} <= y <= {self.max.Y}, "
f"{self.min.Z} <= z <= {self.max.Z}"
)
def center(self) -> Vector:
"""Return center of the bounding box"""
return (self.min + self.max) / 2
def add(
self,
obj: Union[tuple[float, float, float], Vector, BoundBox],
tol: float = None,
) -> BoundBox:
"""Returns a modified (expanded) bounding box
obj can be one of several things:
1. a 3-tuple corresponding to x,y, and z amounts to add
2. a vector, containing the x,y,z values to add
3. another bounding box, where a new box will be created that
encloses both.
This bounding box is not changed.
Args:
obj: Union[tuple[float:
float:
float]:
Vector:
BoundBox]:
tol: float: (Default value = None)
Returns:
"""
tol = TOL if tol is None else tol # tol = TOL (by default)
tmp = Bnd_Box()
tmp.SetGap(tol)
tmp.Add(self.wrapped)
if isinstance(obj, tuple):
tmp.Update(*obj)
elif isinstance(obj, Vector):
tmp.Update(*obj.to_tuple())
elif isinstance(obj, BoundBox):
tmp.Add(obj.wrapped)
return BoundBox(tmp)
@staticmethod
def find_outside_box_2d(bb1: BoundBox, bb2: BoundBox) -> Optional[BoundBox]:
"""Compares bounding boxes
Compares bounding boxes. Returns none if neither is inside the other.
Returns the outer one if either is outside the other.
BoundBox.is_inside works in 3d, but this is a 2d bounding box, so it
doesn't work correctly plus, there was all kinds of rounding error in
the built-in implementation i do not understand.
Args:
bb1: BoundBox:
bb2: BoundBox:
Returns:
"""
if (
bb1.min.X < bb2.min.X
and bb1.max.X > bb2.max.X
and bb1.min.Y < bb2.min.Y
and bb1.max.Y > bb2.max.Y
):
result = bb1
elif (
bb2.min.X < bb1.min.X
and bb2.max.X > bb1.max.X
and bb2.min.Y < bb1.min.Y
and bb2.max.Y > bb1.max.Y
):
result = bb2
else:
result = None
return result
@classmethod
def _from_topo_ds(
cls,
shape: TopoDS_Shape,
tol: float = None,
optimal: bool = True,
):
"""Constructs a bounding box from a TopoDS_Shape
Args:
cls: Type[BoundBox]:
shape: TopoDS_Shape:
tol: float: (Default value = None)
optimal: bool: (Default value = True)
Returns:
"""
tol = TOL if tol is None else tol # tol = TOL (by default)
bbox = Bnd_Box()
if optimal:
BRepBndLib.AddOptimal_s(
shape, bbox
) # this is 'exact' but expensive - not yet wrapped by PythonOCC
else:
mesh = BRepMesh_IncrementalMesh(shape, tol, True)
mesh.Perform()
# this is adds +margin but is faster
BRepBndLib.Add_s(shape, bbox, True)
return cls(bbox)
def is_inside(self, second_box: BoundBox) -> bool:
"""Is the provided bounding box inside this one?
Args:
b2: BoundBox:
Returns:
"""
return not (
second_box.min.X > self.min.X
and second_box.min.Y > self.min.Y
and second_box.min.Z > self.min.Z
and second_box.max.X < self.max.X
and second_box.max.Y < self.max.Y
and second_box.max.Z < self.max.Z
)
def to_solid(self) -> Solid:
"""A box of the same dimensions and location"""
return Solid.make_box(*self.size).locate(Location(self.min))
class Color:
"""
Color object based on OCCT Quantity_ColorRGBA.
"""
@overload
def __init__(self, name: str):
"""Color from name
`OCCT Color Names
<https://dev.opencascade.org/doc/refman/html/_quantity___name_of_color_8hxx.html>`_
Args:
name (str): color, e.g. "blue"
"""
@overload
def __init__(self, red: float, green: float, blue: float, alpha: float = 0.0):
"""Color from RGBA and Alpha values
Args:
red (float): 0.0 <= red <= 1.0
green (float): 0.0 <= green <= 1.0
blue (float): 0.0 <= blue <= 1.0
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 0.0.
"""
def __init__(self, *args, **kwargs):
if len(args) == 1:
self.wrapped = Quantity_ColorRGBA()
exists = Quantity_ColorRGBA.ColorFromName_s(args[0], self.wrapped)
if not exists:
raise ValueError(f"Unknown color name: {args[0]}")
elif len(args) == 3:
red, green, blue = args
self.wrapped = Quantity_ColorRGBA(red, green, blue, 1)
if kwargs.get("a"):
self.wrapped.SetAlpha(kwargs.get("a"))
elif len(args) == 4:
red, green, blue, alpha = args
self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha)
else:
raise ValueError(f"Unsupported arguments: {args}, {kwargs}")
def to_tuple(self) -> Tuple[float, float, float, float]:
"""
Convert Color to RGB tuple.
"""
alpha = self.wrapped.Alpha()
rgb = self.wrapped.GetRGB()
return (rgb.Red(), rgb.Green(), rgb.Blue(), alpha)
class Location:
"""Location in 3D space. Depending on usage can be absolute or relative.
This class wraps the TopLoc_Location class from OCCT. It can be used to move Shape
objects in both relative and absolute manner. It is the preferred type to locate objects
in CQ.
Args:
Returns:
"""
@property
def position(self) -> Vector:
"""Extract Position component of self
Returns:
Vector: Position part of Location
"""
return Vector(self.to_tuple()[0])
@position.setter
def position(self, value: VectorLike):
"""Set the position component of this Location
Args:
value (VectorLike): New position
"""
trsf_position = gp_Trsf()
trsf_position.SetTranslationPart(Vector(value).wrapped)
trsf_orientation = gp_Trsf()
trsf_orientation.SetRotation(self.wrapped.Transformation().GetRotation())
self.wrapped = TopLoc_Location(trsf_position * trsf_orientation)
@property
def orientation(self) -> Vector:
"""Extract orientation/rotation component of self
Returns:
Vector: orientation part of Location
"""
return Vector(self.to_tuple()[1])
@orientation.setter
def orientation(self, rotation: VectorLike):
"""Set the orientation component of this Location
Args:
rotation (VectorLike): Intrinsic XYZ angles in degrees
"""
position_xyz = self.wrapped.Transformation().TranslationPart()
trsf_position = gp_Trsf()
trsf_position.SetTranslationPart(
gp_Vec(position_xyz.X(), position_xyz.Y(), position_xyz.Z())
)
rotation = [radians(a) for a in rotation]
quaternion = gp_Quaternion()
quaternion.SetEulerAngles(gp_EulerSequence.gp_Intrinsic_XYZ, *rotation)
trsf_orientation = gp_Trsf()
trsf_orientation.SetRotation(quaternion)
self.wrapped = TopLoc_Location(trsf_position * trsf_orientation)
@overload
def __init__(self): # pragma: no cover
"""Empty location with not rotation or translation with respect to the original location."""
@overload
def __init__(self, location: Location): # pragma: no cover
"""Location with another given location."""
@overload
def __init__(self, translation: VectorLike, angle: float = 0): # pragma: no cover
"""Location with translation with respect to the original location.
If angle != 0 then the location includes a rotation around z-axis by angle"""
@overload
def __init__(
self, translation: VectorLike, rotation: RotationLike = None
): # pragma: no cover
"""Location with translation with respect to the original location.
If rotation is not None then the location includes the rotation (see also Rotation class)
"""
@overload
def __init__(self, plane: Plane): # pragma: no cover
"""Location corresponding to the location of the Plane."""
@overload
def __init__(self, plane: Plane, plane_offset: VectorLike): # pragma: no cover
"""Location corresponding to the angular location of the Plane with
translation plane_offset."""
@overload
def __init__(self, top_loc: TopLoc_Location): # pragma: no cover
"""Location wrapping the low-level TopLoc_Location object t"""
@overload
def __init__(self, gp_trsf: gp_Trsf): # pragma: no cover
"""Location wrapping the low-level gp_Trsf object t"""
@overload
def __init__(
self, translation: VectorLike, axis: VectorLike, angle: float
): # pragma: no cover
"""Location with translation t and rotation around axis by angle
with respect to the original location."""
def __init__(self, *args):
transform = gp_Trsf()
if len(args) == 0:
pass
elif len(args) == 1:
translation = args[0]
if isinstance(translation, (Vector, tuple)):
transform.SetTranslationPart(Vector(translation).wrapped)
elif isinstance(translation, Plane):
coordinate_system = gp_Ax3(
translation._origin.to_pnt(),
translation.z_dir.to_dir(),
translation.x_dir.to_dir(),
)
transform.SetTransformation(coordinate_system)
transform.Invert()
elif isinstance(args[0], Location):
self.wrapped = translation.wrapped
return
elif isinstance(translation, TopLoc_Location):
self.wrapped = translation
return
elif isinstance(translation, gp_Trsf):
transform = translation
else:
raise TypeError("Unexpected parameters")
elif len(args) == 2:
if isinstance(args[0], (Vector, tuple)):
if isinstance(args[1], (Vector, tuple)):
rotation = [radians(a) for a in args[1]]
quaternion = gp_Quaternion()
quaternion.SetEulerAngles(
gp_EulerSequence.gp_Intrinsic_XYZ, *rotation
)
transform.SetRotation(quaternion)
elif isinstance(args[0], (Vector, tuple)) and isinstance(
args[1], (int, float)
):
angle = radians(args[1])
quaternion = gp_Quaternion()
quaternion.SetEulerAngles(
gp_EulerSequence.gp_Intrinsic_XYZ, 0, 0, angle
)
transform.SetRotation(quaternion)
# set translation part after setting rotation (if exists)
transform.SetTranslationPart(Vector(args[0]).wrapped)
else:
translation, origin = args
coordinate_system = gp_Ax3(
Vector(origin).to_pnt(),
translation.z_dir.to_dir(),
translation.x_dir.to_dir(),
)
transform.SetTransformation(coordinate_system)
transform.Invert()
else:
translation, axis, angle = args
transform.SetRotation(
gp_Ax1(Vector().to_pnt(), Vector(axis).to_dir()), angle * pi / 180.0
)
transform.SetTranslationPart(Vector(translation).wrapped)
self.wrapped = TopLoc_Location(transform)
def inverse(self) -> Location:
"""Inverted location"""
return Location(self.wrapped.Inverted())
def __copy__(self) -> Location:
"""Lib/copy.py shallow copy"""
return Location(self.wrapped.Transformation())
def __deepcopy__(self, _memo) -> Location:
"""Lib/copy.py deep copy"""
return Location(self.wrapped.Transformation())
def __mul__(self, other: Location) -> Location:
"""Combine locations"""
return Location(self.wrapped * other.wrapped)
def __pow__(self, exponent: int) -> Location:
return Location(self.wrapped.Powered(exponent))
def to_axis(self) -> Axis:
"""Convert the location into an Axis"""
return Axis.Z.located(self)
def to_tuple(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Convert the location to a translation, rotation tuple."""
transformation = self.wrapped.Transformation()
trans = transformation.TranslationPart()
rot = transformation.GetRotation()
rv_trans = (trans.X(), trans.Y(), trans.Z())
rv_rot = [
degrees(a) for a in rot.GetEulerAngles(gp_EulerSequence.gp_Intrinsic_XYZ)
]
return rv_trans, tuple(rv_rot)
def __repr__(self):
"""To String
Convert Location to String for display
Returns:
Location as String
"""
position_str = ", ".join((f"{v:.2f}" for v in self.to_tuple()[0]))
orientation_str = ", ".join((f"{v:.2f}" for v in self.to_tuple()[1]))
return f"(p=({position_str}), o=({orientation_str}))"
def __str__(self):
"""To String
Convert Location to String for display
Returns:
Location as String
"""
position_str = ", ".join((f"{v:.2f}" for v in self.to_tuple()[0]))
orientation_str = ", ".join((f"{v:.2f}" for v in self.to_tuple()[1]))
return f"Location: (position=({position_str}), orientation=({orientation_str}))"
class Rotation(Location):
"""Subclass of Location used only for object rotation"""
def __init__(self, about_x: float = 0, about_y: float = 0, about_z: float = 0):
self.about_x = about_x
self.about_y = about_y
self.about_z = about_z
quaternion = gp_Quaternion()
quaternion.SetEulerAngles(
gp_EulerSequence.gp_Intrinsic_XYZ,
radians(about_x),
radians(about_y),
radians(about_z),
)
transformation = gp_Trsf()
transformation.SetRotationPart(quaternion)
super().__init__(transformation)
#:TypeVar("RotationLike"): Three tuple of angles about x, y, z or Rotation
RotationLike = Union[tuple[float, float, float], Rotation]
class Matrix:
"""A 3d , 4x4 transformation matrix.
Used to move geometry in space.
The provided "matrix" parameter may be None, a gp_GTrsf, or a nested list of
values.
If given a nested list, it is expected to be of the form:
[[m11, m12, m13, m14],
[m21, m22, m23, m24],
[m31, m32, m33, m34]]
A fourth row may be given, but it is expected to be: [0.0, 0.0, 0.0, 1.0]
since this is a transform matrix.
Args:
Returns:
"""
@overload
def __init__(self) -> None: # pragma: no cover
...
@overload
def __init__(self, matrix: Union[gp_GTrsf, gp_Trsf]) -> None: # pragma: no cover
...
@overload
def __init__(self, matrix: Sequence[Sequence[float]]) -> None: # pragma: no cover
...
def __init__(self, matrix=None):
if matrix is None:
self.wrapped = gp_GTrsf()
elif isinstance(matrix, gp_GTrsf):
self.wrapped = matrix
elif isinstance(matrix, gp_Trsf):
self.wrapped = gp_GTrsf(matrix)
elif isinstance(matrix, (list, tuple)):
# Validate matrix size & 4x4 last row value
valid_sizes = all(
(isinstance(row, (list, tuple)) and (len(row) == 4)) for row in matrix
) and len(matrix) in (3, 4)
if not valid_sizes:
raise TypeError(
f"Matrix constructor requires 2d list of 4x3 or 4x4, but got: {repr(matrix)}"
)
if (len(matrix) == 4) and (tuple(matrix[3]) != (0, 0, 0, 1)):
raise ValueError(
f"Expected the last row to be [0,0,0,1], but got: {repr(matrix[3])}"
)
# Assign values to matrix
self.wrapped = gp_GTrsf()
for i, row in enumerate(matrix[:3]):
for j, element in enumerate(row):
self.wrapped.SetValue(i + 1, j + 1, element)
else:
raise TypeError(f"Invalid param to matrix constructor: {matrix}")
def rotate(self, axis: Axis, angle: float):
"""General rotate about axis"""
new = gp_Trsf()
new.SetRotation(axis.wrapped, angle)
self.wrapped = self.wrapped * gp_GTrsf(new)
def inverse(self) -> Matrix:
"""Invert Matrix"""
return Matrix(self.wrapped.Inverted())
@overload
def multiply(self, other: Vector) -> Vector: # pragma: no cover
...
@overload
def multiply(self, other: Matrix) -> Matrix: # pragma: no cover
...
def multiply(self, other):
"""Matrix multiplication"""
if isinstance(other, Vector):
return other.transform(self)
return Matrix(self.wrapped.Multiplied(other.wrapped))
def transposed_list(self) -> Sequence[float]:
"""Needed by the cqparts gltf exporter"""
trsf = self.wrapped
data = [[trsf.Value(i, j) for j in range(1, 5)] for i in range(1, 4)] + [
[0.0, 0.0, 0.0, 1.0]
]
return [data[j][i] for i in range(4) for j in range(4)]
def __copy__(self) -> Matrix:
"""Return copy of self"""
return Matrix(self.wrapped.Trsf())
def __deepcopy__(self, _memo) -> Matrix:
"""Return deepcopy of self"""
return Matrix(self.wrapped.Trsf())
def __getitem__(self, row_col: tuple[int, int]) -> float:
"""Provide Matrix[r, c] syntax for accessing individual values. The row
and column parameters start at zero, which is consistent with most
python libraries, but is counter to gp_GTrsf(), which is 1-indexed.
"""
if not isinstance(row_col, tuple) or (len(row_col) != 2):
raise IndexError("Matrix subscript must provide (row, column)")
(row, col) = row_col
if not ((0 <= row <= 3) and (0 <= col <= 3)):
raise IndexError(f"Out of bounds access into 4x4 matrix: {repr(row_col)}")
if row < 3:
return_value = self.wrapped.Value(row + 1, col + 1)
else:
# gp_GTrsf doesn't provide access to the 4th row because it has
# an implied value as below:
return_value = [0.0, 0.0, 0.0, 1.0][col]
return return_value
def __repr__(self) -> str:
"""
Generate a valid python expression representing this Matrix
"""
matrix_transposed = self.transposed_list()
matrix_str = ",\n ".join(str(matrix_transposed[i::4]) for i in range(4))
return f"Matrix([{matrix_str}])"
class Mixin1D:
"""Methods to add to the Edge and Wire classes"""
def _bounds(self) -> Tuple[float, float]:
"""Curve bounds"""
curve = self._geom_adaptor()
return curve.FirstParameter(), curve.LastParameter()
def start_point(self) -> Vector:
"""The start point of this edge
Note that circles may have identical start and end points.
"""
curve = self._geom_adaptor()
umin = curve.FirstParameter()
return Vector(curve.Value(umin))
def end_point(self) -> Vector:
"""The end point of this edge.
Note that circles may have identical start and end points.
"""
curve = self._geom_adaptor()
umax = curve.LastParameter()
return Vector(curve.Value(umax))
def param_at(self, distance: float) -> float:
"""Parameter along a curve
Compute parameter value at the specified normalized distance.
Args:
d (float): normalized distance (0.0 >= d >= 1.0)
Returns:
float: parameter value
"""
curve = self._geom_adaptor()
length = GCPnts_AbscissaPoint.Length_s(curve)
return GCPnts_AbscissaPoint(
curve, length * distance, curve.FirstParameter()
).Parameter()
def tangent_at(
self,
location_param: float = 0.5,
position_mode: PositionMode = PositionMode.LENGTH,
) -> Vector:
"""Tangent At
Compute tangent vector at the specified location.
Args:
location_param (float, optional): distance or parameter value. Defaults to 0.5.
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.LENGTH.
Returns:
Vector: Tangent
"""
curve = self._geom_adaptor()
tmp = gp_Pnt()
res = gp_Vec()
if position_mode == PositionMode.LENGTH:
param = self.param_at(location_param)
else:
param = location_param
curve.D1(param, tmp, res)
return Vector(gp_Dir(res))
def normal(self) -> Vector:
"""Calculate the normal Vector. Only possible for planar curves.
:return: normal vector
Args:
Returns:
"""
curve = self._geom_adaptor()
gtype = self.geom_type()
if gtype == "CIRCLE":
circ = curve.Circle()
return_value = Vector(circ.Axis().Direction())
elif gtype == "ELLIPSE":
ell = curve.Ellipse()
return_value = Vector(ell.Axis().Direction())
else:
find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True)
surf = find_surface.Surface()
if isinstance(surf, Geom_Plane):
pln = surf.Pln()
return_value = Vector(pln.Axis().Direction())
else:
raise ValueError("Normal not defined")
return return_value
def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector:
"""Center of object
Return the center based on center_of
Args:
center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY.
Returns:
Vector: center
"""
if center_of == CenterOf.GEOMETRY:
middle = self.position_at(0.5)
elif center_of == CenterOf.MASS:
properties = GProp_GProps()
BRepGProp.LinearProperties_s(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
elif center_of == CenterOf.BOUNDING_BOX:
middle = self.bounding_box().center()
return middle
@property
def length(self) -> float:
"""Edge or Wire length"""
return GCPnts_AbscissaPoint.Length_s(self._geom_adaptor())
@property
def radius(self) -> float:
"""Calculate the radius.
Note that when applied to a Wire, the radius is simply the radius of the first edge.
Args:
Returns:
radius
Raises:
ValueError: if kernel can not reduce the shape to a circular edge
"""
geom = self._geom_adaptor()
try:
circ = geom.Circle()
except (Standard_NoSuchObject, Standard_Failure) as err:
raise ValueError("Shape could not be reduced to a circle") from err
return circ.Radius()
def is_closed(self) -> bool:
"""Are the start and end points equal?"""
return BRep_Tool.IsClosed_s(self.wrapped)
def position_at(
self, distance: float, position_mode: PositionMode = PositionMode.LENGTH
) -> Vector:
"""Position At
Generate a position along the underlying curve.
Args:
distance (float): distance or parameter value
position_mode (PositionMode, optional): position calculation mode. Defaults to
PositionMode.LENGTH.
Returns:
Vector: position on the underlying curve
"""
curve = self._geom_adaptor()
if position_mode == PositionMode.LENGTH:
param = self.param_at(distance)
else:
param = distance
return Vector(curve.Value(param))
def positions(
self,
distances: Iterable[float],
position_mode: PositionMode = PositionMode.LENGTH,
) -> list[Vector]:
"""Positions along curve
Generate positions along the underlying curve
Args:
distances (Iterable[float]): distance or parameter values
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.LENGTH.
Returns:
list[Vector]: positions along curve
"""
return [self.position_at(d, position_mode) for d in distances]
def location_at(
self,
distance: float,
position_mode: PositionMode = PositionMode.LENGTH,
frame_method: FrameMethod = FrameMethod.FRENET,
planar: bool = False,
) -> Location:
"""Locations along curve
Generate a location along the underlying curve.
Args:
distance (float): distance or parameter value
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.LENGTH.
frame_method (FrameMethod, optional): moving frame calculation method.
Defaults to FrameMethod.FRENET.
planar (bool, optional): planar mode. Defaults to False.
Returns:
Location: A Location object representing local coordinate system
at the specified distance.
"""
curve = self._geom_adaptor()
if position_mode == PositionMode.LENGTH:
param = self.param_at(distance)
else:
param = distance
law: GeomFill_TrihedronLaw
if frame_method == FrameMethod.FRENET:
law = GeomFill_Frenet()
else:
law = GeomFill_CorrectedFrenet()
law.SetCurve(curve)
tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec()
law.D0(param, tangent, normal, binormal)
pnt = curve.Value(param)
transformation = gp_Trsf()
if planar:
transformation.SetTransformation(
gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3()
)
else:
transformation.SetTransformation(
gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
)
return Location(TopLoc_Location(transformation))
def locations(
self,
distances: Iterable[float],
position_mode: PositionMode = PositionMode.LENGTH,
frame_method: FrameMethod = FrameMethod.FRENET,
planar: bool = False,
) -> list[Location]:
"""Locations along curve
Generate location along the curve
Args:
distances (Iterable[float]): distance or parameter values
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.LENGTH.
frame_method (FrameMethod, optional): moving frame calculation method.
Defaults to FrameMethod.FRENET.
planar (bool, optional): planar mode. Defaults to False.
Returns:
list[Location]: A list of Location objects representing local coordinate
systems at the specified distances.
"""
return [
self.location_at(d, position_mode, frame_method, planar) for d in distances
]
def __matmul__(self: Union[Edge, Wire], position: float):
"""Position on wire operator"""
return self.position_at(position)
def __mod__(self: Union[Edge, Wire], position: float):
"""Tangent on wire operator"""
return self.tangent_at(position)
def project(
self, face: Face, direction: VectorLike, closest: bool = True
) -> Union[Mixin1D, list[Mixin1D]]:
"""Project onto a face along the specified direction
Args:
face: Face:
direction: VectorLike:
closest: bool: (Default value = True)
Returns:
"""
bldr = BRepProj_Projection(
self.wrapped, face.wrapped, Vector(direction).to_dir()
)
shapes = Compound(bldr.Shape())
# select the closest projection if requested
return_value: Union[Mixin1D, list[Mixin1D]]
if closest:
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
min_dist = inf
for shape in shapes:
dist_calc.LoadS2(shape.wrapped)
dist_calc.Perform()
dist = dist_calc.Value()
if dist < min_dist:
min_dist = dist
return_value = tcast(Mixin1D, shape)
else:
return_value = [tcast(Mixin1D, shape) for shape in shapes]
return return_value
class Mixin3D:
"""Additional methods to add to 3D Shape classes"""
def fillet(self, radius: float, edge_list: Iterable[Edge]):
"""Fillet
Fillets the specified edges of this solid.
Args:
radius (float): float > 0, the radius of the fillet
edge_list (Iterable[Edge]): a list of Edge objects, which must belong to this solid
Returns:
Any: Filleted solid
"""
native_edges = [e.wrapped for e in edge_list]
fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped)
for native_edge in native_edges:
fillet_builder.Add(radius, native_edge)
return self.__class__(fillet_builder.Shape())
def max_fillet(
self,
edge_list: Iterable[Edge],
tolerance=0.1,
max_iterations: int = 10,
) -> float:
"""Find Maximum Fillet Size
Find the largest fillet radius for the given Shape and edges with a
recursive binary search.
Example:
max_fillet_radius = my_shape.max_fillet(shape_edges)
max_fillet_radius = my_shape.max_fillet(shape_edges, tolerance=0.5, max_iterations=8)
Args:
edge_list (Iterable[Edge]): a sequence of Edge objects, which must belong to this solid
tolerance (float, optional): maximum error from actual value. Defaults to 0.1.
max_iterations (int, optional): maximum number of recursive iterations. Defaults to 10.
Raises:
RuntimeError: failed to find the max value
ValueError: the provided Shape is invalid
Returns:
float: maximum fillet radius
"""
def __max_fillet(window_min: float, window_max: float, current_iteration: int):
window_mid = (window_min + window_max) / 2
if current_iteration == max_iterations:
raise RuntimeError(
f"Failed to find the max value within {tolerance} in {max_iterations}"
)
# Do these numbers work? - if not try with the smaller window
try:
if not self.fillet(window_mid, edge_list).is_valid():
raise fillet_exception
except fillet_exception:
return __max_fillet(window_min, window_mid, current_iteration + 1)
# These numbers work, are they close enough? - if not try larger window
if window_mid - window_min <= tolerance:
return_value = window_mid
else:
return_value = __max_fillet(
window_mid, window_max, current_iteration + 1
)
return return_value
if not self.is_valid():
raise ValueError("Invalid Shape")
# Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform
# specific exceptions are required.
if platform.system() == "Darwin":
fillet_exception = Standard_Failure
else:
fillet_exception = StdFail_NotDone
max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0)
return max_radius
def chamfer(
self, length: float, length2: Optional[float], edge_list: Iterable[Edge]
):
"""Chamfer
Chamfers the specified edges of this solid.
Args:
length (float): length > 0, the length (length) of the chamfer
length2 (Optional[float]): length2 > 0, optional parameter for asymmetrical
chamfer. Should be `None` if not required.
edge_list (Iterable[Edge]): a list of Edge objects, which must belong to
this solid
Returns:
Any: Chamfered solid
"""
native_edges = [e.wrapped for e in edge_list]
# make a edge --> faces mapping
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
)
# note: we prefer 'length' word to 'radius' as opposed to FreeCAD's API
chamfer_builder = BRepFilletAPI_MakeChamfer(self.wrapped)
if length2:
distance1 = length
distance2 = length2
else:
distance1 = length
distance2 = length
for native_edge in native_edges:
face = edge_face_map.FindFromKey(native_edge).First()
chamfer_builder.Add(
distance1, distance2, native_edge, TopoDS.Face_s(face)
) # NB: edge_face_map return a generic TopoDS_Shape
return self.__class__(chamfer_builder.Shape())
def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
"""Return center of object
Find center of object
Args:
center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.
Raises:
ValueError: Center of GEOMETRY is not supported for this object
NotImplementedError: Unable to calculate center of mass of this object
Returns:
Vector: center
"""
if center_of == CenterOf.GEOMETRY:
raise ValueError("Center of GEOMETRY is not supported for this object")
if center_of == CenterOf.MASS:
properties = GProp_GProps()
calc_function = shape_properties_LUT[shapetype(self.wrapped)]
if calc_function:
calc_function(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
else:
raise NotImplementedError
elif center_of == CenterOf.BOUNDING_BOX:
middle = self.center(CenterOf.BOUNDING_BOX)
return middle
def shell(
self,
faces: Optional[Iterable[Face]],
thickness: float,
tolerance: float = 0.0001,
kind: Kind = Kind.ARC,
) -> Solid:
"""Shell
Make a shelled solid of self.
Args:
faces (Optional[Iterable[Face]]): List of faces to be removed,
which must be part of the solid. Can be an empty list.
thickness (float): shell thickness - positive shells outwards, negative
shells inwards.
tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
kind (Kind, optional): intersection type. Defaults to Kind.ARC.
Raises:
ValueError: Kind.TANGENT not supported
Returns:
Solid: A shelled solid.
"""
if kind == Kind.TANGENT:
raise ValueError("Kind.TANGENT not supported")
kind_dict = {
Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
}
occ_faces_list = TopTools_ListOfShape()
for face in faces:
occ_faces_list.Append(face.wrapped)
shell_builder = BRepOffsetAPI_MakeThickSolid()
shell_builder.MakeThickSolidByJoin(
self.wrapped,
occ_faces_list,
thickness,
tolerance,
Intersection=True,
Join=kind_dict[kind],
)
shell_builder.Build()
if faces:
return_value = self.__class__(shell_builder.Shape())
else: # if no faces provided a watertight solid will be constructed
shell1 = self.__class__(shell_builder.Shape()).shells()[0].wrapped
shell2 = self.shells()[0].wrapped
# s1 can be outer or inner shell depending on the thickness sign
if thickness > 0:
sol = BRepBuilderAPI_MakeSolid(shell1, shell2)
else:
sol = BRepBuilderAPI_MakeSolid(shell2, shell1)
# fix needed for the orientations
return_value = self.__class__(sol.Shape()).fix()
return return_value
def offset_3d(
self,
openings: Optional[Iterable[Face]],
thickness: float,
tolerance: float = 0.0001,
kind: Kind = Kind.ARC,
) -> Solid:
"""Shell
Make an offset solid of self.
Args:
openings (Optional[Iterable[Face]]): List of faces to be removed,
which must be part of the solid. Can be an empty list.
thickness (float): offset amount - positive offset outwards, negative inwards
tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
kind (Kind, optional): intersection type. Defaults to Kind.ARC.
Raises:
ValueError: Kind.TANGENT not supported
Returns:
Solid: A shelled solid.
"""
if kind == Kind.TANGENT:
raise ValueError("Kind.TANGENT not supported")
kind_dict = {
Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
}
occ_faces_list = TopTools_ListOfShape()
for face in openings:
occ_faces_list.Append(face.wrapped)
offset_builder = BRepOffsetAPI_MakeThickSolid()
offset_builder.MakeThickSolidByJoin(
self.wrapped,
occ_faces_list,
thickness,
tolerance,
Intersection=True,
RemoveIntEdges=True,
Join=kind_dict[kind],
)
offset_builder.Build()
offset_occt_solid = offset_builder.Shape()
offset_solid = self.__class__(offset_occt_solid)
# The Solid can be inverted, if so reverse
if offset_solid.volume < 0:
offset_solid.wrapped.Reverse()
return offset_solid
def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool:
"""Returns whether or not the point is inside a solid or compound
object within the specified tolerance.
Args:
point: tuple or Vector representing 3D point to be tested
tolerance: tolerance for inside determination, default=1.0e-6
point: VectorLike:
tolerance: float: (Default value = 1.0e-6)
Returns:
bool indicating whether or not point is within solid
"""
solid_classifier = BRepClass3d_SolidClassifier(self.wrapped)
solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance)
return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace()
def dprism(
self,
basis: Optional[Face],
bounds: list[Union[Face, Wire]],
depth: float = None,
taper: float = 0,
up_to_face: Face = None,
thru_all: bool = True,
additive: bool = True,
) -> Solid:
"""dprism
Make a prismatic feature (additive or subtractive)
Args:
basis (Optional[Face]): face to perform the operation on
bounds (list[Union[Face,Wire]]): list of profiles
depth (float, optional): depth of the cut or extrusion. Defaults to None.
taper (float, optional): in degrees. Defaults to 0.
up_to_face (Face, optional): a face to extrude until. Defaults to None.
thru_all (bool, optional): cut thru_all. Defaults to True.
additive (bool, optional): Defaults to True.
Returns:
Solid: prismatic feature
"""
if isinstance(bounds[0], Wire):
sorted_profiles = sort_wires_by_build_order(bounds)
faces = [Face.make_from_wires(p[0], p[1:]) for p in sorted_profiles]
else:
faces = bounds
shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped
for face in faces:
feat = BRepFeat_MakeDPrism(
shape,
face.wrapped,
basis.wrapped if basis else TopoDS_Face(),
taper * DEG2RAD,
additive,
False,
)
if up_to_face is not None:
feat.Perform(up_to_face.wrapped)
elif thru_all or depth is None:
feat.PerformThruAll()
else:
feat.Perform(depth)
shape = feat.Shape()
return self.__class__(shape)
class Shape(NodeMixin):
"""Shape
Base class for all CAD objects such as Edge, Face, Solid, etc.
Args:
obj (TopoDS_Shape, optional): OCCT object. Defaults to None.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
material (str, optional): tag for external tools. Defaults to ''.
joints (dict[str, Joint], optional): names joints - only valid for Solid
and Compound objects. Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
children (list[Shape], optional): assembly children - only valid for Compounds.
Defaults to None.
"""
def __init__(
self,
obj: TopoDS_Shape = None,
label: str = "",
color: Color = None,
material: str = "",
joints: dict[str, Joint] = None,
parent: Compound = None,
children: list[Shape] = None,
):
self.wrapped = downcast(obj) if obj else None
self.for_construction = False
self.label = label
self.color = color
self.material = material
# Bind joints to Solid
if isinstance(self, Solid):
self.joints = joints if joints else {}
# Bind joints and children to Compounds (other Shapes can't have children)
if isinstance(self, Compound):
self.joints = joints if joints else {}
self.children = children if children else []
# parent must be set following children as post install accesses children
self.parent = parent
@property
def location(self) -> Location:
"""Get this Shape's Location"""
return Location(self.wrapped.Location())
@location.setter
def location(self, value: Location):
"""Set Shape's Location to value"""
self.wrapped.Location(value.wrapped)
@property
def position(self) -> Vector:
"""Get the position component of this Shape's Location"""
return self.location.position
@position.setter
def position(self, value: VectorLike):
"""Set the position component of this Shape's Location to value"""
loc = self.location
loc.position = value
self.location = loc
@property
def orientation(self) -> Vector:
"""Get the orientation component of this Shape's Location"""
return self.location.orientation
@orientation.setter
def orientation(self, rotations: VectorLike):
"""Set the orientation component of this Shape's Location to rotations"""
loc = self.location
loc.orientation = rotations
self.location = loc
class _DisplayNode(NodeMixin):
"""Used to create anytree structures from TopoDS_Shapes"""
def __init__(
self,
label: str = "",
address: int = None,
position: Union[Vector, Location] = None,
parent: Shape._DisplayNode = None,
):
self.label = label
self.address = address
self.position = position
self.parent = parent
self.children = []
_ordered_shapes = [
TopAbs_ShapeEnum.TopAbs_COMPOUND,
TopAbs_ShapeEnum.TopAbs_SOLID,
TopAbs_ShapeEnum.TopAbs_SHELL,
TopAbs_ShapeEnum.TopAbs_FACE,
TopAbs_ShapeEnum.TopAbs_WIRE,
TopAbs_ShapeEnum.TopAbs_EDGE,
TopAbs_ShapeEnum.TopAbs_VERTEX,
]
@staticmethod
def _build_tree(
shape: TopoDS_Shape,
tree: list[_DisplayNode],
parent: _DisplayNode = None,
limit: TopAbs_ShapeEnum = TopAbs_ShapeEnum.TopAbs_VERTEX,
show_center: bool = True,
) -> list[_DisplayNode]:
"""Create an anytree copy of the TopoDS_Shape structure"""
obj_type = shape_LUT[shape.ShapeType()]
if show_center:
loc = Shape(shape).bounding_box().center()
else:
loc = Location(shape.Location())
tree.append(Shape._DisplayNode(obj_type, id(shape), loc, parent))
iterator = TopoDS_Iterator()
iterator.Initialize(shape)
parent_node = tree[-1]
while iterator.More():
child = iterator.Value()
if Shape._ordered_shapes.index(
child.ShapeType()
) <= Shape._ordered_shapes.index(limit):
Shape._build_tree(child, tree, parent_node, limit)
iterator.Next()
return tree
@staticmethod
def _show_tree(root_node, show_center: bool) -> str:
"""Display an assembly or TopoDS_Shape anytree structure"""
# Calculate the size of the tree labels
size_tuples = [(node.height, len(node.label)) for node in root_node.descendants]
size_tuples.append((root_node.height, len(root_node.label)))
size_tuples_per_level = [
list(filter(lambda ll: ll[0] == l, size_tuples))
for l in range(root_node.height + 1)
]
max_sizes_per_level = [
max(4, max([l[1] for l in level])) for level in size_tuples_per_level
]
level_sizes_per_level = [
l + i * 4 for i, l in enumerate(reversed(max_sizes_per_level))
]
tree_label_width = max(level_sizes_per_level) + 1
# Build the tree line by line
result = ""
for pre, _fill, node in RenderTree(root_node):
treestr = ("%s%s" % (pre, node.label)).ljust(tree_label_width)
if hasattr(root_node, "address"):
address = node.address
name = ""
loc = (
"Center" + str(node.position.to_tuple())
if show_center
else "Position" + str(node.position.to_tuple())
)
else:
address = id(node)
name = node.__class__.__name__.ljust(9)
loc = (
"Center" + str(node.center().to_tuple())
if show_center
else "Location" + repr(node.location)
)
result += f"{treestr}{name}at {address:#x}, {loc}\n"
return result
def show_topology(
self,
limit_class: Literal[
"Compound", "Edge", "Face", "Shell", "Solid", "Vertex", "Wire"
] = "Vertex",
show_center: bool = None,
) -> str:
"""Display internal topology
Display the internal structure of a Compound 'assembly' or Shape. Example:
.. code::
>>> c1.show_topology()
c1 is the root Compound at 0x7f4a4cafafa0, Location(...))
├── Solid at 0x7f4a4cafafd0, Location(...))
├── c2 is 1st compound Compound at 0x7f4a4cafaee0, Location(...))
│ ├── Solid at 0x7f4a4cafad00, Location(...))
│ └── Solid at 0x7f4a11a52790, Location(...))
└── c3 is 2nd Compound at 0x7f4a4cafad60, Location(...))
├── Solid at 0x7f4a11a52700, Location(...))
└── Solid at 0x7f4a11a58550, Location(...))
Args:
limit_class: type of displayed leaf node. Defaults to 'Vertex'.
show_center (bool, optional): If None, shows the Location of Compound 'assemblies'
and the bounding box center of Shapes. True or False forces the display.
Defaults to None.
Returns:
str: tree representation of internal structure
"""
if isinstance(self, Compound) and self.children:
show_center = False if show_center is None else show_center
result = Shape._show_tree(self, show_center)
else:
tree = Shape._build_tree(
self.wrapped, tree=[], limit=inverse_shape_LUT[limit_class]
)
show_center = True if show_center is None else show_center
result = Shape._show_tree(tree[0], show_center)
return result
def clean(self) -> Shape:
"""clean
Remove internal edges
Returns:
Shape: Original object with extraneous internal edges removed
"""
upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True)
upgrader.AllowInternalEdges(False)
# upgrader.SetAngularTolerance(1e-5)
try:
upgrader.Build()
self.wrapped = downcast(upgrader.Shape())
except:
warnings.warn(f"Unable to clean {self}")
return self
def fix(self) -> Shape:
"""fix - try to fix shape if not valid"""
if not self.is_valid():
shape_copy: Shape = copy.deepcopy(self, None)
shape_copy.wrapped = fix(self.wrapped)
return shape_copy
return self
@classmethod
def cast(cls, obj: TopoDS_Shape, for_construction: bool = False) -> Shape:
"Returns the right type of wrapper, given a OCCT object"
new_shape = None
# define the shape lookup table for casting
constructor__lut = {
ta.TopAbs_VERTEX: Vertex,
ta.TopAbs_EDGE: Edge,
ta.TopAbs_WIRE: Wire,
ta.TopAbs_FACE: Face,
ta.TopAbs_SHELL: Shell,
ta.TopAbs_SOLID: Solid,
ta.TopAbs_COMPOUND: Compound,
}
shape_type = shapetype(obj)
# NB downcast is needed to handle TopoDS_Shape types
new_shape = constructor__lut[shape_type](downcast(obj))
new_shape.for_construction = for_construction
return new_shape
def export_stl(
self,
file_name: str,
tolerance: float = 1e-3,
angular_tolerance: float = 0.1,
ascii_format: bool = False,
) -> bool:
"""Export STL
Exports a shape to a specified STL file.
Args:
file_name (str): The path and file name to write the STL output to.
tolerance (float, optional): A linear deflection setting which limits the distance
between a curve and its tessellation. Setting this value too low will result in
large meshes that can consume computing resources. Setting the value too high can
result in meshes with a level of detail that is too low. The default is a good
starting point for a range of cases. Defaults to 1e-3.
angular_tolerance (float, optional): Angular deflection setting which limits the angle
between subsequent segments in a polyline. Defaults to 0.1.
ascii_format (bool, optional): Export the file as ASCII (True) or binary (False)
STL format. Defaults to False (binary).
Returns:
bool: Success
"""
mesh = BRepMesh_IncrementalMesh(
self.wrapped, tolerance, True, angular_tolerance
)
mesh.Perform()
writer = StlAPI_Writer()
if ascii_format:
writer.ASCIIMode = True
else:
writer.ASCIIMode = False
return writer.Write(self.wrapped, file_name)
def export_step(self, file_name: str, **kwargs) -> IFSelect_ReturnStatus:
"""Export this shape to a STEP file.
kwargs is used to provide optional keyword arguments to configure the exporter.
Args:
file_name (str): Path and filename for writing.
kwargs: used to provide optional keyword arguments to configure the exporter.
Returns:
IFSelect_ReturnStatus: OCCT return status
"""
# Handle the extra settings for the STEP export
pcurves = 1
if "write_pcurves" in kwargs and not kwargs["write_pcurves"]:
pcurves = 0
precision_mode = kwargs["precision_mode"] if "precision_mode" in kwargs else 0
writer = STEPControl_Writer()
Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves)
Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
writer.Transfer(self.wrapped, STEPControl_AsIs)
return writer.Write(file_name)
def export_brep(self, file: Union[str, BytesIO]) -> bool:
"""Export this shape to a BREP file
Args:
file: Union[str, BytesIO]:
Returns:
"""
return_value = BRepTools.Write_s(self.wrapped, file)
return True if return_value is None else return_value
def export_svg(
self,
file_name: str,
viewport_origin: VectorLike,
viewport_up: VectorLike = (0, 0, 1),
look_at: VectorLike = None,
svg_opts: dict = None,
):
"""Export shape to SVG file
Export self to an SVG file with the provided options
Args:
file_name (str): file name
svg_opts (dict, optional): options dictionary. Defaults to None.
SVG Options - e.g. svg_opts = {"pixel_scale":50}:
Other Parameters:
width (int): Viewport width in pixels. Defaults to 240.
height (int): Viewport width in pixels. Defaults to 240.
pixel_scale (float): Pixels per CAD unit.
Defaults to None (calculated based on width & height).
units (str): SVG document units. Defaults to "mm".
margin_left (int): Defaults to 20.
margin_top (int): Defaults to 20.
show_axes (bool): Display an axis indicator. Defaults to True.
axes_scale (float): Length of axis indicator in global units. Defaults to 1.0.
stroke_width (float): Width of visible edges.
Defaults to None (calculated based on unit_scale).
stroke_color (tuple[int]): Visible stroke color. Defaults to RGB(0, 0, 0).
hidden_color (tuple[int]): Hidden stroke color. Defaults to RBG(160, 160, 160).
show_hidden (bool): Display hidden lines. Defaults to True.
"""
# TODO: should use file-like objects, not a fileName, and/or be able to
# return a string instead
svg = SVG.get_svg(self, viewport_origin, viewport_up, look_at, svg_opts)
with open(file_name, "w", encoding="utf-8") as file:
file.write(svg)
@classmethod
def import_brep(cls, file: Union[str, BytesIO]) -> Shape:
"""Import shape from a BREP file
Args:
f: Union[str, BytesIO]:
Returns:
"""
shape = TopoDS_Shape()
builder = BRep_Builder()
BRepTools.Read_s(shape, file, builder)
if shape.IsNull():
raise ValueError(f"Could not import {file}")
return cls.cast(shape)
def geom_type(self) -> Geoms:
"""Gets the underlying geometry type.
Implementations can return any values desired, but the values the user
uses in type filters should correspond to these.
The return values depend on the type of the shape:
| Vertex: always Vertex
| Edge: LINE, ARC, CIRCLE, SPLINE
| Face: PLANE, SPHERE, CONE
| Solid: Solid
| Shell: Shell
| Compound: Compound
| Wire: Wire
Args:
Returns:
A string according to the geometry type
"""
topo_abs: Any = geom_LUT[shapetype(self.wrapped)]
if isinstance(topo_abs, str):
return_value = topo_abs
elif topo_abs is BRepAdaptor_Curve:
return_value = geom_LUT_EDGE[topo_abs(self.wrapped).GetType()]
else:
return_value = geom_LUT_FACE[topo_abs(self.wrapped).GetType()]
return tcast(Geoms, return_value)
def hash_code(self) -> int:
"""Returns a hashed value denoting this shape. It is computed from the
TShape and the Location. The Orientation is not used.
Args:
Returns:
"""
return self.wrapped.HashCode(HASH_CODE_MAX)
def is_null(self) -> bool:
"""Returns true if this shape is null. In other words, it references no
underlying shape with the potential to be given a location and an
orientation.
Args:
Returns:
"""
return self.wrapped.IsNull()
def is_same(self, other: Shape) -> bool:
"""Returns True if other and this shape are same, i.e. if they share the
same TShape with the same Locations. Orientations may differ. Also see
:py:meth:`is_equal`
Args:
other: Shape:
Returns:
"""
return self.wrapped.IsSame(other.wrapped)
def is_equal(self, other: Shape) -> bool:
"""Returns True if two shapes are equal, i.e. if they share the same
TShape with the same Locations and Orientations. Also see
:py:meth:`is_same`.
Args:
other: Shape:
Returns:
"""
return self.wrapped.IsEqual(other.wrapped)
def __eq__(self, other) -> bool:
"""Are shapes same?"""
return self.is_same(other) if isinstance(other, Shape) else False
def is_valid(self) -> bool:
"""Returns True if no defect is detected on the shape S or any of its
subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full
description of what is checked.
Args:
Returns:
"""
return BRepCheck_Analyzer(self.wrapped).IsValid()
def bounding_box(
self, tolerance: float = None
) -> BoundBox: # need to implement that in GEOM
"""Create a bounding box for this Shape.
Args:
tolerance (float, optional): Defaults to None.
Returns:
BoundBox: A box sized to contain this Shape
"""
return BoundBox._from_topo_ds(self.wrapped, tol=tolerance)
def mirror(self, mirror_plane: Plane = None) -> Shape:
"""
Applies a mirror transform to this Shape. Does not duplicate objects
about the plane.
Args:
mirror_plane (Plane): The plane to mirror about. Defaults to Plane.XY
Returns:
The mirrored shape
"""
if not mirror_plane:
mirror_plane = Plane.XY
transformation = gp_Trsf()
transformation.SetMirror(
gp_Ax2(mirror_plane.origin.to_pnt(), mirror_plane.z_dir.to_dir())
)
return self._apply_transform(transformation)
@staticmethod
def combined_center(
objects: Iterable[Shape], center_of: CenterOf = CenterOf.MASS
) -> Vector:
"""combined center
Calculates the center of a multiple objects.
Args:
objects (Iterable[Shape]): list of objects
center_of (CenterOf, optional): centering option. Defaults to CenterOf.MASS.
Raises:
ValueError: CenterOf.GEOMETRY not implemented
Returns:
Vector: center of multiple objects
"""
if center_of == CenterOf.MASS:
total_mass = sum(Shape.compute_mass(o) for o in objects)
weighted_centers = [
o.center(CenterOf.MASS).multiply(Shape.compute_mass(o)) for o in objects
]
sum_wc = weighted_centers[0]
for weighted_center in weighted_centers[1:]:
sum_wc = sum_wc.add(weighted_center)
middle = Vector(sum_wc.multiply(1.0 / total_mass))
elif center_of == CenterOf.BOUNDING_BOX:
total_mass = len(objects)
weighted_centers = []
for obj in objects:
weighted_centers.append(obj.bounding_box().center())
sum_wc = weighted_centers[0]
for weighted_center in weighted_centers[1:]:
sum_wc = sum_wc.add(weighted_center)
middle = Vector(sum_wc.multiply(1.0 / total_mass))
else:
raise ValueError("CenterOf.GEOMETRY not implemented")
return middle
@staticmethod
def compute_mass(obj: Shape) -> float:
"""Calculates the 'mass' of an object.
Args:
obj: Compute the mass of this object
obj: Shape:
Returns:
"""
properties = GProp_GProps()
calc_function = shape_properties_LUT[shapetype(obj.wrapped)]
if not calc_function:
raise NotImplementedError
calc_function(obj.wrapped, properties)
return properties.Mass()
def shape_type(self) -> Shapes:
"""Return the shape type string for this class"""
return tcast(Shapes, shape_LUT[shapetype(self.wrapped)])
def _entities(self, topo_type: Shapes) -> list[TopoDS_Shape]:
out = {} # using dict to prevent duplicates
explorer = TopExp_Explorer(self.wrapped, inverse_shape_LUT[topo_type])
while explorer.More():
item = explorer.Current()
out[
item.HashCode(HASH_CODE_MAX)
] = item # needed to avoid pseudo-duplicate entities
explorer.Next()
return list(out.values())
def _entities_from(
self, child_type: Shapes, parent_type: Shapes
) -> Dict[Shape, list[Shape]]:
res = TopTools_IndexedDataMapOfShapeListOfShape()
TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
self.wrapped,
inverse_shape_LUT[child_type],
inverse_shape_LUT[parent_type],
res,
)
out: Dict[Shape, list[Shape]] = {}
for i in range(1, res.Extent() + 1):
out[Shape.cast(res.FindKey(i))] = [
Shape.cast(el) for el in res.FindFromIndex(i)
]
return out
def vertices(self) -> ShapeList["Vertex"]:
"""vertices - all the vertices in this Shape"""
return ShapeList([Vertex(downcast(i)) for i in self._entities(Vertex.__name__)])
def edges(self) -> ShapeList["Edge"]:
"""edges - all the edges in this Shape"""
return ShapeList(
[
Edge(i)
for i in self._entities(Edge.__name__)
if not BRep_Tool.Degenerated_s(TopoDS.Edge_s(i))
]
)
def compounds(self) -> ShapeList["Compound"]:
"""compounds - all the compounds in this Shape"""
return ShapeList([Compound(i) for i in self._entities(Compound.__name__)])
def wires(self) -> ShapeList["Wire"]:
"""wires - all the wires in this Shape"""
return ShapeList([Wire(i) for i in self._entities(Wire.__name__)])
def faces(self) -> ShapeList["Face"]:
"""faces - all the faces in this Shape"""
return ShapeList([Face(i) for i in self._entities(Face.__name__)])
def shells(self) -> ShapeList["Shell"]:
"""shells - all the shells in this Shape"""
return ShapeList([Shell(i) for i in self._entities(Shell.__name__)])
def solids(self) -> ShapeList["Solid"]:
"""solids - all the solids in this Shape"""
return ShapeList([Solid(i) for i in self._entities(Solid.__name__)])
@property
def area(self) -> float:
"""area -the surface area of all faces in this Shape"""
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(self.wrapped, properties)
return properties.Mass()
@property
def volume(self) -> float:
"""volume - the volume of this Shape"""
# when density == 1, mass == volume
return Shape.compute_mass(self)
def _apply_transform(self, transformation: gp_Trsf) -> Shape:
"""Private Apply Transform
Apply the provided transformation matrix to a copy of Shape
Args:
transformation (gp_Trsf): transformation matrix
Returns:
Shape: copy of transformed Shape
"""
shape_copy: Shape = copy.deepcopy(self, None)
transformed_shape = BRepBuilderAPI_Transform(
shape_copy.wrapped, transformation, True
).Shape()
shape_copy.wrapped = downcast(transformed_shape)
return shape_copy
def rotate(self, axis: Axis, angle: float) -> Shape:
"""rotate a copy
Rotates a shape around an axis.
Args:
axis (Axis): rotation Axis
angle (float): angle to rotate, in degrees
Returns:
a copy of the shape, rotated
"""
transformation = gp_Trsf()
transformation.SetRotation(axis.wrapped, angle * DEG2RAD)
return self._apply_transform(transformation)
def translate(self, vector: VectorLike) -> Shape:
"""Translates this shape through a transformation.
Args:
vector: VectorLike:
Returns:
"""
transformation = gp_Trsf()
transformation.SetTranslation(Vector(vector).wrapped)
return self._apply_transform(transformation)
def scale(self, factor: float) -> Shape:
"""Scales this shape through a transformation.
Args:
factor: float:
Returns:
"""
transformation = gp_Trsf()
transformation.SetScale(gp_Pnt(), factor)
return self._apply_transform(transformation)
def __deepcopy__(self, memo) -> Shape:
"""Return deepcopy of self"""
# The wrapped object is a OCCT TopoDS_Shape which can't be pickled or copied
# with the standard python copy/deepcopy, so create a deepcopy 'memo' with this
# value already copied which causes deepcopy to skip it.
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
memo[id(self.wrapped)] = downcast(BRepBuilderAPI_Copy(self.wrapped).Shape())
for key, value in self.__dict__.items():
setattr(result, key, copy.deepcopy(value, memo))
return result
def __copy__(self) -> Shape:
"""Return shallow copy or reference of self
Create an copy of this Shape that shares the underlying TopoDS_TShape.
Used when there is a need for many objects with the same CAD structure but at
different Locations, etc. - for examples fasteners in a larger assembly. By
sharing the TopoDS_TShape, the memory size of such assemblies can be greatly reduced.
Changes to the CAD structure of the base object will be reflected in all instances.
"""
reference = copy.deepcopy(self)
reference.wrapped.TShape(self.wrapped.TShape())
return reference
def copy(self) -> Shape:
"""Here for backwards compatibility with cq-editor"""
warnings.warn(
"copy() will be deprecated - use copy.copy() or copy.deepcopy() instead",
DeprecationWarning,
stacklevel=2,
)
return copy.deepcopy(self, None)
def transform_shape(self, t_matrix: Matrix) -> Shape:
"""Apply affine transform without changing type
Transforms a copy of this Shape by the provided 3D affine transformation matrix.
Note that not all transformation are supported - primarily designed for translation
and rotation. See :transform_geometry: for more comprehensive transformations.
Args:
t_matrix (Matrix): affine transformation matrix
Returns:
Shape: copy of transformed shape with all objects keeping their type
"""
transformed = Shape.cast(
BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape()
)
new_shape = copy.deepcopy(self, None)
new_shape.wrapped = transformed.wrapped
return new_shape
def transform_geometry(self, t_matrix: Matrix) -> Shape:
"""Apply affine transform
WARNING: transform_geometry will sometimes convert lines and circles to
splines, but it also has the ability to handle skew and stretching
transformations.
If your transformation is only translation and rotation, it is safer to
use :py:meth:`transform_shape`, which doesn't change the underlying type
of the geometry, but cannot handle skew transformations.
Args:
t_matrix (Matrix): affine transformation matrix
Returns:
Shape: a copy of the object, but with geometry transformed
"""
transformed = Shape.cast(
BRepBuilderAPI_GTransform(self.wrapped, t_matrix.wrapped, True).Shape()
)
new_shape = copy.deepcopy(self, None)
new_shape.wrapped = transformed.wrapped
return new_shape
def locate(self, loc: Location) -> Shape:
"""Apply a location in absolute sense to self
Args:
loc: Location:
Returns:
"""
self.wrapped.Location(loc.wrapped)
return self
def located(self, loc: Location) -> Shape:
"""located
Apply a location in absolute sense to a copy of self
Args:
loc (Location): new absolute location
Returns:
Shape: copy of Shape at location
"""
shape_copy: Shape = copy.deepcopy(self, None)
shape_copy.wrapped.Location(loc.wrapped)
return shape_copy
def move(self, loc: Location) -> Shape:
"""Apply a location in relative sense (i.e. update current location) to self
Args:
loc: Location:
Returns:
"""
self.wrapped.Move(loc.wrapped)
return self
def moved(self, loc: Location) -> Shape:
"""moved
Apply a location in relative sense (i.e. update current location) to a copy of self
Args:
loc (Location): new location relative to current location
Returns:
Shape: copy of Shape moved to relative location
"""
shape_copy: Shape = copy.deepcopy(self, None)
shape_copy.wrapped = downcast(shape_copy.wrapped.Moved(loc.wrapped))
return shape_copy
def distance_to_with_closest_points(
self, other: Union[Shape, VectorLike]
) -> list[float, Vector, Vector]:
"""Minimal distance between two shapes and the points on each shape"""
other = other if isinstance(other, Shape) else Vector(other).to_vertex()
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
dist_calc.LoadS2(other.wrapped)
dist_calc.Perform()
return (
dist_calc.Value(),
Vector(dist_calc.PointOnShape1(1)),
Vector(dist_calc.PointOnShape2(1)),
)
def distance_to(self, other: Union[Shape, VectorLike]) -> float:
"""Minimal distance between two shapes"""
return self.distance_to_with_closest_points(other)[0]
def closest_points(self, other: Union[Shape, VectorLike]) -> tuple[Vector, Vector]:
"""Points on two shapes where the distance between them is minimal"""
return tuple(self.distance_to_with_closest_points(other)[1:])
def __hash__(self) -> int:
"""Return has code"""
return self.hash_code()
def _bool_op(
self,
args: Iterable[Shape],
tools: Iterable[Shape],
operation: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter],
) -> Shape:
"""Generic boolean operation
Args:
args: Iterable[Shape]:
tools: Iterable[Shape]:
operation: Union[BRepAlgoAPI_BooleanOperation:
BRepAlgoAPI_Splitter]:
Returns:
"""
arg = TopTools_ListOfShape()
for obj in args:
arg.Append(obj.wrapped)
tool = TopTools_ListOfShape()
for obj in tools:
tool.Append(obj.wrapped)
operation.SetArguments(arg)
operation.SetTools(tool)
operation.SetRunParallel(True)
operation.Build()
return Shape.cast(operation.Shape())
def cut(self, *to_cut: Shape) -> Shape:
"""Remove the positional arguments from this Shape.
Args:
*to_cut: Shape:
Returns:
"""
cut_op = BRepAlgoAPI_Cut()
return self._bool_op((self,), to_cut, cut_op)
def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Shape:
"""fuse
Fuse a sequence of shapes into a single shape.
Args:
to_fuse (sequence Shape): shapes to fuse
glue (bool, optional): performance improvement for some shapes. Defaults to False.
tol (float, optional): tolerarance. Defaults to None.
Returns:
Shape: fused shape
"""
fuse_op = BRepAlgoAPI_Fuse()
if glue:
fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift)
if tol:
fuse_op.SetFuzzyValue(tol)
return_value = self._bool_op((self,), to_fuse, fuse_op)
return return_value
def intersect(self, *to_intersect: Shape) -> Shape:
"""Intersection of the positional arguments and this Shape.
Args:
toIntersect (sequence of Shape): shape to intersect
Returns:
"""
intersect_op = BRepAlgoAPI_Common()
return self._bool_op((self,), to_intersect, intersect_op)
def faces_intersected_by_line(
self,
point: VectorLike,
axis: VectorLike,
tol: float = 1e-4,
direction: Direction = None,
) -> ShapeList[Face]:
"""Line Intersection
Computes the intersections between the provided line and the faces of this Shape
Args:
point (VectorLike): Base point for defining a line
axis (VectorLike): Axis on which the line rest
tol (float, optional): Intersection tolerance. Defaults to 1e-4.
direction (Direction, optional): if specified will ignore all faces that are
not in the specified direction including the face where the :point: lies
if it is the case. Defaults to None.
Raises:
ValueError: Invalid direction
Returns:
list[Face]: A list of intersected faces sorted by distance from :point:
"""
oc_point = (
gp_Pnt(*point.to_tuple()) if isinstance(point, Vector) else gp_Pnt(*point)
)
oc_axis = (
gp_Dir(Vector(axis).wrapped)
if not isinstance(axis, Vector)
else gp_Dir(axis.wrapped)
)
line = gce_MakeLin(oc_point, oc_axis).Value()
shape = self.wrapped
intersect_maker = BRepIntCurveSurface_Inter()
intersect_maker.Init(shape, line, tol)
faces_dist = [] # using a list instead of a dictionary to be able to sort it
while intersect_maker.More():
inter_pt = intersect_maker.Pnt()
inter_dir_mk = gce_MakeDir(oc_point, inter_pt)
distance = oc_point.SquareDistance(inter_pt)
# inter_dir is not done when `oc_point` and `oc_axis` have the same coord
if inter_dir_mk.IsDone():
inter_dir: Any = inter_dir_mk.Value()
else:
inter_dir = None
if direction == Direction.ALONG_AXIS:
if (
inter_dir is not None
and not inter_dir.IsOpposite(oc_axis, tol)
and distance > tol
):
faces_dist.append((intersect_maker.Face(), distance))
elif direction == Direction.OPPOSITE:
if (
inter_dir is not None
and inter_dir.IsOpposite(oc_axis, tol)
and distance > tol
):
faces_dist.append((intersect_maker.Face(), distance))
elif direction is None:
faces_dist.append(
(intersect_maker.Face(), abs(distance))
) # will sort all intersected faces by distance whatever the direction is
intersect_maker.Next()
faces_dist.sort(key=lambda x: x[1])
faces = [face[0] for face in faces_dist]
return ShapeList([Face(face) for face in faces])
def split(self, *splitters: Shape) -> Shape:
"""Split this shape with the positional arguments.
Args:
*splitters: Shape:
Returns:
"""
split_op = BRepAlgoAPI_Splitter()
return self._bool_op((self,), splitters, split_op)
def distance(self, other: Shape) -> float:
"""Minimal distance between two shapes
Args:
other: Shape:
Returns:
"""
return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value()
def distances(self, *others: Shape) -> Iterator[float]:
"""Minimal distances to between self and other shapes
Args:
*others: Shape:
Returns:
"""
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
for other_shape in others:
dist_calc.LoadS2(other_shape.wrapped)
dist_calc.Perform()
yield dist_calc.Value()
def mesh(self, tolerance: float, angular_tolerance: float = 0.1):
"""Generate triangulation if none exists.
Args:
tolerance: float:
angular_tolerance: float: (Default value = 0.1)
Returns:
"""
if not BRepTools.Triangulation_s(self.wrapped, tolerance):
BRepMesh_IncrementalMesh(self.wrapped, tolerance, True, angular_tolerance)
def tessellate(
self, tolerance: float, angular_tolerance: float = 0.1
) -> Tuple[list[Vector], list[Tuple[int, int, int]]]:
"""General triangulated approximation"""
self.mesh(tolerance, angular_tolerance)
vertices: list[Vector] = []
triangles: list[Tuple[int, int, int]] = []
offset = 0
for face in self.faces():
loc = TopLoc_Location()
poly = BRep_Tool.Triangulation_s(face.wrapped, loc)
trsf = loc.Transformation()
reverse = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
# add vertices
vertices += [
Vector(v.X(), v.Y(), v.Z())
for v in (
poly.Node(i).Transformed(trsf) for i in range(1, poly.NbNodes() + 1)
)
]
# add triangles
triangles += [
(
t.Value(1) + offset - 1,
t.Value(3) + offset - 1,
t.Value(2) + offset - 1,
)
if reverse
else (
t.Value(1) + offset - 1,
t.Value(2) + offset - 1,
t.Value(3) + offset - 1,
)
for t in poly.Triangles()
]
offset += poly.NbNodes()
return vertices, triangles
def to_vtk_poly_data(
self,
tolerance: float = None,
angular_tolerance: float = None,
normals: bool = False,
) -> vtkPolyData:
"""Convert shape to vtkPolyData
Args:
tolerance: float:
angular_tolerance: float: (Default value = 0.1)
normals: bool: (Default value = True)
Returns:
"""
vtk_shape = IVtkOCC_Shape(self.wrapped)
shape_data = IVtkVTK_ShapeData()
shape_mesher = IVtkOCC_ShapeMesher()
drawer = vtk_shape.Attributes()
drawer.SetUIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
drawer.SetVIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
if tolerance:
drawer.SetDeviationCoefficient(tolerance)
if angular_tolerance:
drawer.SetDeviationAngle(angular_tolerance)
shape_mesher.Build(vtk_shape, shape_data)
return_value = shape_data.getVtkPolyData()
# convert to triangles and split edges
t_filter = vtkTriangleFilter()
t_filter.SetInputData(return_value)
t_filter.Update()
return_value = t_filter.GetOutput()
# compute normals
if normals:
n_filter = vtkPolyDataNormals()
n_filter.SetComputePointNormals(True)
n_filter.SetComputeCellNormals(True)
n_filter.SetFeatureAngle(360)
n_filter.SetInputData(return_value)
n_filter.Update()
return_value = n_filter.GetOutput()
return return_value
def _repr_javascript_(self):
"""Jupyter 3D representation support"""
from .jupyter_tools import display
return display(self)._repr_javascript_()
def transformed(
self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0)
) -> Shape:
"""Transform Shape
Rotate and translate the Shape by the three angles (in degrees) and offset.
Args:
rotate (VectorLike, optional): 3-tuple of angles to rotate, in degrees.
Defaults to (0, 0, 0).
offset (VectorLike, optional): 3-tuple to offset. Defaults to (0, 0, 0).
Returns:
Shape: transformed object
"""
# Convert to a Vector of radians
rotate_vector = Vector(rotate).multiply(DEG2RAD)
# Compute rotation matrix.
t_rx = gp_Trsf()
t_rx.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), rotate_vector.X)
t_ry = gp_Trsf()
t_ry.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), rotate_vector.Y)
t_rz = gp_Trsf()
t_rz.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), rotate_vector.Z)
t_o = gp_Trsf()
t_o.SetTranslation(Vector(offset).wrapped)
return self._apply_transform(t_o * t_rx * t_ry * t_rz)
def find_intersection(
self, point: VectorLike, direction: VectorLike
) -> list[tuple[Vector, Vector]]:
"""Find point and normal at intersection
Return both the point(s) and normal(s) of the intersection of the line and the shape
Args:
point (VectorLike): point on intersecting line
direction (VectorLike): direction of intersecting line
Returns:
list[tuple[Vector, Vector]]: Point and normal of intersection
"""
oc_point = gp_Pnt(*Vector(point).to_tuple())
oc_axis = gp_Dir(*Vector(direction).to_tuple())
oc_shape = self.wrapped
intersection_line = gce_MakeLin(oc_point, oc_axis).Value()
intersect_maker = BRepIntCurveSurface_Inter()
intersect_maker.Init(oc_shape, intersection_line, 0.0001)
intersections = []
while intersect_maker.More():
inter_pt = intersect_maker.Pnt()
distance = oc_point.Distance(inter_pt)
intersections.append(
(Face(intersect_maker.Face()), Vector(inter_pt), distance)
)
intersect_maker.Next()
intersections.sort(key=lambda x: x[2])
intersecting_faces = [i[0] for i in intersections]
intersecting_points = [i[1] for i in intersections]
intersecting_normals = [
f.normal_at(intersecting_points[i]).normalized()
for i, f in enumerate(intersecting_faces)
]
result = []
for pnt, normal in zip(intersecting_points, intersecting_normals):
result.append((pnt, normal))
return result
def project_text(
self,
txt: str,
fontsize: float,
depth: float,
path: Union[Wire, Edge],
font: str = "Arial",
font_path: str = None,
kind: FontStyle = FontStyle.REGULAR,
valign: Align = Align.CENTER,
start: float = 0,
) -> Compound:
"""Projected 3D text following the given path on Shape
Create 3D text using projection by positioning each face of
the planar text normal to the shape along the path and projecting
onto the surface. If depth is not zero, the resulting face is
thickened to the provided depth.
Note that projection may result in text distortion depending on
the shape at a position along the path.
.. image:: projectText.png
Args:
txt: Text to be rendered
fontsize: Size of the font in model units
depth: Thickness of text, 0 returns a Face object
path: Path on the Shape to follow
font: Font name. Defaults to "Arial".
font_path: Path to font file. Defaults to None.
kind: Font type. Defaults to FontStyle.REGULAR.
valign: Vertical Alignment. Defaults to Align.CENTER.
start: Relative location on path to start the text. Defaults to 0.
Returns:
: The projected text
"""
path_length = path.length
# The derived classes of Shape implement center
shape_center = self.center() # pylint: disable=no-member
# Create text faces
text_faces = Compound.make_2d_text(
txt, fontsize, font, font_path, kind, (Align.MIN, valign), start
).faces()
logger.debug("projecting text sting '%s' as %d face(s)", txt, len(text_faces))
# Position each text face normal to the surface along the path and project to the surface
projected_faces = []
for text_face in text_faces:
bbox = text_face.bounding_box()
face_center_x = (bbox.min.X + bbox.max.X) / 2
relative_position_on_wire = start + face_center_x / path_length
path_position = path.position_at(relative_position_on_wire)
path_tangent = path.tangent_at(relative_position_on_wire)
(surface_point, surface_normal) = self.find_intersection(
path_position,
path_position - shape_center,
)[0]
surface_normal_plane = Plane(
origin=surface_point, x_dir=path_tangent, z_dir=surface_normal
)
projection_face: Face = text_face.translate(
(-face_center_x, 0, 0)
).transform_shape(surface_normal_plane.reverse_transform)
logger.debug("projecting face at %0.2f", relative_position_on_wire)
projected_faces.append(
projection_face.project_to_shape(self, surface_normal * -1)[0]
)
# Assume that the user just want faces if depth is zero
if depth == 0:
projected_text = projected_faces
else:
projected_text = [
f.thicken(depth, f.center() - shape_center) for f in projected_faces
]
logger.debug("finished projecting text sting '%d'", txt)
return Compound.make_compound(projected_text)
# This TypeVar allows IDEs to see the type of objects within the ShapeList
T = TypeVar("T", Shape, Vector)
class ShapeList(list[T]):
"""Subclass of list with custom filter and sort methods appropriate to CAD"""
@property
def first(self) -> T:
"""First element in the ShapeList"""
return self[0]
@property
def last(self) -> T:
"""Last element in the ShapeList"""
return self[-1]
def __init_subclass__(cls) -> None:
super().__init_subclass__()
def filter_by(
self,
filter_by: Union[Axis, GeomType],
reverse: bool = False,
tolerance: float = 1e-5,
) -> ShapeList:
"""filter by Axis or GeomType
Either:
- filter objects of type planar Face or linear Edge by their normal or tangent
(respectively) and sort the results by the given axis, or
- filter the objects by the provided type. Note that not all types apply to all
objects.
Args:
filter_by (Union[Axis,GeomType]): axis or geom type to filter and possibly sort by
reverse (bool, optional): invert the geom type filter. Defaults to False.
tolerance (float, optional): maximum deviation from axis. Defaults to 1e-5.
Raises:
ValueError: Invalid filter_by type
Returns:
ShapeList: filtered list of objects
"""
if isinstance(filter_by, Axis):
planar_faces = filter(
lambda o: isinstance(o, Face) and o.geom_type() == "PLANE", self
)
linear_edges = filter(
lambda o: isinstance(o, Edge) and o.geom_type() == "LINE", self
)
result = list(
filter(
lambda o: filter_by.is_parallel(
Axis(o.center(), o.normal_at(None)), tolerance
),
planar_faces,
)
)
result.extend(
list(
filter(
lambda o: filter_by.is_parallel(
Axis(o.position_at(0), o.tangent_at(0)), tolerance
),
linear_edges,
)
)
)
return_value = ShapeList(result).sort_by(filter_by)
elif isinstance(filter_by, GeomType):
if reverse:
return_value = ShapeList(
filter(lambda o: o.geom_type() != filter_by.name, self)
)
else:
return_value = ShapeList(
filter(lambda o: o.geom_type() == filter_by.name, self)
)
else:
raise ValueError(f"Unable to filter_by type {type(filter_by)}")
return return_value
def filter_by_position(
self,
axis: Axis,
minimum: float,
maximum: float,
inclusive: tuple[bool, bool] = (True, True),
):
"""filter by position
Filter and sort objects by the position of their centers along given axis.
min and max values can be inclusive or exclusive depending on the inclusive tuple.
Args:
axis (Axis): axis to sort by
minimum (float): minimum value
maximum (float): maximum value
inclusive (tuple[bool, bool], optional): include min,max values.
Defaults to (True, True).
Returns:
ShapeList: filtered object list
"""
if inclusive == (True, True):
objects = filter(
lambda o: minimum
<= axis.to_plane().to_local_coords(o).center().Z
<= maximum,
self,
)
elif inclusive == (True, False):
objects = filter(
lambda o: minimum
<= axis.to_plane().to_local_coords(o).center().Z
< maximum,
self,
)
elif inclusive == (False, True):
objects = filter(
lambda o: minimum
< axis.to_plane().to_local_coords(o).center().Z
<= maximum,
self,
)
elif inclusive == (False, False):
objects = filter(
lambda o: minimum
< axis.to_plane().to_local_coords(o).center().Z
< maximum,
self,
)
return ShapeList(objects).sort_by(axis)
def group_by(
self, group_by: Union[Axis, SortBy] = Axis.Z, reverse=False, tol_digits=6
) -> list[ShapeList]:
"""group by
Group objects by provided criteria and then sort the groups according to the criteria.
Note that not all group_by criteria apply to all objects.
Args:
group_by (SortBy, optional): group and sort criteria. Defaults to Axis.Z.
reverse (bool, optional): flip order of sort. Defaults to False.
tol_digits (int, optional): Tolerance for building the group keys by
round(key, tol_digits)
Returns:
List[ShapeList]: sorted list of ShapeLists
"""
groups = {}
for obj in self:
if isinstance(group_by, Axis):
key = group_by.to_plane().to_local_coords(obj).center().Z
elif isinstance(group_by, SortBy):
if group_by == SortBy.LENGTH:
key = obj.length
elif group_by == SortBy.RADIUS:
key = obj.radius
elif group_by == SortBy.DISTANCE:
key = obj.center().length
elif group_by == SortBy.AREA:
key = obj.area
elif group_by == SortBy.VOLUME:
key = obj.volume
else:
raise ValueError(f"Group by {type(group_by)} unsupported")
key = round(key, tol_digits)
if groups.get(key) is None:
groups[key] = [obj]
else:
groups[key].append(obj)
return [
ShapeList(el[1])
for el in sorted(groups.items(), key=lambda o: o[0], reverse=reverse)
]
def sort_by(self, sort_by: Union[Axis, SortBy] = Axis.Z, reverse: bool = False):
"""sort by
Sort objects by provided criteria. Note that not all sort_by criteria apply to all
objects.
Args:
sort_by (SortBy, optional): sort criteria. Defaults to SortBy.Z.
reverse (bool, optional): flip order of sort. Defaults to False.
Returns:
ShapeList: sorted list of objects
"""
if isinstance(sort_by, Axis):
objects = sorted(
self,
key=lambda o: sort_by.to_plane().to_local_coords(o).center().Z,
reverse=reverse,
)
elif isinstance(sort_by, SortBy):
if sort_by == SortBy.LENGTH:
objects = sorted(
self,
key=lambda obj: obj.length,
reverse=reverse,
)
elif sort_by == SortBy.RADIUS:
objects = sorted(
self,
key=lambda obj: obj.radius,
reverse=reverse,
)
elif sort_by == SortBy.DISTANCE:
objects = sorted(
self,
key=lambda obj: obj.center().length,
reverse=reverse,
)
elif sort_by == SortBy.AREA:
objects = sorted(
self,
key=lambda obj: obj.area,
reverse=reverse,
)
elif sort_by == SortBy.VOLUME:
objects = sorted(
self,
key=lambda obj: obj.volume,
reverse=reverse,
)
return ShapeList(objects)
def sort_by_distance(
self, other: Union[Shape, VectorLike], reverse: bool = False
) -> ShapeList:
"""Sort by distance
Sort by minimal distance between objects and other
Args:
other (Union[Shape,VectorLike]): reference object
reverse (bool, optional): flip order of sort. Defaults to False.
Returns:
ShapeList: Sorted shapes
"""
other = other if isinstance(other, Shape) else Vector(other).to_vertex()
distances = {other.distance_to(obj): obj for obj in self}
return ShapeList(
distances[key] for key in sorted(distances.keys(), reverse=reverse)
)
def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z):
"""Sort operator"""
return self.sort_by(sort_by)
def __lt__(self, sort_by: Union[Axis, SortBy] = Axis.Z):
"""Reverse sort operator"""
return self.sort_by(sort_by, reverse=True)
def __rshift__(self, group_by: Union[Axis, SortBy] = Axis.Z):
"""Group and select largest group operator"""
return self.group_by(group_by)[-1]
def __lshift__(self, group_by: Union[Axis, SortBy] = Axis.Z):
"""Group and select smallest group operator"""
return self.group_by(group_by)[0]
def __or__(self, filter_by: Union[Axis, GeomType] = Axis.Z):
"""Filter by axis or geomtype operator"""
return self.filter_by(filter_by)
def __add__(self, other: ShapeList):
"""Combine two ShapeLists together"""
return ShapeList(list(self) + list(other))
def __getitem__(self, key):
"""Return slices of ShapeList as ShapeList"""
if isinstance(key, slice):
return_value = ShapeList(list(self).__getitem__(key))
else:
return_value = list(self).__getitem__(key)
return return_value
class Plane:
"""Plane
A plane is positioned in space with a coordinate system such that the plane is defined by
the origin, x_dir (X direction), y_dir (Y direction), and z_dir (Z direction) of this coordinate
system, which is the "local coordinate system" of the plane. The z_dir is a vector normal to the
plane. The coordinate system is right-handed.
A plane allows the use of local 2D coordinates, which are later converted to
global, 3d coordinates when the operations are complete.
Planes can be created from faces as workplanes for feature creation on objects.
======= ====== ====== ======
Name x_dir y_dir z_dir
======= ====== ====== ======
XY +x +y +z
YZ +y +z +x
ZX +z +x +y
XZ +x +z -y
YX +y +x -z
ZY +z +y -x
front +x +y +z
back -x +y -z
left +z +y -x
right -z +y +x
top +x -z +y
bottom +x +z -y
======= ====== ====== ======
Args:
gp_pln (gp_Pln): an OCCT plane object
origin (Union[tuple[float, float, float], Vector]): the origin in global coordinates
x_dir (Union[tuple[float, float, float], Vector], optional): an optional vector
representing the X Direction. Defaults to None.
z_dir (Union[tuple[float, float, float], Vector], optional): the normal direction
for the plane. Defaults to (0, 0, 1).
Raises:
ValueError: z_dir must be non null
ValueError: x_dir must be non null
ValueError: the specified x_dir is not orthogonal to the provided normal
Returns:
Plane: A plane
"""
@classmethod
@property
def XY(cls) -> Plane:
"""XY Plane"""
return Plane((0, 0, 0), (1, 0, 0), (0, 0, 1))
@classmethod
@property
def YZ(cls) -> Plane:
"""YZ Plane"""
return Plane((0, 0, 0), (0, 1, 0), (1, 0, 0))
@classmethod
@property
def ZX(cls) -> Plane:
"""ZX Plane"""
return Plane((0, 0, 0), (0, 0, 1), (0, 1, 0))
@classmethod
@property
def XZ(cls) -> Plane:
"""XZ Plane"""
return Plane((0, 0, 0), (1, 0, 0), (0, -1, 0))
@classmethod
@property
def YX(cls) -> Plane:
"""YX Plane"""
return Plane((0, 0, 0), (0, 1, 0), (0, 0, -1))
@classmethod
@property
def ZY(cls) -> Plane:
"""ZY Plane"""
return Plane((0, 0, 0), (0, 0, 1), (-1, 0, 0))
@classmethod
@property
def front(cls) -> Plane:
"""Front Plane"""
return Plane((0, 0, 0), (1, 0, 0), (0, 0, 1))
@classmethod
@property
def back(cls) -> Plane:
"""Back Plane"""
return Plane((0, 0, 0), (-1, 0, 0), (0, 0, -1))
@classmethod
@property
def left(cls) -> Plane:
"""Left Plane"""
return Plane((0, 0, 0), (0, 0, 1), (-1, 0, 0))
@classmethod
@property
def right(cls) -> Plane:
"""Right Plane"""
return Plane((0, 0, 0), (0, 0, -1), (1, 0, 0))
@classmethod
@property
def top(cls) -> Plane:
"""Top Plane"""
return Plane((0, 0, 0), (1, 0, 0), (0, 1, 0))
@classmethod
@property
def bottom(cls) -> Plane:
"""Bottom Plane"""
return Plane((0, 0, 0), (1, 0, 0), (0, -1, 0))
@overload
def __init__(self, gp_pln: gp_Pln): # pragma: no cover
"""Return a plane from a OCCT gp_pln"""
@overload
def __init__(self, face: "Face"): # pragma: no cover
"""Return a plane extending the face.
Note: for non planar face this will return the underlying work plane"""
@overload
def __init__(self, location: Location): # pragma: no cover
"""Return a plane aligned with a given location"""
@overload
def __init__(self, plane: Plane): # pragma: no cover
"""Return a new plane colocated with the existing one"""
@overload
def __init__(
self,
origin: VectorLike,
x_dir: VectorLike = None,
z_dir: VectorLike = (0, 0, 1),
): # pragma: no cover
"""Return a new plane at origin with x_dir and z_dir"""
def __init__(self, *args, **kwargs):
"""Create a plane from either an OCCT gp_pln or coordinates"""
if args:
if isinstance(args[0], gp_Pln):
self.wrapped = args[0]
elif isinstance(args[0], (Shape, Location, Plane)): # Face not known yet
obj = args[0]
if isinstance(obj, Plane):
# be sure to return a new Plane, hence take location of plane and continue
obj = obj.to_location()
if isinstance(obj, Location):
face = Face.make_rect(1, 1).move(obj)
origin = obj.position
elif hasattr(obj, "wrapped") and isinstance(
obj.wrapped,
TopoDS_Face, # check the wrapped class to identify faces
):
face = obj
origin = face.center()
else:
raise TypeError(
f"{type(obj)} not supported to initialize a plane with it"
)
self._origin = origin
self.x_dir = Vector(face._geom_adaptor().Position().XDirection())
self.z_dir = face.normal_at(origin)
else:
self._origin = Vector(args[0])
self.x_dir = Vector(args[1]) if len(args) >= 2 else None
self.z_dir = Vector(args[2]) if len(args) == 3 else Vector(0, 0, 1)
if kwargs:
if "gp_pln" in kwargs:
self.wrapped = kwargs.get("gp_pln")
self._origin = Vector(kwargs.get("origin", (0, 0, 0)))
self.x_dir = kwargs.get("x_dir")
self.x_dir = Vector(self.x_dir) if self.x_dir else None
self.z_dir = Vector(kwargs.get("z_dir", (0, 0, 1)))
if hasattr(self, "wrapped"):
self._origin = Vector(self.wrapped.Location())
self.x_dir = Vector(self.wrapped.XAxis().Direction())
self.y_dir = Vector(self.wrapped.YAxis().Direction())
self.z_dir = Vector(self.wrapped.Axis().Direction())
else:
if self.z_dir.length == 0.0:
raise ValueError("z_dir must be non null")
self.z_dir = self.z_dir.normalized()
if not self.x_dir:
ax3 = gp_Ax3(self.origin.to_pnt(), self.z_dir.to_dir())
self.x_dir = Vector(ax3.XDirection()).normalized()
else:
if Vector(self.x_dir).length == 0.0:
raise ValueError("x_dir must be non null")
self.x_dir = Vector(self.x_dir).normalized()
self.y_dir = self.z_dir.cross(self.x_dir).normalized()
self.wrapped = gp_Pln(
gp_Ax3(self._origin.to_pnt(), self.z_dir.to_dir(), self.x_dir.to_dir())
)
self.local_coord_system: gp_Ax3 = None
self.reverse_transform: Matrix = None
self.forward_transform: Matrix = None
self.origin = self._origin # set origin to calculate transformations
def offset(self, amount: float) -> Plane:
"""Move the Plane by amount in the direction of z_dir"""
return Plane(
origin=self.origin + self.z_dir * amount, x_dir=self.x_dir, z_dir=self.z_dir
)
def _eq_iter(self, other: Plane):
"""Iterator to successively test equality
Args:
other: Plane to compare to
Returns:
Are planes equal
"""
# equality tolerances
eq_tolerance_origin = 1e-6
eq_tolerance_dot = 1e-6
yield isinstance(other, Plane) # comparison is with another Plane
# origins are the same
yield abs(self._origin - other.origin) < eq_tolerance_origin
# z-axis vectors are parallel (assumption: both are unit vectors)
yield abs(self.z_dir.dot(other.z_dir) - 1) < eq_tolerance_dot
# x-axis vectors are parallel (assumption: both are unit vectors)
yield abs(self.x_dir.dot(other.x_dir) - 1) < eq_tolerance_dot
def __copy__(self) -> Plane:
"""Return copy of self"""
return Plane(gp_Pln(self.wrapped.Position()))
def __deepcopy__(self, _memo) -> Plane:
"""Return deepcopy of self"""
return Plane(gp_Pln(self.wrapped.Position()))
def __eq__(self, other: Plane):
"""Are planes equal"""
return all(self._eq_iter(other))
def __ne__(self, other: Plane):
"""Are planes not equal"""
return not self.__eq__(other)
def __neg__(self) -> Plane:
"""Reverse z direction of plane"""
return Plane(self.origin, self.x_dir, -self.z_dir)
def __mul__(self, location: Location) -> Plane:
if not isinstance(location, Location):
raise RuntimeError(
"Planes can only be multiplied with Locations to relocate them"
)
return Plane(self.to_location() * location)
def __repr__(self):
"""To String
Convert Plane to String for display
Returns:
Plane as String
"""
origin_str = ", ".join((f"{v:.2f}" for v in self._origin.to_tuple()))
x_dir_str = ", ".join((f"{v:.2f}" for v in self.x_dir.to_tuple()))
z_dir_str = ", ".join((f"{v:.2f}" for v in self.z_dir.to_tuple()))
return f"Plane(o=({origin_str}), x=({x_dir_str}), z=({z_dir_str}))"
@property
def origin(self) -> Vector:
"""Get the Plane origin"""
return self._origin
@origin.setter
def origin(self, value):
"""Set the Plane origin"""
self._origin = Vector(value)
self._calc_transforms()
def set_origin2d(self, x: float, y: float) -> Plane:
"""Set a new origin in the plane itself
Set a new origin in the plane itself. The plane's orientation and
x_dir are unaffected.
Args:
x (float): offset in the x direction
y (float): offset in the y direction
Returns:
None
The new coordinates are specified in terms of the current 2D system.
As an example:
p = Plane.XY
p.set_origin2d(2, 2)
p.set_origin2d(2, 2)
results in a plane with its origin at (x, y) = (4, 4) in global
coordinates. Both operations were relative to local coordinates of the
plane.
"""
self._origin = self.from_local_coords((x, y))
def rotated(self, rotate: VectorLike = (0, 0, 0)) -> Plane:
"""Returns a copy of this plane, rotated about the specified axes
Since the z axis is always normal the plane, rotating around Z will
always produce a plane that is parallel to this one.
The origin of the workplane is unaffected by the rotation.
Rotations are done in order x, y, z. If you need a different order,
manually chain together multiple rotate() commands.
Args:
rotate (VectorLike, optional): (xDegrees, yDegrees, zDegrees). Defaults to (0, 0, 0).
Returns:
Plane: a copy of this plane rotated as requested.
"""
# NB: this is not a geometric Vector
rotate = Vector(rotate)
# Convert to radians.
rotate = rotate.multiply(pi / 180.0)
# Compute rotation matrix.
transformation1 = gp_Trsf()
transformation1.SetRotation(
gp_Ax1(gp_Pnt(*(0, 0, 0)), gp_Dir(*self.x_dir.to_tuple())), rotate.X
)
transformation2 = gp_Trsf()
transformation2.SetRotation(
gp_Ax1(gp_Pnt(*(0, 0, 0)), gp_Dir(*self.y_dir.to_tuple())), rotate.Y
)
transformation3 = gp_Trsf()
transformation3.SetRotation(
gp_Ax1(gp_Pnt(*(0, 0, 0)), gp_Dir(*self.z_dir.to_tuple())), rotate.Z
)
transformation = Matrix(
gp_GTrsf(transformation1 * transformation2 * transformation3)
)
# Compute the new plane.
new_xdir = self.x_dir.transform(transformation)
new_z_dir = self.z_dir.transform(transformation)
return Plane(self._origin, new_xdir, new_z_dir)
def _calc_transforms(self):
"""Computes transformation matrices to convert between local and global coordinates."""
# reverse_transform is the forward transformation matrix from world to local coordinates
# ok i will be really honest, i cannot understand exactly why this works
# something bout the order of the translation and the rotation.
# the double-inverting is strange, and I don't understand it.
forward = Matrix()
inverse = Matrix()
forward_t = gp_Trsf()
inverse_t = gp_Trsf()
global_coord_system = gp_Ax3()
local_coord_system = gp_Ax3(
gp_Pnt(*self._origin.to_tuple()),
gp_Dir(*self.z_dir.to_tuple()),
gp_Dir(*self.x_dir.to_tuple()),
)
forward_t.SetTransformation(global_coord_system, local_coord_system)
forward.wrapped = gp_GTrsf(forward_t)
inverse_t.SetTransformation(local_coord_system, global_coord_system)
inverse.wrapped = gp_GTrsf(inverse_t)
self.local_coord_system: gp_Ax3 = local_coord_system
self.reverse_transform: Matrix = inverse
self.forward_transform: Matrix = forward
def to_location(self) -> Location:
"""Return Location representing the origin and z direction"""
return Location(self)
def to_gp_ax2(self) -> gp_Ax2:
"""Return gp_Ax2 version of the plane"""
axis = gp_Ax2()
axis.SetAxis(gp_Ax1(self.origin.to_pnt(), self.z_dir.to_dir()))
axis.SetXDirection(self.x_dir.to_dir())
return axis
def _to_from_local_coords(
self, obj: Union[VectorLike, Shape, BoundBox], to_from: bool = True
):
"""_to_from_local_coords
Reposition the object relative to this plane
Args:
obj (Union[VectorLike, Shape, BoundBox]): an object to reposition
to_from (bool, optional): direction of transformation. Defaults to True (to).
Raises:
ValueError: Unsupported object type
Returns:
an object of the same type, but repositioned to local coordinates
"""
transform_matrix = self.forward_transform if to_from else self.reverse_transform
if isinstance(obj, (tuple, Vector)):
return_value = Vector(obj).transform(transform_matrix)
elif isinstance(obj, Shape):
return_value = obj.transform_shape(transform_matrix)
elif isinstance(obj, BoundBox):
global_bottom_left = Vector(obj.min.X, obj.min.Y, obj.min.Z)
global_top_right = Vector(obj.max.X, obj.max.Y, obj.max.Z)
local_bottom_left = global_bottom_left.transform(transform_matrix)
local_top_right = global_top_right.transform(transform_matrix)
local_bbox = Bnd_Box(
gp_Pnt(*local_bottom_left.to_tuple()),
gp_Pnt(*local_top_right.to_tuple()),
)
return_value = BoundBox(local_bbox)
else:
raise ValueError(
f"Unable to repositioned type {type(obj)} with respect to local coordinates"
)
return return_value
def to_local_coords(self, obj: Union[VectorLike, Shape, BoundBox]):
"""Reposition the object relative to this plane
Args:
obj: Union[VectorLike, Shape, BoundBox] an object to reposition
Returns:
an object of the same type, but repositioned to local coordinates
"""
return self._to_from_local_coords(obj, True)
def from_local_coords(self, obj: Union[tuple, Vector, Shape, BoundBox]):
"""Reposition the object relative from this plane
Args:
obj: Union[VectorLike, Shape, BoundBox] an object to reposition
Returns:
an object of the same type, but repositioned to world coordinates
"""
return self._to_from_local_coords(obj, False)
def contains(
self, obj: Union[VectorLike, Axis], tolerance: float = TOLERANCE
) -> bool:
"""contains
Is this point or Axis fully contained in this plane?
Args:
obj (Union[VectorLike,Axis]): point or Axis to evaluate
tolerance (float, optional): comparison tolerance. Defaults to TOLERANCE.
Returns:
bool: self contains point or Axis
"""
if isinstance(obj, Axis):
return_value = self.wrapped.Contains(
gp_Lin(obj.position.to_pnt(), obj.direction.to_dir()),
tolerance,
tolerance,
)
else:
return_value = self.wrapped.Contains(Vector(obj).to_pnt(), tolerance)
return return_value
class Compound(Shape, Mixin3D):
"""Compound
A collection of Shapes
"""
def __repr__(self):
"""Return Compound info as string"""
if hasattr(self, "label") and hasattr(self, "children"):
result = (
f"Compound at {id(self):#x}, label({self.label}), "
+ f"#children({len(self.children)})"
)
else:
result = f"Compound at {id(self):#x}"
return result
@staticmethod
def _make_compound(occt_shapes: Iterable[TopoDS_Shape]) -> TopoDS_Compound:
"""Create an OCCT TopoDS_Compound
Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects
Args:
occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes
Returns:
TopoDS_Compound: OCCT compound
"""
comp = TopoDS_Compound()
comp_builder = TopoDS_Builder()
comp_builder.MakeCompound(comp)
for shape in occt_shapes:
comp_builder.Add(comp, shape)
return comp
def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
"""Return center of object
Find center of object
Args:
center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.
Raises:
ValueError: Center of GEOMETRY is not supported for this object
NotImplementedError: Unable to calculate center of mass of this object
Returns:
Vector: center
"""
if center_of == CenterOf.GEOMETRY:
raise ValueError("Center of GEOMETRY is not supported for this object")
if center_of == CenterOf.MASS:
properties = GProp_GProps()
calc_function = shape_properties_LUT[shapetype(self.wrapped)]
if calc_function:
calc_function(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
else:
raise NotImplementedError
elif center_of == CenterOf.BOUNDING_BOX:
middle = self.bounding_box().center()
return middle
@classmethod
def make_compound(cls, shapes: Iterable[Shape]) -> Compound:
"""Create a compound out of a list of shapes
Args:
shapes: Iterable[Shape]:
Returns:
"""
return cls(cls._make_compound((s.wrapped for s in shapes)))
def _remove(self, shape: Shape) -> Compound:
"""Return self with the specified shape removed.
Args:
shape: Shape:
"""
comp_builder = TopoDS_Builder()
comp_builder.Remove(self.wrapped, shape.wrapped)
return self
def _post_detach(self, parent: Compound):
"""Method call after detaching from `parent`."""
logger.debug("Removing parent of %s (%s)", self.label, parent.label)
if parent.children:
parent.wrapped = Compound._make_compound(
[c.wrapped for c in parent.children]
)
else:
parent.wrapped = None
def _pre_attach(self, parent: Compound):
"""Method call before attaching to `parent`."""
if not isinstance(parent, Compound):
raise ValueError("`parent` must be of type Compound")
def _post_attach(self, parent: Compound):
"""Method call after attaching to `parent`."""
logger.debug("Updated parent of %s to %s", self.label, parent.label)
parent.wrapped = Compound._make_compound([c.wrapped for c in parent.children])
def _post_detach_children(self, children):
"""Method call before detaching `children`."""
if children:
kids = ",".join([child.label for child in children])
logger.debug("Removing children %s from %s", kids, self.label)
self.wrapped = Compound._make_compound([c.wrapped for c in self.children])
# else:
# logger.debug("Removing no children from %s", self.label)
def _pre_attach_children(self, children):
"""Method call before attaching `children`."""
if not all([isinstance(child, Shape) for child in children]):
raise ValueError("Each child must be of type Shape")
def _post_attach_children(self, children: Iterable[Shape]):
"""Method call after attaching `children`."""
if children:
kids = ",".join([child.label for child in children])
logger.debug("Adding children %s to %s", kids, self.label)
self.wrapped = Compound._make_compound([c.wrapped for c in self.children])
# else:
# logger.debug("Adding no children to %s", self.label)
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
) -> tuple[bool, tuple[Shape, Shape], float]:
"""Do Children Intersect
Determine if any of the child objects within a Compound/assembly intersect by
intersecting each of the shapes with each other and checking for
a common volume.
Args:
include_parent (bool, optional): check parent for intersections. Defaults to False.
tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5.
Returns:
bool: do the object intersect
"""
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]
child_index_pairs = [
tuple(map(int, comb))
for comb in combinations([i for i in range(len(children))], 2)
]
for child_index_pair in child_index_pairs:
# First check for bounding box intersections ..
# .. then confirm with actual object intersections which could be complex
bbox_common_volume = (
children_bbox[child_index_pair[0]]
.intersect(children_bbox[child_index_pair[1]])
.volume
)
if bbox_common_volume > tolerance:
common_volume = (
children[child_index_pair[0]]
.intersect(children[child_index_pair[1]])
.volume
)
if common_volume > tolerance:
return (
True,
(children[child_index_pair[0]], children[child_index_pair[1]]),
common_volume,
)
return (False, (None, None), None)
@classmethod
def import_step(cls, file_name: str) -> Compound:
"""import_step
Extract shapes from a STEP file and return them as a Compound object.
Args:
file_name (str): file path of STEP file to import
Raises:
ValueError: can't open file
Returns:
Compound: contents of STEP file
"""
# Now read and return the shape
reader = STEPControl_Reader()
read_status = reader.ReadFile(file_name)
if read_status != OCP.IFSelect.IFSelect_RetDone:
raise ValueError(f"STEP File {file_name} could not be loaded")
for i in range(reader.NbRootsForTransfer()):
reader.TransferRoot(i + 1)
occ_shapes = []
for i in range(reader.NbShapes()):
occ_shapes.append(reader.Shape(i + 1))
# Make sure that we extract all the solids
solids = []
for shape in occ_shapes:
solids.append(Shape.cast(shape))
return Compound.make_compound(solids)
@classmethod
def make_text(
cls,
text: str,
size: float,
height: float,
font: str = "Arial",
font_path: str = None,
kind: FontStyle = FontStyle.REGULAR,
align: tuple[Align, Align] = (Align.CENTER, Align.CENTER),
position: Plane = Plane.XY,
) -> Compound:
"""3D text
Create 3d text on provided plane
Args:
text (str): text string
size (float): text size
height (float): text height
font (str, optional): font type. Defaults to "Arial".
font_path (str, optional): system path to fonts. Defaults to None.
kind (FontStyle, optional): font style. Defaults to FontStyle.REGULAR.
align (tuple[Align, Align], optional): align min, center, or max of object.
Defaults to (Align.CENTER, Align.CENTER).
position (Plane, optional): plane to position text. Defaults to Plane.XY.
Returns:
Compound: 3d text
"""
text_flat = Compound.make_2d_text(
text, size, font, font_path, kind, align, None
)
vec_normal = text_flat.faces()[0].normal_at() * height
text_3d = BRepPrimAPI_MakePrism(text_flat.wrapped, vec_normal.wrapped)
return_value = cls(text_3d.Shape()).transform_shape(position.reverse_transform)
return return_value
@classmethod
def make_2d_text(
cls,
txt: str,
fontsize: float,
font: str = "Arial",
font_path: Optional[str] = None,
font_style: FontStyle = FontStyle.REGULAR,
align: tuple[Align, Align] = (Align.CENTER, Align.CENTER),
position_on_path: float = 0.0,
text_path: Union[Edge, Wire] = None,
) -> "Compound":
"""2D Text that optionally follows a path.
The text that is created can be combined as with other sketch features by specifying
a mode or rotated by the given angle. In addition, edges have been previously created
with arc or segment, the text will follow the path defined by these edges. The start
parameter can be used to shift the text along the path to achieve precise positioning.
Args:
txt: text to be rendered
fontsize: 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 (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_2d_text(
txt="The quick brown fox jumped over the lazy dog",
fontsize=10,
position_on_path=0.1,
text_path=jump_edge,
)
"""
def position_face(orig_face: "Face") -> "Face":
"""
Reposition a face to the provided path
Local coordinates are used to calculate the position of the face
relative to the path. Global coordinates to position the face.
"""
bbox = orig_face.bounding_box()
face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
relative_position_on_wire = (
position_on_path + face_bottom_center.X / path_length
)
wire_tangent = text_path.tangent_at(relative_position_on_wire)
wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent)
wire_position = text_path.position_at(relative_position_on_wire)
return orig_face.translate(wire_position - face_bottom_center).rotate(
Axis(wire_position, (0, 0, 1)),
-wire_angle,
)
if sys.platform.startswith("linux"):
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
font_kind = {
FontStyle.REGULAR: Font_FA_Regular,
FontStyle.BOLD: Font_FA_Bold,
FontStyle.ITALIC: Font_FA_Italic,
}[font_style]
mgr = Font_FontMgr.GetInstance_s()
if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()):
font_t = Font_SystemFont(TCollection_AsciiString(font_path))
font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path))
mgr.RegisterFont(font_t, True)
else:
font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind)
builder = Font_BRepTextBuilder()
font_i = StdPrs_BRepFont(
NCollection_Utf8String(font_t.FontName().ToCString()),
font_kind,
float(fontsize),
)
text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt)))
# Align the text from the bounding box
bbox = text_flat.bounding_box()
align_offset = []
for i in range(2):
if align[i] == Align.MIN:
align_offset.append(-bbox.min.to_tuple()[i])
elif align[i] == Align.CENTER:
align_offset.append(
-(bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2
)
elif align[i] == Align.MAX:
align_offset.append(-bbox.max.to_tuple()[i])
text_flat = text_flat.translate(Vector(*align_offset))
if text_path is not None:
path_length = text_path.length
text_flat = Compound.make_compound(
[position_face(f) for f in text_flat.faces()]
)
return text_flat
def __iter__(self) -> Iterator[Shape]:
"""
Iterate over subshapes.
"""
iterator = TopoDS_Iterator(self.wrapped)
while iterator.More():
yield Shape.cast(iterator.Value())
iterator.Next()
def __bool__(self) -> bool:
"""
Check if empty.
"""
return TopoDS_Iterator(self.wrapped).More()
def cut(self, *to_cut: Shape) -> Compound:
"""Remove a shape from another one
Args:
*to_cut: Shape:
Returns:
"""
cut_op = BRepAlgoAPI_Cut()
return tcast(Compound, self._bool_op(self, to_cut, cut_op))
def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Compound:
"""Fuse shapes together
Args:
*to_fuse: Shape:
glue: bool: (Default value = False)
tol: float: (Default value = None)
Returns:
"""
fuse_op = BRepAlgoAPI_Fuse()
if glue:
fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift)
if tol:
fuse_op.SetFuzzyValue(tol)
args = tuple(self) + to_fuse
if len(args) <= 1:
return_value: Shape = args[0]
else:
return_value = self._bool_op(args[:1], args[1:], fuse_op)
# fuse_op.RefineEdges()
# fuse_op.FuseEdges()
return tcast(Compound, return_value)
def intersect(self, *to_intersect: Shape) -> Compound:
"""Construct shape intersection
Args:
*to_intersect: Shape:
Returns:
"""
intersect_op = BRepAlgoAPI_Common()
return tcast(Compound, self._bool_op(self, to_intersect, intersect_op))
def get_type(
self,
obj_type: Union[Type[Edge], Type[Face], Type[Shell], Type[Solid], Type[Wire]],
) -> list[Union[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[Edge, Face, Solid]): Object types to extract
Returns:
list[Union[Edge, Face, Solid]]: Extracted objects
"""
iterator = TopoDS_Iterator()
iterator.Initialize(self.wrapped)
type_map = {
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,
}
results = []
while iterator.More():
child = iterator.Value()
if child.ShapeType() == type_map[obj_type]:
results.append(obj_type(child))
iterator.Next()
return results
class Edge(Shape, Mixin1D):
"""A trimmed curve that represents the border of a face"""
def _geom_adaptor(self) -> BRepAdaptor_Curve:
""" """
return BRepAdaptor_Curve(self.wrapped)
def close(self) -> Union[Edge, Wire]:
"""Close an Edge"""
if not self.is_closed():
return_value = Wire.make_wire([self]).close()
else:
return_value = self
return return_value
def to_wire(self) -> Wire:
"""Edge as Wire"""
return Wire.make_wire([self])
@property
def arc_center(self) -> Vector:
"""center of an underlying circle or ellipse geometry."""
geom_type = self.geom_type()
geom_adaptor = self._geom_adaptor()
if geom_type == "CIRCLE":
return_value = Vector(geom_adaptor.Circle().Position().Location())
elif geom_type == "ELLIPSE":
return_value = Vector(geom_adaptor.Ellipse().Position().Location())
else:
raise ValueError(f"{geom_type} has no arc center")
return return_value
def intersections(
self, plane: Plane, edge: Edge = None, tolerance: float = TOLERANCE
) -> list[Vector]:
"""intersections
Determine the points where a 2D edge crosses itself or another 2D edge
Args:
plane (Plane): plane containing edge(s)
edge (Edge): curve to compare with
tolerance (float, optional): defines the precision of computing the intersection points.
Defaults to TOLERANCE.
Returns:
list[Vector]: list of intersection points
"""
# This will be updated by Geom_Surface to the edge location but isn't otherwise used
edge_location = TopLoc_Location()
# Check if self is on the plane
if not all([plane.contains(self.position_at(i / 7)) for i in range(8)]):
raise ValueError("self must be a 2D edge on the given plane")
edge_surface: Geom_Surface = Face.make_plane(plane)._geom_adaptor()
self_parameters = [
BRep_Tool.Parameter_s(self.vertices()[i].wrapped, self.wrapped)
for i in [0, 1]
]
self_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
self.wrapped,
edge_surface,
edge_location,
*self_parameters,
)
if edge:
# Check if edge is on the plane
if not all([plane.contains(edge.position_at(i / 7)) for i in range(8)]):
raise ValueError("edge must be a 2D edge on the given plane")
edge_parameters = [
BRep_Tool.Parameter_s(edge.vertices()[i].wrapped, edge.wrapped)
for i in [0, 1]
]
edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
edge.wrapped,
edge_surface,
edge_location,
*edge_parameters,
)
intersector = Geom2dAPI_InterCurveCurve(
self_2d_curve, edge_2d_curve, tolerance
)
else:
intersector = Geom2dAPI_InterCurveCurve(self_2d_curve, tolerance)
crosses = [
Vector(intersector.Point(i + 1).X(), intersector.Point(i + 1).Y())
for i in range(intersector.NbPoints())
]
return crosses
@classmethod
def make_bezier(cls, *cntl_pnts: VectorLike, weights: list[float] = None) -> Edge:
"""make_bezier
Create a rational (with weights) or non-rational bezier curve. The first and last
control points represent the start and end of the curve respectively. If weights
are provided, there must be one provided for each control point.
Args:
cntl_pnts (sequence[VectorLike]): points defining the curve
weights (list[float], optional): control point weights list. Defaults to None.
Raises:
ValueError: Too few control points
ValueError: Too many control points
ValueError: A weight is required for each control point
Returns:
Edge: bezier curve
"""
if len(cntl_pnts) < 2:
raise ValueError(
"At least two control points must be provided (start, end)"
)
if len(cntl_pnts) > 25:
raise ValueError("The maximum number of control points is 25")
if weights:
if len(cntl_pnts) != len(weights):
raise ValueError("A weight must be provided for each control point")
cntl_gp_pnts = [Vector(cntl_pnt).to_pnt() for cntl_pnt in cntl_pnts]
# The poles are stored in an OCCT Array object
poles = TColgp_Array1OfPnt(1, len(cntl_gp_pnts))
for i, cntl_gp_pnt in enumerate(cntl_gp_pnts):
poles.SetValue(i + 1, cntl_gp_pnt)
if weights:
pole_weights = TColStd_Array1OfReal(1, len(weights))
for i, weight in enumerate(weights):
pole_weights.SetValue(i + 1, float(weight))
# Create the curve
if weights:
bezier_curve = Geom_BezierCurve(poles, pole_weights)
else:
bezier_curve = Geom_BezierCurve(poles)
return cls(BRepBuilderAPI_MakeEdge(bezier_curve).Edge())
@classmethod
def make_circle(
cls,
radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
) -> Edge:
"""make circle
Create a circle centered on the origin of plane
Args:
radius (float): circle radius
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): start of arc angle. Defaults to 360.0.
end_angle (float, optional): end of arc angle. Defaults to 360.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
Returns:
Edge: full or partial circle
"""
circle_gp = gp_Circ(plane.to_gp_ax2(), radius)
if start_angle == end_angle: # full circle case
return_value = cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge())
else: # arc case
circle_geom = GC_MakeArcOfCircle(
circle_gp,
start_angle * DEG2RAD,
end_angle * DEG2RAD,
angular_direction == AngularDirection.COUNTER_CLOCKWISE,
).Value()
return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
return return_value
@classmethod
def make_ellipse(
cls,
x_radius: float,
y_radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360.0,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
) -> Edge:
"""make ellipse
Makes an ellipse centered at the origin of plane.
Args:
x_radius (float): x radius of the ellipse (along the x-axis of plane)
y_radius (float): y radius of the ellipse (along the y-axis of plane)
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): Defaults to 360.0.
end_angle (float, optional): Defaults to 360.0.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
Returns:
Edge: full or partial ellipse
"""
ax1 = gp_Ax1(plane.origin.to_pnt(), plane.z_dir.to_dir())
if y_radius > x_radius:
# swap x and y radius and rotate by 90° afterwards to create an ellipse
# with x_radius < y_radius
correction_angle = 90.0 * DEG2RAD
ellipse_gp = gp_Elips(plane.to_gp_ax2(), y_radius, x_radius).Rotated(
ax1, correction_angle
)
else:
correction_angle = 0.0
ellipse_gp = gp_Elips(plane.to_gp_ax2(), x_radius, y_radius)
if start_angle == end_angle: # full ellipse case
ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_gp).Edge())
else: # arc case
# take correction_angle into account
ellipse_geom = GC_MakeArcOfEllipse(
ellipse_gp,
start_angle * DEG2RAD - correction_angle,
end_angle * DEG2RAD - correction_angle,
angular_direction == AngularDirection.COUNTER_CLOCKWISE,
).Value()
ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_geom).Edge())
return ellipse
@classmethod
def make_mid_way(cls, first: Edge, second: Edge, middle: float = 0.5) -> Edge:
"""make line between edges
Create a new linear Edge between the two provided Edges. If the Edges are parallel
but in the opposite directions one Edge is flipped such that the mid way Edge isn't
truncated.
Args:
first (Edge): first reference Edge
second (Edge): second reference Edge
middle (float, optional): factional distance between Edges. Defaults to 0.5.
Returns:
Edge: linear Edge between two Edges
"""
flip = first.to_axis().is_opposite(second.to_axis())
pnts = [
Edge.make_line(
first.position_at(i), second.position_at(1 - i if flip else i)
).position_at(middle)
for i in [0, 1]
]
return Edge.make_line(*pnts)
@classmethod
def make_spline(
cls,
points: list[VectorLike],
tangents: list[VectorLike] = None,
periodic: bool = False,
parameters: list[float] = None,
scale: bool = True,
tol: float = 1e-6,
) -> Edge:
"""Spline
Interpolate a spline through the provided points.
Args:
points (list[VectorLike]): the points defining the spline
tangents (list[VectorLike], optional): start and finish tangent.
Defaults to None.
periodic (bool, optional): creation of periodic curves. Defaults to False.
parameters (list[float], optional): the value of the parameter at each
interpolation point. (The interpolated curve is represented as a vector-valued
function of a scalar parameter.) If periodic == True, then len(parameters)
must be len(interpolation points) + 1, otherwise len(parameters)
must be equal to len(interpolation points). Defaults to None.
scale (bool, optional): whether to scale the specified tangent vectors before
interpolating. Each tangent is scaled, so it's length is equal to the derivative
of the Lagrange interpolated curve. I.e., set this to True, if you want to use
only the direction of the tangent vectors specified by `tangents` , but not
their magnitude. Defaults to True.
tol (float, optional): tolerance of the algorithm (consult OCC documentation).
Used to check that the specified points are not too close to each other, and
that tangent vectors are not too short. (In either case interpolation may fail.).
Defaults to 1e-6.
Raises:
ValueError: Parameter for each interpolation point
ValueError: Tangent for each interpolation point
ValueError: B-spline interpolation failed
Returns:
Edge: the spline
"""
points = [Vector(point) for point in points]
if tangents:
tangents = tuple(Vector(v) for v in tangents)
pnts = TColgp_HArray1OfPnt(1, len(points))
for i, point in enumerate(points):
pnts.SetValue(i + 1, point.to_pnt())
if parameters is None:
spline_builder = GeomAPI_Interpolate(pnts, periodic, tol)
else:
if len(parameters) != (len(points) + periodic):
raise ValueError(
"There must be one parameter for each interpolation point "
"(plus one if periodic), or none specified. Parameter count: "
f"{len(parameters)}, point count: {len(points)}"
)
parameters_array = TColStd_HArray1OfReal(1, len(parameters))
for p_index, p_value in enumerate(parameters):
parameters_array.SetValue(p_index + 1, p_value)
spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol)
if tangents:
if len(tangents) == 2 and len(points) != 2:
# Specify only initial and final tangent:
spline_builder.Load(tangents[0].wrapped, tangents[1].wrapped, scale)
else:
if len(tangents) != len(points):
raise ValueError(
f"There must be one tangent for each interpolation point, "
f"or just two end point tangents. Tangent count: "
f"{len(tangents)}, point count: {len(points)}"
)
# Specify a tangent for each interpolation point:
tangents_array = TColgp_Array1OfVec(1, len(tangents))
tangent_enabled_array = TColStd_HArray1OfBoolean(1, len(tangents))
for t_index, t_value in enumerate(tangents):
tangent_enabled_array.SetValue(t_index + 1, t_value is not None)
tangent_vec = t_value if t_value is not None else Vector()
tangents_array.SetValue(t_index + 1, tangent_vec.wrapped)
spline_builder.Load(tangents_array, tangent_enabled_array, scale)
spline_builder.Perform()
if not spline_builder.IsDone():
raise ValueError("B-spline interpolation failed")
spline_geom = spline_builder.Curve()
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_spline_approx(
cls,
points: list[VectorLike],
tol: float = 1e-3,
smoothing: Tuple[float, float, float] = None,
min_deg: int = 1,
max_deg: int = 6,
) -> Edge:
"""make_spline_approx
Approximate a spline through the provided points.
Args:
points (list[Vector]):
tol (float, optional): tolerance of the algorithm. Defaults to 1e-3.
smoothing (Tuple[float, float, float], optional): optional tuple of 3 weights
use for variational smoothing. Defaults to None.
min_deg (int, optional): minimum spline degree. Enforced only when smoothing
is None. Defaults to 1.
max_deg (int, optional): maximum spline degree. Defaults to 6.
Raises:
ValueError: B-spline approximation failed
Returns:
Edge: spline
"""
pnts = TColgp_HArray1OfPnt(1, len(points))
for i, point in enumerate(points):
pnts.SetValue(i + 1, Vector(point).to_pnt())
if smoothing:
spline_builder = GeomAPI_PointsToBSpline(
pnts, *smoothing, DegMax=max_deg, Tol3D=tol
)
else:
spline_builder = GeomAPI_PointsToBSpline(
pnts, DegMin=min_deg, DegMax=max_deg, Tol3D=tol
)
if not spline_builder.IsDone():
raise ValueError("B-spline approximation failed")
spline_geom = spline_builder.Curve()
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_three_point_arc(
cls, point1: VectorLike, point2: VectorLike, point3: VectorLike
) -> Edge:
"""Three Point Arc
Makes a three point arc through the provided points
Args:
point1 (VectorLike): start point
point2 (VectorLike): middle point
point3 (VectorLike): end point
Returns:
Edge: a circular arc through the three points
"""
circle_geom = GC_MakeArcOfCircle(
Vector(point1).to_pnt(), Vector(point2).to_pnt(), Vector(point3).to_pnt()
).Value()
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def make_tangent_arc(
cls, start: VectorLike, tangent: VectorLike, end: VectorLike
) -> Edge:
"""Tangent Arc
Makes a tangent arc from point start, in the direction of tangent and ends at end.
Args:
start (VectorLike): start point
tangent (VectorLike): start tangent
end (VectorLike): end point
Returns:
Edge: circular arc
"""
circle_geom = GC_MakeArcOfCircle(
Vector(start).to_pnt(), Vector(tangent).wrapped, Vector(end).to_pnt()
).Value()
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def make_line(cls, point1: VectorLike, point2: VectorLike) -> Edge:
"""Create a line between two points
Args:
point1: VectorLike: that represents the first point
point2: VectorLike: that represents the second point
Returns:
A linear edge between the two provided points
"""
return cls(
BRepBuilderAPI_MakeEdge(
Vector(point1).to_pnt(), Vector(point2).to_pnt()
).Edge()
)
def distribute_locations(
self: Union[Wire, Edge],
count: int,
start: float = 0.0,
stop: float = 1.0,
positions_only: bool = False,
) -> list[Location]:
"""Distribute Locations
Distribute locations along edge or wire.
Args:
self: Union[Wire:Edge]:
count(int): Number of locations to generate
start(float): position along Edge|Wire to start. Defaults to 0.0.
stop(float): position along Edge|Wire to end. Defaults to 1.0.
positions_only(bool): only generate position not orientation. Defaults to False.
Returns:
list[Location]: locations distributed along Edge|Wire
Raises:
ValueError: count must be two or greater
"""
if count < 2:
raise ValueError("count must be two or greater")
t_values = [start + i * (stop - start) / (count - 1) for i in range(count)]
locations = self.locations(t_values)
if positions_only:
for loc in locations:
loc.orientation = (0, 0, 0)
return locations
def project_to_shape(
self,
target_object: Shape,
direction: VectorLike = None,
center: VectorLike = None,
) -> list[Edge]:
"""Project Edge
Project an Edge onto a Shape generating new wires on the surfaces of the object
one and only one of `direction` or `center` must be provided. Note that one or
more wires may be generated depending on the topology of the target object and
location/direction of projection.
To avoid flipping the normal of a face built with the projected wire the orientation
of the output wires are forced to be the same as self.
Args:
target_object: Object to project onto
direction: Parallel projection direction. Defaults to None.
center: Conical center of projection. Defaults to None.
target_object: Shape:
direction: VectorLike: (Default value = None)
center: VectorLike: (Default value = None)
Returns:
: Projected Edge(s)
Raises:
ValueError: Only one of direction or center must be provided
"""
wire = Wire.make_wire([self])
projected_wires = wire.project_to_shape(target_object, direction, center)
projected_edges = [w.edges()[0] for w in projected_wires]
return projected_edges
def to_axis(self) -> Axis:
"""Translate a linear Edge to an Axis"""
if self.geom_type() != "LINE":
raise TypeError("to_axis is only valid for linear Edges")
return Axis(self.position_at(0), self.position_at(1) - self.position_at(0))
class Face(Shape):
"""a bounded surface that represents part of the boundary of a solid"""
@property
def length(self) -> float:
"""experimental length calculation"""
result = None
if self.geom_type() == "PLANE":
# Reposition on Plane.XY
flat_face = Plane(self.to_pln()).to_local_coords(self)
face_vertices = flat_face.vertices().sort_by(Axis.X)
result = face_vertices[-1].X - face_vertices[0].X
return result
@property
def width(self) -> float:
"""experimental width calculation"""
result = None
if self.geom_type() == "PLANE":
# Reposition on Plane.XY
flat_face = Plane(self.to_pln()).to_local_coords(self)
face_vertices = flat_face.vertices().sort_by(Axis.Y)
result = face_vertices[-1].Y - face_vertices[0].Y
return result
@property
def geometry(self) -> str:
"""experimental geometry type"""
result = None
if self.geom_type() == "PLANE":
flat_face = Plane(self.to_pln()).to_local_coords(self)
flat_face_edges = flat_face.edges()
if all([e.geom_type() == "LINE" for e in flat_face_edges]):
flat_face_vertices = flat_face.vertices()
result = "POLYGON"
if len(flat_face_edges) == 4:
edge_pairs = []
for vertex in flat_face_vertices:
edge_pairs.append(
[e for e in flat_face_edges if vertex in e.vertices()]
)
edge_pair_directions = [
[edge.tangent_at(0) for edge in pair] for pair in edge_pairs
]
if all(
[
edge_directions[0].get_angle(edge_directions[1]) == 90
for edge_directions in edge_pair_directions
]
):
result = "RECTANGLE"
if len(flat_face_edges.group_by(SortBy.LENGTH)) == 1:
result = "SQUARE"
return result
def _geom_adaptor(self) -> Geom_Surface:
""" """
return BRep_Tool.Surface_s(self.wrapped)
def _uv_bounds(self) -> Tuple[float, float, float, float]:
return BRepTools.UVBounds_s(self.wrapped)
def __neg__(self) -> Face:
"""Return a copy of self with the normal reversed"""
new_face = copy.deepcopy(self)
new_face.wrapped = self.wrapped.Complemented()
return new_face
def offset(self, amount: float) -> Face:
"""Return a copy of self moved along the normal by amount"""
return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
def normal_at(self, surface_point: VectorLike = None) -> Vector:
"""normal_at
Computes the normal vector at the desired location on the face.
Args:
surface_point (VectorLike, optional): a point that lies on the surface where the normal.
Defaults to None.
Returns:
Vector: surface normal direction
"""
# get the geometry
surface = self._geom_adaptor()
if surface_point is None:
u_val0, u_val1, v_val0, v_val1 = self._uv_bounds()
u_val = 0.5 * (u_val0 + u_val1)
v_val = 0.5 * (v_val0 + v_val1)
else:
# project point on surface
projector = GeomAPI_ProjectPointOnSurf(
Vector(surface_point).to_pnt(), surface
)
u_val, v_val = projector.LowerDistanceParameters()
gp_pnt = gp_Pnt()
normal = gp_Vec()
BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal)
return Vector(normal)
def center(self, center_of=CenterOf.GEOMETRY):
"""Center of Face
Return the center based on center_of
Args:
center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY.
Returns:
Vector: center
"""
if (center_of == CenterOf.MASS) or (
center_of == CenterOf.GEOMETRY and self.geom_type() == "PLANE"
):
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(self.wrapped, properties)
center_point = properties.CentreOfMass()
elif center_of == CenterOf.BOUNDING_BOX:
center_point = self.bounding_box().center()
elif center_of == CenterOf.GEOMETRY:
u_val0, u_val1, v_val0, v_val1 = self._uv_bounds()
u_val = 0.5 * (u_val0 + u_val1)
v_val = 0.5 * (v_val0 + v_val1)
center_point = gp_Pnt()
normal = gp_Vec()
BRepGProp_Face(self.wrapped).Normal(u_val, v_val, center_point, normal)
else:
raise ValueError(f"Unknown CenterOf value {center_of}")
return Vector(center_point)
def outer_wire(self) -> Wire:
"""Extract the perimeter wire from this Face"""
return Wire(BRepTools.OuterWire_s(self.wrapped))
def inner_wires(self) -> list[Wire]:
"""Extract the inner or hole wires from this Face"""
outer = self.outer_wire()
return [w for w in self.wires() if not w.is_same(outer)]
@classmethod
def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face:
"""make_rect
Make a Rectangle centered on center with the given normal
Args:
width (float, optional): width (local x).
height (float, optional): height (local y).
plane (Plane, optional): base plane. Defaults to Plane.XY.
Returns:
Face: The centered rectangle
"""
pln_shape = BRepBuilderAPI_MakeFace(
plane.wrapped, -height * 0.5, height * 0.5, -width * 0.5, width * 0.5
).Face()
return cls(pln_shape)
@classmethod
def make_plane(
cls,
plane: Plane = Plane.XY,
) -> Face:
"""Create a unlimited size Face aligned with plane"""
pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face()
return cls(pln_shape)
@overload
@classmethod
def make_ruled_surface(cls, edge1: Edge, edge2: Edge) -> Face: # pragma: no cover
...
@overload
@classmethod
def make_ruled_surface(cls, wire1: Wire, wire2: Wire) -> Face: # pragma: no cover
...
@classmethod
def make_surface_from_curves(
cls, curve1: Union[Edge, Wire], curve2: Union[Edge, Wire]
) -> Face:
"""make_surface_from_curves
Create a ruled surface out of two edges or two wires. If wires are used then
these must have the same number of edges.
Args:
curve1 (Union[Edge,Wire]): side of surface
curve2 (Union[Edge,Wire]): opposite side of surface
Returns:
Face: potentially non planar surface
"""
if isinstance(curve1, Wire):
return_value = cls.cast(BRepFill.Shell_s(curve1.wrapped, curve2.wrapped))
else:
return_value = cls.cast(BRepFill.Face_s(curve1.wrapped, curve2.wrapped))
return return_value
@classmethod
def make_from_wires(cls, outer_wire: Wire, inner_wires: list[Wire] = None) -> Face:
"""make_from_wires
Makes a planar face from one or more wires
Args:
outer_wire (Wire): closed perimeter wire
inner_wires (list[Wire], optional): holes. Defaults to None.
Raises:
ValueError: outer wire not closed
ValueError: wires not planar
ValueError: inner wire not closed
ValueError: internal error
Returns:
Face: planar face potentially with holes
"""
if inner_wires and not outer_wire.is_closed():
raise ValueError("Cannot build face(s): outer wire is not closed")
inner_wires = inner_wires if inner_wires else []
# check if wires are coplanar
verification_compound = Compound.make_compound([outer_wire] + inner_wires)
if not BRepLib_FindSurface(
verification_compound.wrapped, OnlyPlane=True
).Found():
raise ValueError("Cannot build face(s): wires not planar")
# fix outer wire
sf_s = ShapeFix_Shape(outer_wire.wrapped)
sf_s.Perform()
topo_wire = TopoDS.Wire_s(sf_s.Shape())
face_builder = BRepBuilderAPI_MakeFace(topo_wire, True)
for inner_wire in inner_wires:
if not inner_wire.is_closed():
raise ValueError("Cannot build face(s): inner wire is not closed")
face_builder.Add(inner_wire.wrapped)
face_builder.Build()
if not face_builder.IsDone():
raise ValueError(f"Cannot build face(s): {face_builder.Error()}")
face = face_builder.Face()
sf_f = ShapeFix_Face(face)
sf_f.FixOrientation()
sf_f.Perform()
return cls(sf_f.Result())
@classmethod
def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]:
"""sew faces
Group contiguous faces and return them in a list of ShapeList
Args:
faces (Iterable[Face]): Faces to sew together
Raises:
RuntimeError: OCCT SewedShape generated unexpected output
Returns:
list[ShapeList[Face]]: grouped contiguous faces
"""
# Create the shell build
shell_builder = BRepBuilderAPI_Sewing()
# Add the given faces to it
for face in faces:
shell_builder.Add(face.wrapped)
# Attempt to sew the faces into a contiguous shell
shell_builder.Perform()
# Extract the sewed shape - a face, a shell, a solid or a compound
sewed_shape = downcast(shell_builder.SewedShape())
# Create a list of ShapeList of Faces
if isinstance(sewed_shape, TopoDS_Face):
sewn_faces = [ShapeList([Face(sewed_shape)])]
elif isinstance(sewed_shape, TopoDS_Shell):
sewn_faces = [Shell(sewed_shape).faces()]
elif isinstance(sewed_shape, TopoDS_Compound):
sewn_faces = []
for face in Compound(sewed_shape).get_type(Face):
sewn_faces.append(ShapeList([face]))
for shell in Compound(sewed_shape).get_type(Shell):
sewn_faces.append(shell.faces())
elif isinstance(sewed_shape, TopoDS_Solid):
sewn_faces = [Solid(sewed_shape).faces()]
else:
raise RuntimeError(
f"SewedShape returned a {type(sewed_shape)} which was unexpected"
)
return sewn_faces
@classmethod
def make_surface_from_points(
cls,
points: list[list[VectorLike]],
tol: float = 1e-2,
smoothing: Tuple[float, float, float] = None,
min_deg: int = 1,
max_deg: int = 3,
) -> Face:
"""make_surface_from_points
Approximate a spline surface through the provided points.
Args:
points (list[list[VectorLike]]): a 2D list of points
tol (float, optional): tolerance of the algorithm. Defaults to 1e-2.
smoothing (Tuple[float, float, float], optional): optional tuple of
3 weights use for variational smoothing. Defaults to None.
min_deg (int, optional): minimum spline degree. Enforced only when
smoothing is None. Defaults to 1.
max_deg (int, optional): maximum spline degree. Defaults to 3.
Raises:
ValueError: _description_
Returns:
Face: a potentially non-planar face defined by points
"""
points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0]))
for i, point_row in enumerate(points):
for j, point in enumerate(point_row):
points_.SetValue(i + 1, j + 1, Vector(point).to_pnt())
if smoothing:
spline_builder = GeomAPI_PointsToBSplineSurface(
points_, *smoothing, DegMax=max_deg, Tol3D=tol
)
else:
spline_builder = GeomAPI_PointsToBSplineSurface(
points_, DegMin=min_deg, DegMax=max_deg, Tol3D=tol
)
if not spline_builder.IsDone():
raise ValueError("B-spline approximation failed")
spline_geom = spline_builder.Surface()
return cls(BRepBuilderAPI_MakeFace(spline_geom, Precision.Confusion_s()).Face())
@classmethod
def make_surface(
cls,
exterior: Union[Wire, list[Edge]],
surface_points: list[VectorLike] = None,
interior_wires: list[Wire] = None,
) -> Face:
"""Create Non-Planar Face
Create a potentially non-planar face bounded by exterior (wire or edges),
optionally refined by surface_points with optional holes defined by
interior_wires.
Args:
exterior (Union[Wire, list[Edge]]): Perimeter of face
surface_points (list[VectorLike], optional): Points on the surface that
refine the shape. Defaults to None.
interior_wires (list[Wire], optional): Hole(s) in the face. Defaults to None.
Raises:
RuntimeError: Internal error building face
RuntimeError: Error building non-planar face with provided surface_points
RuntimeError: Error adding interior hole
RuntimeError: Generated face is invalid
Returns:
Face: Potentially non-planar face
"""
if surface_points:
surface_points = [Vector(p) for p in surface_points]
else:
surface_points = None
# First, create the non-planar surface
surface = BRepOffsetAPI_MakeFilling(
# order of energy criterion to minimize for computing the deformation of the surface
Degree=3,
# average number of points for discretisation of the edges
NbPtsOnCur=15,
NbIter=2,
Anisotropie=False,
# the maximum distance allowed between the support surface and the constraints
Tol2d=0.00001,
# the maximum distance allowed between the support surface and the constraints
Tol3d=0.0001,
# the maximum angle allowed between the normal of the surface and the constraints
TolAng=0.01,
# the maximum difference of curvature allowed between the surface and the constraint
TolCurv=0.1,
# the highest degree which the polynomial defining the filling surface can have
MaxDeg=8,
# the greatest number of segments which the filling surface can have
MaxSegments=9,
)
if isinstance(exterior, Wire):
outside_edges = exterior.edges()
else:
outside_edges = exterior
for edge in outside_edges:
surface.Add(edge.wrapped, GeomAbs_C0)
try:
surface.Build()
surface_face = Face(surface.Shape())
except (StdFail_NotDone, Standard_NoSuchObject) as err:
raise RuntimeError(
"Error building non-planar face with provided exterior"
) from err
if surface_points:
for point in surface_points:
surface.Add(gp_Pnt(*point.to_tuple()))
try:
surface.Build()
surface_face = Face(surface.Shape())
except StdFail_NotDone as err:
raise RuntimeError(
"Error building non-planar face with provided surface_points"
) from err
# Next, add wires that define interior holes - note these wires must be entirely interior
if interior_wires:
makeface_object = BRepBuilderAPI_MakeFace(surface_face.wrapped)
for wire in interior_wires:
makeface_object.Add(wire.wrapped)
try:
surface_face = Face(makeface_object.Face())
except StdFail_NotDone as err:
raise RuntimeError(
"Error adding interior hole in non-planar face with provided interior_wires"
) from err
surface_face = surface_face.fix()
if not surface_face.is_valid():
raise RuntimeError("non planar face is invalid")
return surface_face
def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Face:
"""Apply 2D fillet to a face
Args:
radius: float:
vertices: Iterable[Vertex]:
Returns:
"""
fillet_builder = BRepFilletAPI_MakeFillet2d(self.wrapped)
for vertex in vertices:
fillet_builder.AddFillet(vertex.wrapped, radius)
fillet_builder.Build()
return self.__class__(fillet_builder.Shape())
def chamfer_2d(self, distance: float, vertices: Iterable[Vertex]) -> Face:
"""Apply 2D chamfer to a face
Args:
distance: float:
vertices: Iterable[Vertex]:
Returns:
"""
chamfer_builder = BRepFilletAPI_MakeFillet2d(self.wrapped)
edge_map = self._entities_from(Vertex.__name__, Edge.__name__)
for vertex in vertices:
edges = edge_map[vertex]
if len(edges) < 2:
raise ValueError("Cannot chamfer at this location")
edge1, edge2 = edges
chamfer_builder.AddChamfer(
TopoDS.Edge_s(edge1.wrapped),
TopoDS.Edge_s(edge2.wrapped),
distance,
distance,
)
chamfer_builder.Build()
return self.__class__(chamfer_builder.Shape()).fix()
def to_pln(self) -> gp_Pln:
"""Convert this face to a gp_Pln.
Note the Location of the resulting plane may not equal the center of this face,
however the resulting plane will still contain the center of this face.
Args:
Returns:
"""
adaptor = BRepAdaptor_Surface(self.wrapped)
plane = adaptor.Plane()
# Potentially flip the plane to align the plane and face normal
if self.geom_type() == "PLANE":
plane_ax1: gp_Ax1 = plane.Axis()
face_center = self.center()
face_normal = self.normal_at(face_center)
face_ax1 = gp_Ax1(face_center.to_pnt(), face_normal.to_dir())
if plane_ax1.IsOpposite(face_ax1, TOL):
plane = plane.Rotated(plane.XAxis(), pi)
return plane
def thicken(self, depth: float, direction: VectorLike = None) -> Solid:
"""Thicken Face
Create a solid from a potentially non planar face by thickening along the normals.
.. image:: thickenFace.png
Non-planar faces are thickened both towards and away from the center of the sphere.
Args:
depth (float): Amount to thicken face(s), can be positive or negative.
direction (Vector, optional): The direction vector can be used to
indicate which way is 'up', potentially flipping the face normal direction
such that many faces with different normals all go in the same direction
(direction need only be +/- 90 degrees from the face normal). Defaults to None.
Raises:
RuntimeError: Opencascade internal failures
Returns:
Solid: The resulting Solid object
"""
# Check to see if the normal needs to be flipped
adjusted_depth = depth
if direction is not None:
face_center = self.center()
face_normal = self.normal_at(face_center).normalized()
if face_normal.dot(Vector(direction).normalized()) < 0:
adjusted_depth = -depth
solid = BRepOffset_MakeOffset()
solid.Initialize(
self.wrapped,
Offset=adjusted_depth,
Tol=1.0e-5,
Mode=BRepOffset_Skin,
# BRepOffset_RectoVerso - which describes the offset of a given surface shell along both
# sides of the surface but doesn't seem to work
Intersection=True,
SelfInter=False,
Join=GeomAbs_Intersection, # Could be GeomAbs_Arc,GeomAbs_Tangent,GeomAbs_Intersection
Thickening=True,
RemoveIntEdges=True,
)
solid.MakeOffsetShape()
try:
result = Solid(solid.Shape())
except StdFail_NotDone as err:
raise RuntimeError("Error applying thicken to given Face") from err
return result.clean()
@classmethod
def construct_on(cls, face: Face, outer: Wire, *inner_wires: Wire) -> Face:
"""Create a new face on top of an existing Face"""
bldr = BRepBuilderAPI_MakeFace(face._geom_adaptor(), outer.wrapped)
for inner_wire in inner_wires:
bldr.Add(TopoDS.Wire_s(inner_wire.wrapped))
return cls(bldr.Face()).fix()
def project_to_shape(
self, target_object: Shape, direction: VectorLike, taper: float = 0
) -> ShapeList[Face]:
"""Project Face to target Object
Project a Face onto a Shape generating new Face(s) on the surfaces of the object.
A projection with no taper is illustrated below:
.. image:: flatProjection.png
:alt: flatProjection
Note that an array of faces is returned as the projection might result in faces
on the "front" and "back" of the object (or even more if there are intermediate
surfaces in the projection path). faces "behind" the projection are not
returned.
Args:
target_object (Shape): Object to project onto
direction (VectorLike): projection direction
taper (float, optional): taper angle. Defaults to 0.
Returns:
ShapeList[Face]: Face(s) projected on target object ordered by distance
"""
max_dimension = (
Compound.make_compound([self, target_object]).bounding_box().diagonal
)
face_extruded = Solid.extrude_linear(
self, Vector(direction) * max_dimension, taper=taper
)
intersected_faces = ShapeList()
for target_face in target_object.faces():
intersected_faces.extend(face_extruded.intersect(target_face).faces())
return intersected_faces.sort_by(Axis(self.center(), direction))
def make_holes(self, interior_wires: list[Wire]) -> Face:
"""Make Holes in Face
Create holes in the Face 'self' from interior_wires which must be entirely interior.
Note that making holes in faces is more efficient than using boolean operations
with solid object. Also note that OCCT core may fail unless the orientation of the wire
is correct - use `Wire(forward_wire.wrapped.Reversed())` to reverse a wire.
Example:
For example, make a series of slots on the curved walls of a cylinder.
.. image:: slotted_cylinder.png
Args:
interior_wires: a list of hole outline wires
interior_wires: list[Wire]:
Returns:
Face: 'self' with holes
Raises:
RuntimeError: adding interior hole in non-planar face with provided interior_wires
RuntimeError: resulting face is not valid
"""
# Add wires that define interior holes - note these wires must be entirely interior
makeface_object = BRepBuilderAPI_MakeFace(self.wrapped)
for interior_wire in interior_wires:
makeface_object.Add(interior_wire.wrapped)
try:
surface_face = Face(makeface_object.Face())
except StdFail_NotDone as err:
raise RuntimeError(
"Error adding interior hole in non-planar face with provided interior_wires"
) from err
surface_face = surface_face.fix()
# if not surface_face.is_valid():
# raise RuntimeError("non planar face is invalid")
return surface_face
def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool:
"""Point inside Face
Returns whether or not the point is inside a Face within the specified tolerance.
Points on the edge of the Face are considered inside.
Args:
point(VectorLike): tuple or Vector representing 3D point to be tested
tolerance(float): tolerance for inside determination. Defaults to 1.0e-6.
point: VectorLike:
tolerance: float: (Default value = 1.0e-6)
Returns:
bool: indicating whether or not point is within Face
"""
return Compound.make_compound([self]).is_inside(point, tolerance)
@classmethod
def import_stl(cls, file_name: str) -> Face:
"""import_stl
Extract shape from an STL file and return them as a Face object.
Args:
file_name (str): file path of STL file to import
Raises:
ValueError: Could not import file
Returns:
Face: contents of STL file
"""
# Now read and return the shape
reader = RWStl.ReadFile_s(file_name)
face = TopoDS_Face()
BRep_Builder().MakeFace(face, reader)
if face.IsNull():
raise ValueError(f"Could not import {file_name}")
return cls.cast(face)
class Shell(Shape):
"""the outer boundary of a surface"""
@classmethod
def make_shell(cls, faces: Iterable[Face]) -> Shell:
"""Create a Shell from provided faces"""
shell_builder = BRepBuilderAPI_Sewing()
for face in faces:
shell_builder.Add(face.wrapped)
shell_builder.Perform()
shape = shell_builder.SewedShape()
return cls(shape)
def center(self) -> Vector:
"""Center of mass of the shell"""
properties = GProp_GProps()
BRepGProp.LinearProperties_s(self.wrapped, properties)
return Vector(properties.CentreOfMass())
class Solid(Shape, Mixin3D):
"""a single solid"""
@classmethod
def make_solid(cls, shell: Shell) -> Solid:
"""Create a Solid object from the surface shell"""
return cls(ShapeFix_Solid().SolidFromShell(shell.wrapped))
@classmethod
def make_box(
cls, length: float, width: float, height: float, plane: Plane = Plane.XY
) -> Solid:
"""make box
Make a box at the origin of plane extending in positive direction of each axis.
Args:
length (float):
width (float):
height (float):
plane (Plane, optional): base plane. Defaults to Plane.XY.
Returns:
Solid: Box
"""
return cls(
BRepPrimAPI_MakeBox(
plane.to_gp_ax2(),
length,
width,
height,
).Shape()
)
@classmethod
def make_cone(
cls,
base_radius: float,
top_radius: float,
height: float,
plane: Plane = Plane.XY,
angle: float = 360,
) -> Solid:
"""make cone
Make a cone with given radii and height
Args:
base_radius (float):
top_radius (float):
height (float):
plane (Plane): base plane. Defaults to Plane.XY.
angle (float, optional): arc size. Defaults to 360.
Returns:
Solid: Full or partial cone
"""
return cls(
BRepPrimAPI_MakeCone(
plane.to_gp_ax2(),
base_radius,
top_radius,
height,
angle * DEG2RAD,
).Shape()
)
@classmethod
def make_cylinder(
cls,
radius: float,
height: float,
plane: Plane = Plane.XY,
angle: float = 360,
) -> Solid:
"""make cylinder
Make a cylinder with a given radius and height with the base center on plane origin.
Args:
radius (float):
height (float):
plane (Plane): base plane. Defaults to Plane.XY.
angle (float, optional): arc size. Defaults to 360.
Returns:
Solid: Full or partial cylinder
"""
return cls(
BRepPrimAPI_MakeCylinder(
plane.to_gp_ax2(),
radius,
height,
angle * DEG2RAD,
).Shape()
)
@classmethod
def make_torus(
cls,
major_radius: float,
minor_radius: float,
plane: Plane = Plane.XY,
start_angle: float = 0,
end_angle: float = 360,
major_angle: float = 360,
) -> Solid:
"""make torus
Make a torus with a given radii and angles
Args:
major_radius (float):
minor_radius (float):
plane (Plane): base plane. Defaults to Plane.XY.
start_angle (float, optional): start major arc. Defaults to 0.
end_angle (float, optional): end major arc. Defaults to 360.
Returns:
Solid: Full or partial torus
"""
return cls(
BRepPrimAPI_MakeTorus(
plane.to_gp_ax2(),
major_radius,
minor_radius,
start_angle * DEG2RAD,
end_angle * DEG2RAD,
major_angle * DEG2RAD,
).Shape()
)
@classmethod
def make_loft(cls, wires: list[Wire], ruled: bool = False) -> Solid:
"""make loft
Makes a loft from a list of wires.
Args:
wires (list[Wire]): section perimeters
ruled (bool, optional): stepped or smooth. Defaults to False (smooth).
Raises:
ValueError: Too few wires
Returns:
Solid: Lofted object
"""
# the True flag requests building a solid instead of a shell.
if len(wires) < 2:
raise ValueError("More than one wire is required")
loft_builder = BRepOffsetAPI_ThruSections(True, ruled)
for wire in wires:
loft_builder.AddWire(wire.wrapped)
loft_builder.Build()
return cls(loft_builder.Shape())
@classmethod
def make_wedge(
cls,
delta_x: float,
delta_y: float,
delta_z: float,
min_x: float,
min_z: float,
max_x: float,
max_z: float,
plane: Plane = Plane.XY,
) -> Solid:
"""Make a wedge
Args:
delta_x (float):
delta_y (float):
delta_z (float):
min_x (float):
min_z (float):
max_x (float):
max_z (float):
plane (Plane): base plane. Defaults to Plane.XY.
Returns:
Solid: wedge
"""
return cls(
BRepPrimAPI_MakeWedge(
plane.to_gp_ax2(),
delta_x,
delta_y,
delta_z,
min_x,
min_z,
max_x,
max_z,
).Solid()
)
@classmethod
def make_sphere(
cls,
radius: float,
plane: Plane = Plane.XY,
angle1: float = -90,
angle2: float = 90,
angle3: float = 360,
) -> Shape:
"""Sphere
Make a full or partial sphere - with a given radius center on the origin or plane.
Args:
radius (float):
plane (Plane): base plane. Defaults to Plane.XY.
angle1 (float, optional): Defaults to -90.
angle2 (float, optional): Defaults to 90.
angle3 (float, optional): Defaults to 360.
Returns:
Shape: sphere
"""
return cls(
BRepPrimAPI_MakeSphere(
plane.to_gp_ax2(),
radius,
angle1 * DEG2RAD,
angle2 * DEG2RAD,
angle3 * DEG2RAD,
).Shape()
)
@classmethod
def extrude_linear(
cls,
section: Union[Face, Wire],
normal: VectorLike,
inner_wires: list[Wire] = None,
taper: float = 0,
) -> Solid:
"""Extrude a cross section
Extrude a cross section into a prismatic solid in the provided direction.
The wires must not intersect.
Extruding wires is very non-trivial. Nested wires imply very different geometry, and
there are many geometries that are invalid. In general, the following conditions
must be met:
* all wires must be closed
* there cannot be any intersecting or self-intersecting wires
* wires must be listed from outside in
* more than one levels of nesting is not supported reliably
Args:
section (Union[Face,Wire]): cross section
normal (VectorLike): a vector along which to extrude the wires. The length
of the vector controls the length of the extrusion.
inner_wires (list[Wire], optional): holes - only used if section is a Wire.
Defaults to None.
taper (float, optional): taper angle. Defaults to 0.
Returns:
Solid: extruded cross section
"""
inner_wires = inner_wires if inner_wires else []
normal = Vector(normal)
if isinstance(section, Wire):
# TODO: Should the normal of this face be forced to align with the extrusion normal?
section_face = Face.make_from_wires(section, inner_wires)
else:
section_face = section
if taper == 0:
prism_builder: Any = BRepPrimAPI_MakePrism(
section_face.wrapped, normal.wrapped, True
)
else:
face_normal = section_face.normal_at()
direction = 1 if normal.get_angle(face_normal) < 90 else -1
prism_builder = LocOpe_DPrism(
section_face.wrapped,
direction * normal.length,
direction * taper * DEG2RAD,
)
return cls(prism_builder.Shape())
@classmethod
def extrude_linear_with_rotation(
cls,
section: Union[Face, Wire],
center: VectorLike,
normal: VectorLike,
angle: float,
inner_wires: list[Wire] = None,
) -> Solid:
"""Extrude with Rotation
Creates a 'twisted prism' by extruding, while simultaneously rotating around the
extrusion vector.
Args:
section (Union[Face,Wire]): cross section
vec_center (VectorLike): the center point about which to rotate
vec_normal (VectorLike): a vector along which to extrude the wires
angle (float): the angle to rotate through while extruding
inner_wires (list[Wire], optional): holes - only used if section is of type Wire.
Defaults to None.
Returns:
Solid: extruded object
"""
# Though the signature may appear to be similar enough to extrude_linear to merit
# combining them, the construction methods used here are different enough that they
# should be separate.
# At a high level, the steps followed are:
# (1) accept a set of wires
# (2) create another set of wires like this one, but which are transformed and rotated
# (3) create a ruledSurface between the sets of wires
# (4) create a shell and compute the resulting object
inner_wires = inner_wires if inner_wires else []
center = Vector(center)
normal = Vector(normal)
def extrude_aux_spine(
wire: TopoDS_Wire, spine: TopoDS_Wire, aux_spine: TopoDS_Wire
) -> TopoDS_Shape:
"""Helper function"""
extrude_builder = BRepOffsetAPI_MakePipeShell(spine)
extrude_builder.SetMode(aux_spine, False) # auxiliary spine
extrude_builder.Add(wire)
extrude_builder.Build()
extrude_builder.MakeSolid()
return extrude_builder.Shape()
if isinstance(section, Face):
outer_wire = section.outer_wire()
inner_wires = section.inner_wires()
else:
outer_wire = section
# make straight spine
straight_spine_e = Edge.make_line(center, center.add(normal))
straight_spine_w = Wire.combine(
[
straight_spine_e,
]
)[0].wrapped
# make an auxiliary spine
pitch = 360.0 / angle * normal.length
aux_spine_w = Wire.make_helix(
pitch, normal.length, 1, center=center, normal=normal
).wrapped
# extrude the outer wire
outer_solid = extrude_aux_spine(
outer_wire.wrapped, straight_spine_w, aux_spine_w
)
# extrude inner wires
inner_solids = [
Shape(extrude_aux_spine(w.wrapped, straight_spine_w, aux_spine_w))
for w in inner_wires
]
# combine the inner solids into compound
inner_comp = Compound.make_compound(inner_solids).wrapped
# subtract from the outer solid
return Solid(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape())
@classmethod
def extrude_until(
cls,
section: Face,
target_object: Union[Compound, Solid],
direction: VectorLike,
until: Until = Until.NEXT,
) -> Union[Compound, Solid]:
"""extrude_until
Extrude section in provided direction until it encounters either the
NEXT or LAST surface of target_object. Note that the bounding surface
must be larger than the extruded face where they contact.
Args:
section (Face): Face to extrude
target_object (Union[Compound, Solid]): object to limit extrusion
direction (VectorLike): extrusion direction
until (Until, optional): surface to limit extrusion. Defaults to Until.NEXT.
Raises:
ValueError: provided face does not intersect target_object
Returns:
Union[Compound, Solid]: extruded Face
"""
direction = Vector(direction)
max_dimension = (
Compound.make_compound([section, target_object]).bounding_box().diagonal
)
clipping_direction = (
direction * max_dimension
if until == Until.NEXT
else -direction * max_dimension
)
direction_axis = Axis(section.center(), clipping_direction)
# Create a linear extrusion to start
extrusion = Solid.extrude_linear(section, direction * max_dimension)
# Project section onto the shape to generate faces that will clip the extrusion
# and exclude the planar faces normal to the direction of extrusion and these
# will have no volume when extruded
clip_faces = [
f
for f in section.project_to_shape(target_object, direction)
if not (f.geom_type() == "PLANE" and f.normal_at().dot(direction) == 0.0)
]
if not clip_faces:
raise ValueError("provided face does not intersect target_object")
# Create the objects that will clip the linear extrusion
clipping_objects = [
Solid.extrude_linear(f, clipping_direction).fix() for f in clip_faces
]
if until == Until.NEXT:
extrusion = extrusion.cut(target_object)
for clipping_object in clipping_objects:
# It's possible for clipping faces to self intersect when they are extruded
# thus they could be non manifold which results failed boolean operations
# - so skip these objects
try:
extrusion = (
extrusion.cut(clipping_object)
.solids()
.sort_by(direction_axis)[0]
)
except:
warnings.warn("clipping error - extrusion may be incorrect")
else:
extrusion_parts = [extrusion.intersect(target_object)]
for clipping_object in clipping_objects:
try:
extrusion_parts.append(
extrusion.intersect(clipping_object)
.solids()
.sort_by(direction_axis)[0]
)
except:
warnings.warn("clipping error - extrusion may be incorrect")
extrusion = Shape.fuse(*extrusion_parts)
return extrusion
@classmethod
def revolve(
cls,
section: Union[Face, Wire],
angle: float,
axis: Axis,
inner_wires: list[Wire] = None,
) -> Solid:
"""Revolve
Revolve a cross section about the given Axis by the given angle.
Args:
section (Union[Face,Wire]): cross section
angle (float): the angle to revolve through
axis (Axis): rotation Axis
inner_wires (list[Wire], optional): holes - only used if section is of type Wire.
Defaults to [].
Returns:
Solid: the revolved cross section
"""
inner_wires = inner_wires if inner_wires else []
if isinstance(section, Wire):
section_face = Face.make_from_wires(section, inner_wires)
else:
section_face = section
revol_builder = BRepPrimAPI_MakeRevol(
section_face.wrapped,
axis.wrapped,
angle * DEG2RAD,
True,
)
return cls(revol_builder.Shape())
_transModeDict = {
Transition.TRANSFORMED: BRepBuilderAPI_Transformed,
Transition.ROUND: BRepBuilderAPI_RoundCorner,
Transition.RIGHT: BRepBuilderAPI_RightCorner,
}
@classmethod
def _set_sweep_mode(
cls,
builder: BRepOffsetAPI_MakePipeShell,
path: Union[Wire, Edge],
mode: Union[Vector, Wire, Edge],
) -> bool:
rotate = False
if isinstance(mode, Vector):
coordinate_system = gp_Ax2()
coordinate_system.SetLocation(path.start_point().to_pnt())
coordinate_system.SetDirection(mode.to_dir())
builder.SetMode(coordinate_system)
rotate = True
elif isinstance(mode, (Wire, Edge)):
builder.SetMode(mode.to_wire().wrapped, True)
return rotate
@classmethod
def sweep(
cls,
section: Union[Face, Wire],
path: Union[Wire, Edge],
inner_wires: list[Wire] = None,
make_solid: bool = True,
is_frenet: bool = False,
mode: Union[Vector, Wire, Edge, None] = None,
transition: Transition = Transition.TRANSFORMED,
) -> Solid:
"""Sweep
Sweep the given cross section into a prismatic solid along the provided path
Args:
section (Union[Face, Wire]): cross section to sweep
path (Union[Wire, Edge]): sweep path
inner_wires (list[Wire]): holes - only used if section is a wire
make_solid (bool, optional): return Solid or Shell. Defaults to True.
is_frenet (bool, optional): Frenet mode. Defaults to False.
mode (Union[Vector, Wire, Edge, None], optional): additional sweep
mode parameters. Defaults to None.
transition (Transition, optional): handling of profile orientation at C1 path
discontinuities. Defaults to Transition.TRANSFORMED.
Returns:
Solid: the swept cross section
"""
if isinstance(section, Face):
outer_wire = section.outer_wire()
inner_wires = section.inner_wires()
else:
outer_wire = section
inner_wires = inner_wires if inner_wires else []
shapes = []
for wire in [outer_wire] + inner_wires:
builder = BRepOffsetAPI_MakePipeShell(path.to_wire().wrapped)
rotate = False
# handle sweep mode
if mode:
rotate = Solid._set_sweep_mode(builder, path, mode)
else:
builder.SetMode(is_frenet)
builder.SetTransitionMode(Solid._transModeDict[transition])
builder.Add(wire.wrapped, False, rotate)
builder.Build()
if make_solid:
builder.MakeSolid()
shapes.append(Shape.cast(builder.Shape()))
return_value, inner_shapes = shapes[0], shapes[1:]
if inner_shapes:
return_value = return_value.cut(*inner_shapes)
return return_value
@classmethod
def sweep_multi(
cls,
profiles: Iterable[Union[Wire, Face]],
path: Union[Wire, Edge],
make_solid: bool = True,
is_frenet: bool = False,
mode: Union[Vector, Wire, Edge, None] = None,
) -> Solid:
"""Multi section sweep
Sweep through a sequence of profiles following a path.
Args:
profiles (Iterable[Union[Wire, Face]]): list of profiles
path (Union[Wire, Edge]): The wire to sweep the face resulting from the wires over
make_solid (bool, optional): Solid or Shell. Defaults to True.
is_frenet (bool, optional): Select frenet mode. Defaults to False.
mode (Union[Vector, Wire, Edge, None], optional): additional sweep mode parameters.
Defaults to None.
Returns:
Solid: swept object
"""
path_as_wire = path.to_wire().wrapped
builder = BRepOffsetAPI_MakePipeShell(path_as_wire)
translate = False
rotate = False
if mode:
rotate = cls._set_sweep_mode(builder, path, mode)
else:
builder.SetMode(is_frenet)
for profile in profiles:
path_as_wire = (
profile.wrapped
if isinstance(profile, Wire)
else profile.outer_wire().wrapped
)
builder.Add(path_as_wire, translate, rotate)
builder.Build()
if make_solid:
builder.MakeSolid()
return cls(builder.Shape())
class Vertex(Shape):
"""A Single Point in Space"""
@overload
def __init__(self): # pragma: no cover
...
@overload
def __init__(self, obj: TopoDS_Vertex): # pragma: no cover
...
@overload
def __init__(self, X: float, Y: float, Z: float): # pragma: no cover
...
def __init__(self, *args):
if len(args) == 0:
self.wrapped = downcast(
BRepBuilderAPI_MakeVertex(gp_Pnt(0.0, 0.0, 0.0)).Vertex()
)
elif len(args) == 1 and isinstance(args[0], TopoDS_Vertex):
self.wrapped = args[0]
elif len(args) == 3 and all(isinstance(v, (int, float)) for v in args):
self.wrapped = downcast(
BRepBuilderAPI_MakeVertex(gp_Pnt(args[0], args[1], args[2])).Vertex()
)
else:
raise ValueError(
"Invalid Vertex - expected three floats or OCC TopoDS_Vertex"
)
self.X, self.Y, self.Z = self.to_tuple()
super().__init__(self.wrapped)
def to_tuple(self) -> tuple[float, float, float]:
"""Return vertex as three tuple of floats"""
geom_point = BRep_Tool.Pnt_s(self.wrapped)
return (geom_point.X(), geom_point.Y(), geom_point.Z())
def center(self) -> Vector:
"""The center of a vertex is itself!"""
return Vector(self.to_tuple())
def __add__(
self, other: Union[Vertex, Vector, Tuple[float, float, float]]
) -> Vertex:
"""Add
Add to a Vertex with a Vertex, Vector or Tuple
Args:
other: Value to add
Raises:
TypeError: other not in [Tuple,Vector,Vertex]
Returns:
Result
Example:
part.faces(">z").vertices("<y and <x").val() + (0, 0, 15)
which creates a new Vertex 15 above one extracted from a part. One can add or
subtract a `Vertex` , `Vector` or `tuple` of float values to a Vertex.
"""
if isinstance(other, Vertex):
new_vertex = Vertex(self.X + other.X, self.Y + other.Y, self.Z + other.Z)
elif isinstance(other, (Vector, tuple)):
new_other = Vector(other)
new_vertex = Vertex(
self.X + new_other.X, self.Y + new_other.Y, self.Z + new_other.Z
)
else:
raise TypeError(
"Vertex addition only supports Vertex,Vector or tuple(float,float,float) as input"
)
return new_vertex
def __sub__(self, other: Union[Vertex, Vector, tuple]) -> Vertex:
"""Subtract
Substract a Vertex with a Vertex, Vector or Tuple from self
Args:
other: Value to add
Raises:
TypeError: other not in [Tuple,Vector,Vertex]
Returns:
Result
Example:
part.faces(">z").vertices("<y and <x").val() - Vector(10, 0, 0)
"""
if isinstance(other, Vertex):
new_vertex = Vertex(self.X - other.X, self.Y - other.Y, self.Z - other.Z)
elif isinstance(other, (Vector, tuple)):
new_other = Vector(other)
new_vertex = Vertex(
self.X - new_other.X, self.Y - new_other.Y, self.Z - new_other.Z
)
else:
raise TypeError(
"Vertex subtraction only supports Vertex,Vector or tuple(float,float,float)"
)
return new_vertex
def __repr__(self) -> str:
"""To String
Convert Vertex to String for display
Returns:
Vertex as String
"""
return f"Vertex: ({self.X}, {self.Y}, {self.Z})"
def to_vector(self) -> Vector:
"""To Vector
Convert a Vertex to Vector
Returns:
Vector: representation of Vertex
"""
return Vector(self.to_tuple())
class Wire(Shape, Mixin1D):
"""A series of connected, ordered edges, that typically bounds a Face"""
def _geom_adaptor(self) -> BRepAdaptor_CompCurve:
""" """
return BRepAdaptor_CompCurve(self.wrapped)
def close(self) -> Wire:
"""Close a Wire"""
if not self.is_closed():
edge = Edge.make_line(self.end_point(), self.start_point())
return_value = Wire.combine((self, edge))[0]
else:
return_value = self
return return_value
def to_wire(self) -> Wire:
"""Return Wire - used as a pair with Edge.to_wire when self is Wire | Edge"""
return self
@classmethod
def combine(
cls, wires: Iterable[Union[Wire, Edge]], tol: float = 1e-9
) -> list[Wire]:
"""Attempt to combine a list of wires and edges into a new wire.
Args:
cls: param list_of_wires:
tol: default 1e-9
wires: Iterable[Union[Wire:
Edge]]:
tol: float: (Default value = 1e-9)
Returns:
list[Wire]
"""
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
for edge in Compound.make_compound(wires).edges():
edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
return [cls(wire) for wire in wires_out]
@classmethod
def make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> Wire:
"""make_wire
Build a Wire from the provided unsorted Edges. If sequenced is True the
Edges are placed in such that the end of the nth Edge is coincident with
the n+1th Edge forming an unbroken sequence. Note that sequencing a list
is relatively slow.
Args:
edges (Iterable[Edge]): Edges to assemble
sequenced (bool, optional): arrange in order. Defaults to False.
Raises:
ValueError: Edges are disconnected and can't be sequenced.
RuntimeError: Wire is empty
Returns:
Wire: assembled edges
"""
def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge:
"""Return the Edge closest to the end of last_edge"""
target_point = current.position_at(1)
sorted_edges = sorted(
unplaced_edges,
key=lambda e: min(
(target_point - e.position_at(0)).length,
(target_point - e.position_at(1)).length,
),
)
return sorted_edges[0]
edges = list(edges)
if sequenced:
placed_edges = [edges.pop(0)]
unplaced_edges = edges
while unplaced_edges:
next_edge = closest_to_end(Wire.make_wire(placed_edges), unplaced_edges)
next_edge_index = unplaced_edges.index(next_edge)
placed_edges.append(unplaced_edges.pop(next_edge_index))
edges = placed_edges
wire_builder = BRepBuilderAPI_MakeWire()
for edge in edges:
wire_builder.Add(edge.wrapped)
if sequenced and wire_builder.Error() == BRepBuilderAPI_DisconnectedWire:
raise ValueError("Edges are disconnected")
wire_builder.Build()
if not wire_builder.IsDone():
if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire:
warnings.warn("Wire is non manifold")
elif wire_builder.Error() == BRepBuilderAPI_EmptyWire:
raise RuntimeError("Wire is empty")
return cls(wire_builder.Wire())
@classmethod
def make_circle(cls, radius: float, plane: Plane = Plane.XY) -> Wire:
"""make_circle
Makes a circle centered at the origin of plane
Args:
radius (float): circle radius
plane (Plane): base plane. Defaults to Plane.XY
Returns:
Wire: a circle
"""
circle_edge = Edge.make_circle(radius, plane=plane)
return cls.make_wire([circle_edge])
@classmethod
def make_ellipse(
cls,
x_radius: float,
y_radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360.0,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
closed: bool = True,
) -> Wire:
"""make ellipse
Makes an ellipse centered at the origin of plane.
Args:
x_radius (float): x radius of the ellipse (along the x-axis of plane)
y_radius (float): y radius of the ellipse (along the y-axis of plane)
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): _description_. Defaults to 360.0.
end_angle (float, optional): _description_. Defaults to 360.0.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
closed (bool, optional): close the arc. Defaults to True.
Returns:
Wire: an ellipse
"""
ellipse_edge = Edge.make_ellipse(
x_radius, y_radius, plane, start_angle, end_angle, angular_direction
)
if start_angle != end_angle and closed:
line = Edge.make_line(ellipse_edge.end_point(), ellipse_edge.start_point())
wire = cls.make_wire([ellipse_edge, line])
else:
wire = cls.make_wire([ellipse_edge])
return wire
@classmethod
def make_polygon(cls, vertices: Iterable[VectorLike], close: bool = True) -> Wire:
"""make_polygon
Create an irregular polygon by defining vertices
Args:
vertices (Iterable[VectorLike]):
close (bool, optional): close the polygon. Defaults to True.
Returns:
Wire: an irregular polygon
"""
vertices = [Vector(v) for v in vertices]
if (vertices[0] - vertices[-1]).length > TOLERANCE and close:
vertices.append(vertices[0])
wire_builder = BRepBuilderAPI_MakePolygon()
for vertex in vertices:
wire_builder.Add(vertex.to_pnt())
return cls(wire_builder.Wire())
@classmethod
def make_helix(
cls,
pitch: float,
height: float,
radius: float,
center: VectorLike = (0, 0, 0),
normal: VectorLike = (0, 0, 1),
angle: float = 0.0,
lefthand: bool = False,
) -> Wire:
"""make_helix
Make a helix with a given pitch, height and radius. By default a cylindrical surface is
used to create the helix. If the :angle: is set (the apex given in degree) a conical
surface is used instead.
Args:
pitch (float): distance per revolution along normal
height (float): total height
radius (float):
center (VectorLike, optional): Defaults to (0, 0, 0).
normal (VectorLike, optional): Defaults to (0, 0, 1).
angle (float, optional): conical angle. Defaults to 0.0.
lefthand (bool, optional): Defaults to False.
Returns:
Wire: helix
"""
# 1. build underlying cylindrical/conical surface
if angle == 0.0:
geom_surf: Geom_Surface = Geom_CylindricalSurface(
gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), radius
)
else:
geom_surf = Geom_ConicalSurface(
gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()),
angle * DEG2RAD,
radius,
)
# 2. construct an segment in the u,v domain
if lefthand:
geom_line = Geom2d_Line(gp_Pnt2d(0.0, 0.0), gp_Dir2d(-2 * pi, pitch))
else:
geom_line = Geom2d_Line(gp_Pnt2d(0.0, 0.0), gp_Dir2d(2 * pi, pitch))
# 3. put it together into a wire
u_start = geom_line.Value(0.0)
u_stop = geom_line.Value((height / pitch) * sqrt((2 * pi) ** 2 + pitch**2))
geom_seg = GCE2d_MakeSegment(u_start, u_stop).Value()
topo_edge = BRepBuilderAPI_MakeEdge(geom_seg, geom_surf).Edge()
# 4. Convert to wire and fix building 3d geom from 2d geom
wire = BRepBuilderAPI_MakeWire(topo_edge).Wire()
BRepLib.BuildCurves3d_s(wire, 1e-6, MaxSegment=2000) # NB: preliminary values
return cls(wire)
def stitch(self, other: Wire) -> Wire:
"""Attempt to stich wires
Args:
other: Wire:
Returns:
"""
wire_builder = BRepBuilderAPI_MakeWire()
wire_builder.Add(TopoDS.Wire_s(self.wrapped))
wire_builder.Add(TopoDS.Wire_s(other.wrapped))
wire_builder.Build()
return self.__class__(wire_builder.Wire())
def offset_2d(self, distance: float, kind: Kind = Kind.ARC) -> list[Wire]:
"""Wire Offset
Offsets a planar wire
Args:
distance (float): distance from wire to offset
kind (Kind, optional): offset corner transition. Defaults to Kind.ARC.
Returns:
list[Wire]: offset wires
"""
kind_dict = {
Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
}
offset = BRepOffsetAPI_MakeOffset()
offset.Init(kind_dict[kind])
offset.AddWire(self.wrapped)
offset.Perform(distance)
obj = downcast(offset.Shape())
if isinstance(obj, TopoDS_Compound):
return_value = [self.__class__(el.wrapped) for el in Compound(obj)]
else:
return_value = [self.__class__(obj)]
return return_value
def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Wire:
"""fillet_2d
Apply 2D fillet to a wire
Args:
radius (float):
vertices (Iterable[Vertex]): vertices to fillet
Returns:
Wire: filleted wire
"""
return Face.make_from_wires(self).fillet_2d(radius, vertices).outer_wire()
def chamfer_2d(self, distance: float, vertices: Iterable[Vertex]) -> Wire:
"""chamfer_2d
Apply 2D chamfer to a wire
Args:
distance (float): chamfer length
vertices (Iterable[Vertex]): vertices to chamfer
Returns:
Wire: chamfered wire
"""
return Face.make_from_wires(self).chamfer_2d(distance, vertices).outer_wire()
@classmethod
def make_rect(
cls,
width: float,
height: float,
pnt: VectorLike = (0, 0, 0),
normal: VectorLike = (0, 0, 1),
) -> Wire:
"""Make Rectangle
Make a Rectangle centered on center with the given normal
Args:
width (float): width (local x)
height (float): height (local y)
pnt (Vector): rectangle center point
normal (Vector): rectangle normal
Returns:
Wire: The centered rectangle
"""
corners_local = [
(width / 2, height / 2),
(width / 2, height / -2),
(width / -2, height / -2),
(width / -2, height / 2),
]
user_plane = Plane(origin=Vector(pnt), z_dir=Vector(normal))
corners_world = [user_plane.from_local_coords(c) for c in corners_local]
return Wire.make_polygon(corners_world, close=True)
def project_to_shape(
self,
target_object: Shape,
direction: VectorLike = None,
center: VectorLike = None,
) -> list[Wire]:
"""Project Wire
Project a Wire onto a Shape generating new wires on the surfaces of the object
one and only one of `direction` or `center` must be provided. Note that one or
more wires may be generated depending on the topology of the target object and
location/direction of projection.
To avoid flipping the normal of a face built with the projected wire the orientation
of the output wires are forced to be the same as self.
Args:
target_object: Object to project onto
direction: Parallel projection direction. Defaults to None.
center: Conical center of projection. Defaults to None.
target_object: Shape:
direction: VectorLike: (Default value = None)
center: VectorLike: (Default value = None)
Returns:
: Projected wire(s)
Raises:
ValueError: Only one of direction or center must be provided
"""
if not (direction is None) ^ (center is None):
raise ValueError("One of either direction or center must be provided")
if direction is not None:
direction_vector = Vector(direction).normalized()
center_point = None
else:
direction_vector = None
center_point = Vector(center)
# Project the wire on the target object
if not direction_vector is None:
projection_object = BRepProj_Projection(
self.wrapped,
Shape.cast(target_object.wrapped).wrapped,
gp_Dir(*direction_vector.to_tuple()),
)
else:
projection_object = BRepProj_Projection(
self.wrapped,
Shape.cast(target_object.wrapped).wrapped,
gp_Pnt(*center_point.to_tuple()),
)
# Generate a list of the projected wires with aligned orientation
output_wires = []
target_orientation = self.wrapped.Orientation()
while projection_object.More():
projected_wire = projection_object.Current()
if target_orientation == projected_wire.Orientation():
output_wires.append(Wire(projected_wire))
else:
output_wires.append(Wire(projected_wire.Reversed()))
projection_object.Next()
logger.debug("wire generated %d projected wires", len(output_wires))
# BRepProj_Projection is inconsistent in the order that it returns projected
# wires, sometimes front first and sometimes back - so sort this out by sorting
# by distance from the original planar wire
if len(output_wires) > 1:
output_wires_distances = []
planar_wire_center = self.center()
for output_wire in output_wires:
output_wire_center = output_wire.center()
if direction_vector is not None:
output_wire_direction = (
output_wire_center - planar_wire_center
).normalized()
if output_wire_direction.dot(direction_vector) >= 0:
output_wires_distances.append(
(
output_wire,
(output_wire_center - planar_wire_center).length,
)
)
else:
output_wires_distances.append(
(output_wire, (output_wire_center - center_point).length)
)
output_wires_distances.sort(key=lambda x: x[1])
logger.debug(
"projected, filtered and sorted wire list is of length %d",
len(output_wires_distances),
)
output_wires = [w[0] for w in output_wires_distances]
return output_wires
class SVG:
"""SVG file import and export functionality"""
_DISCRETIZATION_TOLERANCE = 1e-3
_SVG_TEMPLATE = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="%(width)s"
height="%(height)s"
>
<g transform="scale(%(unit_scale)s, -%(unit_scale)s) translate(%(x_translate)s,%(y_translate)s)" stroke-width="%(stroke_width)s" fill="none">
<!-- hidden lines -->
<g stroke="rgb(%(hidden_color)s)" fill="none" stroke-dasharray="%(stroke_width)s,%(stroke_width)s" >
%(hidden_content)s
</g>
<!-- solid lines -->
<g stroke="rgb(%(stroke_color)s)" fill="none">
%(visible_content)s
</g>
</g>
</svg>
"""
_PATHTEMPLATE = '\t\t\t<path d="%s" />\n'
@classmethod
def make_svg_edge(cls, edge: Edge):
"""Creates an SVG edge from a OCCT edge"""
memory_file = StringIO.StringIO()
curve = edge._geom_adaptor() # adapt the edge into curve
start = curve.FirstParameter()
end = curve.LastParameter()
points = GCPnts_QuasiUniformDeflection(
curve, SVG._DISCRETIZATION_TOLERANCE, start, end
)
if points.IsDone():
point_it = (points.Value(i + 1) for i in range(points.NbPoints()))
gp_pnt = next(point_it)
memory_file.write(f"M{gp_pnt.X()},{gp_pnt.Y()} ")
for gp_pnt in point_it:
memory_file.write(f"L{gp_pnt.X()},{gp_pnt.Y()} ")
return memory_file.getvalue()
@classmethod
def get_paths(cls, visible_shapes: list[Shape], hidden_shapes: list[Shape]):
"""Collects the visible and hidden edges from the object"""
hidden_paths = []
visible_paths = []
for shape in visible_shapes:
for edge in shape.edges():
visible_paths.append(SVG.make_svg_edge(edge))
for shape in hidden_shapes:
for edge in shape.edges():
hidden_paths.append(SVG.make_svg_edge(edge))
return (hidden_paths, visible_paths)
@classmethod
def axes(cls, axes_scale: float) -> Compound:
"""The X, Y, Z axis object"""
x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0))
y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0))
z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale))
arrow_arc = Edge.make_spline(
[(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)],
[(-1, 0, 0), (-1, 1.5, 0)],
)
arrow = arrow_arc.fuse(copy.copy(arrow_arc).mirror(Plane.XZ))
x_label = (
Compound.make_2d_text(
"X", fontsize=axes_scale / 4, align=(Align.MIN, Align.CENTER)
)
.move(Location(x_axis @ 1))
.edges()
)
y_label = (
Compound.make_2d_text(
"Y", fontsize=axes_scale / 4, align=(Align.MIN, Align.CENTER)
)
.rotate(Axis.Z, 90)
.move(Location(y_axis @ 1))
.edges()
)
z_label = (
Compound.make_2d_text(
"Z", fontsize=axes_scale / 4, align=(Align.CENTER, Align.MIN)
)
.rotate(Axis.Y, 90)
.rotate(Axis.X, 90)
.move(Location(z_axis @ 1))
.edges()
)
axes = Edge.fuse(
x_axis,
y_axis,
z_axis,
arrow.moved(Location(x_axis @ 1)),
arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)),
arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)),
*x_label,
*y_label,
*z_label,
)
return axes
@classmethod
def get_svg(
cls,
shape: Shape,
viewport_origin: VectorLike,
viewport_up: VectorLike = (0, 0, 1),
look_at: VectorLike = None,
svg_opts: dict = None,
) -> str:
"""get_svg
Translate a shape to SVG text.
Args:
shape (Shape): target object
viewport_origin (VectorLike): location of viewport
viewport_up (VectorLike, optional): direction of the viewport y axis.
Defaults to (0, 0, 1).
look_at (VectorLike, optional): point to look at.
Defaults to None (center of shape).
SVG Options - e.g. svg_opts = {"pixel_scale":50}:
Other Parameters:
width (int): Viewport width in pixels. Defaults to 240.
height (int): Viewport width in pixels. Defaults to 240.
pixel_scale (float): Pixels per CAD unit.
Defaults to None (calculated based on width & height).
units (str): SVG document units. Defaults to "mm".
margin_left (int): Defaults to 20.
margin_top (int): Defaults to 20.
show_axes (bool): Display an axis indicator. Defaults to True.
axes_scale (float): Length of axis indicator in global units.
Defaults to 1.0.
stroke_width (float): Width of visible edges.
Defaults to None (calculated based on unit_scale).
stroke_color (tuple[int]): Visible stroke color. Defaults to RGB(0, 0, 0).
hidden_color (tuple[int]): Hidden stroke color. Defaults to RBG(160, 160, 160).
show_hidden (bool): Display hidden lines. Defaults to True.
Returns:
str: SVG text string
"""
# Available options and their defaults
defaults = {
"width": 240,
"height": 240,
"pixel_scale": None,
"units": "mm",
"margin_left": 20,
"margin_top": 20,
"show_axes": True,
"axes_scale": 1.0,
"stroke_width": None, # calculated based on unit_scale
"stroke_color": (0, 0, 0), # RGB 0-255
"hidden_color": (160, 160, 160), # RGB 0-255
"show_hidden": True,
}
if svg_opts:
defaults.update(svg_opts)
width = float(defaults["width"])
height = float(defaults["height"])
margin_left = float(defaults["margin_left"])
margin_top = float(defaults["margin_top"])
show_axes = bool(defaults["show_axes"])
stroke_color = tuple(defaults["stroke_color"])
hidden_color = tuple(defaults["hidden_color"])
show_hidden = bool(defaults["show_hidden"])
# Setup the projector
hidden_line_removal = HLRBRep_Algo()
hidden_line_removal.Add(shape.wrapped)
if show_axes:
hidden_line_removal.Add(SVG.axes(defaults["axes_scale"]).wrapped)
viewport_origin = Vector(viewport_origin)
look_at = Vector(look_at) if look_at else shape.center()
projection_dir: Vector = (viewport_origin - look_at).normalized()
viewport_up = Vector(viewport_up).normalized()
camera_coordinate_system = gp_Ax2()
camera_coordinate_system.SetAxis(
gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
)
camera_coordinate_system.SetYDirection(viewport_up.to_dir())
projector = HLRAlgo_Projector(camera_coordinate_system)
hidden_line_removal.Projector(projector)
hidden_line_removal.Update()
hidden_line_removal.Hide()
hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
# Create the visible edges
visible_edges = []
visible_sharp_edges = hlr_shapes.VCompound()
if not visible_sharp_edges.IsNull():
visible_edges.append(visible_sharp_edges)
visible_smooth_edges = hlr_shapes.Rg1LineVCompound()
if not visible_smooth_edges.IsNull():
visible_edges.append(visible_smooth_edges)
visible_contour_edges = hlr_shapes.OutLineVCompound()
if not visible_contour_edges.IsNull():
visible_edges.append(visible_contour_edges)
# print("Visible Edges")
# for edge_compound in visible_edges:
# for edge in Compound(edge_compound).edges():
# print(type(edge), edge.geom_type())
# topo_abs: Any = geom_LUT[shapetype(edge)]
# print(downcast(edge).GetType())
# geom_LUT_EDGE[topo_abs(self.wrapped).GetType()]
# Create the hidden edges
hidden_edges = []
hidden_sharp_edges = hlr_shapes.HCompound()
if not hidden_sharp_edges.IsNull():
hidden_edges.append(hidden_sharp_edges)
hidden_contour_edges = hlr_shapes.OutLineHCompound()
if not hidden_contour_edges.IsNull():
hidden_edges.append(hidden_contour_edges)
# Fix the underlying geometry - otherwise we will get segfaults
for edge in visible_edges:
BRepLib.BuildCurves3d_s(edge, TOLERANCE)
for edge in hidden_edges:
BRepLib.BuildCurves3d_s(edge, TOLERANCE)
# convert to native shape objects
visible_edges = list(map(Shape, visible_edges))
hidden_edges = list(map(Shape, hidden_edges))
(hidden_paths, visible_paths) = SVG.get_paths(visible_edges, hidden_edges)
# get bounding box -- these are all in 2D space
b_box = Compound.make_compound(hidden_edges + visible_edges).bounding_box()
# width pixels for x, height pixels for y
if defaults["pixel_scale"]:
unit_scale = defaults["pixel_scale"]
width = int(unit_scale * b_box.size.X + 2 * defaults["margin_left"])
height = int(unit_scale * b_box.size.Y + 2 * defaults["margin_left"])
else:
unit_scale = min(width / b_box.size.X * 0.75, height / b_box.size.Y * 0.75)
# compute amount to translate-- move the top left into view
(x_translate, y_translate) = (
(0 - b_box.min.X) + margin_left / unit_scale,
(0 - b_box.max.Y) - margin_top / unit_scale,
)
# If the user did not specify a stroke width, calculate it based on the unit scale
if defaults["stroke_width"]:
stroke_width = float(defaults["stroke_width"])
else:
stroke_width = 1.0 / unit_scale
# compute paths
hidden_content = ""
# Prevent hidden paths from being added if the user disabled them
if show_hidden:
for paths in hidden_paths:
hidden_content += SVG._PATHTEMPLATE % paths
visible_content = ""
for paths in visible_paths:
visible_content += SVG._PATHTEMPLATE % paths
svg = SVG._SVG_TEMPLATE % (
{
"unit_scale": str(unit_scale),
"stroke_width": str(stroke_width),
"stroke_color": ",".join([str(x) for x in stroke_color]),
"hidden_color": ",".join([str(x) for x in hidden_color]),
"hidden_content": hidden_content,
"visible_content": visible_content,
"x_translate": str(x_translate),
"y_translate": str(y_translate),
"width": str(width),
"height": str(height),
"text_box_y": str(height - 30),
"uom": defaults["units"],
}
)
return svg
@classmethod
def translate_to_buildline_code(cls, filename: str) -> tuple[str, str]:
"""translate_to_buildline_code
Translate the contents of the given svg file into executable build123d/BuildLine code.
Args:
filename (str): svg file name
Returns:
tuple[str, str]: code, builder instance name
"""
translator = {
"Line": ["Line", "start", "end"],
"CubicBezier": ["Bezier", "start", "control1", "control2", "end"],
"QuadraticBezier": ["Bezier", "start", "control", "end"],
"Arc": [
"EllipticalCenterArc",
# "EllipticalStartArc",
"start",
"end",
"radius",
"rotation",
"large_arc",
"sweep",
],
}
paths, _path_attributes = svg2paths(filename)
builder_name = filename.split(".")[0]
buildline_code = [
"from build123d import *",
f"with BuildLine() as {builder_name}:",
]
for path in paths:
for curve in path:
class_name = type(curve).__name__
if class_name == "Arc":
values = [
(curve.__dict__["center"].real, curve.__dict__["center"].imag)
]
values.append(curve.__dict__["radius"].real)
values.append(curve.__dict__["radius"].imag)
values.append(curve.__dict__["theta"])
values.append(curve.__dict__["theta"] + curve.__dict__["delta"])
values.append(degrees(curve.__dict__["phi"]))
if curve.__dict__["delta"] < 0.0:
values.append("AngularDirection.CLOCKWISE")
else:
values.append("AngularDirection.COUNTER_CLOCKWISE")
# EllipticalStartArc implementation
# values = [p.__dict__[parm] for parm in translator[class_name][1:3]]
# values.append(p.__dict__["radius"].real)
# values.append(p.__dict__["radius"].imag)
# values.extend([p.__dict__[parm] for parm in translator[class_name][4:]])
else:
values = [
curve.__dict__[parm] for parm in translator[class_name][1:]
]
values_str = ",".join(
[
f"({v.real}, {v.imag})" if isinstance(v, complex) else str(v)
for v in values
]
)
buildline_code.append(f" {translator[class_name][0]}({values_str})")
return ("\n".join(buildline_code), builder_name)
@classmethod
def import_svg(cls, filepath: str) -> ShapeList[Edge]:
"""import_svg
Get a ShapeList of Edge from the paths in the provided svg file.
Args:
filepath (str): svg file
Raises:
ValueError: File not found
Returns:
ShapeList[Edge]: Edges in svg file
"""
if not os.path.exists(filepath):
raise ValueError(f"{filepath} not found")
svg_code, builder_name = SVG.translate_to_buildline_code(filepath)
ex_locals = {}
exec(svg_code, None, ex_locals)
return ex_locals[builder_name].edges()
class Joint(ABC):
"""Joint
Abstract Base Joint class - used to join two components together
Args:
parent (Union[Solid, Compound]): object that joint to bound to
"""
def __init__(self, label: str, parent: Union[Solid, Compound]):
self.label = label
self.parent = parent
self.connected_to: Joint = None
def connect_to(self, other: Joint, *args, **kwargs): # pragma: no cover
"""Connect Joint self by repositioning other"""
if not isinstance(other, Joint):
raise TypeError(f"other must of type Joint not {type(other)}")
relative_location = None
try:
relative_location = self.relative_to(other, *args, **kwargs)
except TypeError:
relative_location = other.relative_to(self, *args, **kwargs).inverse()
other.parent.locate(self.parent.location * relative_location)
self.connected_to = other
@abstractmethod
def relative_to(self, other: Joint, *args, **kwargs) -> Location:
"""Return relative location to another joint"""
return NotImplementedError
@property
@abstractmethod
def symbol(self) -> Compound: # pragma: no cover
"""A CAD object positioned in global space to illustrate the joint"""
return NotImplementedError
class RigidJoint(Joint):
"""RigidJoint
A rigid joint fixes two components to one another.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
joint_location (Location): global location of joint
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol (XYZ indicator) as bound to part"""
size = self.parent.bounding_box().diagonal / 12
return SVG.axes(axes_scale=size).locate(
self.parent.location * self.relative_location
)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
joint_location: Location = Location(),
):
self.relative_location = to_part.location.inverse() * joint_location
to_part.joints[label] = self
super().__init__(label, to_part)
def relative_to(self, other: Joint, **kwargs) -> Location:
"""relative_to
Return the relative position to move the other.
Args:
other (RigidJoint): joint to connect to
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
return self.relative_location * other.relative_location.inverse()
class RevoluteJoint(Joint):
"""RevoluteJoint
Component rotates around axis like a hinge.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of rotation
angle_reference (VectorLike, optional): direction normal to axis defining where
angles will be measured from. Defaults to None.
range (tuple[float, float], optional): (min,max) angle or joint. Defaults to (0, 360).
Raises:
ValueError: angle_reference must be normal to axis
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing the axis of rotation as bound to part"""
radius = self.parent.bounding_box().diagonal / 30
return Compound.make_compound(
[
Edge.make_line((0, 0, 0), (0, 0, radius * 10)),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.to_location())
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
angle_reference: VectorLike = None,
angular_range: tuple[float, float] = (0, 360),
):
self.angular_range = angular_range
if angle_reference:
if not axis.is_normal(Axis((0, 0, 0), angle_reference)):
raise ValueError("angle_reference must be normal to axis")
self.angle_reference = Vector(angle_reference)
else:
self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir
self.angle = None
self.relative_axis = axis.located(to_part.location.inverse())
to_part.joints[label] = self
super().__init__(label, to_part)
def relative_to(
self, other: Joint, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to
Return the relative location from this joint to the RigidJoint of another object
- a hinge joint.
Args:
other (RigidJoint): joint to connect to
angle (float, optional): angle within angular range. Defaults to minimum.
Raises:
TypeError: other must of type RigidJoint
ValueError: angle out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
angle = self.angular_range[0] if angle is None else angle
if angle < self.angular_range[0] or angle > self.angular_range[1]:
raise ValueError(f"angle ({angle}) must in range of {self.angular_range}")
self.angle = angle
# Avoid strange rotations when angle is zero by using 360 instead
angle = 360.0 if angle == 0.0 else angle
rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=self.angle_reference.rotate(Axis.Z, angle),
z_dir=(0, 0, 1),
)
)
return (
self.relative_axis.to_location()
* rotation
* other.relative_location.inverse()
)
class LinearJoint(Joint):
"""LinearJoint
Component moves along a single axis.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of linear motion
range (tuple[float, float], optional): (min,max) position of joint.
Defaults to (0, inf).
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol of the linear axis positioned relative to_part"""
radius = (self.linear_range[1] - self.linear_range[0]) / 15
return Compound.make_compound(
[
Edge.make_line(
(0, 0, self.linear_range[0]), (0, 0, self.linear_range[1])
),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.to_location())
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
linear_range: tuple[float, float] = (0, inf),
):
self.axis = axis
self.linear_range = linear_range
self.position = None
self.relative_axis = axis.located(to_part.location.inverse())
self.angle = None
to_part.joints[label]: dict[str, Joint] = self
super().__init__(label, to_part)
@overload
def relative_to(
self, other: RigidJoint, position: float = None
): # pylint: disable=arguments-differ
"""relative_to - RigidJoint
Return the relative location from this joint to the RigidJoint of another object
- a slider joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint range. Defaults to middle.
"""
@overload
def relative_to(
self, other: RevoluteJoint, position: float = None, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to - RevoluteJoint
Return the relative location from this joint to the RevoluteJoint of another object
- a pin slot joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint range. Defaults to middle.
angle (float, optional): angle within angular range. Defaults to minimum.
"""
def relative_to(self, *args, **kwargs): # pylint: disable=arguments-differ
"""Return the relative position of other to linear joint defined by self"""
# Parse the input parameters
other, position, angle = None, None, None
if args:
other = args[0]
position = args[1] if len(args) >= 2 else position
angle = args[2] if len(args) == 3 else angle
if kwargs:
other = kwargs["other"] if "other" in kwargs else other
position = kwargs["position"] if "position" in kwargs else position
angle = kwargs["angle"] if "angle" in kwargs else angle
if not isinstance(other, (RigidJoint, RevoluteJoint)):
raise TypeError(
f"other must of type RigidJoint or RevoluteJoint not {type(other)}"
)
position = sum(self.linear_range) / 2 if position is None else position
if not self.linear_range[0] <= position <= self.linear_range[1]:
raise ValueError(
f"position ({position}) must in range of {self.linear_range}"
)
self.position = position
if isinstance(other, RevoluteJoint):
angle = other.angular_range[0] if angle is None else angle
if not other.angular_range[0] <= angle <= other.angular_range[1]:
raise ValueError(
f"angle ({angle}) must in range of {other.angular_range}"
)
rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=other.angle_reference.rotate(other.relative_axis, angle),
z_dir=other.relative_axis.direction,
)
)
else:
angle = 0.0
rotation = Location()
self.angle = angle
joint_relative_position = (
Location(
self.relative_axis.position + self.relative_axis.direction * position,
)
* rotation
)
if isinstance(other, RevoluteJoint):
other_relative_location = Location(other.relative_axis.position)
else:
other_relative_location = other.relative_location
return joint_relative_position * other_relative_location.inverse()
class CylindricalJoint(Joint):
"""CylindricalJoint
Component rotates around and moves along a single axis like a screw.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of rotation and linear motion
angle_reference (VectorLike, optional): direction normal to axis defining where
angles will be measured from. Defaults to None.
linear_range (tuple[float, float], optional): (min,max) position of joint.
Defaults to (0, inf).
angular_range (tuple[float, float], optional): (min,max) angle of joint.
Defaults to (0, 360).
Raises:
ValueError: angle_reference must be normal to axis
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing the cylindrical axis as bound to part"""
radius = (self.linear_range[1] - self.linear_range[0]) / 15
return Compound.make_compound(
[
Edge.make_line(
(0, 0, self.linear_range[0]), (0, 0, self.linear_range[1])
),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.to_location())
# @property
# def axis_location(self) -> Location:
# """Current global location of joint axis"""
# return self.parent.location * self.relative_axis.to_location()
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
angle_reference: VectorLike = None,
linear_range: tuple[float, float] = (0, inf),
angular_range: tuple[float, float] = (0, 360),
):
self.axis = axis
self.linear_position = None
self.rotational_position = None
if angle_reference:
if not axis.is_normal(Axis((0, 0, 0), angle_reference)):
raise ValueError("angle_reference must be normal to axis")
self.angle_reference = Vector(angle_reference)
else:
self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir
self.angular_range = angular_range
self.linear_range = linear_range
self.relative_axis = axis.located(to_part.location.inverse())
self.position = None
self.angle = None
to_part.joints[label]: dict[str, Joint] = self
super().__init__(label, to_part)
def relative_to(
self, other: RigidJoint, position: float = None, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to - CylindricalJoint
Return the relative location from this joint to the RigidJoint of another object
- a sliding and rotating joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint linear range. Defaults to middle.
angle (float, optional): angle within rotational range.
Defaults to angular_range minimum.
Raises:
TypeError: other must be of type RigidJoint
ValueError: position out of range
ValueError: angle out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
position = sum(self.linear_range) / 2 if position is None else position
if not self.linear_range[0] <= position <= self.linear_range[1]:
raise ValueError(
f"position ({position}) must in range of {self.linear_range}"
)
self.position = position
angle = sum(self.angular_range) / 2 if angle is None else angle
if not self.angular_range[0] <= angle <= self.angular_range[1]:
raise ValueError(f"angle ({angle}) must in range of {self.angular_range}")
self.angle = angle
joint_relative_position = Location(
self.relative_axis.position + self.relative_axis.direction * position
)
joint_rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=self.angle_reference.rotate(self.relative_axis, angle),
z_dir=self.relative_axis.direction,
)
)
return (
joint_relative_position * joint_rotation * other.relative_location.inverse()
)
class BallJoint(Joint):
"""BallJoint
A component rotates around all 3 axes using a gimbal system (3 nested rotations).
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
joint_location (Location): global location of joint
angular_range
(tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional):
X, Y, Z angle (min, max) pairs. Defaults to ((0, 360), (0, 360), (0, 360)).
angle_reference (Plane, optional): plane relative to part defining zero degrees of
rotation. Defaults to Plane.XY.
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing joint as bound to part"""
radius = self.parent.bounding_box().diagonal / 30
circle_x = Edge.make_circle(radius, self.angle_reference)
circle_y = Edge.make_circle(radius, self.angle_reference.rotated((90, 0, 0)))
circle_z = Edge.make_circle(radius, self.angle_reference.rotated((0, 90, 0)))
return Compound.make_compound(
[
circle_x,
circle_y,
circle_z,
Compound.make_2d_text(
"X", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)),
Compound.make_2d_text(
"Y", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)),
Compound.make_2d_text(
"Z", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)),
]
).move(self.parent.location * self.relative_location)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
joint_location: Location = Location(),
angular_range: tuple[
tuple[float, float], tuple[float, float], tuple[float, float]
] = ((0, 360), (0, 360), (0, 360)),
angle_reference: Plane = Plane.XY,
):
"""_summary_
_extended_summary_
Args:
label (str): _description_
to_part (Union[Solid, Compound]): _description_
joint_location (Location, optional): _description_. Defaults to Location().
angular_range
(tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional):
_description_. Defaults to ((0, 360), (0, 360), (0, 360)).
angle_reference (Plane, optional): _description_. Defaults to Plane.XY.
"""
self.relative_location = to_part.location.inverse() * joint_location
to_part.joints[label] = self
self.angular_range = angular_range
self.angle_reference = angle_reference
super().__init__(label, to_part)
def relative_to(
self, other: RigidJoint, angles: RotationLike = None
): # pylint: disable=arguments-differ
"""relative_to - CylindricalJoint
Return the relative location from this joint to the RigidJoint of another object
Args:
other (RigidJoint): joint to connect to
angles (RotationLike, optional): orientation of other's parent relative to
self. Defaults to the minimums of the angle ranges.
Raises:
TypeError: invalid other joint type
ValueError: angles out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
rotation = (
Rotation(*[self.angular_range[i][0] for i in [0, 1, 2]])
if angles is None
else Rotation(*angles)
) * self.angle_reference.to_location()
for i, rotations in zip(
[0, 1, 2],
[rotation.orientation.X, rotation.orientation.Y, rotation.orientation.Z],
):
if not self.angular_range[i][0] <= rotations <= self.angular_range[i][1]:
raise ValueError(
f"angles ({angles}) must in range of {self.angular_range}"
)
return self.relative_location * rotation * other.relative_location.inverse()
def downcast(obj: TopoDS_Shape) -> TopoDS_Shape:
"""Downcasts a TopoDS object to suitable specialized type
Args:
obj: TopoDS_Shape:
Returns:
"""
f_downcast: Any = downcast_LUT[shapetype(obj)]
return_value = f_downcast(obj)
return return_value
def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> list[Wire]:
"""Convert edges to a list of wires.
Args:
edges: Iterable[Edge]:
tol: float: (Default value = 1e-6)
Returns:
"""
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
for edge in edges:
edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
return [Wire(el) for el in wires_out]
def fix(obj: TopoDS_Shape) -> TopoDS_Shape:
"""Fix a TopoDS object to suitable specialized type
Args:
obj: TopoDS_Shape:
Returns:
"""
shape_fix = ShapeFix_Shape(obj)
shape_fix.Perform()
return downcast(shape_fix.Shape())
def shapetype(obj: TopoDS_Shape) -> TopAbs_ShapeEnum:
"""Return TopoDS_Shape's TopAbs_ShapeEnum"""
if obj.IsNull():
raise ValueError("Null TopoDS_Shape object")
return obj.ShapeType()
def sort_wires_by_build_order(wire_list: list[Wire]) -> list[list[Wire]]:
"""Tries to determine how wires should be combined into faces.
Assume:
The wires make up one or more faces, which could have 'holes'
Outer wires are listed ahead of inner wires
there are no wires inside wires inside wires
( IE, islands -- we can deal with that later on )
none of the wires are construction wires
Compute:
one or more sets of wires, with the outer wire listed first, and inner
ones
Returns, list of lists.
Args:
wire_list: list[Wire]:
Returns:
"""
# check if we have something to sort at all
if len(wire_list) < 2:
return [
wire_list,
]
# make a Face, NB: this might return a compound of faces
faces = Face.make_from_wires(wire_list[0], wire_list[1:])
return_value = []
for face in faces.faces():
return_value.append(
[
face.outer_wire(),
]
+ face.inner_wires()
)
return return_value