Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
@@ -0,0 +1 @@
"""OCP-native kernel helpers for the 2.0 rearchitecture path."""
@@ -0,0 +1,93 @@
"""OCP-native boolean helpers."""
from __future__ import annotations
from typing import List, Optional, Sequence
from OCP.BOPAlgo import BOPAlgo_GlueOff, BOPAlgo_GlueShift
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCP.TopAbs import TopAbs_SOLID
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Solid
from OCP.TopTools import TopTools_ListOfShape
def _list_of(shapes: Sequence[TopoDS_Shape]) -> TopTools_ListOfShape:
out = TopTools_ListOfShape()
for shape in shapes:
out.Append(shape)
return out
def solids_of(shape: TopoDS_Shape) -> List[TopoDS_Solid]:
out: List[TopoDS_Solid] = []
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
while explorer.More():
out.append(TopoDS.Solid_s(explorer.Current()))
explorer.Next()
if not out and shape.ShapeType() == TopAbs_SOLID:
out.append(TopoDS.Solid_s(shape))
return out
def clean_shape(shape: TopoDS_Shape) -> TopoDS_Shape:
unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, True)
unifier.Build()
return unifier.Shape()
def fuse_shapes(shapes: Sequence[TopoDS_Shape], *, glue: bool = True, tol: Optional[float] = None, clean: bool = True) -> TopoDS_Shape:
if not shapes:
raise ValueError("fuse_shapes requires at least one shape")
if len(shapes) == 1:
return shapes[0]
builder = BRepAlgoAPI_Fuse()
builder.SetRunParallel(True)
builder.SetUseOBB(True)
builder.SetArguments(_list_of([shapes[0]]))
builder.SetTools(_list_of(list(shapes[1:])))
if tol is not None:
builder.SetFuzzyValue(float(tol))
# Match CadQuery's Shape.fuse(glue=True) behavior: CadQuery maps glue=True
# to OCC's GlueShift, not GlueFull. GlueFull can leave overlapping solids
# separate in cases where CQ would return one fused solid.
builder.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP fuse failed")
result = builder.Shape()
return clean_shape(result) if clean else result
def cut_shapes(body: TopoDS_Shape, tools: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
if not tools:
return body
builder = BRepAlgoAPI_Cut()
builder.SetRunParallel(True)
builder.SetUseOBB(True)
builder.SetArguments(_list_of([body]))
builder.SetTools(_list_of(tools))
builder.Build()
if not builder.IsDone():
raise ValueError("OCP cut failed")
return builder.Shape()
def common_shapes(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
if not shapes:
raise ValueError("common_shapes requires at least one shape")
if len(shapes) == 1:
return shapes[0]
result = shapes[0]
for tool in shapes[1:]:
builder = BRepAlgoAPI_Common()
builder.SetRunParallel(True)
builder.SetUseOBB(True)
builder.SetArguments(_list_of([result]))
builder.SetTools(_list_of([tool]))
builder.Build()
if not builder.IsDone():
raise ValueError("OCP common failed")
result = builder.Shape()
return result
@@ -0,0 +1,74 @@
"""Thin OCP-native primitive builders used by the public API layer."""
from __future__ import annotations
from OCP.BRepPrimAPI import (
BRepPrimAPI_MakeBox,
BRepPrimAPI_MakeCone,
BRepPrimAPI_MakeCylinder,
BRepPrimAPI_MakeSphere,
)
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
def _point(value: tuple[float, float, float]) -> gp_Pnt:
return gp_Pnt(float(value[0]), float(value[1]), float(value[2]))
def _axis2(
origin: tuple[float, float, float], direction: tuple[float, float, float]
) -> gp_Ax2:
return gp_Ax2(
_point(origin),
gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])),
)
def make_box_solid(corner: tuple[float, float, float], dx: float, dy: float, dz: float):
builder = BRepPrimAPI_MakeBox(_point(corner), float(dx), float(dy), float(dz))
builder.Build()
if not builder.IsDone():
raise ValueError("OCP box builder failed")
return builder.Solid()
def make_cylinder_solid(
origin: tuple[float, float, float],
axis: tuple[float, float, float],
radius: float,
height: float,
):
builder = BRepPrimAPI_MakeCylinder(
_axis2(origin, axis), float(radius), float(height)
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP cylinder builder failed")
return builder.Solid()
def make_cone_solid(
origin: tuple[float, float, float],
axis: tuple[float, float, float],
bottom_radius: float,
top_radius: float,
height: float,
):
builder = BRepPrimAPI_MakeCone(
_axis2(origin, axis),
float(bottom_radius),
float(top_radius),
float(height),
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP cone builder failed")
return builder.Solid()
def make_sphere_solid(center: tuple[float, float, float], radius: float):
builder = BRepPrimAPI_MakeSphere(_point(center), float(radius))
builder.Build()
if not builder.IsDone():
raise ValueError("OCP sphere builder failed")
return builder.Solid()
@@ -0,0 +1,72 @@
"""OCP shape casting and type helpers."""
from __future__ import annotations
from typing import Any
from OCP.TopAbs import (
TopAbs_COMPOUND,
TopAbs_EDGE,
TopAbs_FACE,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Shape
def shape_type_name(shape: TopoDS_Shape) -> str:
st = shape.ShapeType()
if st == TopAbs_VERTEX:
return "vertex"
if st == TopAbs_EDGE:
return "edge"
if st == TopAbs_WIRE:
return "wire"
if st == TopAbs_FACE:
return "face"
if st == TopAbs_SOLID:
return "solid"
if st == TopAbs_COMPOUND:
return "compound"
return str(st)
def as_vertex(shape: TopoDS_Shape):
return TopoDS.Vertex_s(shape)
def as_edge(shape: TopoDS_Shape):
return TopoDS.Edge_s(shape)
def as_wire(shape: TopoDS_Shape):
return TopoDS.Wire_s(shape)
def as_face(shape: TopoDS_Shape):
return TopoDS.Face_s(shape)
def as_solid(shape: TopoDS_Shape):
st = shape.ShapeType()
if st == TopAbs_SOLID:
return TopoDS.Solid_s(shape)
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
if explorer.More():
return TopoDS.Solid_s(explorer.Current())
raise ValueError(f"Expected a solid-compatible OCP shape, got {shape_type_name(shape)}")
def as_compound(shape: TopoDS_Shape):
st = shape.ShapeType()
if st == TopAbs_COMPOUND:
return TopoDS.Compound_s(shape)
raise ValueError(f"Expected a compound OCP shape, got {shape_type_name(shape)}")
def require_shape(value: Any) -> TopoDS_Shape:
if isinstance(value, TopoDS_Shape):
return value
raise TypeError(f"Expected an OCP TopoDS_Shape, got {type(value).__name__}")
@@ -0,0 +1,143 @@
"""Thin OCP-native curve and wire builders."""
from __future__ import annotations
import math
from typing import Any, Iterable, Optional, Sequence
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire
from OCP.BRepLib import BRepLib
from OCP.GC import GC_MakeArcOfCircle, GC_MakeCircle
from OCP.GCE2d import GCE2d_MakeSegment
from OCP.Geom import Geom_BSplineCurve
from OCP.Geom2d import Geom2d_Line
from OCP.Geom import Geom_ConicalSurface, Geom_CylindricalSurface
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.TColStd import TColStd_Array1OfInteger, TColStd_Array1OfReal
from OCP.gp import (
gp_Ax2,
gp_Ax3,
gp_Circ,
gp_Dir,
gp_Dir2d,
gp_Pnt,
gp_Pnt2d,
)
def _pnt(value: Sequence[float]) -> gp_Pnt:
return gp_Pnt(float(value[0]), float(value[1]), float(value[2]))
def _dir(value: Sequence[float]) -> gp_Dir:
return gp_Dir(float(value[0]), float(value[1]), float(value[2]))
def make_line_edge(start: Sequence[float], end: Sequence[float]):
return BRepBuilderAPI_MakeEdge(_pnt(start), _pnt(end)).Edge()
def make_circle_edge(center: Sequence[float], radius: float, normal: Sequence[float]):
geom = GC_MakeCircle(gp_Ax2(_pnt(center), _dir(normal)), float(radius)).Value()
return BRepBuilderAPI_MakeEdge(geom).Edge()
def make_arc_three_point_edge(
start: Sequence[float], middle: Sequence[float], end: Sequence[float]
):
geom = GC_MakeArcOfCircle(_pnt(start), _pnt(middle), _pnt(end)).Value()
return BRepBuilderAPI_MakeEdge(geom).Edge()
def make_arc_angle_edge(
center: Sequence[float],
radius: float,
start_angle: float,
end_angle: float,
normal: Sequence[float],
):
circ = gp_Circ(gp_Ax2(_pnt(center), _dir(normal)), float(radius))
geom = GC_MakeArcOfCircle(circ, float(start_angle), float(end_angle), True).Value()
return BRepBuilderAPI_MakeEdge(geom).Edge()
def make_bspline_edge(
*,
control_points: Sequence[Sequence[float]],
degree: int,
knots: Sequence[float],
multiplicities: Sequence[int],
weights: Optional[Sequence[float]] = None,
periodic: bool = False,
):
poles = TColgp_Array1OfPnt(1, len(control_points))
for idx, point in enumerate(control_points, start=1):
poles.SetValue(idx, _pnt(point))
knot_array = TColStd_Array1OfReal(1, len(knots))
for idx, knot in enumerate(knots, start=1):
knot_array.SetValue(idx, float(knot))
mult_array = TColStd_Array1OfInteger(1, len(multiplicities))
for idx, multiplicity in enumerate(multiplicities, start=1):
mult_array.SetValue(idx, int(multiplicity))
if weights is None:
curve = Geom_BSplineCurve(
poles,
knot_array,
mult_array,
int(degree),
bool(periodic),
)
else:
weight_array = TColStd_Array1OfReal(1, len(weights))
for idx, weight in enumerate(weights, start=1):
weight_array.SetValue(idx, float(weight))
curve = Geom_BSplineCurve(
poles,
weight_array,
knot_array,
mult_array,
int(degree),
bool(periodic),
)
return BRepBuilderAPI_MakeEdge(curve).Edge()
def make_wire_from_edges(edges: Iterable[Any]):
builder = BRepBuilderAPI_MakeWire()
for edge in edges:
builder.Add(edge)
return builder.Wire()
def make_polyline_wire(points: Iterable[Sequence[float]], closed: bool = False):
pts = list(points)
edges = [make_line_edge(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
if closed and len(pts) > 2:
edges.append(make_line_edge(pts[-1], pts[0]))
return make_wire_from_edges(edges)
def make_helix_wire(
pitch: float,
height: float,
radius: float,
center: Sequence[float],
direction: Sequence[float],
):
geom_surf = Geom_CylindricalSurface(
gp_Ax3(_pnt(center), _dir(direction)), float(radius)
)
geom_line = Geom2d_Line(gp_Pnt2d(0.0, 0.0), gp_Dir2d(2 * math.pi, float(pitch)))
n_turns = float(height) / float(pitch)
u_start = geom_line.Value(0.0)
u_stop = geom_line.Value(
n_turns * math.sqrt((2 * math.pi) ** 2 + float(pitch) ** 2)
)
geom_seg = GCE2d_MakeSegment(u_start, u_stop).Value()
edge = BRepBuilderAPI_MakeEdge(geom_seg, geom_surf).Edge()
wire = BRepBuilderAPI_MakeWire(edge).Wire()
BRepLib.BuildCurves3d_s(wire, 1e-6, MaxSegment=2000)
return wire
@@ -0,0 +1,53 @@
"""OCP-native export helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Sequence
from OCP.BRep import BRep_Builder
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
from OCP.StlAPI import StlAPI_Writer
from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape
from OCP.IFSelect import IFSelect_RetDone
def make_compound(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
if not shapes:
raise ValueError("No shapes to export")
if len(shapes) == 1:
return shapes[0]
return make_compound_always(shapes)
def make_compound_always(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
if not shapes:
raise ValueError("No shapes to export")
builder = BRep_Builder()
compound = TopoDS_Compound()
builder.MakeCompound(compound)
for shape in shapes:
builder.Add(compound, shape)
return compound
def export_step_shapes(shapes: Sequence[TopoDS_Shape], filename: str) -> None:
writer = STEPControl_Writer()
compound = make_compound(shapes)
status = writer.Transfer(compound, STEPControl_AsIs)
# Some OCP builds return int-like statuses; keep failure detection conservative.
if status != IFSelect_RetDone and int(status) != int(IFSelect_RetDone):
raise ValueError(f"STEP transfer failed: {status}")
path = str(Path(filename))
write_status = writer.Write(path)
if write_status != IFSelect_RetDone and int(write_status) != int(IFSelect_RetDone):
raise ValueError(f"STEP write failed: {write_status}")
def export_stl_shape(shape: TopoDS_Shape, filename: str) -> None:
BRepMesh_IncrementalMesh(shape, 0.1).Perform()
writer = StlAPI_Writer()
ok = writer.Write(shape, str(Path(filename)))
if ok is False:
raise ValueError("STL write failed")
@@ -0,0 +1,76 @@
"""Thin OCP-native feature builders for loft/sweep/helical sweep."""
from __future__ import annotations
from typing import Any, Iterable, Sequence
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_ThruSections
from OCP.gp import gp_Trsf, gp_Vec
from OCP.TopoDS import TopoDS
from .ocp_curves import make_helix_wire
def make_face_from_wire(wire):
builder = BRepBuilderAPI_MakeFace(wire, True)
if not builder.IsDone():
raise ValueError("OCP face builder failed")
return builder.Face()
def make_face_from_wires(outer_wire, inner_wires: Sequence[Any]):
builder = BRepBuilderAPI_MakeFace(outer_wire, True)
if not builder.IsDone():
raise ValueError("OCP face builder failed for outer wire")
for inner_wire in inner_wires:
builder.Add(TopoDS.Wire_s(inner_wire.Reversed()))
if not builder.IsDone():
raise ValueError("OCP face builder failed while adding inner wire")
return builder.Face()
def make_loft_solid(wires: Iterable[Any], ruled: bool = False):
builder = BRepOffsetAPI_ThruSections(True, bool(ruled))
builder.CheckCompatibility(True)
for wire in wires:
builder.AddWire(wire)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP loft builder failed")
return builder.Shape()
def make_sweep_solid(profile_wire, path_wire, is_frenet: bool = False):
builder = BRepOffsetAPI_MakePipeShell(path_wire)
builder.SetMode(bool(is_frenet))
builder.Add(profile_wire, False, False)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP sweep builder failed")
if not builder.MakeSolid():
raise ValueError("OCP sweep solid conversion failed")
return builder.Shape()
def translate_shape(shape, vector: Sequence[float]):
trsf = gp_Trsf()
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
builder = BRepBuilderAPI_Transform(shape, trsf, True)
builder.Build()
if not builder.IsDone():
raise ValueError("OCP feature translation failed")
return builder.Shape()
def make_helical_sweep_solid(
profile_wire,
pitch: float,
height: float,
radius: float,
center: Sequence[float],
direction: Sequence[float],
):
helix = make_helix_wire(pitch, height, radius, center, direction)
moved_profile = translate_shape(profile_wire, (float(radius), 0.0, 0.0))
return make_sweep_solid(moved_profile, helix, is_frenet=True)
@@ -0,0 +1,72 @@
"""OCP-native mesh/shell construction and tessellation helpers."""
from __future__ import annotations
from typing import Iterable, List, Sequence, Tuple
from OCP.BRep import BRep_Builder, BRep_Tool
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeSolid
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.Poly import Poly_Triangulation
from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shell
from OCP.gp import gp_Pnt
from .ocp_properties import bounding_box
from .ocp_topology import faces_of
def make_triangle_face(points: Sequence[Sequence[float]]) -> TopoDS_Face:
if len(points) != 3:
raise ValueError("Triangle face requires exactly three points")
polygon = BRepBuilderAPI_MakePolygon()
for p in points:
polygon.Add(gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
polygon.Close()
if not polygon.IsDone():
raise ValueError("OCP polygon builder failed")
face = BRepBuilderAPI_MakeFace(polygon.Wire(), True)
if not face.IsDone():
raise ValueError("OCP triangle face builder failed")
return face.Face()
def shell_metric(shell) -> tuple[int, float]:
bb = bounding_box(shell)
volume = bb.xlen * bb.ylen * bb.zlen
return (len(faces_of(shell)), float(volume))
def shell_is_closed(shell) -> bool:
return bool(TopoDS.Shell_s(shell).Closed())
def solid_from_shell(shell):
maker = BRepBuilderAPI_MakeSolid(TopoDS.Shell_s(shell))
if not maker.IsDone():
raise ValueError("OCP solid-from-shell builder failed")
return maker.Solid()
def tessellate_face(face: TopoDS_Face, tolerance: float = 0.35, angular_tolerance: float = 0.22):
mesh = BRepMesh_IncrementalMesh(face, float(tolerance), False, float(angular_tolerance), True)
mesh.Perform()
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(face, loc)
if tri is None:
return [], []
trsf = loc.Transformation()
vertices = []
for idx in range(1, tri.NbNodes() + 1):
p = tri.Node(idx).Transformed(trsf)
vertices.append((float(p.X()), float(p.Y()), float(p.Z())))
triangles = []
reversed_face = face.Orientation() == TopAbs_REVERSED
for idx in range(1, tri.NbTriangles() + 1):
a, b, c = tri.Triangle(idx).Get()
if reversed_face:
triangles.append((a - 1, c - 1, b - 1))
else:
triangles.append((a - 1, b - 1, c - 1))
return vertices, triangles
@@ -0,0 +1,266 @@
"""OCP-native geometry properties, bounding boxes, distance and normals."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence, Tuple
from OCP.Bnd import Bnd_Box
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepGProp import BRepGProp
from OCP.BRepLProp import BRepLProp_SLProps
from OCP.GProp import GProp_GProps
from OCP.GeomAbs import GeomAbs_Plane
import math
from OCP.TopAbs import (
TopAbs_COMPOUND,
TopAbs_COMPSOLID,
TopAbs_EDGE,
TopAbs_FACE,
TopAbs_REVERSED,
TopAbs_SHELL,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Solid
from OCP.gp import gp_Pnt, gp_Vec
from .ocp_topology import vertex_point
@dataclass(frozen=True)
class Vec3:
x: float
y: float
z: float
def __iter__(self):
yield self.x
yield self.y
yield self.z
def to_tuple(self) -> tuple[float, float, float]:
return (self.x, self.y, self.z)
def dot(self, other: object) -> float:
ox, oy, oz = _coerce_vec3(other)
return self.x * ox + self.y * oy + self.z * oz
def norm(self) -> float:
return math.sqrt(self.dot(self))
def normalized(self) -> "Vec3":
n = self.norm()
if n <= 1e-15:
raise ValueError("Cannot normalize a zero-length vector")
return Vec3(self.x / n, self.y / n, self.z / n)
def getAngle(self, other: object) -> float:
ox, oy, oz = _coerce_vec3(other)
on = math.sqrt(ox * ox + oy * oy + oz * oz)
sn = self.norm()
if sn <= 1e-15 or on <= 1e-15:
raise ValueError("Cannot compute angle with a zero-length vector")
value = max(-1.0, min(1.0, self.dot((ox, oy, oz)) / (sn * on)))
return math.acos(value)
def __mul__(self, scalar: float) -> "Vec3":
return Vec3(self.x * float(scalar), self.y * float(scalar), self.z * float(scalar))
__rmul__ = __mul__
def __repr__(self) -> str:
return f"Vec3({self.x:.6g}, {self.y:.6g}, {self.z:.6g})"
def _coerce_vec3(value: object) -> tuple[float, float, float]:
if isinstance(value, Vec3):
return value.to_tuple()
if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
return (float(getattr(value, "x")), float(getattr(value, "y")), float(getattr(value, "z")))
if isinstance(value, (tuple, list)) and len(value) == 3:
return (float(value[0]), float(value[1]), float(value[2]))
raise TypeError(f"Expected a 3D vector-like value, got {type(value).__name__}")
def _props_point(props: GProp_GProps) -> Vec3:
p = props.CentreOfMass()
return Vec3(float(p.X()), float(p.Y()), float(p.Z()))
def linear_length(edge: TopoDS_Edge) -> float:
props = GProp_GProps()
BRepGProp.LinearProperties_s(edge, props)
return float(props.Mass())
def surface_area(face: TopoDS_Face) -> float:
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(face, props)
return float(props.Mass())
def volume(solid: TopoDS_Solid) -> float:
props = GProp_GProps()
BRepGProp.VolumeProperties_s(solid, props)
return float(props.Mass())
def _bbox_center(shape: TopoDS_Shape) -> Vec3:
bb = bounding_box(shape)
return Vec3((bb.xmin + bb.xmax) / 2, (bb.ymin + bb.ymax) / 2, (bb.zmin + bb.zmax) / 2)
def _props_mass_point(props: GProp_GProps, eps: float = 1e-12) -> Vec3 | None:
"""Return the CentreOfMass when the reported mass is meaningfully nonzero."""
try:
mass = float(props.Mass())
except Exception:
return None
if abs(mass) <= eps:
return None
return _props_point(props)
def center_of_mass(shape: TopoDS_Shape) -> Vec3:
"""Dimension-aware centre of mass.
Selects the OCP mass-properties routine that matches the shape's own
topological dimension. This avoids the numerical residue that
``VolumeProperties_s`` produces when applied to lower-dimensional shapes
(e.g. a planar face), which previously leaked a bogus centre into
``Face.get_center()``.
"""
try:
kind = shape.ShapeType()
except Exception:
kind = None
# Solids / compsolids / compounds carry a meaningful volume.
if kind in (TopAbs_SOLID, TopAbs_COMPSOLID, TopAbs_COMPOUND):
props = GProp_GProps()
try:
BRepGProp.VolumeProperties_s(shape, props)
point = _props_mass_point(props)
if point is not None:
return point
except Exception:
pass
# A compound may wrap only faces/edges; fall through to the
# lower-dimensional properties below.
if kind == TopAbs_COMPOUND:
try:
BRepGProp.SurfaceProperties_s(shape, props)
point = _props_mass_point(props)
if point is not None:
return point
except Exception:
pass
try:
BRepGProp.LinearProperties_s(shape, props)
point = _props_mass_point(props)
if point is not None:
return point
except Exception:
pass
return _bbox_center(shape)
# Shells / faces are 2D -> use surface properties.
if kind in (TopAbs_FACE, TopAbs_SHELL):
props = GProp_GProps()
try:
BRepGProp.SurfaceProperties_s(shape, props)
point = _props_mass_point(props)
if point is not None:
return point
except Exception:
pass
return _bbox_center(shape)
# Wires / edges are 1D -> use linear properties.
if kind in (TopAbs_WIRE, TopAbs_EDGE):
props = GProp_GProps()
try:
BRepGProp.LinearProperties_s(shape, props)
point = _props_mass_point(props)
if point is not None:
return point
except Exception:
pass
return _bbox_center(shape)
# Vertex is a point.
if kind == TopAbs_VERTEX:
try:
return Vec3(*vertex_point(TopoDS.Vertex_s(shape)))
except Exception:
pass
return _bbox_center(shape)
# Unknown / undetermined kind: fall back to bbox centre.
return _bbox_center(shape)
@dataclass(frozen=True)
class BoundingBox:
xmin: float
ymin: float
zmin: float
xmax: float
ymax: float
zmax: float
@property
def xlen(self) -> float:
return self.xmax - self.xmin
@property
def ylen(self) -> float:
return self.ymax - self.ymin
@property
def zlen(self) -> float:
return self.zmax - self.zmin
def bounding_box(shape: TopoDS_Shape) -> BoundingBox:
box = Bnd_Box()
box.SetGap(0.0)
BRepBndLib.AddOptimal_s(shape, box, False, False)
xmin, ymin, zmin, xmax, ymax, zmax = box.Get()
return BoundingBox(float(xmin), float(ymin), float(zmin), float(xmax), float(ymax), float(zmax))
def distance(shape_a: TopoDS_Shape, shape_b: TopoDS_Shape) -> float:
dist = BRepExtrema_DistShapeShape(shape_a, shape_b)
dist.Perform()
if not dist.IsDone():
raise ValueError("OCP distance calculation failed")
return float(dist.Value())
def face_normal_at(face: TopoDS_Face, u: float = 0.5, v: float = 0.5) -> Vec3:
adaptor = BRepAdaptor_Surface(face, True)
umin = float(adaptor.FirstUParameter())
umax = float(adaptor.LastUParameter())
vmin = float(adaptor.FirstVParameter())
vmax = float(adaptor.LastVParameter())
uu = umin + (umax - umin) * float(u)
vv = vmin + (vmax - vmin) * float(v)
props = BRepLProp_SLProps(adaptor, uu, vv, 1, 1e-7)
if not props.IsNormalDefined():
raise ValueError("Face normal is not defined at the requested parameters")
n = props.Normal()
if face.Orientation() == TopAbs_REVERSED:
n.Reverse()
return Vec3(float(n.X()), float(n.Y()), float(n.Z()))
def edge_center(edge: TopoDS_Edge) -> Vec3:
return center_of_mass(edge)
@@ -0,0 +1,69 @@
"""OCP-native topology traversal helpers."""
from __future__ import annotations
from typing import List
from OCP.BRep import BRep_Tool
from OCP.BRepTools import BRepTools
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_VERTEX, TopAbs_WIRE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Vertex, TopoDS_Wire
def vertices_of(shape: TopoDS_Shape) -> List[TopoDS_Vertex]:
out: List[TopoDS_Vertex] = []
explorer = TopExp_Explorer(shape, TopAbs_VERTEX)
while explorer.More():
out.append(TopoDS.Vertex_s(explorer.Current()))
explorer.Next()
return out
def edges_of(shape: TopoDS_Shape) -> List[TopoDS_Edge]:
out: List[TopoDS_Edge] = []
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
out.append(TopoDS.Edge_s(explorer.Current()))
explorer.Next()
return out
def wires_of(shape: TopoDS_Shape) -> List[TopoDS_Wire]:
out: List[TopoDS_Wire] = []
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
while explorer.More():
out.append(TopoDS.Wire_s(explorer.Current()))
explorer.Next()
return out
def faces_of(shape: TopoDS_Shape) -> List[TopoDS_Face]:
out: List[TopoDS_Face] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
out.append(TopoDS.Face_s(explorer.Current()))
explorer.Next()
return out
def vertex_point(vertex: TopoDS_Vertex) -> tuple[float, float, float]:
p = BRep_Tool.Pnt_s(vertex)
return (float(p.X()), float(p.Y()), float(p.Z()))
def is_wire_closed(wire: TopoDS_Wire) -> bool:
return bool(BRep_Tool.IsClosed_s(wire))
def outer_wire_of(face: TopoDS_Face) -> TopoDS_Wire:
return BRepTools.OuterWire_s(face)
def inner_wires_of(face: TopoDS_Face) -> List[TopoDS_Wire]:
outer = outer_wire_of(face)
out: List[TopoDS_Wire] = []
for wire in wires_of(face):
if not wire.IsSame(outer):
out.append(wire)
return out
@@ -0,0 +1,116 @@
"""Thin OCP-native transform helpers for public geometry wrappers."""
from __future__ import annotations
import math
from typing import Tuple
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.TopAbs import (
TopAbs_COMPOUND,
TopAbs_EDGE,
TopAbs_FACE,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from ..core import AnyShape, Compound, Edge, Face, Solid, Vertex, Wire
def _shape_from_transformed(shape: AnyShape, transformed) -> AnyShape:
shape_type = transformed.ShapeType()
if shape_type == TopAbs_VERTEX:
return Vertex(TopoDS.Vertex_s(transformed))
if shape_type == TopAbs_EDGE:
return Edge(TopoDS.Edge_s(transformed))
if shape_type == TopAbs_WIRE:
return Wire(TopoDS.Wire_s(transformed))
if shape_type == TopAbs_FACE:
return Face(TopoDS.Face_s(transformed))
if shape_type == TopAbs_SOLID:
return Solid(TopoDS.Solid_s(transformed))
if shape_type == TopAbs_COMPOUND:
return Compound(TopoDS.Compound_s(transformed))
raise ValueError(f"Unsupported transformed shape type: {shape_type}")
def apply_transform(shape: AnyShape, trsf: gp_Trsf) -> AnyShape:
xform = BRepBuilderAPI_Transform(shape.wrapped, trsf, True)
xform.Build()
if not xform.IsDone():
raise ValueError("OCP transform build failed")
return _shape_from_transformed(shape, xform.Shape())
def translate_shape_ocp(
shape: AnyShape, vector: Tuple[float, float, float]
) -> AnyShape:
trsf = gp_Trsf()
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
return apply_transform(shape, trsf)
def rotate_shape_ocp(
shape: AnyShape,
angle_degrees: float,
axis: Tuple[float, float, float],
origin: Tuple[float, float, float],
) -> AnyShape:
trsf = gp_Trsf()
trsf.SetRotation(
gp_Ax1(
gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])),
gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])),
),
math.radians(float(angle_degrees)),
)
return apply_transform(shape, trsf)
def mirror_shape_ocp(
shape: AnyShape,
plane_origin: Tuple[float, float, float],
plane_normal: Tuple[float, float, float],
) -> AnyShape:
trsf = gp_Trsf()
trsf.SetMirror(
gp_Ax2(
gp_Pnt(
float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])
),
gp_Dir(
float(plane_normal[0]),
float(plane_normal[1]),
float(plane_normal[2]),
),
)
)
return apply_transform(shape, trsf)
def place_shape_ocp(
shape: AnyShape,
origin: Tuple[float, float, float],
x_axis: Tuple[float, float, float],
y_axis: Tuple[float, float, float],
z_axis: Tuple[float, float, float],
) -> AnyShape:
trsf = gp_Trsf()
trsf.SetValues(
float(x_axis[0]),
float(y_axis[0]),
float(z_axis[0]),
float(origin[0]),
float(x_axis[1]),
float(y_axis[1]),
float(z_axis[1]),
float(origin[1]),
float(x_axis[2]),
float(y_axis[2]),
float(z_axis[2]),
float(origin[2]),
)
return apply_transform(shape, trsf)