Files
cdsl-cad/backend/engine/cdsl_engine/build123d_adapter.py
T
ganjihong 9e5aa48c3d feat(engine): 支持多实体 body 与 selector 持久化
- 多实体主体拆分为独立 body_id,支持前缀匹配解析
- 重建结果新增 solid_count 字段
- fillet/chamfer 后 selector stable_id 可解析到演化后继
2026-08-27 18:08:54 +08:00

500 lines
26 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 Axis, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, 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."""
@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:
# 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
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
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 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) < 2:
continue
face = Face(self._wire(outer))
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
result.append(face.make_holes(holes) if holes else face)
return result
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
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 extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction))
@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)。
unit = _vector(direction).normalized()
hits = [
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
for point in Build123dGeometryAdapter.profile_sample_points(face)
]
distances = [value for value in hits if value is not None]
if not distances:
raise ValueError("extent target is not reached by the profile")
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
if isinstance(trimmed, ShapeList):
members = list(trimmed)
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。
trimmed = members[0] if len(members) == 1 else Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue]
if trimmed is None or (hasattr(trimmed, "is_empty") and trimmed.is_empty()):
raise ValueError("extent target produced an empty trimmed solid")
return trimmed
@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 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 _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 fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
return solid if body is None else body.fuse(solid)
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
return body.cut(tool)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
# 以给定球心与半径生成球体实体。
return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
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 fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
# 对指定边以给定半径做圆角。
return body.fillet(radius_mm, list(edges))
@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 提供时形成非对称倒角。
return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
@staticmethod
def mirror(body: Any, plane: PlaneSpec) -> Any:
# 沿给定平面镜像主体。
return body.mirror(Build123dGeometryAdapter.plane(plane))
@staticmethod
def export(body: Any, path: str) -> None:
# 将主体导出为 STEP 文件。
export_step(body, 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()
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),
}
@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. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
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
records.append(TopologyRecord(
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
geometry=geometry,
))
# 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 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