Files
cdsl-cad/backend/engine/cdsl_engine/build123d_adapter.py
T

3342 lines
169 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/OCC implementation of the runtime-neutral geometry adapter."""
from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import AngularDirection, Axis, Compound, Edge, Face, GeomType, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BOPAlgo import BOPAlgo_Splitter
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL, TopAbs_VERTEX
from OCP.TopExp import TopExp_Explorer
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from .parametric_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid
from .parametric_thread import build_thread_solid
from .runtime_types import (
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
TopologyBlendRelation, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
canonical_plane_signature,
)
from .topology_export import (
body_geometry as _body_geometry_impl,
surface_geometry as _surface_geometry_impl,
topology_records as _topology_records_impl,
)
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
# 将三元坐标(list 或 tuple)转换为 build123d 的 Vector 对象。
return Vector(float(value[0]), float(value[1]), float(value[2]))
def make_interpolated_bspline_edge(
points: list[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
periodic: bool = False,
parameters: list[float] | None = None,
) -> Edge:
"""Build the exact non-scaling interpolator used by CDSL sketch edges.
FeatureScript ``skFitSpline`` lowering retains its centripetal parameters
and endpoint derivatives. Keep this construction in one kernel helper so
geometry execution and consumers that need a source-curve differential do
not accidentally use a chord or a separately parameterized interpolator.
Callers remain responsible for their own CDSL contract validation.
"""
if (start_tangent is None) != (end_tangent is None):
raise ValueError("interpolated B-spline requires both endpoint tangents")
return Edge.make_spline(
[_vector(point) for point in points],
tangents=(
[_vector(start_tangent), _vector(end_tangent)]
if start_tangent is not None else None
),
periodic=periodic,
parameters=parameters,
scale=False,
)
def interpolated_bspline_point_and_tangent(
points: list[Vector3],
*,
start_tangent: Vector3,
end_tangent: Vector3,
parameters: list[float],
interpolation_index: int,
) -> tuple[Vector3, Vector3]:
"""Evaluate a CDSL interpolation point with the runtime's OCC curve.
``Edge.tangent_at`` accepts normalized edge positions, which need not be
the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at
the source parameter and return the actual point as well, allowing the
caller to prove that OCC still interpolated the named source vertex.
"""
if interpolation_index < 0 or interpolation_index >= len(points):
raise ValueError("B-spline interpolation index is out of range")
if len(parameters) != len(points):
raise ValueError("B-spline interpolation parameters must match points")
if not all(math.isfinite(float(value)) for value in parameters):
raise ValueError("B-spline interpolation parameters must be finite")
if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])):
raise ValueError("B-spline interpolation parameters must be strictly increasing")
edge = make_interpolated_bspline_edge(
points,
start_tangent=start_tangent,
end_tangent=end_tangent,
parameters=parameters,
)
point = gp_Pnt()
tangent = gp_Vec()
BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent)
return (
(float(point.X()), float(point.Y()), float(point.Z())),
(float(tangent.X()), float(tangent.Y()), float(tangent.Z())),
)
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
radius = float(edge.get("radius_mm") or (start - center).length)
first = start - center
second = end - center
# 2. 起点或终点与圆心重合时,圆弧退化为线段,中点取两端中点。
if first.length <= 1e-9 or second.length <= 1e-9:
return (start + end) / 2
# 3. 确定圆弧所在平面法向:优先显式 normal,其次由两半径向量叉积推得,最后回退到 +Z。
normal = _vector(edge.get("normal") or [0, 0, 1])
if normal.length <= 1e-9:
normal = first.cross(second)
if normal.length <= 1e-9:
normal = Vector(0, 0, 1)
normal = normal.normalized()
# 4. 未指定旋转方向:取两条半径单位向量之和(角平分线)指向圆弧中点。
if "clockwise" not in edge:
bisector = first.normalized() + second.normalized()
if bisector.length <= 1e-9:
bisector = normal.cross(first)
return center + bisector.normalized() * radius
# 5. 指定了方向:按有符号扫掠角规整到 (−π, π],再沿首半径旋转半角得到中点。
sweep = math.atan2(normal.dot(first.cross(second)), first.dot(second))
if bool(edge["clockwise"]):
if sweep >= 0:
sweep -= math.tau
elif sweep <= 0:
sweep += math.tau
half = sweep / 2
radius_vector = first.normalized() * radius
return center + radius_vector * math.cos(half) + normal.cross(radius_vector) * math.sin(half)
class Build123dGeometryAdapter:
"""All B-rep construction and mutation lives in this adapter."""
CONTACT_FUSE_TOLERANCE_MM = 1e-7
COINCIDENT_FUSE_TOLERANCE_MM = 1e-3
@staticmethod
def plane(spec: PlaneSpec) -> Plane:
# 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。
return Plane(origin=_vector(spec.origin_mm), x_dir=_vector(spec.x_dir), z_dir=_vector(spec.normal))
@staticmethod
def axis(spec: AxisSpec) -> Axis:
# 将运行时轴定义 AxisSpec 转换为 build123d 的 Axis。
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
@staticmethod
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "circle":
center = _vector(edge["center_mm"])
x_dir = _vector(edge.get("x_dir_mm") or [1, 0, 0])
normal = _vector(edge.get("normal") or [0, 0, 1])
radius = float(edge.get("radius_mm") or 0.0)
if radius <= 0.0 or x_dir.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("circle contour edge has a degenerate frame")
direction = AngularDirection.CLOCKWISE if bool(edge.get("clockwise")) else AngularDirection.COUNTER_CLOCKWISE
built.append(Edge.make_circle(radius, Plane(origin=center, x_dir=x_dir, z_dir=normal), angular_direction=direction))
continue
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
parameters = edge.get("parameters")
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(points) < 2:
raise ValueError("bspline contour edge needs at least 2 points")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("bspline contour edge requires both endpoint tangents")
if len(points) == 2:
if math.dist(
(points[0].X, points[0].Y, points[0].Z),
(points[1].X, points[1].Y, points[1].Z),
) <= 1e-5:
raise ValueError("two-point bspline contour edge endpoints must be distinct")
if bool(edge.get("periodic")) or start_tangent is None or end_tangent is None:
raise ValueError("two-point bspline contour edge requires non-periodic endpoint tangents")
if not isinstance(parameters, list) or len(parameters) != 2:
raise ValueError("two-point bspline contour edge requires explicit parameters")
try:
parameter_values = [float(value) for value in parameters]
except (OverflowError, TypeError, ValueError) as exc:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") from exc
if not all(math.isfinite(value) for value in parameter_values) or parameter_values[1] - parameter_values[0] <= 1e-5:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
else:
parameter_values = [float(value) for value in parameters] if parameters is not None else None
built.append(make_interpolated_bspline_edge(
[(point.X, point.Y, point.Z) for point in points],
start_tangent=start_tangent,
end_tangent=end_tangent,
periodic=bool(edge.get("periodic")),
parameters=parameter_values,
))
continue
if edge.get("type") == "ellipse":
center = _vector(edge["center_mm"])
major_axis = _vector(edge["major_axis_mm"])
normal = _vector(edge.get("normal") or [0, 0, 1])
if major_axis.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("ellipse contour edge has a degenerate frame")
plane = Plane(origin=center, x_dir=major_axis, z_dir=normal)
built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
# 圆弧边:由起点、中点、终点三点构造圆弧。
center = _vector(edge["center_mm"])
built.append(Edge.make_three_point_arc(start, _arc_midpoint(edge, start, end, center), end))
else:
# 直线边:直接连接首尾。
built.append(Edge.make_line(start, end))
return built
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
return Wire(Build123dGeometryAdapter._wire_edges(edges))
@staticmethod
def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]:
"""Construct one wire and retain only explicit one-to-one source edges."""
built = Build123dGeometryAdapter._wire_edges(edges)
return Wire(built), [
(str(edge["source_entity_id"]), built[index])
for index, edge in enumerate(edges)
if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"]
]
@staticmethod
def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]:
"""Build one direct wire and retain the builder's exact source edges.
``Wire(built)`` may repair shared vertices by replacing individual
edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at
insertion time. The caller must still prove those handles are members
of the final face before registering a source anchor.
"""
wire_builder = BRepBuilderAPI_MakeWire()
source_edges: list[tuple[str, Edge]] = []
for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)):
wire_builder.Add(edge.wrapped)
if not wire_builder.IsDone():
raise ValueError("analytic contour wire construction failed")
source_entity_id = definition.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge())))
return wire_builder.Wire(), source_edges
def _face_from_direct_wires(
self,
outer: list[dict[str, Any]],
holes: Iterable[list[dict[str, Any]]] = (),
*,
logical_circle_sources: dict[str, dict[str, Any]] | None = None,
plane_spec: PlaneSpec | None = None,
) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build one direct profile face with exact outer and hole wire handles.
Constructing a face and then calling ``Face.make_holes`` can replace
linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace``
instead gives the face builder every source wire, so a later ``IsSame``
membership check can establish provenance without geometry matching.
"""
outer_wire, source_edges = self._direct_wire_with_source_edges(outer)
face_builder = BRepBuilderAPI_MakeFace(outer_wire, True)
for hole in holes:
hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges(
hole,
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
# OCC requires an inner wire to have the opposite orientation to
# its outer boundary. ``Wire.Reversed`` returns a generic shape,
# so cast it back to TopoDS_Wire for the face-builder API.
face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed()))
source_edges.extend(hole_source_edges)
if not face_builder.IsDone():
raise ValueError("analytic contour face construction failed")
return Face.cast(face_builder.Face()), source_edges
def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build a direct face with no inner wires for existing callers."""
return self._face_from_direct_wires(edges)
def _direct_hole_wire_with_source_edges(
self,
edges: list[dict[str, Any]],
*,
logical_circle_sources: dict[str, dict[str, Any]] | None,
plane_spec: PlaneSpec | None,
) -> tuple[Any, list[tuple[str, Edge]]]:
"""Restore one solver-split source circle only from explicit provenance.
``analytic_contours`` decomposes a circle into four arcs to classify
regions. Those arcs are not source edges. The solver records one
logical-circle source marker on every generated arc, so a complete
marked loop may be rebuilt as one native circle wire. Any missing,
mixed, or unregistered marker uses the ordinary direct wire path and
therefore retains no invented source edge.
"""
logical_ids = {
edge.get("logical_circle_source_entity_id")
for edge in edges
if isinstance(edge.get("logical_circle_source_entity_id"), str)
and edge["logical_circle_source_entity_id"]
}
if (
len(edges) == 4
and len(logical_ids) == 1
and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges)
and logical_circle_sources is not None
and plane_spec is not None
):
source_entity_id = next(iter(logical_ids))
source = logical_circle_sources.get(source_entity_id)
center = source.get("center") if isinstance(source, dict) else None
radius = source.get("radius_mm") if isinstance(source, dict) else None
if (
isinstance(center, list)
and len(center) >= 2
and isinstance(radius, (int, float))
and math.isfinite(float(radius))
and float(radius) > 0
):
wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec)
return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()]
return self._direct_wire_with_source_edges(edges)
@staticmethod
def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Index uniquely named, unsplit circle source entities from one profile."""
sources: dict[str, dict[str, Any]] = {}
duplicates: set[str] = set()
for contour in profile.get("contours") or []:
if not isinstance(contour, dict):
continue
for segment in contour.get("segments") or []:
if not isinstance(segment, dict) or segment.get("type") != "circle":
continue
source_entity_id = segment.get("source_entity_id")
if not isinstance(source_entity_id, str) or not source_entity_id:
continue
if source_entity_id in sources:
duplicates.add(source_entity_id)
else:
sources[source_entity_id] = segment
for source_entity_id in duplicates:
sources.pop(source_entity_id, None)
return sources
@staticmethod
def _face_source_anchor_specs(
face: Face,
source_edges: list[tuple[str, Edge]],
source_sketch_id: str | None,
) -> list[dict[str, Any]]:
"""Bind direct source labels to the exact face boundary subshapes.
The caller retains the wire edges created for this profile. A source
label is emitted only when it has one exact final face edge; there is
no geometry-based reconstruction when splitting, trimming, or wire
construction changes that one-to-one relationship.
"""
if not isinstance(source_sketch_id, str) or not source_sketch_id:
return []
actual_edges = list(face.edges())
mapped: list[tuple[str, Edge]] = []
source_counts: dict[str, int] = {}
for source_entity_id, _edge in source_edges:
source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1
for source_entity_id, source_edge in source_edges:
if source_counts[source_entity_id] != 1:
continue
matches = [
edge for edge in actual_edges
if edge.wrapped.IsSame(source_edge.wrapped)
]
if len(matches) == 1:
mapped.append((source_entity_id, matches[0]))
specs: list[dict[str, Any]] = [
{
"kind": "edge",
"value": edge,
"source_entity": (source_sketch_id, source_entity_id),
}
for source_entity_id, edge in mapped
]
vertex_groups: list[tuple[Any, set[str]]] = []
for source_entity_id, edge in mapped:
for vertex in edge.vertices():
group = next(
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
None,
)
if group is None:
vertex_groups.append((vertex, {source_entity_id}))
else:
group[1].add(source_entity_id)
for vertex, entity_ids in vertex_groups:
if len(entity_ids) < 2:
continue
specs.append({
"kind": "vertex",
"value": vertex,
"source_entities": tuple(
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
),
})
return specs
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
origin = Vector(*plane_spec.origin_mm) + Vector(*plane_spec.x_dir) * float(center[0]) + Vector(*plane_spec.y_dir) * float(center[1])
circle_plane = Plane(origin=origin, x_dir=Vector(*plane_spec.x_dir), z_dir=Vector(*plane_spec.normal))
return Wire.make_circle(radius, circle_plane)
def _faces_from_circles(self, entities: list[dict[str, Any]], plane_spec: PlaneSpec) -> list[Face]:
# 由草图中的实体圆生成面,按圆间包含关系识别孔洞并跳过落入孔洞区的圆。
# 1. 筛选非构造圆;没有实体圆时直接返回空列表。
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
if not circles:
return []
# 2. 逐个生成整圆 Wire,非法半径(≤0)的圆跳过。
entries = []
for item in circles:
radius = float(item.get("radius_mm") or 0)
if radius <= 0:
continue
center = [float(value) for value in item.get("center") or [0, 0]]
entries.append({"center": center, "radius": radius, "wire": self._circle_wire(center, radius, plane_spec)})
faces: list[Face] = []
for entry in entries:
# 3. 统计当前圆被多少个更大圆完整包含;被奇数层包含说明其处于孔洞区,跳过不建面。
containing = sum(
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
for other in entries
if other is not entry
)
if containing % 2:
continue
# 4. 收集直接包在自身内部的圆作为孔洞,且它们只能被当前这一层包含。
holes = [
other["wire"]
for other in entries
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
and sum(
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
for candidate in entries
if candidate is not other
) == containing + 1
]
# 5. 以当前圆为外轮廓建面,必要时打孔。
face = Face(entry["wire"])
faces.append(face.make_holes(holes) if holes else face)
return faces
def _faces_from_circles_with_source_anchors(
self,
entities: list[dict[str, Any]],
plane_spec: PlaneSpec,
source_sketch_id: str | None,
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Build direct circular profile faces while retaining their wire edges."""
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
if not circles:
return [], []
entries = []
for item in circles:
radius = float(item.get("radius_mm") or 0)
if radius <= 0:
continue
center = [float(value) for value in item.get("center") or [0, 0]]
entries.append({
"center": center,
"radius": radius,
"wire": self._circle_wire(center, radius, plane_spec),
"source_entity_id": item.get("source_entity_id"),
})
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for entry in entries:
containing = sum(
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
for other in entries
if other is not entry
)
if containing % 2:
continue
holes = [
other for other in entries
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
and sum(
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
for candidate in entries
if candidate is not other
) == containing + 1
]
face = Face(entry["wire"])
result = face.make_holes([item["wire"] for item in holes]) if holes else face
source_edges = [
(str(item["source_entity_id"]), edge)
for item in [entry, *holes]
if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"]
for edge in item["wire"].edges()
]
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
@staticmethod
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
"""Return OCC split history, keeping an unchanged input as one image."""
images = [Edge.cast(shape) for shape in splitter.Modified(edge.wrapped)]
return images or [edge]
@staticmethod
def _imprint_support_face(edges: list[Edge], plane_spec: PlaneSpec) -> Face:
"""Build a finite support face around all source curves.
A fixed world-aligned box would silently make the result depend on a
sketch's orientation. Projecting every edge bounding-box corner into
the explicit workplane creates a deterministic support boundary for
the OCC splitter. Any selected face that touches that boundary is
rejected later as an unbounded IMPRINT region.
"""
if not edges:
raise ValueError("planar_imprint has no source edges")
origin = _vector(plane_spec.origin_mm)
x_dir = _vector(plane_spec.x_dir)
y_dir = _vector(plane_spec.y_dir)
coordinates: list[tuple[float, float]] = []
for edge in edges:
box = edge.bounding_box()
for x in (box.min.X, box.max.X):
for y in (box.min.Y, box.max.Y):
for z in (box.min.Z, box.max.Z):
offset = Vector(x, y, z) - origin
coordinates.append((offset.dot(x_dir), offset.dot(y_dir)))
if not coordinates:
raise ValueError("planar_imprint cannot bound source geometry")
u_values, v_values = zip(*coordinates)
u_min, u_max = min(u_values), max(u_values)
v_min, v_max = min(v_values), max(v_values)
span = max(u_max - u_min, v_max - v_min, 1.0)
margin = max(span * 0.1, 1.0)
corners = [
origin + x_dir * (u_min - margin) + y_dir * (v_min - margin),
origin + x_dir * (u_max + margin) + y_dir * (v_min - margin),
origin + x_dir * (u_max + margin) + y_dir * (v_max + margin),
origin + x_dir * (u_min - margin) + y_dir * (v_max + margin),
]
return Face(Wire([
Edge.make_line(corners[index], corners[(index + 1) % len(corners)])
for index in range(len(corners))
]))
@staticmethod
def _intersection_parameters(source: Edge, anchors: list[Edge]) -> list[float]:
"""Return unique exact intersection points ordered by source direction."""
parameters: list[float] = []
for anchor in anchors:
distance = BRepExtrema_DistShapeShape(source.wrapped, anchor.wrapped)
distance.Perform()
if not distance.IsDone() or distance.Value() > 1e-6:
continue
for index in range(1, distance.NbSolution() + 1):
point = Vector(distance.PointOnShape1(index))
parameter = float(source.param_at_point(point))
if not math.isfinite(parameter):
continue
if not any(abs(parameter - existing) <= 1e-7 for existing in parameters):
parameters.append(parameter)
return sorted(parameters)
@staticmethod
def _selected_imprint_edges(
splitter: BOPAlgo_Splitter,
source: Edge,
anchors: list[Edge],
fragment: dict[str, Any] | None,
) -> list[Edge]:
"""Resolve a logical source or one ordered split fragment exactly."""
images = Build123dGeometryAdapter._split_images(splitter, source)
if not fragment:
# A bare IMPRINT source query denotes every builder image of the
# same logical FeatureScript edge. The caller proves that every
# requested-side candidate is bounded; it must not pick an
# arbitrary image just because OCC introduced vertices at contact
# points.
return images
intersections = Build123dGeometryAdapter._intersection_parameters(source, anchors)
if not intersections:
raise ValueError("planar_imprint fragment source and anchor do not intersect")
order = fragment.get("intersection_index")
if order is None:
if len(intersections) != 1:
raise ValueError("planar_imprint fragment intersection is not unique")
anchor_parameter = intersections[0]
elif isinstance(order, int) and 0 <= order < len(intersections):
anchor_parameter = intersections[order]
else:
raise ValueError("planar_imprint fragment intersection index is invalid")
# FeatureScript's topology disambiguation uses -1 for the directed
# successor of a vertex and +1 for its predecessor. Comparing raw
# parameters would break at a periodic curve's 0/1 seam, so resolve
# the image by the exact split endpoint and its source-aligned tangent.
forward = float(fragment.get("side")) < 0.0
anchor_point = source.position_at(anchor_parameter)
source_tangent = source.tangent_at(anchor_parameter)
if source_tangent.length <= 1e-9:
raise ValueError("planar_imprint source edge has no directed tangent")
if len(images) == 1 and bool(source.wrapped.Closed()):
# One exact vertex does not divide a periodic OCC edge. Both
# directed choices therefore refer to its sole logical fragment.
return images
candidates: list[Edge] = []
for edge in images:
endpoint = edge.position_at(0 if forward else 1)
tangent = edge.tangent_at(0 if forward else 1)
if (endpoint - anchor_point).length > 1e-6:
continue
if tangent.length <= 1e-9 or source_tangent.normalized().dot(tangent.normalized()) < 1.0 - 1e-7:
continue
candidates.append(edge)
if len(candidates) != 1:
raise ValueError("planar_imprint fragment side does not resolve one split edge")
return candidates
@staticmethod
def _face_uses_boundary(face: Face, boundary_edges: list[Edge]) -> bool:
return any(
edge.wrapped.IsSame(boundary.wrapped)
for edge in face.edges()
for boundary in boundary_edges
)
@staticmethod
def _imprint_source_anchor_specs(
selected: list[Face],
source_edges: dict[str, list[Edge]],
splitter: BOPAlgo_Splitter,
source_sketch_id: str | None,
) -> list[dict[str, Any]]:
"""Return only selected IMPRINT boundary fragments with exact sources.
A logical FeatureScript edge can be split at an arrangement
intersection. Its descendants are a source *set*, not an ambiguous
geometry match. Keep every descendant that is an exact ``IsSame``
boundary of a selected region; do not expose unselected splitter
images or reconstruct a fragment from coordinates.
"""
if not isinstance(source_sketch_id, str) or not source_sketch_id:
return []
mapped: list[tuple[str, Edge]] = []
for source_entity_id, source in source_edges.items():
# The IMPRINT selector path itself accepts only one native source
# edge per logical entity. Withhold a source label when that
# invariant is not true instead of flattening a multi-edge curve.
if len(source) != 1:
continue
for image in Build123dGeometryAdapter._split_images(splitter, source[0]):
for face in selected:
for boundary in face.edges():
if not boundary.wrapped.IsSame(image.wrapped):
continue
if not any(
source_entity_id == existing_id
and boundary.wrapped.IsSame(existing.wrapped)
for existing_id, existing in mapped
):
mapped.append((source_entity_id, boundary))
specs: list[dict[str, Any]] = [
{
"kind": "edge",
"value": edge,
"source_entity": (source_sketch_id, source_entity_id),
}
for source_entity_id, edge in mapped
]
vertex_groups: list[tuple[Any, set[str]]] = []
for source_entity_id, edge in mapped:
for vertex in edge.vertices():
group = next(
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
None,
)
if group is None:
vertex_groups.append((vertex, {source_entity_id}))
else:
group[1].add(source_entity_id)
for vertex, entity_ids in vertex_groups:
if len(entity_ids) < 2:
continue
specs.append({
"kind": "vertex",
"value": vertex,
"source_entities": tuple(
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
),
})
return specs
def _faces_from_planar_imprint_with_source_anchors(
self,
sketch: dict[str, Any],
*,
support_face: Face | None = None,
external_anchor_edges: dict[str, Edge] | None = None,
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Materialize bounded IMPRINT regions and their exact source anchors.
``external_anchor_edges`` is deliberately an adapter-only input. It
accepts an already proven native edge (for example a CAP edge on an
attached support face) as a splitter tool and fragment anchor. It is
not a geometry lookup, does not export an anchor record, and does not
make an external selector valid in CDSL by itself. The source lowerer
must establish that provenance before this hook is ever used by a
production profile contract.
"""
source_entries = sketch.get("imprint_entities_mm") or []
selections = sketch.get("imprint_selections") or []
if not source_entries or not selections:
raise ValueError("planar_imprint is missing resolved source entities or selections")
source_edges: dict[str, list[Edge]] = {}
all_edges: list[Edge] = []
for entry in source_entries:
source_id = str(entry.get("id") or "")
raw_edges = entry.get("edges") or []
if not source_id or not raw_edges or source_id in source_edges:
raise ValueError("planar_imprint source entities are invalid")
edges = list(self._wire(raw_edges).edges())
if not edges:
raise ValueError(f"planar_imprint source entity {source_id!r} has no OCC edge")
source_edges[source_id] = edges
all_edges.extend(edges)
external_anchors = dict(external_anchor_edges or {})
if any(not isinstance(anchor_id, str) or not anchor_id or not isinstance(edge, Edge)
for anchor_id, edge in external_anchors.items()):
raise ValueError("planar_imprint external anchors are invalid")
if set(source_edges).intersection(external_anchors):
raise ValueError("planar_imprint external anchor duplicates a source entity")
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {})
# An attached face is exact live topology, unlike the artificial
# planar box used for unattached source sketches. Rebuilding that box
# here would silently discard the support-face boundary semantics.
support = support_face or self._imprint_support_face(all_edges, plane_spec)
artificial_support = support_face is None
splitter = BOPAlgo_Splitter()
splitter.AddArgument(support.wrapped)
for edge in all_edges:
splitter.AddTool(edge.wrapped)
for edge in external_anchors.values():
splitter.AddTool(edge.wrapped)
splitter.Perform()
if splitter.HasErrors():
raise ValueError("planar_imprint OCC splitter failed")
explorer = TopExp_Explorer(splitter.Shape(), TopAbs_FACE)
regions: list[Face] = []
while explorer.More():
face = Face.cast(explorer.Current())
if face.area > 1e-10:
regions.append(face)
explorer.Next()
if not regions:
raise ValueError("planar_imprint OCC splitter produced no regions")
boundary_edges = [
image
for edge in support.edges()
for image in self._split_images(splitter, edge)
]
normal = _vector(plane_spec.normal)
selected: list[Face] = []
for selection in selections:
source_id = str(selection.get("source_entity_id") or "")
source = source_edges.get(source_id) or []
if len(source) != 1:
raise ValueError("planar_imprint selection source must resolve to one analytic edge")
fragment = selection.get("fragment")
anchor_id = str((fragment or {}).get("anchor_entity_id") or "")
external_anchor_id = str((fragment or {}).get("external_anchor_id") or "")
if fragment and bool(anchor_id) == bool(external_anchor_id):
raise ValueError("planar_imprint fragment must name exactly one anchor")
anchors = source_edges.get(anchor_id) or [] if anchor_id else []
if external_anchor_id:
external = external_anchors.get(external_anchor_id)
anchors = [external] if external is not None else []
if fragment and not anchors:
raise ValueError("planar_imprint fragment anchor is unavailable")
edges = self._selected_imprint_edges(splitter, source[0], anchors, fragment)
face_side = float(selection.get("face_side") or 0.0)
if face_side not in {-1.0, 1.0}:
raise ValueError("planar_imprint face side is invalid")
candidate_faces: list[Face] = []
for edge in edges:
tangent = edge.tangent_at(0.5)
lateral = normal.cross(tangent)
if lateral.length <= 1e-9:
raise ValueError("planar_imprint selected edge has no in-plane side")
extent = max(edge.length, 1e-4)
probe_distance = max(1e-5, min(extent / 1000.0, 0.01))
probe = edge.position_at(0.5) + lateral.normalized() * (probe_distance * face_side)
candidates = [face for face in regions if face.is_inside(probe, probe_distance / 10.0)]
if len(candidates) != 1:
raise ValueError("planar_imprint face side does not resolve one region")
face = candidates[0]
if not any(face.wrapped.IsSame(existing.wrapped) for existing in candidate_faces):
candidate_faces.append(face)
if artificial_support:
unbounded = [
face for face in candidate_faces
if self._face_uses_boundary(face, boundary_edges)
]
if unbounded:
if len(candidate_faces) == 1 and self._face_uses_boundary(candidate_faces[0], boundary_edges):
raise ValueError("planar_imprint selected region is unbounded")
raise ValueError("planar_imprint source side includes an unbounded region")
# A bare source query can legitimately select every bounded face
# adjacent to its OCC split descendants. This is a set-valued
# FeatureScript IMPRINT result, not an invitation to choose one
# segment by length, order, or proximity.
for face in candidate_faces:
if not any(face.wrapped.IsSame(existing.wrapped) for existing in selected):
selected.append(face)
return selected, self._imprint_source_anchor_specs(
selected,
source_edges,
splitter,
sketch.get("source_sketch_id"),
)
def _faces_from_planar_imprint(
self, sketch: dict[str, Any], *, support_face: Face | None = None,
external_anchor_edges: dict[str, Edge] | None = None,
) -> list[Face]:
"""Materialize exact bounded IMPRINT regions with OCC planar splitting."""
faces, _anchors = self._faces_from_planar_imprint_with_source_anchors(
sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
)
return faces
def faces_for_sketch(
self, sketch: dict[str, Any], *, support_face: Face | None = None,
external_anchor_edges: dict[str, Edge] | None = None,
) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
# 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自
# FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
return self._faces_from_planar_imprint(
sketch,
support_face=support_face,
external_anchor_edges=external_anchor_edges,
)
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}))
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
regions = sketch.get("contour_regions_mm") or []
if regions:
result: list[Face] = []
for region in regions:
outer = region.get("outer") or []
# 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。
if len(outer) < 1:
continue
face = Face(self._wire(outer))
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1]
result.append(face.make_holes(holes) if holes else face)
return result
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 1:
return [Face(self._wire(edges))]
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
def faces_for_sketch_with_source_anchors(
self,
sketch: dict[str, Any],
*,
support_face: Face | None = None,
external_anchor_edges: dict[str, Edge] | None = None,
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Return profile faces plus direct source anchors for prism history.
This intentionally covers only direct analytic builders and the
separately proven planar-IMPRINT splitter path. Other generated or
transformed profile paths keep their normal geometry but expose no
semantic source anchor.
"""
profile = sketch.get("profile") or {}
source_sketch_id = sketch.get("source_sketch_id")
if profile.get("type") == "planar_imprint":
return self._faces_from_planar_imprint_with_source_anchors(
sketch,
support_face=support_face,
external_anchor_edges=external_anchor_edges,
)
if not isinstance(source_sketch_id, str):
return self.faces_for_sketch(sketch), []
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
# Keep each original circular source edge before the sketch solver
# expands it into analytic arc fragments for region bookkeeping.
# ``_faces_from_circles_with_source_anchors`` verifies each wire
# against the resulting face by exact ``IsSame``; a missing source
# label remains unavailable rather than being inferred from its
# radius or centre. This also covers a direct annulus or multiple
# independent circular regions built by the same face constructor.
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
**(
{"source_entity_id": segment["source_entity_id"]}
if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"]
else {}
),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles_with_source_anchors(
circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id,
)
regions = sketch.get("contour_regions_mm") or []
if regions:
# ``Face.make_holes`` remains the authoritative geometry path for
# a mixed multi-region profile. OCC can give a valid direct face
# for each individual region while changing how those regions
# interact when their prisms are combined. The direct builder is
# therefore limited to one hole-bearing region; keep the existing
# executable profile and withhold anchors for the broader case.
if len(regions) > 1 and any(region.get("holes") for region in regions):
return self.faces_for_sketch(sketch), []
logical_circle_sources = self._logical_circle_sources(profile)
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for region in regions:
outer = region.get("outer") or []
if len(outer) < 1:
continue
# Build all direct boundary wires in one face builder. Each
# prospective source edge is verified below against the final
# face, so an OCC wire repair or an unsupported hole topology
# removes evidence rather than creating a geometric fallback.
try:
result, source_edges = self._face_from_direct_wires(
outer,
region.get("holes") or (),
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
except ValueError:
# Keep the established executable profile path when the
# direct builder cannot represent this analytic region.
# It deliberately carries no provenance anchor.
return self.faces_for_sketch(sketch), []
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
if profile.get("type") == "circle":
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
# A lowered direct circle keeps its sole source identity on the
# profile itself, not in the generic sketch entity list. Pass
# through only that declared identity; never recover it from the
# resulting circle's radius or centre.
circle = {
"type": "circle",
"center": profile.get("center"),
"radius_mm": profile.get("radius_mm"),
}
if isinstance(profile.get("source_entity_id"), str) and profile["source_entity_id"]:
circle["source_entity_id"] = profile["source_entity_id"]
return self._faces_from_circles_with_source_anchors(
[circle], plane, source_sketch_id,
)
return self.faces_for_sketch(sketch), []
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
if outer.inner_wires():
raise ValueError("profile outer face must not already contain holes")
wires = []
for hole in holes:
if hole.inner_wires():
raise ValueError("profile hole face must have exactly one outer wire")
wires.append(hole.outer_wire())
if not wires:
raise ValueError("profile hole feature requires at least one cap face")
return Face(outer.outer_wire()).make_holes(wires)
def _loft_wires(self, sketches: list[dict[str, Any]]) -> list[Wire]:
"""Resolve the bounded closed-wire CDSL loft contract once."""
wires: list[Wire] = []
for index, sketch in enumerate(sketches):
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
# 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系
# 尚未由 CDSL 表达,必须显式拒绝而非猜测。
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
if len(wires) < 2:
raise ValueError("loft requires at least two profile sketches")
return wires
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
result, _delta = self.loft_with_topology_delta(sketches)
return result
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Solid, TopologyDelta]:
"""Build a simple closed-wire solid loft through one OCC builder."""
wires = self._loft_wires(sketches)
builder = BRepOffsetAPI_ThruSections(True, False)
builder.CheckCompatibility(True)
for wire in wires:
builder.AddWire(wire.wrapped)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC loft operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC loft operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for wire, output, role in (
(wires[0], builder.FirstShape(), "loft.start"),
(wires[-1], builder.LastShape(), "loft.end"),
):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", wire.wrapped, (output,), output_role=role,
))
return result, TopologyDelta(operation="loft", relations=tuple(relations))
def loft_surface(self, sketches: list[dict[str, Any]]) -> Shell:
"""Loft closed source wires into an independent shell, never a solid."""
wires = self._loft_wires(sketches)
builder = BRepOffsetAPI_ThruSections(False, False)
builder.CheckCompatibility(True)
for wire in wires:
builder.AddWire(wire.wrapped)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC surface loft operation did not complete")
shape = builder.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("OCC surface loft operation did not produce one shell")
return Shell(shape)
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
"""Loft from one cap face's outer wire to one closed sketch profile."""
if not sketches:
raise ValueError("cap-face loft requires at least one profile sketch")
wires = [cap_face.outer_wire()]
for index, sketch in enumerate(sketches):
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
# build123d 的布尔方法有时返回 None(无结果)、ShapeList(多/单成员)
# 或直接返回 Solid/Compound,这里统一为单实体或 Compound。
if result is None:
if empty_error is not None:
raise ValueError(empty_error)
return None
members = list(result) if isinstance(result, ShapeList) else [result]
if not members:
if empty_error is not None:
raise ValueError(empty_error)
return None
if len(members) == 1:
return members[0]
# ``Compound`` constructor accepts an iterable on both supported
# Build123d runtimes. ``make_composite`` is not available in every
# deployed version, so using it here breaks valid multi-solid cuts.
return Compound(members)
@staticmethod
def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction))
@staticmethod
def face_normal(face: Face) -> Vector3:
normal = face.normal_at()
return (float(normal.X), float(normal.Y), float(normal.Z))
@staticmethod
def planar_face_workplane(face: Face) -> PlaneSpec:
"""Build a sketch frame from one already-proven active planar face.
This is deliberately a query of the exact resolved B-rep face, not a
geometric selector. The face's native U direction retains the
attachment handedness through the boolean; its support plane projects
the global origin, matching FeatureScript's face-sketch convention.
"""
if str(face.geom_type).split(".")[-1].lower() != "plane":
raise ValueError("attached sketch requires a planar face")
normal = Build123dGeometryAdapter.face_normal(face)
try:
# build123d's Face.position_at takes normalized 0..1 coordinates,
# then maps them to the native OCC UV bounds internally. Passing
# raw UV values here re-applies that mapping and can put an attached
# sketch frame kilometres away from its resolved support face.
u_mid, v_mid = 0.5, 0.5
step = 1e-6
before, after = face.position_at(u_mid - step, v_mid), face.position_at(u_mid + step, v_mid)
except (AttributeError, TypeError, ValueError) as error:
raise ValueError("attached planar face has no stable native U direction") from error
x_dir = (float(after.X - before.X), float(after.Y - before.Y), float(after.Z - before.Z))
point = face.position_at(u_mid, v_mid)
origin = (float(point.X), float(point.Y), float(point.Z))
offset = sum(origin[index] * normal[index] for index in range(3))
# The FeatureScript face-sketch convention uses the global origin
# projected onto the resolved support plane. ``origin - n * offset``
# instead projects this arbitrary in-plane sample onto the global
# zero plane, retaining an accidental U/V offset for parallel faces.
projected_origin = tuple(offset * normal[index] for index in range(3))
return PlaneSpec.from_mapping({
"origin_mm": list(projected_origin), "x_dir": list(x_dir), "normal": list(normal),
})
@staticmethod
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
"""Extrude one face with exact cap, side-wall, and swept-edge history."""
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("extrude direction must be non-zero")
builder = BRepPrimAPI_MakePrism(
face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True,
)
if not builder.IsDone():
raise ValueError("OCC extrude operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC extrude operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for output, role in ((builder.FirstShape(), "extrude.start"), (builder.LastShape(), "extrude.end")):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (output,), output_role=role,
))
for source_edge in face.edges():
# ``Generated(edge)`` proves the lateral face only. Prism's
# source-edge overloads preserve the distinct start/end cap-edge
# mapping, verified against the final result snapshot here.
for role, cap_edge in (
("extrude.start", builder.FirstShape(source_edge.wrapped)),
("extrude.end", builder.LastShape(source_edge.wrapped)),
):
is_final_edge = (
not cap_edge.IsNull()
and cap_edge.ShapeType() == TopAbs_EDGE
and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges())
)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped,
(cap_edge,) if is_final_edge else (),
output_role=role,
source_kind="edge",
result_kind="edge",
derivation="boundary",
coverage="complete" if is_final_edge else "partial",
status="proven" if is_final_edge else "unknown",
))
generated = tuple(builder.Generated(source_edge.wrapped))
side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped, side_faces,
source_kind="edge", result_kind="face", derivation="boundary",
coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial",
status="proven" if len(side_faces) == len(generated) and side_faces else "unknown",
))
for source_vertex in source_edge.vertices():
generated_edges = tuple(builder.Generated(source_vertex.wrapped))
swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex.wrapped, swept_edges,
source_kind="vertex", result_kind="edge", derivation="boundary",
coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial",
status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown",
))
for source_vertex in face.vertices():
# Unlike a smooth loft, MakePrism exposes per-vertex FirstShape /
# LastShape overloads. Retain them only when OCC's exact handle
# is a vertex in the final snapshot; source coordinates are never
# used to reconstruct a cap vertex.
for role, cap_vertex in (
("extrude.start", builder.FirstShape(source_vertex.wrapped)),
("extrude.end", builder.LastShape(source_vertex.wrapped)),
):
is_final_vertex = (
not cap_vertex.IsNull()
and cap_vertex.ShapeType() == TopAbs_VERTEX
and any(cap_vertex.IsSame(vertex.wrapped) for vertex in result.vertices())
)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex.wrapped,
(cap_vertex,) if is_final_vertex else (),
output_role=role,
source_kind="vertex",
result_kind="vertex",
derivation="boundary",
coverage="complete" if is_final_vertex else "partial",
status="proven" if is_final_vertex else "unknown",
))
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
@staticmethod
def _dedupe_topology_values(values: Iterable[Any]) -> tuple[Any, ...]:
"""Keep exact OCC handles in first-seen order without hashing them."""
unique: list[Any] = []
for value in values:
if value is None or value.IsNull():
continue
if not any(value.IsSame(existing) for existing in unique):
unique.append(value)
return tuple(unique)
@staticmethod
def _is_result_topology_member(result: Any, candidate: Any) -> bool:
"""Return whether an exact history handle still belongs to a result."""
if candidate is None or candidate.IsNull():
return False
if result.IsSame(candidate):
return True
explorer = TopExp_Explorer(result, candidate.ShapeType())
while explorer.More():
if explorer.Current().IsSame(candidate):
return True
explorer.Next()
return False
@classmethod
def _final_history_descendants(
cls,
operation: Any,
source_value: Any,
result: Any,
expected_type: Any,
) -> tuple[Any, ...]:
"""Project one exact intermediate handle through a later OCC builder."""
candidates: list[Any] = []
if cls._is_result_topology_member(result, source_value):
candidates.append(source_value)
for method_name in ("Modified", "Generated"):
method = getattr(operation, method_name, None)
if not callable(method):
continue
try:
candidates.extend(method(source_value))
except (AttributeError, TypeError, ValueError):
continue
return cls._dedupe_topology_values(
candidate for candidate in candidates
if candidate.ShapeType() == expected_type and cls._is_result_topology_member(result, candidate)
)
@classmethod
def extrude_faces_with_composed_topology_delta(
cls,
faces: Iterable[Face],
direction: Vector3,
) -> tuple[Any, TopologyDelta] | None:
"""Fuse multi-region prisms while retaining only final-snapshot history.
Each profile region gets its own exact prism builder. One OCC fuse
then maps those intermediate outputs into the final B-rep. Relations
are emitted only from original profile handles to final handles; an
intermediate handle never reaches the registry. Missing/deleted fuse
descendants remain an explicit partial relation, so an ``all_fragments``
selector cannot turn a partial result into a successful selection.
"""
profile_faces = tuple(faces)
vector = _vector(direction)
if len(profile_faces) < 2 or vector.length <= 1e-9:
return None
prisms = [
BRepPrimAPI_MakePrism(face.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
for face in profile_faces
]
if not all(builder.IsDone() for builder in prisms):
return None
arguments = TopTools_ListOfShape(); arguments.Append(prisms[0].Shape())
tools = TopTools_ListOfShape()
for prism in prisms[1:]:
tools.Append(prism.Shape())
fuse = BRepAlgoAPI_Fuse()
fuse.SetRunParallel(True); fuse.SetUseOBB(True); fuse.SetToFillHistory(True)
fuse.SetArguments(arguments); fuse.SetTools(tools); fuse.Build()
if not fuse.IsDone():
return None
result = Solid(fuse.Shape())
if not result.is_valid or not cls.body_solids(result) or result.volume <= 1e-9:
return None
relations: list[TopologyDeltaRelation] = []
def append_relation(
kind: str,
source_value: Any,
direct_values: Iterable[Any],
*,
expected_type: Any,
output_role: str | None = None,
source_kind: str | None = None,
result_kind: str | None = None,
) -> None:
direct = cls._dedupe_topology_values(direct_values)
per_direct = [
cls._final_history_descendants(fuse, value, fuse.Shape(), expected_type)
for value in direct
]
final = cls._dedupe_topology_values(value for values in per_direct for value in values)
complete = bool(direct) and all(values for values in per_direct)
relations.append(TopologyDeltaRelation(
"generated", kind, source_value, final,
output_role=output_role,
derivation="fragment" if len(final) > 1 else "boundary",
source_kind=source_kind,
result_kind=result_kind,
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
# Cap roles are per input face because a later fuse may merge two cap
# regions into one physical face. Output-role resolution consumes the
# final set, rather than a reconstructed profile face.
edge_groups: list[tuple[Any, list[tuple[Any, Face]]]] = []
vertex_groups: list[tuple[Any, list[tuple[Any, Face]]]] = []
for face, prism in zip(profile_faces, prisms):
for role, direct in (
("extrude.start", prism.FirstShape(face.wrapped)),
("extrude.end", prism.LastShape(face.wrapped)),
):
if not direct.IsNull() and direct.ShapeType() == TopAbs_FACE:
append_relation("face", face.wrapped, (direct,), expected_type=TopAbs_FACE, output_role=role)
for edge in face.edges():
group = next((item for item in edge_groups if edge.wrapped.IsSame(item[0])), None)
if group is None:
group = (edge.wrapped, [])
edge_groups.append(group)
group[1].append((prism, edge))
for vertex in edge.vertices():
vertex_group = next((item for item in vertex_groups if vertex.wrapped.IsSame(item[0])), None)
if vertex_group is None:
vertex_group = (vertex.wrapped, [])
vertex_groups.append(vertex_group)
vertex_group[1].append((prism, vertex))
for source_edge, occurrences in edge_groups:
for role in ("extrude.start", "extrude.end"):
direct = [
prism.FirstShape(edge.wrapped) if role == "extrude.start" else prism.LastShape(edge.wrapped)
for prism, edge in occurrences
]
append_relation(
"edge", source_edge, direct, expected_type=TopAbs_EDGE, output_role=role,
source_kind="edge", result_kind="edge",
)
generated = [
value
for prism, edge in occurrences
for value in prism.Generated(edge.wrapped)
]
side_faces = [value for value in generated if value.ShapeType() == TopAbs_FACE]
append_relation(
"edge", source_edge,
side_faces if len(side_faces) == len(generated) else (),
expected_type=TopAbs_FACE, source_kind="edge", result_kind="face",
)
for source_vertex, occurrences in vertex_groups:
generated = [
value
for prism, vertex in occurrences
for value in prism.Generated(vertex.wrapped)
]
swept_edges = [value for value in generated if value.ShapeType() == TopAbs_EDGE]
append_relation(
"vertex", source_vertex,
swept_edges if len(swept_edges) == len(generated) else (),
expected_type=TopAbs_EDGE, source_kind="vertex", result_kind="edge",
)
return result, TopologyDelta(
operation="extrude", relations=tuple(relations), history_reason="exact_prism_fuse_history",
)
@staticmethod
def _single_face_from_shape(shape: Any) -> Face | None:
"""Return one face only when an OCC builder output contains exactly one.
``LocOpe_DPrism.FirstShape`` and ``LastShape`` are shells in the OCP
binding, even for the single cap faces they represent. Requiring one
contained face keeps those roles tied to the builder output rather
than guessing a cap from a coincident planar result face.
"""
explorer = TopExp_Explorer(shape, TopAbs_FACE)
faces: list[Face] = []
while explorer.More():
faces.append(Face.cast(explorer.Current()))
explorer.Next()
return faces[0] if len(faces) == 1 else None
@staticmethod
def extrude_taper_with_topology_delta(
face: Face,
direction: Vector3,
taper_deg: float,
) -> tuple[Solid, TopologyDelta | None]:
"""Extrude a drafted face and retain exact caps from ``LocOpe_DPrism``.
The native tapered-extrude fallback has no history interface. Only
the narrow ``LocOpe_DPrism`` path can expose a cap role, and only when
each of its ``FirstShape``/``LastShape`` outputs contains exactly one
face. All other valid draft results deliberately retain no topology
delta instead of inferring one from geometry.
"""
vector = _vector(direction)
normal = face.normal_at()
if (
vector.length > 1e-9
and normal.length > 1e-9
and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9
and not face.inner_wires()
):
angle_rad = math.radians(taper_deg)
prism = LocOpe_DPrism(
face.wrapped,
vector.length / math.cos(angle_rad),
angle_rad,
)
if prism.IsDone():
result = Solid(TopoDS.Solid_s(prism.Shape()))
if result.is_valid:
relations: list[TopologyDeltaRelation] = []
for cap_shape, role in (
(prism.FirstShape(), "extrude.start"),
(prism.LastShape(), "extrude.end"),
):
cap = Build123dGeometryAdapter._single_face_from_shape(cap_shape)
if cap is not None:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (cap.wrapped,), output_role=role,
))
return result, TopologyDelta(operation="extrude_taper", relations=tuple(relations))
return Solid.extrude_taper(face, vector, taper_deg), None
@staticmethod
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
result, _topology_delta = Build123dGeometryAdapter.extrude_taper_with_topology_delta(
face, direction, taper_deg,
)
return result
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions.
Issue #5: when a profile intersects the up_to_surface target
non-uniformly (part of the profile reaches the face, part hangs
outside it), a plain vector extrusion is wrong. The CAD semantics is
to keep only the material between the profile and the target. We
pierce the profile through the target, push the target face backward
by the same margin to build a slab, and keep their boolean common
(intersection) as the trimmed solid.
"""
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
target_body = target if not isinstance(target, Face) else None
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
for candidate in targets:
hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points]
distances = [value for value in hits if value is not None]
if distances:
candidates.append((len(distances), min(distances), candidate, distances))
if not candidates:
raise ValueError("extent target is not reached by the profile")
# 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
if target_body is not None and len(distances) == len(points):
# through_next 的 target 是当前实体,不是孤立的终止面。先让工具体
# 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。
# 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。
pierced = Solid.extrude(face, unit * margin)
return Build123dGeometryAdapter._coerce_single_or_compound(
pierced.cut(target_body), empty_error="extent target leaves no leading material",
)
# 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的
# OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能
# 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
candidates = []
for slab_direction in (-unit * margin, unit * margin):
trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
if trimmed is not None:
candidates.append((trimmed.distance_to(face), trimmed))
if not candidates:
raise ValueError("extent target produced an empty trimmed solid")
return min(candidates, key=lambda item: item[0])[1]
@staticmethod
def body_center(body: Any) -> Vector3:
# 取主体包围盒的中心坐标,作为体心的近似。
bbox = body.bounding_box()
return ((bbox.min.X + bbox.max.X) / 2, (bbox.min.Y + bbox.max.Y) / 2, (bbox.min.Z + bbox.max.Z) / 2)
@staticmethod
def body_span(body: Any, direction: Vector3) -> float:
# 计算主体在指定方向上的最大跨度:8 个包围盒角点沿方向投影后取极差。
unit = _vector(direction).normalized()
bbox = body.bounding_box()
values = [
Vector(x, y, z).dot(unit)
for x in (bbox.min.X, bbox.max.X)
for y in (bbox.min.Y, bbox.max.Y)
for z in (bbox.min.Z, bbox.max.Z)
]
return max(values) - min(values)
@staticmethod
def vertex_coordinates(vertex: Any) -> Vector3:
# 提取顶点的三维坐标元组。
if not hasattr(vertex, "X"):
point = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex))
return (float(point.X()), float(point.Y()), float(point.Z()))
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
@staticmethod
def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any:
"""Resolve one current-body vertex shared by every selected face set."""
if not face_sets or any(not faces for faces in face_sets):
raise ValueError("intersection selector has an empty face set")
matched = []
for vertex in body.vertices():
if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets):
point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6))
if not any(point == existing[0] for existing in matched): matched.append((point, vertex))
if len(matched) != 1:
raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices")
return matched[0][1]
@staticmethod
def profile_sample_points(face: Face) -> list[Vector]:
"""Sample a profile face before a selector-dependent termination.
A simple vector extrusion is exact only when the selected target is
reached at one common distance over the complete profile. Center and
boundary samples let the runtime prove that precondition instead of
silently constructing a wrong prismatic solid.
"""
# 采样轮廓面的代表性点:面心 + 每条边的 0/0.25/0.5/0.75 参数点,
# 用于后续校验目标面到轮廓的距离是否处处一致。
points = [face.center()]
for edge in face.edges():
for fraction in (0.0, 0.25, 0.5, 0.75):
points.append(edge.position_at(fraction))
# 去重:彼此距离在 1e-6 内的采样点只保留一个,减少重复求交。
unique: list[Vector] = []
for point in points:
if not any((point - current).length <= 1e-6 for current in unique):
unique.append(point)
return unique
@staticmethod
def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool:
"""Return whether a profile starts on the selected extent target."""
# `up_to_surface` 允许 profile 从 selected face 出发。这时 selected
# face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前
# 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于
# 平面、圆柱和其他可用作 selector 的 B-rep face。
for face in faces:
try:
if face.distance_to(target) <= 1e-6:
return True
except Exception as error:
raise ValueError("extent target does not support profile distance") from error
return False
@staticmethod
def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face:
"""Find the next current-body face reached after an extent start face."""
# 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取
# 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。
unit = _vector(direction).normalized()
samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)]
if not samples:
raise ValueError("extent feature has no profile samples")
candidates = []
for candidate in body.faces():
# Selector resolution can retain the preceding B-rep snapshot,
# whose wrapper is no longer `is_same` after a later boolean.
# Geometrically coincident current faces are still the selected
# start boundary and must not win by their near-zero ray hits.
if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6:
continue
distances = [
value for point in samples
if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None
]
if distances:
candidates.append((len(distances), min(distances), candidate))
if not candidates:
raise ValueError("extent target has no following body face")
coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1]))
if coverage != len(samples):
raise ValueError("extent target does not reach every profile ray after the start face")
return target
@staticmethod
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
try:
intersections = target.find_intersection_points(Axis(point, direction)) or []
except Exception as error:
raise ValueError("extent target does not support ray intersection") from error
# 只保留方向一致(点积 > 0)的交点,返回其中最近距离;无交点则返回 None。
distances = [
(hit_point - point).dot(direction)
for hit_point, _normal in intersections
if (hit_point - point).dot(direction) > 1e-6
]
return min(distances) if distances else None
def uniform_intersection_distance(self, target: Any, faces: Iterable[Face], direction: Vector3) -> float:
"""Return a proven uniform positive target distance for a profile set."""
# 对所有轮廓采样点求到目标的距离,各点距离必须一致,简单拉伸才能精确表达终止条件。
unit_direction = _vector(direction).normalized()
distances: list[float] = []
for face in faces:
for point in self.profile_sample_points(face):
distance = self._forward_intersection_distance(target, point, unit_direction)
if distance is None:
raise ValueError("extent target is not reached by every profile ray")
distances.append(distance)
if not distances:
raise ValueError("extent feature has no profile samples")
minimum, maximum = min(distances), max(distances)
if maximum - minimum > 1e-5:
raise ValueError("extent target requires non-uniform profile trimming")
return sum(distances) / len(distances)
@staticmethod
def target_has_forward_intersection(target: Any, faces: Iterable[Face], direction: Vector3) -> bool:
"""Return whether any sampled profile ray reaches the finite target."""
unit_direction = _vector(direction).normalized()
return any(
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit_direction) is not None
for face in faces
for point in Build123dGeometryAdapter.profile_sample_points(face)
)
@staticmethod
def uniform_planar_supporting_surface_distance(
target: Any,
faces: Iterable[Face],
direction: Vector3,
) -> float:
"""Return a uniform forward distance to a planar target's support.
``UP_TO_SURFACE`` normally uses the selected face's finite trim: a
partial intersection is handled by ``extrude_trimmed``. A planar
face can, however, be a provenance-resolved support whose trim no
longer overlaps the complete downstream profile after a shell. In
that strictly separate case FeatureScript's surface termination is
represented by its unbounded supporting plane. This helper proves
only that plane construction; callers must first prove that the
finite face has no forward hit at all, so it cannot replace partial
face-trim semantics.
"""
if not isinstance(target, Face) or target.geom_type != GeomType.PLANE:
raise ValueError("extent target has no planar supporting surface")
unit_direction = _vector(direction).normalized()
plane_normal = target.normal_at().normalized()
denominator = plane_normal.dot(unit_direction)
if abs(denominator) <= 1e-9:
raise ValueError("extent target supporting surface is parallel to the extrusion")
plane_point = target.center()
distances: list[float] = []
for face in faces:
for point in Build123dGeometryAdapter.profile_sample_points(face):
distance = (plane_point - point).dot(plane_normal) / denominator
if not math.isfinite(distance) or distance <= 1e-6:
raise ValueError("extent target supporting surface is not ahead of the profile")
distances.append(distance)
if not distances:
raise ValueError("extent feature has no profile samples")
minimum, maximum = min(distances), max(distances)
if maximum - minimum > 1e-5:
raise ValueError("extent target supporting surface is non-uniform")
return sum(distances) / len(distances)
@staticmethod
def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
# 绕给定轴将面旋转指定角度,生成回转实体。
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def revolve_with_topology_delta(face: Face, angle_deg: float, axis: AxisSpec) -> tuple[Solid, TopologyDelta]:
"""Revolve one face while retaining exact source-vertex edge history.
``BRepPrimAPI_MakeRevol.Generated(vertex)`` is the only accepted
witness for a CADFS ``SWEPT_EDGE`` from a full solid revolve. Circle
centre/radius signatures are not used to reconstruct this relation.
"""
if abs(abs(float(angle_deg)) - 360.0) > 1e-8:
raise ValueError("revolve topology history requires a full 360 degree revolution")
operation = BRepPrimAPI_MakeRevol(
face.wrapped,
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(float(angle_deg)),
True,
)
operation.Build()
if not operation.IsDone():
raise ValueError("OCC revolve operation did not complete")
result = Solid(operation.Shape())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC revolve operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
source_vertices: list[Any] = []
for source_edge in face.edges():
for source_vertex in source_edge.vertices():
if any(source_vertex.wrapped.IsSame(existing) for existing in source_vertices):
continue
source_vertices.append(source_vertex.wrapped)
for source_vertex in source_vertices:
generated = tuple(operation.Generated(source_vertex))
swept_edges = tuple(
value for value in generated
if value.ShapeType() == TopAbs_EDGE
and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, value)
)
complete = bool(swept_edges) and len(swept_edges) == len(generated)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex, swept_edges if complete else (),
source_kind="vertex", result_kind="edge", derivation="boundary",
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
return result, TopologyDelta(operation="revolve", relations=tuple(relations))
@staticmethod
def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell:
# 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为
# Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。
operation = BRepPrimAPI_MakeRevol(
wire.wrapped,
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(angle_deg),
True,
)
operation.Build()
if not operation.IsDone():
raise ValueError("surface revolve did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface revolve did not produce one shell")
return Shell(shape)
@staticmethod
def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]:
# CADFS pure-surface extrusion may consume one original open wire.
# Its resolved edges are supplied directly by the sketch solver, so
# this path never adds the solid-cut closing edge or guesses a loop.
source_wires = sketch.get("surface_wires_mm")
if isinstance(source_wires, list) and source_wires:
wires = []
for edges in source_wires:
if not isinstance(edges, list) or not edges:
raise ValueError("surface extrude source wire is unresolved")
wires.append(Wire(Build123dGeometryAdapter._wire_edges(edges)))
return wires
# Mixed surface extrusion consumes explicitly selected closed circles
# without converting concentric wires into a solid face with holes.
profile = sketch.get("profile") or {}
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
if profile.get("type") == "circle":
return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)]
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not isinstance(contours, list) or not contours:
raise ValueError("surface extrude requires closed contour wires")
wires = []
for contour in contours:
segments = contour.get("segments") or []
if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle":
raise ValueError("surface extrude currently supports selected circular wires only")
segment = segments[0]
wires.append(Build123dGeometryAdapter()._circle_wire(
segment.get("center") or [0, 0], float(segment["radius_mm"]), plane,
))
return wires
@staticmethod
def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any:
# 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续
# selector 通过 register_surface 的独立 topology snapshot 追溯其来源。
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("surface extrude direction must be non-zero")
surfaces = []
for wire in wires:
operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
operation.Build()
if not operation.IsDone():
raise ValueError("surface extrude did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface extrude did not produce one shell")
surfaces.append(Shell(shape))
if not surfaces:
raise ValueError("surface extrude requires at least one wire")
return surfaces[0] if len(surfaces) == 1 else Compound(surfaces)
@staticmethod
def combine_surfaces(*surfaces: Any) -> Any:
# 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃
# Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。
members = []
for surface in surfaces:
if isinstance(surface, Compound):
members.extend(surface.faces())
else:
members.append(surface)
if not members:
raise ValueError("surface combination requires at least one shell")
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def fuse(body: Any | None, solid: Any) -> Any:
"""Fuse bodies without retaining a builder history."""
result, _delta = Build123dGeometryAdapter.fuse_with_topology_delta(
body, solid, record_history=False,
)
return result
@staticmethod
def fuse_with_topology_delta(
body: Any | None, solid: Any, *, record_history: bool = True,
) -> tuple[Any, TopologyDelta | None]:
"""Fuse one explicit body pair and retain history when it stays exact.
The established fallback sequence changes the kernel result: a normal
build123d fuse or fuzzy OCC fuse has a different history object. Such
results remain executable, but must not inherit relationships from the
discarded first builder. Multi-member inputs are likewise outside the
one-builder proof boundary.
"""
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
if body is None:
return solid, None
if (
not record_history
or len(Build123dGeometryAdapter.body_solids(body)) != 1
or len(Build123dGeometryAdapter.body_solids(solid)) != 1
):
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
return Build123dGeometryAdapter._fuse_with_history(body, solid)
@staticmethod
def _fuse_without_history(body: Any, solid: Any) -> Any:
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
# 一般 add/replay 的同一 union 语义。
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if result.is_valid:
# OBB 对多个相交的曲面 sweep 偶尔会把交叠区单独保留为一个
# Solid。普通 fuse 若能以更少的有效实体表示相同并集,应优先
# 使用它;不相交结果仍保留 OBB 的多实体 body 语义。
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid and (
len(Build123dGeometryAdapter.body_solids(fallback))
< len(Build123dGeometryAdapter.body_solids(result))
):
return fallback
# 两个输入在数学上已经接触时,曲面 sweep 的近似交界可能只因
# 内核容差留下重叠成员。仅在这种零距离情形重试 fuzzy boolean
# 有实际间隙的独立 body 不参与该修复,不能被错误地桥接合并。
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True)
operation.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
fuzzy = Solid(operation.Shape()) if operation.IsDone() else None
if fuzzy is not None and fuzzy.is_valid and (
len(Build123dGeometryAdapter.body_solids(fuzzy))
< len(Build123dGeometryAdapter.body_solids(result))
) and fuzzy.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
return fuzzy
return result
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
# 不能悄然进入后续 feature history。
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid:
return fallback
raise ValueError("OCC union operation produced an invalid shape")
@staticmethod
def _fuse_with_history(body: Any, solid: Any) -> tuple[Any, TopologyDelta | None]:
"""Run the primary fuse algorithm with its own exact history object."""
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if not result.is_valid:
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid and (
len(Build123dGeometryAdapter.body_solids(fallback))
< len(Build123dGeometryAdapter.body_solids(result))
):
return fallback, None
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
fuzzy = BRepAlgoAPI_Fuse()
fuzzy.SetRunParallel(True); fuzzy.SetUseOBB(True)
fuzzy.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
fuzzy.SetArguments(arguments); fuzzy.SetTools(tools); fuzzy.Build()
fuzzy_result = Solid(fuzzy.Shape()) if fuzzy.IsDone() else None
if fuzzy_result is not None and fuzzy_result.is_valid and (
len(Build123dGeometryAdapter.body_solids(fuzzy_result))
< len(Build123dGeometryAdapter.body_solids(result))
) and fuzzy_result.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
return fuzzy_result, None
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body, solid), "union")
@staticmethod
def combine(body: Any | None, solid: Any) -> Any:
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
members = ([] if body is None else Build123dGeometryAdapter.body_solids(body))
members.extend(Build123dGeometryAdapter.body_solids(solid))
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
# Compound 内的独立实体分别切除再组合,与整体差集的集合语义一致。
# 对包含抽壳薄壁的多个成员,直接对整个 Compound 做 OCC boolean 会在
# 内核中长时间求解,且不会改善任何成员间不存在的拓扑关系。
members = Build123dGeometryAdapter.body_solids(body)
if len(members) > 1:
result = None
for member in members:
cut_member = Build123dGeometryAdapter._coerce_single_or_compound(member.cut(tool))
# 多实体差集允许 cutter 完全移除其中一个成员;其他成员仍是
# 当前 feature 的有效结果。只有所有成员均被移除才是空切除。
if cut_member is not None:
result = Build123dGeometryAdapter.combine(result, cut_member)
if result is None:
raise ValueError("OCC cut operation produced no shape")
return result
return Build123dGeometryAdapter._coerce_single_or_compound(
body.cut(tool), empty_error="OCC cut operation produced no shape",
)
@staticmethod
def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]:
"""Subtract each independent target solid with exact OCC history.
A multi-solid body is a set of independent CAD members. Each member
has its own `BRepAlgoAPI_Cut` builder, so its history can be composed
only by concatenating those disjoint kernel relations. No aggregate
boolean relation or member-order correspondence is invented here.
"""
target_solids = Build123dGeometryAdapter.body_solids(body)
if not target_solids or len(Build123dGeometryAdapter.body_solids(tool)) != 1:
return Build123dGeometryAdapter.cut(body, tool), None
results: list[Any] = []
deltas: list[TopologyDelta] = []
for target in target_solids:
arguments = TopTools_ListOfShape(); arguments.Append(target.wrapped)
tools = TopTools_ListOfShape(); tools.Append(tool.wrapped)
operation = BRepAlgoAPI_Cut()
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC cut operation did not complete")
result = Build123dGeometryAdapter._coerce_single_or_compound(Solid(operation.Shape()))
if result is not None:
results.append(result)
deltas.append(Build123dGeometryAdapter._builder_topology_delta(
operation, (target, tool), "subtract",
))
combined = None
for result in results:
combined = Build123dGeometryAdapter.combine(combined, result)
if combined is None:
raise ValueError("OCC cut operation produced no shape")
return combined, TopologyDelta(
operation="subtract",
relations=tuple(relation for delta in deltas for relation in delta.relations),
section_values=tuple(value for delta in deltas for value in delta.section_values),
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
history_reason="per_member_exact_cut_history" if len(target_solids) > 1 else None,
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
# 以给定球心与半径生成球体实体。
return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
@staticmethod
def box(length_mm: float, width_mm: float, height_mm: float, plane: PlaneSpec | None = None) -> Solid:
# 原生立方体图元。plane 缺省为世界 XY;plane 的原点是长方体最小角点,
# 长/宽/高分别沿 plane 的 x/y/z 方向生长(build123d Solid.make_box 原生语义)。
build_plane = Build123dGeometryAdapter.plane(plane) if plane is not None else Plane.XY
return Solid.make_box(length_mm, width_mm, height_mm, build_plane)
@staticmethod
def cylinder(radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> Solid:
# 原生圆柱图元。axis 缺省为世界 +Z;axis 的原点是底面圆心,
# 轴向由 axis 的方向决定,沿该方向生长高度。
build_plane = (
Plane(origin=_vector(axis.origin_mm), z_dir=_vector(axis.direction))
if axis is not None
else Plane.XY
)
return Solid.make_cylinder(radius_mm, height_mm, build_plane)
@staticmethod
def cylinder_with_topology_delta(
radius_mm: float,
height_mm: float,
axis: AxisSpec | None = None,
) -> tuple[Solid, TopologyDelta]:
"""Build a cylinder with exact OCC witnesses for its two cap faces."""
origin = axis.origin_mm if axis is not None else (0.0, 0.0, 0.0)
direction = axis.direction if axis is not None else (0.0, 0.0, 1.0)
placement = gp_Ax2(
gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])),
gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])),
)
builder = BRepPrimAPI_MakeCylinder(placement, float(radius_mm), float(height_mm))
builder.Build()
if not builder.IsDone():
raise ValueError("OCC cylinder operation did not complete")
result = Solid(builder.Solid())
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
raise ValueError("OCC cylinder operation did not produce a valid solid")
primitive = builder.Cylinder()
relations = tuple(
TopologyDeltaRelation("generated", "face", result.wrapped, (face,), output_role=role)
for face, role in (
(primitive.BottomFace(), "cylinder.start"),
(primitive.TopFace(), "cylinder.end"),
)
if not face.IsNull()
)
return result, TopologyDelta(operation="cylinder", relations=relations)
@staticmethod
def intersect(left: Any, right: Any) -> Any:
# 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触),
# 此时规整 helper 会抛出明确的空交集错误。
return Build123dGeometryAdapter._coerce_single_or_compound(
left.intersect(right), empty_error="boolean intersection produced no solid",
)
@staticmethod
def intersect_with_topology_delta(left: Any, right: Any) -> tuple[Any, TopologyDelta | None]:
"""Intersect single bodies through one OCC builder and retain history."""
if len(Build123dGeometryAdapter.body_solids(left)) != 1 or len(Build123dGeometryAdapter.body_solids(right)) != 1:
return Build123dGeometryAdapter.intersect(left, right), None
arguments = TopTools_ListOfShape(); arguments.Append(left.wrapped)
tools = TopTools_ListOfShape(); tools.Append(right.wrapped)
operation = BRepAlgoAPI_Common()
operation.SetToFillHistory(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC intersection operation did not complete")
result = Build123dGeometryAdapter._coerce_single_or_compound(
Solid(operation.Shape()), empty_error="boolean intersection produced no solid",
)
return result, Build123dGeometryAdapter._builder_topology_delta(
operation, (left, right), "intersect",
)
@staticmethod
def transform(body: Any, transform: dict[str, Any]) -> Any:
"""Apply one explicit body transform without exposing kernel history."""
result, _delta = Build123dGeometryAdapter.transform_with_topology_delta(body, transform)
return result
@staticmethod
def transform_with_topology_delta(body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]:
"""Apply one body transform and retain exact OCC subshape history."""
kind = str(transform.get("type") or "")
conversion = gp_Trsf()
if kind == "translation":
offset = transform.get("translation_mm")
if not isinstance(offset, list) or len(offset) != 3:
raise ValueError("translation transform requires translation_mm")
conversion.SetTranslation(gp_Vec(*(float(value) for value in offset)))
elif kind == "rotation":
axis = AxisSpec.from_mapping(transform.get("axis") or {})
angle_deg = transform.get("angle_deg")
if not isinstance(angle_deg, (int, float)):
raise ValueError("rotation transform requires angle_deg")
conversion.SetRotation(
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(float(angle_deg)),
)
elif kind == "uniform_scale":
center = transform.get("center_mm")
scale_factor = transform.get("scale_factor")
if not isinstance(center, list) or len(center) != 3:
raise ValueError("uniform_scale transform requires center_mm")
if not isinstance(scale_factor, (int, float)) or not math.isfinite(float(scale_factor)) or float(scale_factor) <= 0:
raise ValueError("uniform_scale transform requires a finite positive scale_factor")
conversion.SetScale(gp_Pnt(*(float(value) for value in center)), float(scale_factor))
else:
raise ValueError(f"unsupported body transform type {kind!r}")
operation = BRepBuilderAPI_Transform(body.wrapped, conversion, True)
operation.Build()
if not operation.IsDone():
raise ValueError("OCC body transform did not complete")
result = Solid(operation.Shape())
if not result.is_valid:
raise ValueError("OCC body transform produced an invalid shape")
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind)
@classmethod
def _builder_blend_relations(
cls,
operation: Any,
sources: Iterable[Any],
result: Any,
operation_name: str,
) -> tuple[TopologyBlendRelation, ...]:
"""Return exact fillet/chamfer patch-boundary transition facts.
For each source edge, OCC reports generated patch faces. A CADFS
``BLEND_EDGE`` also qualifies that patch by its source face, so retain
only patch boundary edges that are exactly shared with a final
``Modified(source_face)`` handle. This is an adapter fact, not a
geometry search or an executable selector by itself.
"""
if operation_name not in {"fillet", "chamfer"} or result is None:
return ()
relations: list[TopologyBlendRelation] = []
for source in sources:
source_edges = tuple(edge.wrapped for edge in source.edges())
source_faces = tuple(face.wrapped for face in source.faces())
for source_edge in source_edges:
try:
generated = tuple(operation.Generated(source_edge))
except (AttributeError, TypeError, ValueError):
continue
patch_faces = cls._dedupe_topology_values(
value for value in generated
if value.ShapeType() == TopAbs_FACE and cls._is_result_topology_member(result, value)
)
patch_history_complete = (
bool(patch_faces)
and len(patch_faces) == len(generated)
and all(value.ShapeType() == TopAbs_FACE for value in generated)
)
if not patch_faces:
continue
for source_face in source_faces:
try:
modified = tuple(operation.Modified(source_face))
except (AttributeError, TypeError, ValueError):
modified = ()
final_faces = cls._dedupe_topology_values(
value for value in modified
if value.ShapeType() == TopAbs_FACE and cls._is_result_topology_member(result, value)
)
face_history_complete = (
bool(final_faces)
and len(final_faces) == len(modified)
and all(value.ShapeType() == TopAbs_FACE for value in modified)
)
if not final_faces:
continue
final_face = final_faces[0] if len(final_faces) == 1 else None
final_face_edges = cls._topology_members(final_face, TopAbs_EDGE) if final_face is not None else ()
for patch_face in patch_faces:
shared = cls._dedupe_topology_values(
edge for edge in cls._topology_members(patch_face, TopAbs_EDGE)
if any(edge.IsSame(face_edge) for face_edge in final_face_edges)
and cls._is_result_topology_member(result, edge)
)
complete = (
patch_history_complete
and len(patch_faces) == 1
and face_history_complete
and final_face is not None
and len(shared) == 1
)
relations.append(TopologyBlendRelation(
source_edge_value=source_edge,
source_face_value=source_face,
patch_face_value=patch_face,
blend_into_result_value=final_face,
result_values=shared if complete else (),
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
return tuple(relations)
@staticmethod
def _topology_members(shape: Any, shape_type: Any) -> tuple[Any, ...]:
"""Collect exact OCC subshape handles without converting their geometry."""
explorer = TopExp_Explorer(shape, shape_type)
values: list[Any] = []
while explorer.More():
values.append(explorer.Current())
explorer.Next()
return tuple(values)
@staticmethod
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
"""Translate OCC builder history into adapter-neutral opaque relations."""
source_bodies = tuple(sources)
relations: list[TopologyDeltaRelation] = []
kind_by_shape_type = {
TopAbs_FACE: "face",
TopAbs_EDGE: "edge",
TopAbs_VERTEX: "vertex",
}
try:
result_shape = operation.Shape()
except (AttributeError, TypeError, ValueError):
result_shape = None
source_faces: list[tuple[int, Any]] = []
for source_index, source in enumerate(source_bodies):
source_faces.extend((source_index, face.wrapped) for face in source.faces())
for kind, shapes in (
("face", list(source.faces())),
("edge", list(source.edges())),
("vertex", list(source.vertices())),
):
for shape in shapes:
source_value = shape.wrapped
is_deleted = bool(
operation.IsDeleted(source_value)
if hasattr(operation, "IsDeleted") else operation.IsRemoved(source_value)
)
if is_deleted:
relations.append(TopologyDeltaRelation("deleted", kind, source_value))
modified = tuple(operation.Modified(source_value))
generated = tuple(operation.Generated(source_value))
same_modified = (
len(modified) == 1 and bool(modified[0].IsSame(source_value))
)
if modified:
relations.append(TopologyDeltaRelation(
"preserved" if same_modified and not generated else "modified",
kind, source_value, modified,
))
elif not generated and not is_deleted:
# A no-op transform can retain the original OCC object.
# The registry still requires it to appear in the result
# snapshot before treating this as a continuation.
relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,)))
if generated:
# Dress-up builders commonly generate a FACE from an
# input EDGE. Retain that exact cross-kind fact
# rather than labelling a face as an edge and losing
# it during final-snapshot registration. This is
# still only builder history: a future BLEND_EDGE
# consumer must separately prove an exact boundary
# incidence between this patch face and its source
# face, not select one of its edges by geometry.
generated_by_kind: dict[str, list[Any]] = {}
for value in generated:
result_kind = kind_by_shape_type.get(value.ShapeType())
if result_kind is None:
continue
generated_by_kind.setdefault(result_kind, []).append(value)
for result_kind, values in generated_by_kind.items():
final_values = tuple(
value for value in values
if result_shape is not None
and Build123dGeometryAdapter._is_result_topology_member(result_shape, value)
)
complete = bool(final_values) and len(final_values) == len(values)
relations.append(TopologyDeltaRelation(
"generated", kind, source_value,
final_values if complete else (),
source_kind=kind,
result_kind=result_kind,
derivation="boundary",
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
section_values: tuple[Any, ...] = ()
section_relations: list[TopologySectionRelation] = []
section_edges = getattr(operation, "SectionEdges", None)
if callable(section_edges):
try:
# BRepAlgoAPI boolean builders expose the exact intersection
# edges. Builders without that API simply carry no section
# evidence; callers must not infer it from result geometry.
section_values = tuple(section_edges())
except (AttributeError, TypeError, ValueError):
section_values = ()
if section_values and len(source_bodies) >= 2:
# A section edge becomes source-qualified only when OCC returns
# that exact edge from Generated(face) for one face from each
# boolean input. Do not inspect BOPDS internals here: those
# Python bindings are unsafe for this traversal and ordinary
# SectionEdges remain useful unqualified diagnostics.
for section_edge in section_values:
generators: list[tuple[int, Any]] = []
for source_index, source_face in source_faces:
try:
generated = tuple(operation.Generated(source_face))
except (AttributeError, TypeError, ValueError):
generated = ()
if any(candidate.IsSame(section_edge) for candidate in generated):
generators.append((source_index, source_face))
if len(generators) != 2 or {item[0] for item in generators} != {0, 1}:
continue
section_relations.append(TopologySectionRelation(
source_values=(generators[0][1], generators[1][1]),
result_value=section_edge,
))
qualified_section_values = tuple(item.result_value for item in section_relations)
unqualified_section_values = tuple(
value for value in section_values
if not any(value.IsSame(qualified) for qualified in qualified_section_values)
)
return TopologyDelta(
operation=operation_name,
relations=tuple(relations),
section_values=unqualified_section_values,
section_relations=tuple(section_relations),
blend_relations=Build123dGeometryAdapter._builder_blend_relations(
operation, source_bodies, result_shape, operation_name,
),
)
@staticmethod
def _shell_topology_delta(
operation: Any, source: Solid, closing_faces: Iterable[Face],
) -> TopologyDelta:
"""Annotate exact shell history with only builder-proven output roles."""
base_delta = Build123dGeometryAdapter._builder_topology_delta(operation, (source,), "shell")
closing_values = tuple(face.wrapped for face in closing_faces)
closing_edge_values = tuple(
edge.wrapped for face in closing_faces for edge in face.edges()
)
def is_member(value: Any, candidates: tuple[Any, ...]) -> bool:
return any(bool(value.IsSame(candidate)) for candidate in candidates)
def output_role(relation: TopologyDeltaRelation) -> str | None:
if relation.kind == "face":
is_closing = is_member(relation.source_value, closing_values)
if is_closing and relation.event in {"preserved", "modified", "generated"}:
return "shell.closing_descendant"
if not is_closing and relation.event == "generated":
return "shell.offset_face"
if not is_closing and relation.event in {"preserved", "modified"}:
return "shell.body_face"
if (
relation.kind == "edge" and relation.event == "generated"
and is_member(relation.source_value, closing_edge_values)
):
return "shell.wall"
return None
return TopologyDelta(
operation=base_delta.operation,
relations=tuple(
TopologyDeltaRelation(
relation.event, relation.kind, relation.source_value, relation.result_values,
output_role=output_role(relation),
)
for relation in base_delta.relations
),
)
def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid:
"""Build a neutral ``HoleSpec`` into one OCC cutting tool."""
# 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。
# 1. 深度:通孔取贯穿深度(保证穿透),盲孔取规格中的深度。
depth = through_depth_mm if spec.end_condition != "blind" else spec.depth_mm
result: Solid | None = None
for start in starts:
# 2. 每个孔位:以起点为原点、向内方向为轴向,先生成主孔圆柱。
plane = Plane(origin=_vector(start), z_dir=_vector(inward))
tool = Solid.make_cylinder(spec.diameter_mm / 2, depth, plane)
# 3. 沉孔(counterbore):在主孔上并一个更大直径、更浅的短圆柱。
if spec.counterbore:
diameter, bore_depth = spec.counterbore
tool = tool.fuse(Solid.make_cylinder(diameter / 2, bore_depth, plane))
# 4. 锪孔(countersink):按锥角与口径差推得锥深,并一个上大下小的圆锥。
if spec.countersink:
diameter, angle = spec.countersink
sink_depth = ((diameter - spec.diameter_mm) / 2) / math.tan(angle / 2)
tool = tool.fuse(Solid.make_cone(diameter / 2, spec.diameter_mm / 2, sink_depth, plane))
# 5. 汇总所有孔位的工具实体。
result = self.fuse(result, tool)
if result is None:
raise ValueError("hole has no positions")
return result
@staticmethod
def thread_solid(spec: ThreadSpec) -> Any:
"""Build a threaded solid segment anchored on ``spec.axis``.
The parametric generator constructs the thread in a local +Z frame
spanning ``[0, length_mm]``. This gate rotates that frame so the
thread axis lands on ``spec.axis.direction`` with
``spec.axis.origin_mm`` at the leading end face, keeping placement in
the adapter and geometry construction in ``parametric_thread.py``.
"""
solid = build_thread_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.X) <= 1e-9 and abs(direction.Y) <= 1e-9:
# 轴沿 ±Z:X 方向任意即可,螺纹起始相位绕轴无意义。
frame_x = Vector(1.0, 0.0, 0.0)
else:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def bend_solid(spec: BendSpec) -> Any:
"""Build one bent sheet segment placed on ``spec.frame``.
The parametric generator constructs the sheet locally with the first
wing along +X, its mid-plane spanning +X/+Z (thickness along +Y) and
the fold (width) axis along +Z. This gate rotates that local frame so
the first wing lands on ``spec.frame.y_dir`` (= normal x x_dir), the
sheet thickness on ``spec.frame.normal`` and the width axis on
``spec.frame.x_dir``.
"""
# ``bend_add`` 生成器是可选的几何实现。不能因该模块未随部署产物
# 提交而让所有非钣金 CDSL 在 adapter import 阶段失效;真正执行
# 折弯时仍须报出精确的缺失依赖,不能退化为虚构实体。
try:
from .parametric_bend import build_bend_solid
except ModuleNotFoundError as error:
if error.name != f"{__package__}.parametric_bend":
raise
raise RuntimeError(
"bend_add requires cdsl_engine.parametric_bend.build_bend_solid, "
"but the generator module is not present in this checkout"
) from error
solid = build_bend_solid(spec)
frame = spec.frame
plane = Plane(
origin=_vector(frame.origin_mm),
x_dir=_vector(frame.y_dir),
z_dir=_vector(frame.x_dir),
)
return solid.moved(Location(plane))
@staticmethod
def gear_solid(spec: GearSpec) -> Any:
"""Build an involute gear placed on ``spec.axis``.
The parametric generator constructs the gear in a local +Z frame
spanning ``z in [0, width_mm]``. This gate rotates that frame so the
gear axis lands on ``spec.axis.direction`` with ``spec.axis.origin_mm``
at the centre of the ``z = 0`` end face (same placement contract as
``thread_solid``).
"""
solid = build_gear_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.X) <= 1e-9 and abs(direction.Y) <= 1e-9:
# 轴沿 ±Z:起始相位绕轴无意义,X 方向任意。
frame_x = Vector(1.0, 0.0, 0.0)
else:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def rack_solid(spec: RackSpec) -> Any:
"""Build a rack placed on ``spec.axis``.
The parametric generator constructs the rack locally with the length
along +X, thickness along +Y and the teeth pointing along +Z (the
root/backing plane spans +X/+Y at ``z = 0``). This gate maps local
+Z onto ``spec.axis.direction`` (the direction the teeth point) with
``spec.axis.origin_mm`` at the length-start / thickness-start corner;
the length direction (+X) is a deterministic orthogonal of the tooth
direction.
"""
solid = build_rack_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.Z) <= 0.9:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
else:
frame_x = Vector(1.0, 0.0, 0.0)
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
# 对指定边以给定半径做圆角。
return body.fillet(radius_mm, list(edges))
@staticmethod
def _single_member_dressup_with_topology_delta(
body: Any,
selected: list[Edge],
operation_name: str,
configure_builder: Any,
) -> tuple[Any, TopologyDelta] | None:
"""Run one dress-up builder against one exact member of a Compound.
``BRepFilletAPI`` history applies to its input solid, not to the
aggregate Compound. When every selected edge belongs to exactly one
member, retain that builder's history and carry unrelated members
through unchanged. An edge crossing members, ambiguous membership,
multiple selected members, or any builder failure remains on the
established history-free fallback path.
"""
solids = Build123dGeometryAdapter.body_solids(body)
if len(solids) <= 1 or not selected:
return None
target_indexes: set[int] = set()
for edge in selected:
matches = [
index for index, solid in enumerate(solids)
if any(edge.is_same(candidate) for candidate in solid.edges())
]
if len(matches) != 1:
return None
target_indexes.add(matches[0])
if len(target_indexes) != 1:
return None
target_index = next(iter(target_indexes))
target = solids[target_index]
builder = configure_builder(target, selected)
builder.Build()
if not builder.IsDone():
return None
changed = Solid(builder.Shape())
if not changed.is_valid:
return None
members = [changed if index == target_index else solid for index, solid in enumerate(solids)]
result = members[0] if len(members) == 1 else Compound(members)
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (target,), operation_name)
@staticmethod
def fillet_with_topology_delta(
body: Any, radius_mm: float, edges: Iterable[Edge],
) -> tuple[Any, TopologyDelta | None]:
"""Fillet a single body and retain its direct OCC builder history."""
selected = list(edges)
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
def configure_member_builder(member: Solid, member_edges: list[Edge]) -> Any:
builder = BRepFilletAPI_MakeFillet(member.wrapped)
for edge in member_edges:
builder.Add(radius_mm, edge.wrapped)
return builder
member_result = Build123dGeometryAdapter._single_member_dressup_with_topology_delta(
body, selected, "fillet", configure_member_builder,
)
if member_result is not None:
return member_result
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
builder = BRepFilletAPI_MakeFillet(body.wrapped)
for edge in selected:
builder.Add(radius_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "fillet")
# Preserve build123d's existing fallback/error semantics when OCC's
# direct builder cannot construct this dress-up.
return Build123dGeometryAdapter.fillet(body, radius_mm, selected), None
@staticmethod
def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]:
"""Expand selected edges through actual tangent, vertex-adjacent chains.
The expansion is based solely on the current B-rep. It never uses a
global edge set or source stable IDs, and is consequently safe after a
body mutation invalidates earlier topology objects.
"""
# 从种子边出发,沿“共顶点且切线平行”的边链扩展,得到相切连续的一整组边。
edges = list(body.edges())
selected = [edge for edge in seeds]
# 1. 用 is_same 把种子边映射到主体边列表的下标集合。
selected_indexes = {
index
for index, edge in enumerate(edges)
if any(edge.is_same(seed) for seed in selected)
}
if not selected_indexes:
return []
def shared_vertex(first: Edge, second: Edge) -> tuple[float, float] | None:
# 找两条边共用的端点,返回各自在该端点处的参数位置;无共用端点返回 None。
first_ends = [(0.0, vertex) for vertex in first.vertices()[:1]] + [(1.0, vertex) for vertex in first.vertices()[-1:]]
second_ends = [(0.0, vertex) for vertex in second.vertices()[:1]] + [(1.0, vertex) for vertex in second.vertices()[-1:]]
for first_parameter, first_vertex in first_ends:
for second_parameter, second_vertex in second_ends:
if first_vertex.is_same(second_vertex):
return first_parameter, second_parameter
return None
# 共顶点且端点处切线平行(方向无关)的边即构成相切连续链。
# Edges sharing a vertex whose tangents are parallel (orientation is
# irrelevant) are a tangent-continuous chain.
# 2. BFS 扩展:新加入的边作为候选种子,继续寻找与其相切的下一条边。
pending = list(selected_indexes)
while pending:
current_index = pending.pop()
for candidate_index, candidate in enumerate(edges):
if candidate_index in selected_indexes:
continue
shared = shared_vertex(edges[current_index], candidate)
if shared is None:
continue
# 比较两条边在共用端点处的切线方向(取绝对值以忽略方向)。
first_tangent = edges[current_index].tangent_at(shared[0]).normalized()
second_tangent = candidate.tangent_at(shared[1]).normalized()
if abs(abs(first_tangent.dot(second_tangent)) - 1.0) <= angular_tolerance:
selected_indexes.add(candidate_index)
pending.append(candidate_index)
# 3. 按下标映射回边对象列表。
return [edge for index, edge in enumerate(edges) if index in selected_indexes]
@staticmethod
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
# OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d
# 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变
# 倒角结果,因此仅等距倒角优先走原生重载。
selected = list(edges)
if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1:
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
for edge in selected:
builder.Add(distance_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
@staticmethod
def chamfer_with_topology_delta(
body: Any, distance_mm: float, distance_2_mm: float | None,
edges: Iterable[Edge], face: Face | None = None,
) -> tuple[Any, TopologyDelta | None]:
"""Retain history for the equal-distance single-body chamfer subset."""
selected = list(edges)
if distance_2_mm is not None or face is not None or len(Build123dGeometryAdapter.body_solids(body)) != 1:
if distance_2_mm is None and face is None:
def configure_member_builder(member: Solid, member_edges: list[Edge]) -> Any:
builder = BRepFilletAPI_MakeChamfer(member.wrapped)
for edge in member_edges:
builder.Add(distance_mm, edge.wrapped)
return builder
member_result = Build123dGeometryAdapter._single_member_dressup_with_topology_delta(
body, selected, "chamfer", configure_member_builder,
)
if member_result is not None:
return member_result
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
for edge in selected:
builder.Add(distance_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "chamfer")
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
@staticmethod
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
"""Build the removable material for one surface-supported circular chamfer.
A regular equal-offset chamfer is first attempted by ``chamfer``. This
helper only handles the narrow CADFS case where that operation reaches
a concentric surface split before the requested second offset. The
explicit shell must contain the limiting circle at the selected plane;
without that evidence this method deliberately rejects the fallback.
"""
if str(edge.geom_type).split(".")[-1].lower() != "circle":
raise ValueError("surface-limited chamfer requires circular edges")
try:
radius = float(edge.radius)
center = edge.arc_center
except (TypeError, ValueError) as error:
raise ValueError("surface-limited chamfer edge has no circle radius") from error
if radius <= 0:
raise ValueError("surface-limited chamfer edge radius must be positive")
adjacent = [
face for face in body.faces()
if any(face_edge.is_same(edge) for face_edge in face.edges())
]
planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"]
cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"]
if len(planes) != 1 or len(cylinders) != 1:
raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face")
plane_face, cylinder_face = planes[0], cylinders[0]
axis = cylinder_face.axis_of_rotation
if axis is None:
raise ValueError("surface-limited chamfer cylinder has no axis")
axis_direction = axis.direction.normalized()
if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6:
raise ValueError("surface-limited chamfer faces are not perpendicular")
boundaries = [
candidate for candidate in plane_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and (candidate.arc_center - center).length <= 1e-6
]
if len(boundaries) != 1:
raise ValueError("surface-limited chamfer plane has no unique concentric support")
support_edge = boundaries[0]
support_radius = float(support_edge.radius)
radial_span = radius - support_radius
if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6:
raise ValueError("surface-limited chamfer does not need a constrained outer transition")
cylinder_ends = [
candidate for candidate in cylinder_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and abs(float(candidate.radius) - radius) <= 1e-6
and (candidate.arc_center - center).length > 1e-6
]
if len(cylinder_ends) != 1:
raise ValueError("surface-limited chamfer cylinder has no unique opposite cap")
axial_span = cylinder_ends[0].arc_center - center
if axial_span.length <= distance_mm + 1e-6:
raise ValueError("surface-limited chamfer exceeds the selected cylindrical face")
direction = axial_span.normalized()
supported = False
for surface in surfaces:
for face in surface.faces():
if str(face.geom_type).split(".")[-1].lower() != "cylinder":
continue
surface_axis = face.axis_of_rotation
if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6:
continue
if abs(float(face.radius) - support_radius) > 1e-6:
continue
if any(
str(boundary.geom_type).split(".")[-1].lower() == "circle"
and abs(float(boundary.radius) - support_radius) <= 1e-6
and (boundary.arc_center - center).length <= 1e-6
for boundary in face.edges()
):
supported = True
break
if supported:
break
if not supported:
raise ValueError("surface-limited chamfer has no explicit surface support")
frame_x = edge.tangent_at(0.0).normalized()
first_length = distance_mm - radial_span
outer_radius = radius + max(distance_mm, 1.0)
start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction)
cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction)
outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane)
core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse(
Solid.make_cone(support_radius, radius, radial_span, cone_plane),
)
return outer.cut(core)
@staticmethod
def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any:
# 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边
# 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响
# 另一条 source selector。
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
raise ValueError("surface-limited chamfer requires one solid body")
selected = list(edges)
if not selected:
raise ValueError("surface-limited chamfer requires at least one edge")
surface_members = list(surfaces)
tools = [
Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members)
for edge in selected
]
result = body
for tool in tools:
result = result.cut(tool)
if not isinstance(result, Solid) or not result.is_valid:
raise ValueError("surface-limited chamfer produced an invalid shape")
if result.volume >= body.volume - 1e-6:
raise ValueError("surface-limited chamfer removed no material")
return result
@staticmethod
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
result, _delta = Build123dGeometryAdapter.shell_with_topology_delta(
body, faces, thickness_mm, inward=inward,
)
return result
@staticmethod
def shell_with_topology_delta(
body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True,
) -> tuple[Any, TopologyDelta]:
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
selected = list(faces)
if not selected:
raise ValueError("shell requires at least one face to remove")
thickness = float(thickness_mm)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
solids = Build123dGeometryAdapter.body_solids(body)
if len(solids) != 1:
raise ValueError("shell adapter requires exactly one target solid")
closing_faces = TopTools_ListOfShape()
for face in selected:
closing_faces.Append(face.wrapped)
builder = BRepOffsetAPI_MakeThickSolid()
builder.MakeThickSolidByJoin(
solids[0].wrapped,
closing_faces,
-thickness if inward else thickness,
1e-6,
BRepOffset_Skin,
False,
False,
GeomAbs_Arc,
False,
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC shell operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected)
@staticmethod
def sweep(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> Solid:
result, _delta = Build123dGeometryAdapter.sweep_with_topology_delta(
section, spine, inner_wires=inner_wires, make_solid=make_solid,
is_frenet=is_frenet, transition=transition,
)
return result
@staticmethod
def _sweep_without_topology_delta(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> Solid:
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
# 默认值对齐 build123d Solid.sweepmake_solid=True 封盖成体;is_frenet=True
# 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需);
# transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。
sweep_options: dict[str, Any] = {
"inner_wires": inner_wires,
"make_solid": make_solid,
"is_frenet": is_frenet,
}
if transition is not None:
sweep_options["transition"] = transition
try:
result = Solid.sweep(section, spine, **sweep_options)
except Exception as error:
# Some build123d/OCC sweep failures surface as a bare
# ``AssertionError`` (whose text is empty), notably for certain
# hollow profiles along segmented wires. Translate that kernel
# boundary into the same attributable feature failure contract as
# the direct pipe-shell path.
raise ValueError("OCC sweep operation raised while building the native sweep") from error
if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9):
# OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状,
# 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。
raise ValueError("OCC sweep operation did not produce a solid")
return result
@staticmethod
def sweep_with_topology_delta(
section: Face | Wire,
spine: Edge | Wire,
*,
inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
transition: Any = None,
) -> tuple[Solid, TopologyDelta | None]:
"""Sweep one simple profile through its direct pipe-shell builder.
The existing native path remains authoritative for hollow profiles,
transition variants and non-solid output. Those cases can still
execute, but their final builder provenance is not available through
this bounded contract.
"""
if (
not isinstance(section, Face)
or section.inner_wires()
or inner_wires
or not make_solid
or transition is not None
):
return Build123dGeometryAdapter._sweep_without_topology_delta(
section, spine, inner_wires=inner_wires, make_solid=make_solid,
is_frenet=is_frenet, transition=transition,
), None
try:
path = spine if isinstance(spine, Wire) else Wire.combine([spine])[0]
builder = BRepOffsetAPI_MakePipeShell(path.wrapped)
builder.SetMode(bool(is_frenet))
builder.Add(section.outer_wire().wrapped, False, False)
builder.Build()
except Exception as error:
raise ValueError("OCC sweep operation raised while preparing or building the pipe shell") from error
if not builder.IsDone():
raise ValueError("OCC sweep operation did not complete")
try:
made_solid = builder.MakeSolid()
except Exception as error:
raise ValueError("OCC sweep operation raised while converting the pipe shell to a solid") from error
if not made_solid:
raise ValueError("OCC sweep operation did not produce a solid")
result = Solid(builder.Shape())
if not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9 or not result.is_valid:
raise ValueError("OCC sweep operation did not produce a valid solid")
relations: list[TopologyDeltaRelation] = []
for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")):
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
relations.append(TopologyDeltaRelation(
"generated", "face", section.wrapped, (output,), output_role=role,
))
# PipeShell exposes cap faces but, unlike MakePrism, has no
# FirstShape/LastShape(source_edge) overload. A source edge can
# therefore receive a cap-edge relation only in the one-edge profile
# case: the builder-proven cap face has one exact final boundary edge.
# Multi-edge/inner-wire profiles deliberately emit no inferred edge
# correspondence.
profile_edges = list(section.edges())
if len(profile_edges) == 1:
for output, role in ((builder.FirstShape(), "sweep.start"), (builder.LastShape(), "sweep.end")):
if output.IsNull() or output.ShapeType() != TopAbs_FACE:
continue
cap_edges = list(Face(output).edges())
if len(cap_edges) != 1:
continue
cap_edge = cap_edges[0].wrapped
is_final_edge = Build123dGeometryAdapter._is_result_topology_member(result.wrapped, cap_edge)
relations.append(TopologyDeltaRelation(
"generated", "edge", profile_edges[0].wrapped,
(cap_edge,) if is_final_edge else (),
output_role=role,
source_kind="edge",
result_kind="edge",
derivation="boundary",
coverage="complete" if is_final_edge else "partial",
status="proven" if is_final_edge else "unknown",
))
for source_edge in profile_edges:
# Unlike the cap-edge form, PipeShell does expose Generated(edge).
# Keep every final face returned for this exact source handle;
# omitted/deleted builder values remain explicit partial evidence.
try:
generated = tuple(builder.Generated(source_edge.wrapped))
except (AttributeError, TypeError, ValueError):
generated = ()
side_faces = tuple(
shape for shape in generated
if shape.ShapeType() == TopAbs_FACE
and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, shape)
)
complete = bool(generated) and len(side_faces) == len(generated)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped, side_faces,
source_kind="edge",
result_kind="face",
derivation="boundary",
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
source_vertices: list[Any] = []
for source_edge in profile_edges:
for source_vertex in source_edge.vertices():
if any(source_vertex.wrapped.IsSame(existing) for existing in source_vertices):
continue
source_vertices.append(source_vertex.wrapped)
for source_vertex in source_vertices:
# PipeShell exposes the exact generated path edge for a profile
# vertex. Keep only builder handles that remain in the final
# solid; a missing/split/deleted output is explicit partial
# evidence rather than an inferred edge correspondence.
try:
generated = tuple(builder.Generated(source_vertex))
except (AttributeError, TypeError, ValueError):
generated = ()
swept_edges = tuple(
shape for shape in generated
if shape.ShapeType() == TopAbs_EDGE
and Build123dGeometryAdapter._is_result_topology_member(result.wrapped, shape)
)
complete = bool(generated) and len(swept_edges) == len(generated)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex, swept_edges,
source_kind="vertex", result_kind="edge", derivation="boundary",
coverage="complete" if complete else "partial",
status="proven" if complete else "unknown",
))
return result, TopologyDelta(operation="sweep", relations=tuple(relations))
@staticmethod
def sweep_path(
points: Iterable[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
# 缺失时不能伪造,交给内核自动求解。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
if len(vertices) == 2 and tangents is None:
if parameters is not None:
raise ValueError("line sweep path does not accept B-spline parameters")
return Edge.make_line(vertices[0], vertices[1])
direction = vertices[-1] - vertices[0]
tolerance = 1e-9 * max(1.0, direction.length)
collinear_points = direction.length > tolerance and all(
(point - vertices[0]).cross(direction).length <= tolerance
for point in vertices[1:-1]
)
collinear_tangents = tangents is None or all(
tangent.cross(direction).length <= tolerance and tangent.dot(direction) > tolerance
for tangent in tangents
)
if collinear_points and collinear_tangents:
# OCC 对完全共线的插值 B-spline 做实体 sweep 时可能无限求解。
# 此处的点列和端切线没有曲率信息,几何上严格等价于一条直线;
# 仅在同向条件成立时退化,反向切线仍保留 B-spline 语义。
return Edge.make_line(vertices[0], vertices[-1])
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
@staticmethod
def sweep_arc_path(
start: Vector3,
end: Vector3,
center: Vector3,
normal: Vector3,
*,
radius_mm: float,
clockwise: bool,
) -> Edge:
"""Build one explicit directed source arc without approximating it."""
return Build123dGeometryAdapter._wire_edges([{
"type": "arc",
"start_mm": list(start),
"end_mm": list(end),
"center_mm": list(center),
"normal": list(normal),
"radius_mm": float(radius_mm),
"clockwise": clockwise,
}])[0]
@staticmethod
def sweep_circle_path(
center: Vector3,
x_dir: Vector3,
normal: Vector3,
*,
radius_mm: float,
) -> Wire:
"""Build one explicit closed source circle as a one-edge wire.
PipeShell treats a bare closed edge as a degenerate spine. Retaining
the closed wire is part of the CDSL path contract and avoids inventing
an endpoint or a cap for a FeatureScript ``skCircle`` path.
"""
radius = float(radius_mm)
if not math.isfinite(radius) or radius <= 0:
raise ValueError("sweep circle path requires a positive radius")
plane = Plane(origin=_vector(center), x_dir=_vector(x_dir), z_dir=_vector(normal))
return Wire(Edge.make_circle(radius, plane))
@staticmethod
def sweep_path_segments(segments: Iterable[dict[str, Any]]) -> Wire:
"""Build one explicit open path wire from source-ordered curve segments.
This does not infer or repair a path from an output body. The CDSL
lowerer has already proved the source wire's ordered, non-branching
endpoints; this builder merely sends those exact line/arc/B-spline
definitions to OCC's wire construction API.
"""
definitions = list(segments)
if len(definitions) < 2:
raise ValueError("sweep segmented path requires at least two segments")
builder = BRepBuilderAPI_MakeWire()
for edge in Build123dGeometryAdapter._wire_edges(definitions):
builder.Add(edge.wrapped)
if not builder.IsDone():
raise ValueError("sweep segmented path is not a connected wire")
return Wire(builder.Wire())
@staticmethod
def helix_path(
radius_mm: float,
pitch_mm: float,
turns: float | None = None,
*,
height_mm: float | None = None,
lefthand: bool = False,
) -> Edge:
# 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。
# 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。
# turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键,
# 按高度适配 parametric_threadlength + 两端余量)。构造与 parametric_thread
# 原 Edge.make_helix 同源,保证生成器复用后逐位一致。
if pitch_mm <= 0:
raise ValueError("helix pitch must be positive")
if (turns is None) == (height_mm is None):
raise ValueError("helix_path needs exactly one of turns or height_mm")
if turns is not None:
if turns <= 0:
raise ValueError("helix turns must be positive")
height = turns * pitch_mm
else:
if height_mm <= 0:
raise ValueError("helix height must be positive")
height = height_mm
return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand)
@staticmethod
def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any:
# 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。
# 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or spacing_mm == 0:
return body
vector = _vector(direction)
if vector.length <= 1e-12:
raise ValueError("pattern direction must be non-zero")
unit = vector.normalized()
result = body
for index in range(1, count):
offset = unit * (index * spacing_mm)
result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z))))
return result
@staticmethod
def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any:
# 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction
# 旋转 sweep_angle_deg 均布 count 份并合并。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or sweep_angle_deg == 0:
return body
origin = _vector(axis.origin_mm)
direction = _vector(axis.direction)
if direction.length <= 1e-12:
raise ValueError("pattern rotation axis must be non-zero")
unit = direction.normalized()
step_angle = sweep_angle_deg / count
# 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”,
# 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。
to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z)))
back = Location((float(origin.X), float(origin.Y), float(origin.Z)))
result = body
for index in range(1, count):
rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle)
instance = body.moved(to_origin).moved(rotation).moved(back)
result = result.fuse(instance)
return result
@staticmethod
def mirror(body: Any, plane: PlaneSpec) -> Any:
# 沿给定平面镜像主体。
return body.mirror(Build123dGeometryAdapter.plane(plane))
@staticmethod
def export(body: Any, path: str) -> None:
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
# B-rep 的拓扑和导出体积。
segments = []
for solid in Build123dGeometryAdapter.body_solids(body):
if not isinstance(solid, Solid):
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
# STEP exporter,才能保留 pure-surface feature history 的面。
segments.append(solid); continue
has_revolution = any(
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
for face in solid.faces()
)
if not has_revolution:
segments.append(solid); continue
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
segmented = Solid(divider.Result())
if not segmented.is_valid:
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
segments.append(segmented)
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
try:
solids = list(body.solids())
except Exception:
return [body] if body is not None else []
return solids or ([body] if body is not None else [])
@staticmethod
def body_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。
return _body_geometry_impl(body)
@staticmethod
def surface_geometry(surface: Any) -> dict[str, Any]:
# 曲面结果的独立拓扑摘要(实现见 topology_export)。
return _surface_geometry_impl(surface)
@staticmethod
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
# 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。
return _topology_records_impl(body, feature_id, body_id)