diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 0e81abcd..511f117d 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -11,26 +11,33 @@ from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector 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: @@ -47,34 +54,43 @@ class Build123dGeometryAdapter: @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) @@ -84,6 +100,7 @@ class Build123dGeometryAdapter: 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 @@ -91,6 +108,7 @@ class Build123dGeometryAdapter: ) if containing % 2: continue + # 4. 收集直接包在自身内部的圆作为孔洞,且它们只能被当前这一层包含。 holes = [ other["wire"] for other in entries @@ -101,11 +119,14 @@ class Build123dGeometryAdapter: 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] = [] @@ -117,23 +138,28 @@ class Build123dGeometryAdapter: 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 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 = [ @@ -146,6 +172,7 @@ class Build123dGeometryAdapter: @staticmethod def vertex_coordinates(vertex: Any) -> Vector3: + # 提取顶点的三维坐标元组。 return (float(vertex.X), float(vertex.Y), float(vertex.Z)) @staticmethod @@ -157,10 +184,13 @@ class Build123dGeometryAdapter: 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): @@ -169,10 +199,12 @@ class Build123dGeometryAdapter: @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 @@ -182,6 +214,7 @@ class Build123dGeometryAdapter: 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: @@ -199,34 +232,44 @@ class Build123dGeometryAdapter: @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: Solid) -> Any: + # 布尔并:没有既有主体时,直接以该实体作为新主体。 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") @@ -234,6 +277,7 @@ class Build123dGeometryAdapter: @staticmethod def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any: + # 对指定边以给定半径做圆角。 return body.fillet(radius_mm, list(edges)) @staticmethod @@ -244,8 +288,10 @@ class Build123dGeometryAdapter: 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) @@ -255,6 +301,7 @@ class Build123dGeometryAdapter: 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: @@ -263,8 +310,10 @@ class Build123dGeometryAdapter: 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() @@ -274,27 +323,33 @@ class Build123dGeometryAdapter: 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_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], @@ -303,6 +358,7 @@ class Build123dGeometryAdapter: @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()) @@ -310,17 +366,20 @@ class Build123dGeometryAdapter: 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(): @@ -329,6 +388,7 @@ class Build123dGeometryAdapter: 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(), @@ -336,6 +396,8 @@ class Build123dGeometryAdapter: str(len(edge_faces[edge_index])), )) + # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; + # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 for index, face in enumerate(faces): bbox = face.bounding_box() center = face.center() @@ -361,6 +423,7 @@ class Build123dGeometryAdapter: 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() @@ -378,6 +441,7 @@ class Build123dGeometryAdapter: 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(