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

2176 lines
116 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""build123d/OCC implementation of the runtime-neutral geometry adapter."""
from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import AngularDirection, Axis, Compound, Edge, Face, 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.sweepmake_solid=True 封盖成体;is_frenet=True
# 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需);
# transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。
sweep_options: dict[str, Any] = {
"inner_wires": inner_wires,
"make_solid": make_solid,
"is_frenet": is_frenet,
}
if transition is not None:
sweep_options["transition"] = transition
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_threadlength + 两端余量)。构造与 parametric_thread
# 原 Edge.make_helix 同源,保证生成器复用后逐位一致。
if pitch_mm <= 0:
raise ValueError("helix pitch must be positive")
if (turns is None) == (height_mm is None):
raise ValueError("helix_path needs exactly one of turns or height_mm")
if turns is not None:
if turns <= 0:
raise ValueError("helix turns must be positive")
height = turns * pitch_mm
else:
if height_mm <= 0:
raise ValueError("helix height must be positive")
height = height_mm
return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand)
@staticmethod
def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any:
# 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。
# 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or spacing_mm == 0:
return body
vector = _vector(direction)
if vector.length <= 1e-12:
raise ValueError("pattern direction must be non-zero")
unit = vector.normalized()
result = body
for index in range(1, count):
offset = unit * (index * spacing_mm)
result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z))))
return result
@staticmethod
def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any:
# 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction
# 旋转 sweep_angle_deg 均布 count 份并合并。
if count < 1:
raise ValueError("pattern count must be at least 1")
if count == 1 or sweep_angle_deg == 0:
return body
origin = _vector(axis.origin_mm)
direction = _vector(axis.direction)
if direction.length <= 1e-12:
raise ValueError("pattern rotation axis must be non-zero")
unit = direction.normalized()
step_angle = sweep_angle_deg / count
# 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”,
# 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。
to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z)))
back = Location((float(origin.X), float(origin.Y), float(origin.Z)))
result = body
for index in range(1, count):
rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle)
instance = body.moved(to_origin).moved(rotation).moved(back)
result = result.fuse(instance)
return result
@staticmethod
def mirror(body: Any, plane: PlaneSpec) -> Any:
# 沿给定平面镜像主体。
return body.mirror(Build123dGeometryAdapter.plane(plane))
@staticmethod
def export(body: Any, path: str) -> None:
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
# B-rep 的拓扑和导出体积。
segments = []
for solid in Build123dGeometryAdapter.body_solids(body):
if not isinstance(solid, Solid):
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
# STEP exporter,才能保留 pure-surface feature history 的面。
segments.append(solid); continue
has_revolution = any(
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
for face in solid.faces()
)
if not has_revolution:
segments.append(solid); continue
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
segmented = Solid(divider.Result())
if not segmented.is_valid:
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
segments.append(segmented)
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
try:
solids = list(body.solids())
except Exception:
return [body] if body is not None else []
return solids or ([body] if body is not None else [])
@staticmethod
def body_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积。
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