2176 lines
116 KiB
Python
2176 lines
116 KiB
Python
"""build123d/OCC implementation of the runtime-neutral geometry adapter."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Any, Iterable
|
||
|
||
from build123d import AngularDirection, Axis, Compound, Edge, Face, 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.BRepExtrema import BRepExtrema_DistShapeShape
|
||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||
from OCP.BRepOffset import BRepOffset_Skin
|
||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||
from OCP.BRepBuilderAPI import BRepBuilderAPI_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_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_Trsf, gp_Vec
|
||
|
||
from .parametric_thread import build_thread_solid
|
||
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:
|
||
# 将三元坐标(list 或 tuple)转换为 build123d 的 Vector 对象。
|
||
return Vector(float(value[0]), float(value[1]), float(value[2]))
|
||
|
||
|
||
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
|
||
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
||
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
||
radius = float(edge.get("radius_mm") or (start - center).length)
|
||
first = start - center
|
||
second = end - center
|
||
# 2. 起点或终点与圆心重合时,圆弧退化为线段,中点取两端中点。
|
||
if first.length <= 1e-9 or second.length <= 1e-9:
|
||
return (start + end) / 2
|
||
# 3. 确定圆弧所在平面法向:优先显式 normal,其次由两半径向量叉积推得,最后回退到 +Z。
|
||
normal = _vector(edge.get("normal") or [0, 0, 1])
|
||
if normal.length <= 1e-9:
|
||
normal = first.cross(second)
|
||
if normal.length <= 1e-9:
|
||
normal = Vector(0, 0, 1)
|
||
normal = normal.normalized()
|
||
# 4. 未指定旋转方向:取两条半径单位向量之和(角平分线)指向圆弧中点。
|
||
if "clockwise" not in edge:
|
||
bisector = first.normalized() + second.normalized()
|
||
if bisector.length <= 1e-9:
|
||
bisector = normal.cross(first)
|
||
return center + bisector.normalized() * radius
|
||
# 5. 指定了方向:按有符号扫掠角规整到 (−π, π],再沿首半径旋转半角得到中点。
|
||
sweep = math.atan2(normal.dot(first.cross(second)), first.dot(second))
|
||
if bool(edge["clockwise"]):
|
||
if sweep >= 0:
|
||
sweep -= math.tau
|
||
elif sweep <= 0:
|
||
sweep += math.tau
|
||
half = sweep / 2
|
||
radius_vector = first.normalized() * radius
|
||
return center + radius_vector * math.cos(half) + normal.cross(radius_vector) * math.sin(half)
|
||
|
||
|
||
class Build123dGeometryAdapter:
|
||
"""All B-rep construction and mutation lives in this adapter."""
|
||
|
||
CONTACT_FUSE_TOLERANCE_MM = 1e-7
|
||
COINCIDENT_FUSE_TOLERANCE_MM = 1e-3
|
||
|
||
@staticmethod
|
||
def plane(spec: PlaneSpec) -> Plane:
|
||
# 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。
|
||
return Plane(origin=_vector(spec.origin_mm), x_dir=_vector(spec.x_dir), z_dir=_vector(spec.normal))
|
||
|
||
@staticmethod
|
||
def axis(spec: AxisSpec) -> Axis:
|
||
# 将运行时轴定义 AxisSpec 转换为 build123d 的 Axis。
|
||
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
||
|
||
@staticmethod
|
||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||
built: list[Edge] = []
|
||
for edge in edges:
|
||
if edge.get("type") == "circle":
|
||
center = _vector(edge["center_mm"])
|
||
x_dir = _vector(edge.get("x_dir_mm") or [1, 0, 0])
|
||
normal = _vector(edge.get("normal") or [0, 0, 1])
|
||
radius = float(edge.get("radius_mm") or 0.0)
|
||
if radius <= 0.0 or x_dir.length <= 1e-9 or normal.length <= 1e-9:
|
||
raise ValueError("circle contour edge has a degenerate frame")
|
||
direction = AngularDirection.CLOCKWISE if bool(edge.get("clockwise")) else AngularDirection.COUNTER_CLOCKWISE
|
||
built.append(Edge.make_circle(radius, Plane(origin=center, x_dir=x_dir, z_dir=normal), angular_direction=direction))
|
||
continue
|
||
if edge.get("type") == "bspline":
|
||
points = [_vector(point) for point in edge.get("points_mm") or []]
|
||
parameters = edge.get("parameters")
|
||
start_tangent = edge.get("start_tangent_mm")
|
||
end_tangent = edge.get("end_tangent_mm")
|
||
if len(points) < 2:
|
||
raise ValueError("bspline contour edge needs at least 2 points")
|
||
if (start_tangent is None) != (end_tangent is None):
|
||
raise ValueError("bspline contour edge requires both endpoint tangents")
|
||
if len(points) == 2:
|
||
if math.dist(
|
||
(points[0].X, points[0].Y, points[0].Z),
|
||
(points[1].X, points[1].Y, points[1].Z),
|
||
) <= 1e-5:
|
||
raise ValueError("two-point bspline contour edge endpoints must be distinct")
|
||
if bool(edge.get("periodic")) or start_tangent is None or end_tangent is None:
|
||
raise ValueError("two-point bspline contour edge requires non-periodic endpoint tangents")
|
||
if not isinstance(parameters, list) or len(parameters) != 2:
|
||
raise ValueError("two-point bspline contour edge requires explicit parameters")
|
||
try:
|
||
parameter_values = [float(value) for value in parameters]
|
||
except (OverflowError, TypeError, ValueError) as exc:
|
||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing") from exc
|
||
if not all(math.isfinite(value) for value in parameter_values) or parameter_values[1] - parameter_values[0] <= 1e-5:
|
||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
||
else:
|
||
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
||
built.append(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=parameter_values,
|
||
scale=False,
|
||
))
|
||
continue
|
||
if edge.get("type") == "ellipse":
|
||
center = _vector(edge["center_mm"])
|
||
major_axis = _vector(edge["major_axis_mm"])
|
||
normal = _vector(edge.get("normal") or [0, 0, 1])
|
||
if major_axis.length <= 1e-9 or normal.length <= 1e-9:
|
||
raise ValueError("ellipse contour edge has a degenerate frame")
|
||
plane = Plane(origin=center, x_dir=major_axis, z_dir=normal)
|
||
built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane))
|
||
continue
|
||
start = _vector(edge["start_mm"])
|
||
end = _vector(edge["end_mm"])
|
||
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
|
||
# 圆弧边:由起点、中点、终点三点构造圆弧。
|
||
center = _vector(edge["center_mm"])
|
||
built.append(Edge.make_three_point_arc(start, _arc_midpoint(edge, start, end, center), end))
|
||
else:
|
||
# 直线边:直接连接首尾。
|
||
built.append(Edge.make_line(start, end))
|
||
return Wire(built)
|
||
|
||
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
|
||
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
||
origin = Vector(*plane_spec.origin_mm) + Vector(*plane_spec.x_dir) * float(center[0]) + Vector(*plane_spec.y_dir) * float(center[1])
|
||
circle_plane = Plane(origin=origin, x_dir=Vector(*plane_spec.x_dir), z_dir=Vector(*plane_spec.normal))
|
||
return Wire.make_circle(radius, circle_plane)
|
||
|
||
def _faces_from_circles(self, entities: list[dict[str, Any]], plane_spec: PlaneSpec) -> list[Face]:
|
||
# 由草图中的实体圆生成面,按圆间包含关系识别孔洞并跳过落入孔洞区的圆。
|
||
# 1. 筛选非构造圆;没有实体圆时直接返回空列表。
|
||
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
|
||
if not circles:
|
||
return []
|
||
# 2. 逐个生成整圆 Wire,非法半径(≤0)的圆跳过。
|
||
entries = []
|
||
for item in circles:
|
||
radius = float(item.get("radius_mm") or 0)
|
||
if radius <= 0:
|
||
continue
|
||
center = [float(value) for value in item.get("center") or [0, 0]]
|
||
entries.append({"center": center, "radius": radius, "wire": self._circle_wire(center, radius, plane_spec)})
|
||
faces: list[Face] = []
|
||
for entry in entries:
|
||
# 3. 统计当前圆被多少个更大圆完整包含;被奇数层包含说明其处于孔洞区,跳过不建面。
|
||
containing = sum(
|
||
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
|
||
for other in entries
|
||
if other is not entry
|
||
)
|
||
if containing % 2:
|
||
continue
|
||
# 4. 收集直接包在自身内部的圆作为孔洞,且它们只能被当前这一层包含。
|
||
holes = [
|
||
other["wire"]
|
||
for other in entries
|
||
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
|
||
and sum(
|
||
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
|
||
for candidate in entries
|
||
if candidate is not other
|
||
) == containing + 1
|
||
]
|
||
# 5. 以当前圆为外轮廓建面,必要时打孔。
|
||
face = Face(entry["wire"])
|
||
faces.append(face.make_holes(holes) if holes else face)
|
||
return faces
|
||
|
||
@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 展开成四段圆弧。圆弧分段会
|
||
# 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自
|
||
# FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于
|
||
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
|
||
# _faces_from_circles 的包含关系生成带孔面。
|
||
profile = sketch.get("profile") or {}
|
||
if profile.get("type") == "planar_imprint":
|
||
return self._faces_from_planar_imprint(sketch)
|
||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||
if isinstance(contours, list) and contours and all(
|
||
isinstance(contour, dict)
|
||
and bool(contour.get("closed"))
|
||
and len(contour.get("segments") or []) == 1
|
||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||
for contour in contours
|
||
):
|
||
circles = [
|
||
{
|
||
"type": "circle",
|
||
"center": segment.get("center"),
|
||
"radius_mm": segment.get("radius_mm"),
|
||
}
|
||
for contour in contours
|
||
for segment in contour.get("segments") or []
|
||
]
|
||
return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}))
|
||
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
|
||
regions = sketch.get("contour_regions_mm") or []
|
||
if regions:
|
||
result: list[Face] = []
|
||
for region in regions:
|
||
outer = region.get("outer") or []
|
||
# 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。
|
||
if len(outer) < 1:
|
||
continue
|
||
face = Face(self._wire(outer))
|
||
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1]
|
||
result.append(face.make_holes(holes) if holes else face)
|
||
return result
|
||
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
|
||
edges = sketch.get("contour_edges_mm") or []
|
||
if len(edges) >= 1:
|
||
return [Face(self._wire(edges))]
|
||
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
|
||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
||
|
||
@staticmethod
|
||
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
|
||
"""Build one planar profile from a sketch outer wire and cap-face holes."""
|
||
if outer.inner_wires():
|
||
raise ValueError("profile outer face must not already contain holes")
|
||
wires = []
|
||
for hole in holes:
|
||
if hole.inner_wires():
|
||
raise ValueError("profile hole face must have exactly one outer wire")
|
||
wires.append(hole.outer_wire())
|
||
if not wires:
|
||
raise ValueError("profile hole feature requires at least one cap face")
|
||
return Face(outer.outer_wire()).make_holes(wires)
|
||
|
||
def _loft_wires(self, sketches: list[dict[str, Any]]) -> list[Wire]:
|
||
"""Resolve the bounded closed-wire CDSL loft contract once."""
|
||
wires: list[Wire] = []
|
||
for index, sketch in enumerate(sketches):
|
||
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
|
||
# 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系
|
||
# 尚未由 CDSL 表达,必须显式拒绝而非猜测。
|
||
faces = self.faces_for_sketch(sketch)
|
||
if len(faces) != 1:
|
||
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
|
||
if faces[0].inner_wires():
|
||
raise ValueError(f"loft profile {index} must not contain inner loops")
|
||
wires.append(faces[0].outer_wire())
|
||
if len(wires) < 2:
|
||
raise ValueError("loft requires at least two profile sketches")
|
||
return wires
|
||
|
||
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
|
||
result, _delta = self.loft_with_topology_delta(sketches)
|
||
return result
|
||
|
||
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Solid, TopologyDelta]:
|
||
"""Build a simple closed-wire solid loft through one OCC builder."""
|
||
wires = self._loft_wires(sketches)
|
||
builder = BRepOffsetAPI_ThruSections(True, False)
|
||
builder.CheckCompatibility(True)
|
||
for wire in wires:
|
||
builder.AddWire(wire.wrapped)
|
||
builder.Build()
|
||
if not builder.IsDone():
|
||
raise ValueError("OCC loft operation did not complete")
|
||
result = Solid(builder.Shape())
|
||
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
|
||
raise ValueError("OCC loft operation did not produce a valid solid")
|
||
relations: list[TopologyDeltaRelation] = []
|
||
for wire, output, role in (
|
||
(wires[0], builder.FirstShape(), "loft.start"),
|
||
(wires[-1], builder.LastShape(), "loft.end"),
|
||
):
|
||
if not output.IsNull() and output.ShapeType() == TopAbs_FACE:
|
||
relations.append(TopologyDeltaRelation(
|
||
"generated", "face", wire.wrapped, (output,), output_role=role,
|
||
))
|
||
return result, TopologyDelta(operation="loft", relations=tuple(relations))
|
||
|
||
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
|
||
"""Loft from one cap face's outer wire to one closed sketch profile."""
|
||
if not sketches:
|
||
raise ValueError("cap-face loft requires at least one profile sketch")
|
||
wires = [cap_face.outer_wire()]
|
||
for index, sketch in enumerate(sketches):
|
||
faces = self.faces_for_sketch(sketch)
|
||
if len(faces) != 1:
|
||
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
|
||
if faces[0].inner_wires():
|
||
raise ValueError(f"loft profile {index} must not contain inner loops")
|
||
wires.append(faces[0].outer_wire())
|
||
return Solid.make_loft(wires)
|
||
|
||
@staticmethod
|
||
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
|
||
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
|
||
# build123d 的布尔方法有时返回 None(无结果)、ShapeList(多/单成员)
|
||
# 或直接返回 Solid/Compound,这里统一为单实体或 Compound。
|
||
if result is None:
|
||
if empty_error is not None:
|
||
raise ValueError(empty_error)
|
||
return None
|
||
members = list(result) if isinstance(result, ShapeList) else [result]
|
||
if not members:
|
||
if empty_error is not None:
|
||
raise ValueError(empty_error)
|
||
return None
|
||
if len(members) == 1:
|
||
return members[0]
|
||
# ``Compound`` constructor accepts an iterable on both supported
|
||
# Build123d runtimes. ``make_composite`` is not available in every
|
||
# deployed version, so using it here breaks valid multi-solid cuts.
|
||
return Compound(members)
|
||
|
||
@staticmethod
|
||
def extrude(face: Face, direction: Vector3) -> Solid:
|
||
# 沿给定方向向量拉伸一个面,生成实体。
|
||
return Solid.extrude(face, _vector(direction))
|
||
|
||
@staticmethod
|
||
def face_normal(face: Face) -> Vector3:
|
||
normal = face.normal_at()
|
||
return (float(normal.X), float(normal.Y), float(normal.Z))
|
||
|
||
@staticmethod
|
||
def 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 (
|
||
vector.length > 1e-9
|
||
and normal.length > 1e-9
|
||
and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9
|
||
and not face.inner_wires()
|
||
):
|
||
angle_rad = math.radians(taper_deg)
|
||
prism = LocOpe_DPrism(
|
||
face.wrapped,
|
||
vector.length / math.cos(angle_rad),
|
||
angle_rad,
|
||
)
|
||
if prism.IsDone():
|
||
result = Solid(TopoDS.Solid_s(prism.Shape()))
|
||
if result.is_valid:
|
||
relations: list[TopologyDeltaRelation] = []
|
||
for cap_shape, role in (
|
||
(prism.FirstShape(), "extrude.start"),
|
||
(prism.LastShape(), "extrude.end"),
|
||
):
|
||
cap = Build123dGeometryAdapter._single_face_from_shape(cap_shape)
|
||
if cap is not None:
|
||
relations.append(TopologyDeltaRelation(
|
||
"generated", "face", face.wrapped, (cap.wrapped,), output_role=role,
|
||
))
|
||
return result, TopologyDelta(operation="extrude_taper", relations=tuple(relations))
|
||
return Solid.extrude_taper(face, vector, taper_deg), None
|
||
|
||
@staticmethod
|
||
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
|
||
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
|
||
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
|
||
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
|
||
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
|
||
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
|
||
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
|
||
result, _topology_delta = Build123dGeometryAdapter.extrude_taper_with_topology_delta(
|
||
face, direction, taper_deg,
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
|
||
"""Extrude the profile to the target face, trimming unreached regions.
|
||
|
||
Issue #5: when a profile intersects the up_to_surface target
|
||
non-uniformly (part of the profile reaches the face, part hangs
|
||
outside it), a plain vector extrusion is wrong. The CAD semantics is
|
||
to keep only the material between the profile and the target. We
|
||
pierce the profile through the target, push the target face backward
|
||
by the same margin to build a slab, and keep their boolean common
|
||
(intersection) as the trimmed solid.
|
||
"""
|
||
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
|
||
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
|
||
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
|
||
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
|
||
unit = _vector(direction).normalized()
|
||
target_body = target if not isinstance(target, Face) else None
|
||
points = Build123dGeometryAdapter.profile_sample_points(face)
|
||
targets = [target] if isinstance(target, Face) else list(target.faces())
|
||
candidates = []
|
||
for candidate in targets:
|
||
hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points]
|
||
distances = [value for value in hits if value is not None]
|
||
if distances:
|
||
candidates.append((len(distances), min(distances), candidate, distances))
|
||
if not candidates:
|
||
raise ValueError("extent target is not reached by the profile")
|
||
# 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同
|
||
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
|
||
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
|
||
margin = max(distances) + 2.0
|
||
if target_body is not None and len(distances) == len(points):
|
||
# through_next 的 target 是当前实体,不是孤立的终止面。先让工具体
|
||
# 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。
|
||
# 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。
|
||
pierced = Solid.extrude(face, unit * margin)
|
||
return Build123dGeometryAdapter._coerce_single_or_compound(
|
||
pierced.cut(target_body), empty_error="extent target leaves no leading material",
|
||
)
|
||
# 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的
|
||
# OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能
|
||
# 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。
|
||
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
|
||
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
|
||
pierced = Solid.extrude(face, unit * margin)
|
||
candidates = []
|
||
for slab_direction in (-unit * margin, unit * margin):
|
||
trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
|
||
if trimmed is not None:
|
||
candidates.append((trimmed.distance_to(face), trimmed))
|
||
if not candidates:
|
||
raise ValueError("extent target produced an empty trimmed solid")
|
||
return min(candidates, key=lambda item: item[0])[1]
|
||
|
||
@staticmethod
|
||
def body_center(body: Any) -> Vector3:
|
||
# 取主体包围盒的中心坐标,作为体心的近似。
|
||
bbox = body.bounding_box()
|
||
return ((bbox.min.X + bbox.max.X) / 2, (bbox.min.Y + bbox.max.Y) / 2, (bbox.min.Z + bbox.max.Z) / 2)
|
||
|
||
@staticmethod
|
||
def body_span(body: Any, direction: Vector3) -> float:
|
||
# 计算主体在指定方向上的最大跨度:8 个包围盒角点沿方向投影后取极差。
|
||
unit = _vector(direction).normalized()
|
||
bbox = body.bounding_box()
|
||
values = [
|
||
Vector(x, y, z).dot(unit)
|
||
for x in (bbox.min.X, bbox.max.X)
|
||
for y in (bbox.min.Y, bbox.max.Y)
|
||
for z in (bbox.min.Z, bbox.max.Z)
|
||
]
|
||
return max(values) - min(values)
|
||
|
||
@staticmethod
|
||
def vertex_coordinates(vertex: Any) -> Vector3:
|
||
# 提取顶点的三维坐标元组。
|
||
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
|
||
|
||
@staticmethod
|
||
def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any:
|
||
"""Resolve one current-body vertex shared by every selected face set."""
|
||
if not face_sets or any(not faces for faces in face_sets):
|
||
raise ValueError("intersection selector has an empty face set")
|
||
matched = []
|
||
for vertex in body.vertices():
|
||
if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets):
|
||
point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6))
|
||
if not any(point == existing[0] for existing in matched): matched.append((point, vertex))
|
||
if len(matched) != 1:
|
||
raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices")
|
||
return matched[0][1]
|
||
|
||
@staticmethod
|
||
def profile_sample_points(face: Face) -> list[Vector]:
|
||
"""Sample a profile face before a selector-dependent termination.
|
||
|
||
A simple vector extrusion is exact only when the selected target is
|
||
reached at one common distance over the complete profile. Center and
|
||
boundary samples let the runtime prove that precondition instead of
|
||
silently constructing a wrong prismatic solid.
|
||
"""
|
||
# 采样轮廓面的代表性点:面心 + 每条边的 0/0.25/0.5/0.75 参数点,
|
||
# 用于后续校验目标面到轮廓的距离是否处处一致。
|
||
points = [face.center()]
|
||
for edge in face.edges():
|
||
for fraction in (0.0, 0.25, 0.5, 0.75):
|
||
points.append(edge.position_at(fraction))
|
||
# 去重:彼此距离在 1e-6 内的采样点只保留一个,减少重复求交。
|
||
unique: list[Vector] = []
|
||
for point in points:
|
||
if not any((point - current).length <= 1e-6 for current in unique):
|
||
unique.append(point)
|
||
return unique
|
||
|
||
@staticmethod
|
||
def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool:
|
||
"""Return whether a profile starts on the selected extent target."""
|
||
# `up_to_surface` 允许 profile 从 selected face 出发。这时 selected
|
||
# face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前
|
||
# 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于
|
||
# 平面、圆柱和其他可用作 selector 的 B-rep face。
|
||
for face in faces:
|
||
try:
|
||
if face.distance_to(target) <= 1e-6:
|
||
return True
|
||
except Exception as error:
|
||
raise ValueError("extent target does not support profile distance") from error
|
||
return False
|
||
|
||
@staticmethod
|
||
def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face:
|
||
"""Find the next current-body face reached after an extent start face."""
|
||
# 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取
|
||
# 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。
|
||
unit = _vector(direction).normalized()
|
||
samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)]
|
||
if not samples:
|
||
raise ValueError("extent feature has no profile samples")
|
||
candidates = []
|
||
for candidate in body.faces():
|
||
# Selector resolution can retain the preceding B-rep snapshot,
|
||
# whose wrapper is no longer `is_same` after a later boolean.
|
||
# Geometrically coincident current faces are still the selected
|
||
# start boundary and must not win by their near-zero ray hits.
|
||
if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6:
|
||
continue
|
||
distances = [
|
||
value for point in samples
|
||
if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None
|
||
]
|
||
if distances:
|
||
candidates.append((len(distances), min(distances), candidate))
|
||
if not candidates:
|
||
raise ValueError("extent target has no following body face")
|
||
coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1]))
|
||
if coverage != len(samples):
|
||
raise ValueError("extent target does not reach every profile ray after the start face")
|
||
return target
|
||
|
||
@staticmethod
|
||
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
|
||
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
|
||
try:
|
||
intersections = target.find_intersection_points(Axis(point, direction)) or []
|
||
except Exception as error:
|
||
raise ValueError("extent target does not support ray intersection") from error
|
||
# 只保留方向一致(点积 > 0)的交点,返回其中最近距离;无交点则返回 None。
|
||
distances = [
|
||
(hit_point - point).dot(direction)
|
||
for hit_point, _normal in intersections
|
||
if (hit_point - point).dot(direction) > 1e-6
|
||
]
|
||
return min(distances) if distances else None
|
||
|
||
def uniform_intersection_distance(self, target: Any, faces: Iterable[Face], direction: Vector3) -> float:
|
||
"""Return a proven uniform positive target distance for a profile set."""
|
||
# 对所有轮廓采样点求到目标的距离,各点距离必须一致,简单拉伸才能精确表达终止条件。
|
||
unit_direction = _vector(direction).normalized()
|
||
distances: list[float] = []
|
||
for face in faces:
|
||
for point in self.profile_sample_points(face):
|
||
distance = self._forward_intersection_distance(target, point, unit_direction)
|
||
if distance is None:
|
||
raise ValueError("extent target is not reached by every profile ray")
|
||
distances.append(distance)
|
||
if not distances:
|
||
raise ValueError("extent feature has no profile samples")
|
||
minimum, maximum = min(distances), max(distances)
|
||
if maximum - minimum > 1e-5:
|
||
raise ValueError("extent target requires non-uniform profile trimming")
|
||
return sum(distances) / len(distances)
|
||
|
||
@staticmethod
|
||
def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
|
||
# 绕给定轴将面旋转指定角度,生成回转实体。
|
||
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
||
|
||
@staticmethod
|
||
def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell:
|
||
# 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为
|
||
# Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。
|
||
operation = BRepPrimAPI_MakeRevol(
|
||
wire.wrapped,
|
||
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
|
||
math.radians(angle_deg),
|
||
True,
|
||
)
|
||
operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("surface revolve did not complete")
|
||
shape = operation.Shape()
|
||
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
|
||
raise ValueError("surface revolve did not produce one shell")
|
||
return Shell(shape)
|
||
|
||
@staticmethod
|
||
def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]:
|
||
# 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。
|
||
# 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。
|
||
profile = sketch.get("profile") or {}
|
||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||
if profile.get("type") == "circle":
|
||
return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)]
|
||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||
if not isinstance(contours, list) or not contours:
|
||
raise ValueError("surface extrude requires closed contour wires")
|
||
wires = []
|
||
for contour in contours:
|
||
segments = contour.get("segments") or []
|
||
if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle":
|
||
raise ValueError("surface extrude currently supports selected circular wires only")
|
||
segment = segments[0]
|
||
wires.append(Build123dGeometryAdapter()._circle_wire(
|
||
segment.get("center") or [0, 0], float(segment["radius_mm"]), plane,
|
||
))
|
||
return wires
|
||
|
||
@staticmethod
|
||
def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any:
|
||
# 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续
|
||
# selector 通过 register_surface 的独立 topology snapshot 追溯其来源。
|
||
vector = _vector(direction)
|
||
if vector.length <= 1e-9:
|
||
raise ValueError("surface extrude direction must be non-zero")
|
||
surfaces = []
|
||
for wire in wires:
|
||
operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
|
||
operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("surface extrude did not complete")
|
||
shape = operation.Shape()
|
||
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
|
||
raise ValueError("surface extrude did not produce one shell")
|
||
surfaces.append(Shell(shape))
|
||
if not surfaces:
|
||
raise ValueError("surface extrude requires at least one wire")
|
||
return surfaces[0] if len(surfaces) == 1 else Compound(surfaces)
|
||
|
||
@staticmethod
|
||
def combine_surfaces(*surfaces: Any) -> Any:
|
||
# 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃
|
||
# Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。
|
||
members = []
|
||
for surface in surfaces:
|
||
if isinstance(surface, Compound):
|
||
members.extend(surface.faces())
|
||
else:
|
||
members.append(surface)
|
||
if not members:
|
||
raise ValueError("surface combination requires at least one shell")
|
||
return members[0] if len(members) == 1 else Compound(members)
|
||
|
||
@staticmethod
|
||
def fuse(body: Any | None, solid: Any) -> Any:
|
||
"""Fuse bodies without retaining a builder history."""
|
||
result, _delta = Build123dGeometryAdapter.fuse_with_topology_delta(
|
||
body, solid, record_history=False,
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def fuse_with_topology_delta(
|
||
body: Any | None, solid: Any, *, record_history: bool = True,
|
||
) -> tuple[Any, TopologyDelta | None]:
|
||
"""Fuse one explicit body pair and retain history when it stays exact.
|
||
|
||
The established fallback sequence changes the kernel result: a normal
|
||
build123d fuse or fuzzy OCC fuse has a different history object. Such
|
||
results remain executable, but must not inherit relationships from the
|
||
discarded first builder. Multi-member inputs are likewise outside the
|
||
one-builder proof boundary.
|
||
"""
|
||
# 布尔并:没有既有主体时,直接以该实体作为新主体。
|
||
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
|
||
if body is None:
|
||
return solid, None
|
||
if (
|
||
not record_history
|
||
or len(Build123dGeometryAdapter.body_solids(body)) != 1
|
||
or len(Build123dGeometryAdapter.body_solids(solid)) != 1
|
||
):
|
||
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
|
||
return Build123dGeometryAdapter._fuse_with_history(body, solid)
|
||
|
||
@staticmethod
|
||
def _fuse_without_history(body: Any, solid: Any) -> Any:
|
||
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
|
||
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
|
||
# 一般 add/replay 的同一 union 语义。
|
||
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
|
||
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
|
||
operation = BRepAlgoAPI_Fuse()
|
||
operation.SetRunParallel(True); operation.SetUseOBB(True)
|
||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("OCC union operation did not complete")
|
||
result = Solid(operation.Shape())
|
||
if result.is_valid:
|
||
# OBB 对多个相交的曲面 sweep 偶尔会把交叠区单独保留为一个
|
||
# Solid。普通 fuse 若能以更少的有效实体表示相同并集,应优先
|
||
# 使用它;不相交结果仍保留 OBB 的多实体 body 语义。
|
||
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
|
||
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
|
||
if fallback is not None and fallback.is_valid and (
|
||
len(Build123dGeometryAdapter.body_solids(fallback))
|
||
< len(Build123dGeometryAdapter.body_solids(result))
|
||
):
|
||
return fallback
|
||
# 两个输入在数学上已经接触时,曲面 sweep 的近似交界可能只因
|
||
# 内核容差留下重叠成员。仅在这种零距离情形重试 fuzzy boolean;
|
||
# 有实际间隙的独立 body 不参与该修复,不能被错误地桥接合并。
|
||
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
|
||
operation = BRepAlgoAPI_Fuse()
|
||
operation.SetRunParallel(True); operation.SetUseOBB(True)
|
||
operation.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
|
||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||
fuzzy = Solid(operation.Shape()) if operation.IsDone() else None
|
||
if fuzzy is not None and fuzzy.is_valid and (
|
||
len(Build123dGeometryAdapter.body_solids(fuzzy))
|
||
< len(Build123dGeometryAdapter.body_solids(result))
|
||
) and fuzzy.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
|
||
return fuzzy
|
||
return result
|
||
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
|
||
# 不能悄然进入后续 feature history。
|
||
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
|
||
if fallback is not None and fallback.is_valid:
|
||
return fallback
|
||
raise ValueError("OCC union operation produced an invalid shape")
|
||
|
||
@staticmethod
|
||
def _fuse_with_history(body: Any, solid: Any) -> tuple[Any, TopologyDelta | None]:
|
||
"""Run the primary fuse algorithm with its own exact history object."""
|
||
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
|
||
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
|
||
operation = BRepAlgoAPI_Fuse()
|
||
operation.SetRunParallel(True); operation.SetUseOBB(True); operation.SetToFillHistory(True)
|
||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("OCC union operation did not complete")
|
||
result = Solid(operation.Shape())
|
||
if not result.is_valid:
|
||
return Build123dGeometryAdapter._fuse_without_history(body, solid), None
|
||
if len(Build123dGeometryAdapter.body_solids(result)) > 1:
|
||
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
|
||
if fallback is not None and fallback.is_valid and (
|
||
len(Build123dGeometryAdapter.body_solids(fallback))
|
||
< len(Build123dGeometryAdapter.body_solids(result))
|
||
):
|
||
return fallback, None
|
||
if body.distance_to(solid) <= Build123dGeometryAdapter.CONTACT_FUSE_TOLERANCE_MM:
|
||
fuzzy = BRepAlgoAPI_Fuse()
|
||
fuzzy.SetRunParallel(True); fuzzy.SetUseOBB(True)
|
||
fuzzy.SetFuzzyValue(Build123dGeometryAdapter.COINCIDENT_FUSE_TOLERANCE_MM)
|
||
fuzzy.SetArguments(arguments); fuzzy.SetTools(tools); fuzzy.Build()
|
||
fuzzy_result = Solid(fuzzy.Shape()) if fuzzy.IsDone() else None
|
||
if fuzzy_result is not None and fuzzy_result.is_valid and (
|
||
len(Build123dGeometryAdapter.body_solids(fuzzy_result))
|
||
< len(Build123dGeometryAdapter.body_solids(result))
|
||
) and fuzzy_result.volume + 1e-6 >= max(float(body.volume), float(solid.volume)):
|
||
return fuzzy_result, None
|
||
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body, solid), "union")
|
||
|
||
@staticmethod
|
||
def combine(body: Any | None, solid: Any) -> Any:
|
||
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
|
||
members = ([] if body is None else Build123dGeometryAdapter.body_solids(body))
|
||
members.extend(Build123dGeometryAdapter.body_solids(solid))
|
||
return members[0] if len(members) == 1 else Compound(members)
|
||
|
||
@staticmethod
|
||
def cut(body: Any, tool: Any) -> Any:
|
||
# 从主体上减去工具实体。
|
||
# Compound 内的独立实体分别切除再组合,与整体差集的集合语义一致。
|
||
# 对包含抽壳薄壁的多个成员,直接对整个 Compound 做 OCC boolean 会在
|
||
# 内核中长时间求解,且不会改善任何成员间不存在的拓扑关系。
|
||
members = Build123dGeometryAdapter.body_solids(body)
|
||
if len(members) > 1:
|
||
result = None
|
||
for member in members:
|
||
cut_member = Build123dGeometryAdapter._coerce_single_or_compound(member.cut(tool))
|
||
# 多实体差集允许 cutter 完全移除其中一个成员;其他成员仍是
|
||
# 当前 feature 的有效结果。只有所有成员均被移除才是空切除。
|
||
if cut_member is not None:
|
||
result = Build123dGeometryAdapter.combine(result, cut_member)
|
||
if result is None:
|
||
raise ValueError("OCC cut operation produced no shape")
|
||
return result
|
||
return Build123dGeometryAdapter._coerce_single_or_compound(
|
||
body.cut(tool), empty_error="OCC cut operation produced no shape",
|
||
)
|
||
|
||
@staticmethod
|
||
def cut_with_topology_delta(body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]:
|
||
"""Subtract 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:
|
||
# 以给定球心与半径生成球体实体。
|
||
return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
|
||
|
||
@staticmethod
|
||
def box(length_mm: float, width_mm: float, height_mm: float, plane: PlaneSpec | None = None) -> Solid:
|
||
# 原生立方体图元。plane 缺省为世界 XY;plane 的原点是长方体最小角点,
|
||
# 长/宽/高分别沿 plane 的 x/y/z 方向生长(build123d Solid.make_box 原生语义)。
|
||
build_plane = Build123dGeometryAdapter.plane(plane) if plane is not None else Plane.XY
|
||
return Solid.make_box(length_mm, width_mm, height_mm, build_plane)
|
||
|
||
@staticmethod
|
||
def cylinder(radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> Solid:
|
||
# 原生圆柱图元。axis 缺省为世界 +Z;axis 的原点是底面圆心,
|
||
# 轴向由 axis 的方向决定,沿该方向生长高度。
|
||
build_plane = (
|
||
Plane(origin=_vector(axis.origin_mm), z_dir=_vector(axis.direction))
|
||
if axis is not None
|
||
else Plane.XY
|
||
)
|
||
return Solid.make_cylinder(radius_mm, height_mm, build_plane)
|
||
|
||
@staticmethod
|
||
def intersect(left: Any, right: Any) -> Any:
|
||
# 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触),
|
||
# 此时规整 helper 会抛出明确的空交集错误。
|
||
return Build123dGeometryAdapter._coerce_single_or_compound(
|
||
left.intersect(right), empty_error="boolean intersection produced no solid",
|
||
)
|
||
|
||
@staticmethod
|
||
def intersect_with_topology_delta(left: Any, right: Any) -> tuple[Any, TopologyDelta | None]:
|
||
"""Intersect single bodies through one OCC builder and retain history."""
|
||
if len(Build123dGeometryAdapter.body_solids(left)) != 1 or len(Build123dGeometryAdapter.body_solids(right)) != 1:
|
||
return Build123dGeometryAdapter.intersect(left, right), None
|
||
arguments = TopTools_ListOfShape(); arguments.Append(left.wrapped)
|
||
tools = TopTools_ListOfShape(); tools.Append(right.wrapped)
|
||
operation = BRepAlgoAPI_Common()
|
||
operation.SetToFillHistory(True)
|
||
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("OCC intersection operation did not complete")
|
||
result = Build123dGeometryAdapter._coerce_single_or_compound(
|
||
Solid(operation.Shape()), empty_error="boolean intersection produced no solid",
|
||
)
|
||
return result, Build123dGeometryAdapter._builder_topology_delta(
|
||
operation, (left, right), "intersect",
|
||
)
|
||
|
||
@staticmethod
|
||
def transform(body: Any, transform: dict[str, Any]) -> Any:
|
||
"""Apply one explicit body transform without exposing kernel history."""
|
||
result, _delta = Build123dGeometryAdapter.transform_with_topology_delta(body, transform)
|
||
return result
|
||
|
||
@staticmethod
|
||
def transform_with_topology_delta(body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]:
|
||
"""Apply one body transform and retain exact OCC subshape history."""
|
||
kind = str(transform.get("type") or "")
|
||
conversion = gp_Trsf()
|
||
if kind == "translation":
|
||
offset = transform.get("translation_mm")
|
||
if not isinstance(offset, list) or len(offset) != 3:
|
||
raise ValueError("translation transform requires translation_mm")
|
||
conversion.SetTranslation(gp_Vec(*(float(value) for value in offset)))
|
||
elif kind == "rotation":
|
||
axis = AxisSpec.from_mapping(transform.get("axis") or {})
|
||
angle_deg = transform.get("angle_deg")
|
||
if not isinstance(angle_deg, (int, float)):
|
||
raise ValueError("rotation transform requires angle_deg")
|
||
conversion.SetRotation(
|
||
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
|
||
math.radians(float(angle_deg)),
|
||
)
|
||
elif kind == "uniform_scale":
|
||
center = transform.get("center_mm")
|
||
scale_factor = transform.get("scale_factor")
|
||
if not isinstance(center, list) or len(center) != 3:
|
||
raise ValueError("uniform_scale transform requires center_mm")
|
||
if not isinstance(scale_factor, (int, float)) or not math.isfinite(float(scale_factor)) or float(scale_factor) <= 0:
|
||
raise ValueError("uniform_scale transform requires a finite positive scale_factor")
|
||
conversion.SetScale(gp_Pnt(*(float(value) for value in center)), float(scale_factor))
|
||
else:
|
||
raise ValueError(f"unsupported body transform type {kind!r}")
|
||
operation = BRepBuilderAPI_Transform(body.wrapped, conversion, True)
|
||
operation.Build()
|
||
if not operation.IsDone():
|
||
raise ValueError("OCC body transform did not complete")
|
||
result = Solid(operation.Shape())
|
||
if not result.is_valid:
|
||
raise ValueError("OCC body transform produced an invalid shape")
|
||
return result, Build123dGeometryAdapter._builder_topology_delta(operation, (body,), kind)
|
||
|
||
@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 工具体。
|
||
# 1. 深度:通孔取贯穿深度(保证穿透),盲孔取规格中的深度。
|
||
depth = through_depth_mm if spec.end_condition != "blind" else spec.depth_mm
|
||
result: Solid | None = None
|
||
for start in starts:
|
||
# 2. 每个孔位:以起点为原点、向内方向为轴向,先生成主孔圆柱。
|
||
plane = Plane(origin=_vector(start), z_dir=_vector(inward))
|
||
tool = Solid.make_cylinder(spec.diameter_mm / 2, depth, plane)
|
||
# 3. 沉孔(counterbore):在主孔上并一个更大直径、更浅的短圆柱。
|
||
if spec.counterbore:
|
||
diameter, bore_depth = spec.counterbore
|
||
tool = tool.fuse(Solid.make_cylinder(diameter / 2, bore_depth, plane))
|
||
# 4. 锪孔(countersink):按锥角与口径差推得锥深,并一个上大下小的圆锥。
|
||
if spec.countersink:
|
||
diameter, angle = spec.countersink
|
||
sink_depth = ((diameter - spec.diameter_mm) / 2) / math.tan(angle / 2)
|
||
tool = tool.fuse(Solid.make_cone(diameter / 2, spec.diameter_mm / 2, sink_depth, plane))
|
||
# 5. 汇总所有孔位的工具实体。
|
||
result = self.fuse(result, tool)
|
||
if result is None:
|
||
raise ValueError("hole has no positions")
|
||
return result
|
||
|
||
@staticmethod
|
||
def thread_solid(spec: ThreadSpec) -> Any:
|
||
"""Build a threaded solid segment anchored on ``spec.axis``.
|
||
|
||
The parametric generator constructs the thread in a local +Z frame
|
||
spanning ``[0, length_mm]``. This gate rotates that frame so the
|
||
thread axis lands on ``spec.axis.direction`` with
|
||
``spec.axis.origin_mm`` at the leading end face, keeping placement in
|
||
the adapter and geometry construction in ``parametric_thread.py``.
|
||
"""
|
||
solid = build_thread_solid(spec)
|
||
direction = _vector(spec.axis.direction)
|
||
if abs(direction.X) <= 1e-9 and abs(direction.Y) <= 1e-9:
|
||
# 轴沿 ±Z:X 方向任意即可,螺纹起始相位绕轴无意义。
|
||
frame_x = Vector(1.0, 0.0, 0.0)
|
||
else:
|
||
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
|
||
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
|
||
return solid.moved(Location(plane))
|
||
|
||
@staticmethod
|
||
def bend_solid(spec: BendSpec) -> Any:
|
||
"""Build one bent sheet segment placed on ``spec.frame``.
|
||
|
||
The parametric generator constructs the sheet locally with the first
|
||
wing along +X, its mid-plane spanning +X/+Z (thickness along +Y) and
|
||
the fold (width) axis along +Z. This gate rotates that local frame so
|
||
the first wing lands on ``spec.frame.y_dir`` (= normal x x_dir), the
|
||
sheet thickness on ``spec.frame.normal`` and the width axis on
|
||
``spec.frame.x_dir``.
|
||
"""
|
||
# ``bend_add`` 生成器是可选的几何实现。不能因该模块未随部署产物
|
||
# 提交而让所有非钣金 CDSL 在 adapter import 阶段失效;真正执行
|
||
# 折弯时仍须报出精确的缺失依赖,不能退化为虚构实体。
|
||
try:
|
||
from .parametric_bend import build_bend_solid
|
||
except ModuleNotFoundError as error:
|
||
if error.name != f"{__package__}.parametric_bend":
|
||
raise
|
||
raise RuntimeError(
|
||
"bend_add requires cdsl_engine.parametric_bend.build_bend_solid, "
|
||
"but the generator module is not present in this checkout"
|
||
) from error
|
||
solid = build_bend_solid(spec)
|
||
frame = spec.frame
|
||
plane = Plane(
|
||
origin=_vector(frame.origin_mm),
|
||
x_dir=_vector(frame.y_dir),
|
||
z_dir=_vector(frame.x_dir),
|
||
)
|
||
return solid.moved(Location(plane))
|
||
|
||
@staticmethod
|
||
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
|
||
# 对指定边以给定半径做圆角。
|
||
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.
|
||
|
||
The expansion is based solely on the current B-rep. It never uses a
|
||
global edge set or source stable IDs, and is consequently safe after a
|
||
body mutation invalidates earlier topology objects.
|
||
"""
|
||
# 从种子边出发,沿“共顶点且切线平行”的边链扩展,得到相切连续的一整组边。
|
||
edges = list(body.edges())
|
||
selected = [edge for edge in seeds]
|
||
# 1. 用 is_same 把种子边映射到主体边列表的下标集合。
|
||
selected_indexes = {
|
||
index
|
||
for index, edge in enumerate(edges)
|
||
if any(edge.is_same(seed) for seed in selected)
|
||
}
|
||
if not selected_indexes:
|
||
return []
|
||
|
||
def shared_vertex(first: Edge, second: Edge) -> tuple[float, float] | None:
|
||
# 找两条边共用的端点,返回各自在该端点处的参数位置;无共用端点返回 None。
|
||
first_ends = [(0.0, vertex) for vertex in first.vertices()[:1]] + [(1.0, vertex) for vertex in first.vertices()[-1:]]
|
||
second_ends = [(0.0, vertex) for vertex in second.vertices()[:1]] + [(1.0, vertex) for vertex in second.vertices()[-1:]]
|
||
for first_parameter, first_vertex in first_ends:
|
||
for second_parameter, second_vertex in second_ends:
|
||
if first_vertex.is_same(second_vertex):
|
||
return first_parameter, second_parameter
|
||
return None
|
||
|
||
# 共顶点且端点处切线平行(方向无关)的边即构成相切连续链。
|
||
# Edges sharing a vertex whose tangents are parallel (orientation is
|
||
# irrelevant) are a tangent-continuous chain.
|
||
# 2. BFS 扩展:新加入的边作为候选种子,继续寻找与其相切的下一条边。
|
||
pending = list(selected_indexes)
|
||
while pending:
|
||
current_index = pending.pop()
|
||
for candidate_index, candidate in enumerate(edges):
|
||
if candidate_index in selected_indexes:
|
||
continue
|
||
shared = shared_vertex(edges[current_index], candidate)
|
||
if shared is None:
|
||
continue
|
||
# 比较两条边在共用端点处的切线方向(取绝对值以忽略方向)。
|
||
first_tangent = edges[current_index].tangent_at(shared[0]).normalized()
|
||
second_tangent = candidate.tangent_at(shared[1]).normalized()
|
||
if abs(abs(first_tangent.dot(second_tangent)) - 1.0) <= angular_tolerance:
|
||
selected_indexes.add(candidate_index)
|
||
pending.append(candidate_index)
|
||
# 3. 按下标映射回边对象列表。
|
||
return [edge for index, edge in enumerate(edges) if index in selected_indexes]
|
||
|
||
@staticmethod
|
||
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
|
||
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
|
||
# OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d
|
||
# 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变
|
||
# 倒角结果,因此仅等距倒角优先走原生重载。
|
||
selected = list(edges)
|
||
if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1:
|
||
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
|
||
for edge in selected:
|
||
builder.Add(distance_mm, edge.wrapped)
|
||
builder.Build()
|
||
if builder.IsDone():
|
||
result = Solid(builder.Shape())
|
||
if result.is_valid:
|
||
return result
|
||
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
|
||
|
||
@staticmethod
|
||
def chamfer_with_topology_delta(
|
||
body: Any, distance_mm: float, distance_2_mm: float | None,
|
||
edges: Iterable[Edge], face: Face | None = None,
|
||
) -> tuple[Any, TopologyDelta | None]:
|
||
"""Retain history for the equal-distance single-body chamfer subset."""
|
||
selected = list(edges)
|
||
if distance_2_mm is not None or face is not None or len(Build123dGeometryAdapter.body_solids(body)) != 1:
|
||
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
|
||
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
|
||
for edge in selected:
|
||
builder.Add(distance_mm, edge.wrapped)
|
||
builder.Build()
|
||
if builder.IsDone():
|
||
result = Solid(builder.Shape())
|
||
if result.is_valid:
|
||
return result, Build123dGeometryAdapter._builder_topology_delta(builder, (body,), "chamfer")
|
||
return Build123dGeometryAdapter.chamfer(body, distance_mm, distance_2_mm, selected, face=face), None
|
||
|
||
@staticmethod
|
||
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
|
||
"""Build the removable material for one surface-supported circular chamfer.
|
||
|
||
A regular equal-offset chamfer is first attempted by ``chamfer``. This
|
||
helper only handles the narrow CADFS case where that operation reaches
|
||
a concentric surface split before the requested second offset. The
|
||
explicit shell must contain the limiting circle at the selected plane;
|
||
without that evidence this method deliberately rejects the fallback.
|
||
"""
|
||
if str(edge.geom_type).split(".")[-1].lower() != "circle":
|
||
raise ValueError("surface-limited chamfer requires circular edges")
|
||
try:
|
||
radius = float(edge.radius)
|
||
center = edge.arc_center
|
||
except (TypeError, ValueError) as error:
|
||
raise ValueError("surface-limited chamfer edge has no circle radius") from error
|
||
if radius <= 0:
|
||
raise ValueError("surface-limited chamfer edge radius must be positive")
|
||
|
||
adjacent = [
|
||
face for face in body.faces()
|
||
if any(face_edge.is_same(edge) for face_edge in face.edges())
|
||
]
|
||
planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"]
|
||
cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"]
|
||
if len(planes) != 1 or len(cylinders) != 1:
|
||
raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face")
|
||
plane_face, cylinder_face = planes[0], cylinders[0]
|
||
axis = cylinder_face.axis_of_rotation
|
||
if axis is None:
|
||
raise ValueError("surface-limited chamfer cylinder has no axis")
|
||
axis_direction = axis.direction.normalized()
|
||
if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6:
|
||
raise ValueError("surface-limited chamfer faces are not perpendicular")
|
||
|
||
boundaries = [
|
||
candidate for candidate in plane_face.edges()
|
||
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
|
||
and not candidate.is_same(edge)
|
||
and (candidate.arc_center - center).length <= 1e-6
|
||
]
|
||
if len(boundaries) != 1:
|
||
raise ValueError("surface-limited chamfer plane has no unique concentric support")
|
||
support_edge = boundaries[0]
|
||
support_radius = float(support_edge.radius)
|
||
radial_span = radius - support_radius
|
||
if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6:
|
||
raise ValueError("surface-limited chamfer does not need a constrained outer transition")
|
||
|
||
cylinder_ends = [
|
||
candidate for candidate in cylinder_face.edges()
|
||
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
|
||
and not candidate.is_same(edge)
|
||
and abs(float(candidate.radius) - radius) <= 1e-6
|
||
and (candidate.arc_center - center).length > 1e-6
|
||
]
|
||
if len(cylinder_ends) != 1:
|
||
raise ValueError("surface-limited chamfer cylinder has no unique opposite cap")
|
||
axial_span = cylinder_ends[0].arc_center - center
|
||
if axial_span.length <= distance_mm + 1e-6:
|
||
raise ValueError("surface-limited chamfer exceeds the selected cylindrical face")
|
||
direction = axial_span.normalized()
|
||
|
||
supported = False
|
||
for surface in surfaces:
|
||
for face in surface.faces():
|
||
if str(face.geom_type).split(".")[-1].lower() != "cylinder":
|
||
continue
|
||
surface_axis = face.axis_of_rotation
|
||
if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6:
|
||
continue
|
||
if abs(float(face.radius) - support_radius) > 1e-6:
|
||
continue
|
||
if any(
|
||
str(boundary.geom_type).split(".")[-1].lower() == "circle"
|
||
and abs(float(boundary.radius) - support_radius) <= 1e-6
|
||
and (boundary.arc_center - center).length <= 1e-6
|
||
for boundary in face.edges()
|
||
):
|
||
supported = True
|
||
break
|
||
if supported:
|
||
break
|
||
if not supported:
|
||
raise ValueError("surface-limited chamfer has no explicit surface support")
|
||
|
||
frame_x = edge.tangent_at(0.0).normalized()
|
||
first_length = distance_mm - radial_span
|
||
outer_radius = radius + max(distance_mm, 1.0)
|
||
start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction)
|
||
cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction)
|
||
outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane)
|
||
core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse(
|
||
Solid.make_cone(support_radius, radius, radial_span, cone_plane),
|
||
)
|
||
return outer.cut(core)
|
||
|
||
@staticmethod
|
||
def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any:
|
||
# 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边
|
||
# 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响
|
||
# 另一条 source selector。
|
||
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
|
||
raise ValueError("surface-limited chamfer requires one solid body")
|
||
selected = list(edges)
|
||
if not selected:
|
||
raise ValueError("surface-limited chamfer requires at least one edge")
|
||
surface_members = list(surfaces)
|
||
tools = [
|
||
Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members)
|
||
for edge in selected
|
||
]
|
||
result = body
|
||
for tool in tools:
|
||
result = result.cut(tool)
|
||
if not isinstance(result, Solid) or not result.is_valid:
|
||
raise ValueError("surface-limited chamfer produced an invalid shape")
|
||
if result.volume >= body.volume - 1e-6:
|
||
raise ValueError("surface-limited chamfer removed no material")
|
||
return result
|
||
|
||
@staticmethod
|
||
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
|
||
result, _delta = Build123dGeometryAdapter.shell_with_topology_delta(
|
||
body, faces, thickness_mm, inward=inward,
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def shell_with_topology_delta(
|
||
body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True,
|
||
) -> tuple[Any, TopologyDelta]:
|
||
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
|
||
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
|
||
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
|
||
selected = list(faces)
|
||
if not selected:
|
||
raise ValueError("shell requires at least one face to remove")
|
||
thickness = float(thickness_mm)
|
||
if thickness <= 0:
|
||
raise ValueError("shell thickness_mm must be > 0")
|
||
solids = Build123dGeometryAdapter.body_solids(body)
|
||
if len(solids) != 1:
|
||
raise ValueError("shell adapter requires exactly one target solid")
|
||
closing_faces = TopTools_ListOfShape()
|
||
for face in selected:
|
||
closing_faces.Append(face.wrapped)
|
||
builder = BRepOffsetAPI_MakeThickSolid()
|
||
builder.MakeThickSolidByJoin(
|
||
solids[0].wrapped,
|
||
closing_faces,
|
||
-thickness if inward else thickness,
|
||
1e-6,
|
||
BRepOffset_Skin,
|
||
False,
|
||
False,
|
||
GeomAbs_Arc,
|
||
False,
|
||
)
|
||
builder.Build()
|
||
if not builder.IsDone():
|
||
raise ValueError("OCC shell operation did not complete")
|
||
result = Solid(builder.Shape())
|
||
if not result.is_valid:
|
||
raise ValueError("OCC shell operation produced an invalid shape")
|
||
return result, Build123dGeometryAdapter._shell_topology_delta(builder, solids[0], selected)
|
||
|
||
@staticmethod
|
||
def sweep(
|
||
section: Face | Wire,
|
||
spine: Edge | Wire,
|
||
*,
|
||
inner_wires: list[Wire] | None = None,
|
||
make_solid: bool = True,
|
||
is_frenet: bool = False,
|
||
transition: Any = None,
|
||
) -> Solid:
|
||
result, _delta = Build123dGeometryAdapter.sweep_with_topology_delta(
|
||
section, spine, inner_wires=inner_wires, make_solid=make_solid,
|
||
is_frenet=is_frenet, transition=transition,
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _sweep_without_topology_delta(
|
||
section: Face | Wire,
|
||
spine: Edge | Wire,
|
||
*,
|
||
inner_wires: list[Wire] | None = None,
|
||
make_solid: bool = True,
|
||
is_frenet: bool = False,
|
||
transition: Any = None,
|
||
) -> Solid:
|
||
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
|
||
# 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True
|
||
# 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需);
|
||
# transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。
|
||
sweep_options: dict[str, Any] = {
|
||
"inner_wires": inner_wires,
|
||
"make_solid": make_solid,
|
||
"is_frenet": is_frenet,
|
||
}
|
||
if transition is not None:
|
||
sweep_options["transition"] = transition
|
||
result = Solid.sweep(section, spine, **sweep_options)
|
||
if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9):
|
||
# OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状,
|
||
# 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。
|
||
raise ValueError("OCC sweep operation did not produce a solid")
|
||
return result
|
||
|
||
@staticmethod
|
||
def sweep_with_topology_delta(
|
||
section: Face | Wire,
|
||
spine: Edge | Wire,
|
||
*,
|
||
inner_wires: list[Wire] | None = None,
|
||
make_solid: bool = True,
|
||
is_frenet: bool = False,
|
||
transition: Any = None,
|
||
) -> tuple[Solid, TopologyDelta | None]:
|
||
"""Sweep one simple profile through its direct pipe-shell builder.
|
||
|
||
The existing native path remains authoritative for hollow profiles,
|
||
transition variants and non-solid output. Those cases can still
|
||
execute, but their final builder provenance is not available through
|
||
this bounded contract.
|
||
"""
|
||
if (
|
||
not isinstance(section, Face)
|
||
or section.inner_wires()
|
||
or inner_wires
|
||
or not make_solid
|
||
or transition is not None
|
||
):
|
||
return Build123dGeometryAdapter._sweep_without_topology_delta(
|
||
section, spine, inner_wires=inner_wires, make_solid=make_solid,
|
||
is_frenet=is_frenet, transition=transition,
|
||
), None
|
||
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],
|
||
*,
|
||
start_tangent: Vector3 | None = None,
|
||
end_tangent: Vector3 | None = None,
|
||
parameters: list[float] | None = None,
|
||
) -> Edge | Wire:
|
||
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
|
||
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
|
||
vertices = [_vector(point) for point in points]
|
||
if len(vertices) < 2:
|
||
raise ValueError("sweep path needs at least two points")
|
||
if len(vertices) == 2:
|
||
if start_tangent is not None or end_tangent is not None or parameters is not None:
|
||
raise ValueError("line sweep path does not accept B-spline tangents")
|
||
return Edge.make_line(vertices[0], vertices[1])
|
||
if (start_tangent is None) != (end_tangent is None):
|
||
raise ValueError("sweep B-spline path requires both endpoint tangents")
|
||
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
|
||
if parameters is not None and len(parameters) != len(vertices):
|
||
raise ValueError("sweep B-spline path parameters must match point count")
|
||
direction = vertices[-1] - vertices[0]
|
||
tolerance = 1e-9 * max(1.0, direction.length)
|
||
collinear_points = direction.length > tolerance and all(
|
||
(point - vertices[0]).cross(direction).length <= tolerance
|
||
for point in vertices[1:-1]
|
||
)
|
||
collinear_tangents = tangents is None or all(
|
||
tangent.cross(direction).length <= tolerance and tangent.dot(direction) > tolerance
|
||
for tangent in tangents
|
||
)
|
||
if collinear_points and collinear_tangents:
|
||
# OCC 对完全共线的插值 B-spline 做实体 sweep 时可能无限求解。
|
||
# 此处的点列和端切线没有曲率信息,几何上严格等价于一条直线;
|
||
# 仅在同向条件成立时退化,反向切线仍保留 B-spline 语义。
|
||
return Edge.make_line(vertices[0], vertices[-1])
|
||
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
|
||
|
||
@staticmethod
|
||
def helix_path(
|
||
radius_mm: float,
|
||
pitch_mm: float,
|
||
turns: float | None = None,
|
||
*,
|
||
height_mm: float | None = None,
|
||
lefthand: bool = False,
|
||
) -> Edge:
|
||
# 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。
|
||
# 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。
|
||
# turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键,
|
||
# 按高度适配 parametric_thread(length + 两端余量)。构造与 parametric_thread
|
||
# 原 Edge.make_helix 同源,保证生成器复用后逐位一致。
|
||
if pitch_mm <= 0:
|
||
raise ValueError("helix pitch must be positive")
|
||
if (turns is None) == (height_mm is None):
|
||
raise ValueError("helix_path needs exactly one of turns or height_mm")
|
||
if turns is not None:
|
||
if turns <= 0:
|
||
raise ValueError("helix turns must be positive")
|
||
height = turns * pitch_mm
|
||
else:
|
||
if height_mm <= 0:
|
||
raise ValueError("helix height must be positive")
|
||
height = height_mm
|
||
return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand)
|
||
|
||
@staticmethod
|
||
def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any:
|
||
# 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。
|
||
# 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。
|
||
if count < 1:
|
||
raise ValueError("pattern count must be at least 1")
|
||
if count == 1 or spacing_mm == 0:
|
||
return body
|
||
vector = _vector(direction)
|
||
if vector.length <= 1e-12:
|
||
raise ValueError("pattern direction must be non-zero")
|
||
unit = vector.normalized()
|
||
result = body
|
||
for index in range(1, count):
|
||
offset = unit * (index * spacing_mm)
|
||
result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z))))
|
||
return result
|
||
|
||
@staticmethod
|
||
def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any:
|
||
# 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction)
|
||
# 旋转 sweep_angle_deg 均布 count 份并合并。
|
||
if count < 1:
|
||
raise ValueError("pattern count must be at least 1")
|
||
if count == 1 or sweep_angle_deg == 0:
|
||
return body
|
||
origin = _vector(axis.origin_mm)
|
||
direction = _vector(axis.direction)
|
||
if direction.length <= 1e-12:
|
||
raise ValueError("pattern rotation axis must be non-zero")
|
||
unit = direction.normalized()
|
||
step_angle = sweep_angle_deg / count
|
||
# 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”,
|
||
# 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。
|
||
to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z)))
|
||
back = Location((float(origin.X), float(origin.Y), float(origin.Z)))
|
||
result = body
|
||
for index in range(1, count):
|
||
rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle)
|
||
instance = body.moved(to_origin).moved(rotation).moved(back)
|
||
result = result.fuse(instance)
|
||
return result
|
||
|
||
@staticmethod
|
||
def mirror(body: Any, plane: PlaneSpec) -> Any:
|
||
# 沿给定平面镜像主体。
|
||
return body.mirror(Build123dGeometryAdapter.plane(plane))
|
||
|
||
@staticmethod
|
||
def export(body: Any, path: str) -> None:
|
||
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
|
||
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
|
||
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
|
||
# B-rep 的拓扑和导出体积。
|
||
segments = []
|
||
for solid in Build123dGeometryAdapter.body_solids(body):
|
||
if not isinstance(solid, Solid):
|
||
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
|
||
# STEP exporter,才能保留 pure-surface feature history 的面。
|
||
segments.append(solid); continue
|
||
has_revolution = any(
|
||
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
|
||
for face in solid.faces()
|
||
)
|
||
if not has_revolution:
|
||
segments.append(solid); continue
|
||
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
|
||
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
|
||
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
|
||
segmented = Solid(divider.Result())
|
||
if not segmented.is_valid:
|
||
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
|
||
segments.append(segmented)
|
||
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
|
||
|
||
@staticmethod
|
||
def body_solids(body: Any) -> list[Any]:
|
||
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
|
||
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
|
||
try:
|
||
solids = list(body.solids())
|
||
except Exception:
|
||
return [body] if body is not None else []
|
||
return solids or ([body] if body is not None else [])
|
||
|
||
@staticmethod
|
||
def body_geometry(body: Any) -> dict[str, Any]:
|
||
# 汇总主体基本几何信息:包围盒与体积。
|
||
bbox = body.bounding_box()
|
||
# A feature history can contain several body IDs while still ending in
|
||
# one connected solid (for example, a base extrusion followed by hole
|
||
# cuts). Count the current OCC result, never feature history entries.
|
||
solids = list(body.solids()) if hasattr(body, "solids") else [body]
|
||
return {
|
||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||
"volume_mm3": float(body.volume),
|
||
"solid_count": len(solids),
|
||
}
|
||
|
||
@staticmethod
|
||
def surface_geometry(surface: Any) -> dict[str, Any]:
|
||
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
|
||
bbox = surface.bounding_box()
|
||
return {
|
||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||
"area_mm2": float(surface.area),
|
||
"face_count": len(surface.faces()),
|
||
}
|
||
|
||
@staticmethod
|
||
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
|
||
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
|
||
records: list[TopologyRecord] = []
|
||
faces = list(body.faces())
|
||
edges = list(body.edges())
|
||
vertices = list(body.vertices())
|
||
|
||
def index_for(shape: Any, candidates: list[Any]) -> int | None:
|
||
"""Map a subshape returned by a face/edge back to body topology."""
|
||
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
|
||
for index, candidate in enumerate(candidates):
|
||
if shape.is_same(candidate):
|
||
return index
|
||
return None
|
||
|
||
# 1. 建立邻接索引:每条边关联的面集合(edge_faces)。
|
||
edge_faces: list[set[int]] = [set() for _edge in edges]
|
||
for face_index, face in enumerate(faces):
|
||
for edge in face.edges():
|
||
edge_index = index_for(edge, edges)
|
||
if edge_index is not None:
|
||
edge_faces[edge_index].add(face_index)
|
||
# 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。
|
||
vertex_edges: list[set[int]] = [set() for _vertex in vertices]
|
||
for edge_index, edge in enumerate(edges):
|
||
for vertex in edge.vertices():
|
||
vertex_index = index_for(vertex, vertices)
|
||
if vertex_index is not None:
|
||
vertex_edges[vertex_index].add(edge_index)
|
||
|
||
def edge_signature(edge_index: int) -> str:
|
||
# 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。
|
||
edge = edges[edge_index]
|
||
return ":".join((
|
||
str(edge.geom_type).split(".")[-1].lower(),
|
||
f"{float(edge.length):.6f}",
|
||
str(len(edge_faces[edge_index])),
|
||
))
|
||
|
||
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
|
||
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
|
||
# 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从
|
||
# 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据
|
||
# author 传入的 blind-depth 文字猜测“贯穿”。
|
||
face_edge_indexes: list[set[int]] = []
|
||
face_geometries: list[dict[str, Any]] = []
|
||
for index, face in enumerate(faces):
|
||
bbox = face.bounding_box()
|
||
center = face.center()
|
||
normal = face.normal_at()
|
||
boundary_edge_indexes = [
|
||
edge_index
|
||
for edge in face.edges()
|
||
if (edge_index := index_for(edge, edges)) is not None
|
||
]
|
||
geometry = {
|
||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||
"center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z],
|
||
"area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(),
|
||
"adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes),
|
||
}
|
||
if geometry["surface_type"] == "plane":
|
||
plane_normal, plane_offset = canonical_plane_signature(
|
||
(normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z),
|
||
)
|
||
geometry["plane_normal"] = list(plane_normal)
|
||
geometry["plane_offset_mm"] = plane_offset
|
||
boundary_loops: list[list[list[float]]] = []
|
||
for wire in face.wires():
|
||
samples: list[list[float]] = []
|
||
for edge in wire.edges():
|
||
curve_type = str(edge.geom_type).split(".")[-1].lower()
|
||
fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0]
|
||
for fraction in fractions:
|
||
point = edge.position_at(fraction)
|
||
value = [float(point.X), float(point.Y), float(point.Z)]
|
||
if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12:
|
||
samples.append(value)
|
||
if len(samples) >= 3:
|
||
boundary_loops.append(samples)
|
||
if boundary_loops:
|
||
geometry["boundary_loops_mm"] = boundary_loops
|
||
elif geometry["surface_type"] in {"cylinder", "cone"}:
|
||
axis = face.axis_of_rotation
|
||
if axis is None:
|
||
# Build123d can omit this optional OCC property for valid
|
||
# swept rotational faces. Keep their generic B-rep record
|
||
# so a selector-free workflow remains executable; do not
|
||
# invent axis/radius evidence for an axis-based selector.
|
||
pass
|
||
else:
|
||
direction = axis.direction
|
||
origin = axis.position
|
||
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
|
||
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
|
||
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
|
||
if geometry["surface_type"] == "cone":
|
||
boundary_radii: list[float] = []
|
||
for edge in face.edges():
|
||
if str(edge.geom_type).split(".")[-1].lower() != "circle":
|
||
continue
|
||
try:
|
||
boundary_radii.append(float(edge.radius))
|
||
except ValueError:
|
||
continue
|
||
geometry["boundary_radii_mm"] = sorted(boundary_radii)
|
||
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
|
||
# ``through`` alone describes a cylinder spanning two opposed
|
||
# planar faces. That applies to both a through bore and the
|
||
# outside wall of a cylindrical extrusion. Classify the B-rep
|
||
# face by its oriented normal so downstream acceptance claims
|
||
# can prove holes without mistaking an exterior wall for one.
|
||
unit_axis = (direction.X, direction.Y, direction.Z)
|
||
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
|
||
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
|
||
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
|
||
radial_length = sum(component * component for component in radial) ** 0.5
|
||
if geometry["surface_type"] == "cylinder":
|
||
# OCC can report a cylinder surface with ``radius=None``
|
||
# after a non-planar-side Boolean cut. The face centre is
|
||
# still on that cylinder, so its perpendicular distance to
|
||
# the rotation axis is an equivalent measured radius. Do
|
||
# not fail an otherwise valid build merely because that
|
||
# optional OCC convenience property is absent.
|
||
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
|
||
geometry["radius_mm"] = float(raw_cylinder_radius)
|
||
elif radial_length > 1e-9:
|
||
geometry["radius_mm"] = radial_length
|
||
if radial_length > 1e-9:
|
||
normal_components = (normal.X, normal.Y, normal.Z)
|
||
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
|
||
geometry["radial_normal_alignment"] = alignment
|
||
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
|
||
else:
|
||
geometry["cylinder_role"] = "unknown"
|
||
face_edge_indexes.append(set(boundary_edge_indexes))
|
||
face_geometries.append(geometry)
|
||
records.append(TopologyRecord(
|
||
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
|
||
geometry=geometry,
|
||
))
|
||
plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"]
|
||
|
||
def directly_linked_planes(face_index: int) -> list[int]:
|
||
return [
|
||
plane_index
|
||
for plane_index in plane_indexes
|
||
if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index])
|
||
]
|
||
|
||
def same_inner_rotational_channel(first: int, second: int) -> bool:
|
||
"""Whether two inner rotational faces share one B-rep bore channel."""
|
||
if not face_edge_indexes[first].intersection(face_edge_indexes[second]):
|
||
return False
|
||
left, right = face_geometries[first], face_geometries[second]
|
||
if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner":
|
||
return False
|
||
left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction")
|
||
left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm")
|
||
if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)):
|
||
return False
|
||
try:
|
||
left_direction = tuple(float(value) for value in left_axis)
|
||
right_direction = tuple(float(value) for value in right_axis)
|
||
offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3))
|
||
except (TypeError, ValueError):
|
||
return False
|
||
alignment = sum(left_direction[index] * right_direction[index] for index in range(3))
|
||
if abs(alignment) < 1.0 - 1e-6:
|
||
return False
|
||
axial_offset = sum(offset[index] * left_direction[index] for index in range(3))
|
||
radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3))
|
||
return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5
|
||
|
||
inner_rotational_indexes = [
|
||
index
|
||
for index, geometry in enumerate(face_geometries)
|
||
if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner"
|
||
]
|
||
|
||
def channel_plane_indexes(start: int) -> list[int]:
|
||
"""Collect endpoint planes through joined, co-axial inner faces.
|
||
|
||
A countersink or counterbore splits a physical bore into a cone and
|
||
a cylinder. The cylinder has only one direct planar neighbour, so
|
||
direct adjacency alone cannot prove that the complete channel exits
|
||
the part. Traverse shared B-rep edges only across co-axial inner
|
||
rotational faces, then inspect the channel's actual plane ends.
|
||
"""
|
||
pending = [start]
|
||
visited: set[int] = set()
|
||
endpoints: set[int] = set()
|
||
while pending:
|
||
index = pending.pop()
|
||
if index in visited:
|
||
continue
|
||
visited.add(index)
|
||
endpoints.update(directly_linked_planes(index))
|
||
pending.extend(
|
||
candidate
|
||
for candidate in inner_rotational_indexes
|
||
if candidate not in visited and same_inner_rotational_channel(index, candidate)
|
||
)
|
||
return sorted(endpoints)
|
||
|
||
def spans_opposed_planes(linked: list[int], axis: Any) -> bool:
|
||
if not isinstance(axis, list) or len(axis) != 3:
|
||
return False
|
||
try:
|
||
direction = tuple(float(value) for value in axis)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return any(
|
||
sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99
|
||
and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second))
|
||
for first in linked
|
||
for second in linked
|
||
if first < second
|
||
)
|
||
|
||
for index, geometry in enumerate(face_geometries):
|
||
if geometry["surface_type"] != "cylinder":
|
||
continue
|
||
linked = directly_linked_planes(index)
|
||
geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked]
|
||
channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked
|
||
geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked]
|
||
geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction"))
|
||
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
|
||
for index, edge in enumerate(edges):
|
||
bbox = edge.bounding_box()
|
||
center = edge.center()
|
||
vertices = edge.vertices()
|
||
geometry = {
|
||
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
|
||
"center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length),
|
||
"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])
|
||
records.append(TopologyRecord(
|
||
record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge,
|
||
geometry=geometry,
|
||
))
|
||
# 5. 导出顶点记录:含坐标与关联边数。
|
||
for index, vertex in enumerate(vertices):
|
||
point = [vertex.X, vertex.Y, vertex.Z]
|
||
records.append(TopologyRecord(
|
||
record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex,
|
||
geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])},
|
||
))
|
||
return records
|