"""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, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCP.BRep import BRep_Tool from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer from OCP.BRepOffset import BRepOffset_Skin from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol from OCP.Geom import Geom_SurfaceOfRevolution from OCP.GeomAbs import GeomAbs_Arc from OCP.LocOpe import LocOpe_DPrism from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle from OCP.TopAbs import TopAbs_SHELL from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec from .parametric_bend import build_bend_solid from .parametric_gears import build_gear_solid, build_rack_solid from .parametric_thread import build_thread_solid from .runtime_types import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, 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: # 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。 built: list[Edge] = [] for edge in edges: if edge.get("type") == "bspline": points = [_vector(point) for point in edge.get("points_mm") or []] if len(points) < 3: raise ValueError("bspline contour edge needs at least 3 points") parameters = edge.get("parameters") start_tangent = edge.get("start_tangent_mm") end_tangent = edge.get("end_tangent_mm") if (start_tangent is None) != (end_tangent is None): raise ValueError("bspline contour edge requires both endpoint tangents") built.append(Edge.make_spline( points, tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None, periodic=bool(edge.get("periodic")), parameters=[float(value) for value in parameters] if parameters is not None else None, 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 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 {} 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(self, sketches: list[dict[str, Any]]) -> Solid: """由多条简单闭合草图轮廓生成实体放样。""" 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 Solid.make_loft(wires) 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 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 同向时始终优先使用它。 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: return result return Solid.extrude_taper(face, _vector(direction), taper_deg) @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: # 布尔并:没有既有主体时,直接以该实体作为新主体。 # 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。 if body is None: return solid # 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: 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 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: # 从主体上减去工具实体。 return Build123dGeometryAdapter._coerce_single_or_compound( body.cut(tool), empty_error="OCC cut operation produced no shape", ) @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 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``. """ 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 gear_solid(spec: GearSpec) -> Any: """Build an involute gear placed on ``spec.axis``. The parametric generator constructs the gear in a local +Z frame spanning ``z in [0, width_mm]``. This gate rotates that frame so the gear axis lands on ``spec.axis.direction`` with ``spec.axis.origin_mm`` at the centre of the ``z = 0`` end face (same placement contract as ``thread_solid``). """ solid = build_gear_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 rack_solid(spec: RackSpec) -> Any: """Build a rack placed on ``spec.axis``. The parametric generator constructs the rack locally with the length along +X, thickness along +Y and the teeth pointing along +Z (the root/backing plane spans +X/+Y at ``z = 0``). This gate maps local +Z onto ``spec.axis.direction`` (the direction the teeth point) with ``spec.axis.origin_mm`` at the length-start / thickness-start corner; the length direction (+X) is a deterministic orthogonal of the tooth direction. """ solid = build_rack_solid(spec) direction = _vector(spec.axis.direction) if abs(direction.Z) <= 0.9: frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized() else: frame_x = Vector(1.0, 0.0, 0.0) 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 提供时形成非对称倒角。 # 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 _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: # 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 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 @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: # 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。 # 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True # 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需); # transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。 sweep_options: dict[str, Any] = { "inner_wires": inner_wires, "make_solid": make_solid, "is_frenet": is_frenet, } if transition is not None: sweep_options["transition"] = transition result = Solid.sweep(section, spine, **sweep_options) if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9): # OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状, # 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。 raise ValueError("OCC sweep operation did not produce a solid") return result @staticmethod def sweep_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") return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False) @staticmethod def helix_path( radius_mm: float, pitch_mm: float, turns: float | None = None, *, height_mm: float | None = None, lefthand: bool = False, ) -> Edge: # 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。 # 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。 # turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键, # 按高度适配 parametric_thread(length + 两端余量)。构造与 parametric_thread # 原 Edge.make_helix 同源,保证生成器复用后逐位一致。 if pitch_mm <= 0: raise ValueError("helix pitch must be positive") if (turns is None) == (height_mm is None): raise ValueError("helix_path needs exactly one of turns or height_mm") if turns is not None: if turns <= 0: raise ValueError("helix turns must be positive") height = turns * pitch_mm else: if height_mm <= 0: raise ValueError("helix height must be positive") height = height_mm return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand) @staticmethod def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any: # 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。 # 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。 if count < 1: raise ValueError("pattern count must be at least 1") if count == 1 or spacing_mm == 0: return body vector = _vector(direction) if vector.length <= 1e-12: raise ValueError("pattern direction must be non-zero") unit = vector.normalized() result = body for index in range(1, count): offset = unit * (index * spacing_mm) result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z)))) return result @staticmethod def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any: # 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction) # 旋转 sweep_angle_deg 均布 count 份并合并。 if count < 1: raise ValueError("pattern count must be at least 1") if count == 1 or sweep_angle_deg == 0: return body origin = _vector(axis.origin_mm) direction = _vector(axis.direction) if direction.length <= 1e-12: raise ValueError("pattern rotation axis must be non-zero") unit = direction.normalized() step_angle = sweep_angle_deg / count # 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”, # 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。 to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z))) back = Location((float(origin.X), float(origin.Y), float(origin.Z))) result = body for index in range(1, count): rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle) instance = body.moved(to_origin).moved(rotation).moved(back) result = result.fuse(instance) return result @staticmethod def mirror(body: Any, plane: PlaneSpec) -> Any: # 沿给定平面镜像主体。 return body.mirror(Build123dGeometryAdapter.plane(plane)) @staticmethod def export(body: Any, path: str) -> None: # OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会 # 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的 # 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余 # B-rep 的拓扑和导出体积。 segments = [] for solid in Build123dGeometryAdapter.body_solids(body): if not isinstance(solid, Solid): # Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给 # STEP exporter,才能保留 pure-surface feature history 的面。 segments.append(solid); continue has_revolution = any( BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s()) for face in solid.faces() ) if not has_revolution: segments.append(solid); continue divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped) divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5) if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed") segmented = Solid(divider.Result()) if not segmented.is_valid: raise ValueError("STEP revolution surface segmentation produced an invalid solid") segments.append(segmented) export_step(segments[0] if len(segments) == 1 else Compound(segments), path) @staticmethod def body_solids(body: Any) -> list[Any]: # 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。 # build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。 try: solids = list(body.solids()) except Exception: return [body] if body is not None else [] return solids or ([body] if body is not None else []) @staticmethod def body_geometry(body: Any) -> dict[str, Any]: # 汇总主体基本几何信息:包围盒与体积。 bbox = body.bounding_box() # A feature history can contain several body IDs while still ending in # one connected solid (for example, a base extrusion followed by hole # cuts). Count the current OCC result, never feature history entries. solids = list(body.solids()) if hasattr(body, "solids") else [body] return { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "volume_mm3": float(body.volume), "solid_count": len(solids), } @staticmethod def surface_geometry(surface: Any) -> dict[str, Any]: # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 bbox = surface.bounding_box() return { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "area_mm2": float(surface.area), "face_count": len(surface.faces()), } @staticmethod def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 records: list[TopologyRecord] = [] faces = list(body.faces()) edges = list(body.edges()) vertices = list(body.vertices()) def index_for(shape: Any, candidates: list[Any]) -> int | None: """Map a subshape returned by a face/edge back to body topology.""" # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 for index, candidate in enumerate(candidates): if shape.is_same(candidate): return index return None # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 edge_faces: list[set[int]] = [set() for _edge in edges] for face_index, face in enumerate(faces): for edge in face.edges(): edge_index = index_for(edge, edges) if edge_index is not None: edge_faces[edge_index].add(face_index) # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 vertex_edges: list[set[int]] = [set() for _vertex in vertices] for edge_index, edge in enumerate(edges): for vertex in edge.vertices(): vertex_index = index_for(vertex, vertices) if vertex_index is not None: vertex_edges[vertex_index].add(edge_index) def edge_signature(edge_index: int) -> str: # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 edge = edges[edge_index] return ":".join(( str(edge.geom_type).split(".")[-1].lower(), f"{float(edge.length):.6f}", str(len(edge_faces[edge_index])), )) # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 # author 传入的 blind-depth 文字猜测“贯穿”。 face_edge_indexes: list[set[int]] = [] face_geometries: list[dict[str, Any]] = [] for index, face in enumerate(faces): bbox = face.bounding_box() center = face.center() normal = face.normal_at() boundary_edge_indexes = [ edge_index for edge in face.edges() if (edge_index := index_for(edge, edges)) is not None ] geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), } if geometry["surface_type"] == "plane": plane_normal, plane_offset = canonical_plane_signature( (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), ) geometry["plane_normal"] = list(plane_normal) geometry["plane_offset_mm"] = plane_offset boundary_loops: list[list[list[float]]] = [] for wire in face.wires(): samples: list[list[float]] = [] for edge in wire.edges(): curve_type = str(edge.geom_type).split(".")[-1].lower() fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] for fraction in fractions: point = edge.position_at(fraction) value = [float(point.X), float(point.Y), float(point.Z)] if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: samples.append(value) if len(samples) >= 3: boundary_loops.append(samples) if boundary_loops: geometry["boundary_loops_mm"] = boundary_loops elif geometry["surface_type"] in {"cylinder", "cone"}: axis = face.axis_of_rotation if axis is None: # Build123d can omit this optional OCC property for valid # swept rotational faces. Keep their generic B-rep record # so a selector-free workflow remains executable; do not # invent axis/radius evidence for an axis-based selector. pass else: direction = axis.direction origin = axis.position geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None if geometry["surface_type"] == "cone": boundary_radii: list[float] = [] for edge in face.edges(): if str(edge.geom_type).split(".")[-1].lower() != "circle": continue try: boundary_radii.append(float(edge.radius)) except ValueError: continue geometry["boundary_radii_mm"] = sorted(boundary_radii) geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None # ``through`` alone describes a cylinder spanning two opposed # planar faces. That applies to both a through bore and the # outside wall of a cylindrical extrusion. Classify the B-rep # face by its oriented normal so downstream acceptance claims # can prove holes without mistaking an exterior wall for one. unit_axis = (direction.X, direction.Y, direction.Z) radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) radial_length = sum(component * component for component in radial) ** 0.5 if geometry["surface_type"] == "cylinder": # OCC can report a cylinder surface with ``radius=None`` # after a non-planar-side Boolean cut. The face centre is # still on that cylinder, so its perpendicular distance to # the rotation axis is an equivalent measured radius. Do # not fail an otherwise valid build merely because that # optional OCC convenience property is absent. if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): geometry["radius_mm"] = float(raw_cylinder_radius) elif radial_length > 1e-9: geometry["radius_mm"] = radial_length if radial_length > 1e-9: normal_components = (normal.X, normal.Y, normal.Z) alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length geometry["radial_normal_alignment"] = alignment geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" else: geometry["cylinder_role"] = "unknown" face_edge_indexes.append(set(boundary_edge_indexes)) face_geometries.append(geometry) records.append(TopologyRecord( record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, geometry=geometry, )) plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] def directly_linked_planes(face_index: int) -> list[int]: return [ plane_index for plane_index in plane_indexes if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) ] def same_inner_rotational_channel(first: int, second: int) -> bool: """Whether two inner rotational faces share one B-rep bore channel.""" if not face_edge_indexes[first].intersection(face_edge_indexes[second]): return False left, right = face_geometries[first], face_geometries[second] if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": return False left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): return False try: left_direction = tuple(float(value) for value in left_axis) right_direction = tuple(float(value) for value in right_axis) offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) except (TypeError, ValueError): return False alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) if abs(alignment) < 1.0 - 1e-6: return False axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 inner_rotational_indexes = [ index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" ] def channel_plane_indexes(start: int) -> list[int]: """Collect endpoint planes through joined, co-axial inner faces. A countersink or counterbore splits a physical bore into a cone and a cylinder. The cylinder has only one direct planar neighbour, so direct adjacency alone cannot prove that the complete channel exits the part. Traverse shared B-rep edges only across co-axial inner rotational faces, then inspect the channel's actual plane ends. """ pending = [start] visited: set[int] = set() endpoints: set[int] = set() while pending: index = pending.pop() if index in visited: continue visited.add(index) endpoints.update(directly_linked_planes(index)) pending.extend( candidate for candidate in inner_rotational_indexes if candidate not in visited and same_inner_rotational_channel(index, candidate) ) return sorted(endpoints) def spans_opposed_planes(linked: list[int], axis: Any) -> bool: if not isinstance(axis, list) or len(axis) != 3: return False try: direction = tuple(float(value) for value in axis) except (TypeError, ValueError): return False return any( sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) for first in linked for second in linked if first < second ) for index, geometry in enumerate(face_geometries): if geometry["surface_type"] != "cylinder": continue linked = directly_linked_planes(index) geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 for index, edge in enumerate(edges): bbox = edge.bounding_box() center = edge.center() vertices = edge.vertices() geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), "curve_type": str(edge.geom_type).split(".")[-1].lower(), "adjacent_face_count": len(edge_faces[index]), } if 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