feat(cadfs): expand reconstruction capability coverage
This commit is contained in:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user