791 lines
42 KiB
Python
791 lines
42 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, Helix, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
|
||
|
||
from .parametric_thread import build_thread_solid
|
||
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, 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 _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]
|
||
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。
|
||
return Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue]
|
||
|
||
@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)
|
||
return Build123dGeometryAdapter._coerce_single_or_compound(
|
||
trimmed, empty_error="extent target produced an empty trimmed solid",
|
||
)
|
||
|
||
@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)))
|
||
|
||
@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",
|
||
)
|
||
|
||
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 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 sweep(section: Face, spine: Edge | Wire) -> Solid:
|
||
# 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
|
||
return Solid.sweep(section, spine)
|
||
|
||
@staticmethod
|
||
def sweep_path(points: Iterable[Vector3]) -> Wire:
|
||
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。
|
||
vertices = [_vector(point) for point in points]
|
||
if len(vertices) < 2:
|
||
raise ValueError("sweep path needs at least two points")
|
||
return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)])
|
||
|
||
@staticmethod
|
||
def helix_path(radius_mm: float, pitch_mm: float, turns: float, *, lefthand: bool = False) -> Edge:
|
||
# 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。
|
||
# 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。
|
||
if pitch_mm <= 0:
|
||
raise ValueError("helix pitch must be positive")
|
||
if turns <= 0:
|
||
raise ValueError("helix turns must be positive")
|
||
helix = Helix(pitch=pitch_mm, height=turns * pitch_mm, radius=radius_mm, lefthand=lefthand)
|
||
return helix.edges()[0]
|
||
|
||
@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:
|
||
# 将主体导出为 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()
|
||
# 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 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:
|
||
raise ValueError("rotational face is missing an axis")
|
||
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 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
|