feat(cadfs): expand reconstruction capability coverage

This commit is contained in:
2026-09-09 10:11:46 +08:00
parent 5bd1011335
commit 2f89d7930c
21 changed files with 8373 additions and 418 deletions
+799 -25
View File
@@ -5,25 +5,31 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from build123d import AngularDirection, Axis, Compound, Edge, Face, 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.BRepFilletAPI import BRepFilletAPI_MakeChamfer
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_MakeThickSolid
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import 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_SHELL
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
from OCP.TopExp import TopExp_Explorer
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from .parametric_bend import build_bend_solid
from .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
from .runtime_types import (
AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyDelta,
TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature,
)
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
@@ -68,6 +74,9 @@ def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vect
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。
@@ -83,20 +92,48 @@ class Build123dGeometryAdapter:
# 将边字典列表(直线/圆弧/椭圆/插值 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 []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
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(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
periodic=bool(edge.get("periodic")),
parameters=[float(value) for value in parameters] if parameters is not None else None,
parameters=parameter_values,
scale=False,
))
continue
@@ -166,6 +203,222 @@ class Build123dGeometryAdapter:
faces.append(face.make_holes(holes) if holes else face)
return faces
@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
)
def _faces_from_planar_imprint(self, sketch: dict[str, Any]) -> list[Face]:
"""Materialize exact bounded IMPRINT regions with OCC planar splitting."""
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)
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {})
support = self._imprint_support_face(all_edges, plane_spec)
splitter = BOPAlgo_Splitter()
splitter.AddArgument(support.wrapped)
for edge in all_edges:
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 "")
anchors = source_edges.get(anchor_id) or [] if fragment 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)
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
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
@@ -174,6 +427,8 @@ class Build123dGeometryAdapter:
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
return self._faces_from_planar_imprint(sketch)
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
@@ -227,8 +482,8 @@ class Build123dGeometryAdapter:
raise ValueError("profile hole feature requires at least one cap face")
return Face(outer.outer_wire()).make_holes(wires)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
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 保持放样、
@@ -242,7 +497,35 @@ class Build123dGeometryAdapter:
wires.append(faces[0].outer_wire())
if len(wires) < 2:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
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_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."""
@@ -285,13 +568,62 @@ class Build123dGeometryAdapter:
return Solid.extrude(face, _vector(direction))
@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 同向时始终优先使用它。
def face_normal(face: Face) -> Vector3:
normal = face.normal_at()
return (float(normal.X), float(normal.Y), float(normal.Z))
@staticmethod
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
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,
))
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
@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 (
@@ -309,8 +641,31 @@ class Build123dGeometryAdapter:
if prism.IsDone():
result = Solid(TopoDS.Solid_s(prism.Shape()))
if result.is_valid:
return result
return Solid.extrude_taper(face, _vector(direction), taper_deg)
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:
@@ -587,10 +942,38 @@ class Build123dGeometryAdapter:
@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
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 语义。
@@ -603,6 +986,30 @@ class Build123dGeometryAdapter:
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。
@@ -611,6 +1018,39 @@ class Build123dGeometryAdapter:
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,否则相交实体会被内核合并。
@@ -621,10 +1061,50 @@ class Build123dGeometryAdapter:
@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 single explicit bodies while preserving exact OCC history.
The established multi-member path cuts each member independently to
bound OCC work. It has no single builder history for the aggregate,
so it deliberately keeps the executable result but returns no delta
rather than composing an unproven history graph.
"""
if len(Build123dGeometryAdapter.body_solids(body)) != 1 or len(Build123dGeometryAdapter.body_solids(tool)) != 1:
return Build123dGeometryAdapter.cut(body, tool), None
arguments = TopTools_ListOfShape(); arguments.Append(body.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()), empty_error="OCC cut operation produced no shape",
)
return result, Build123dGeometryAdapter._builder_topology_delta(
operation, (body, tool), "subtract",
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
# 以给定球心与半径生成球体实体。
@@ -656,6 +1136,147 @@ class Build123dGeometryAdapter:
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)
@staticmethod
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
"""Translate OCC builder history into adapter-neutral opaque relations."""
relations: list[TopologyDeltaRelation] = []
for source in sources:
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:
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
return TopologyDelta(operation=operation_name, relations=tuple(relations))
@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 工具体。
@@ -712,6 +1333,18 @@ class Build123dGeometryAdapter:
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(
@@ -726,6 +1359,26 @@ class Build123dGeometryAdapter:
# 对指定边以给定半径做圆角。
return body.fillet(radius_mm, list(edges))
@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:
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.
@@ -796,6 +1449,25 @@ class Build123dGeometryAdapter:
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:
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.
@@ -920,6 +1592,15 @@ class Build123dGeometryAdapter:
@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 生命周期。
@@ -953,7 +1634,7 @@ class Build123dGeometryAdapter:
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result
return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected)
@staticmethod
def sweep(
@@ -964,6 +1645,22 @@ class Build123dGeometryAdapter:
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.sweep:make_solid=True 封盖成体;is_frenet=True
@@ -983,6 +1680,54 @@ class Build123dGeometryAdapter:
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
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()
if not builder.IsDone():
raise ValueError("OCC sweep operation did not complete")
if not builder.MakeSolid():
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,
))
return result, TopologyDelta(operation="sweep", relations=tuple(relations))
@staticmethod
def sweep_path(
points: Iterable[Vector3],
@@ -1005,6 +1750,21 @@ class Build123dGeometryAdapter:
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")
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
@@ -1384,6 +2144,20 @@ class Build123dGeometryAdapter:
"curve_type": str(edge.geom_type).split(".")[-1].lower(),
"adjacent_face_count": len(edge_faces[index]),
}
if geometry["curve_type"] == "circle":
# ``Edge.center()`` is a point on a periodic circle, not its
# geometric centre. Preserve the OCC circle data separately
# so a provenance-backed rotational selector can distinguish
# concentric full circles at different axial locations.
try:
circle_center = edge.arc_center
radius = float(edge.radius)
values = (circle_center.X, circle_center.Y, circle_center.Z, radius)
except (AttributeError, TypeError, ValueError):
values = ()
if values and all(math.isfinite(float(value)) for value in values) and radius > 0:
geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z]
geometry["radius_mm"] = radius
if vertices:
geometry["start_mm"] = list(vertices[0])
geometry["end_mm"] = list(vertices[-1])