"""B-rep topology evidence export for the build123d adapter. These module-level functions produce the face/edge/vertex snapshots that ``TopologyRegistry`` stores for selector resolution and that rebuild reports expose as ``topology_records``. They are pure queries over the current B-rep: no construction, no mutation, no OCP imports beyond what the shape objects themselves expose. ``Build123dGeometryAdapter`` forwards to them, keeping kernel construction and evidence export in separate files. """ from __future__ import annotations import math from typing import Any from .specs import canonical_plane_signature from .topology import TopologyRecord 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), } 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()), } def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 records: list[TopologyRecord] = [] faces = list(body.faces()) edges = list(body.edges()) vertices = list(body.vertices()) def index_for(shape: Any, candidates: list[Any]) -> int | None: """Map a subshape returned by a face/edge back to body topology.""" # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 for index, candidate in enumerate(candidates): if shape.is_same(candidate): return index return None # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 edge_faces: list[set[int]] = [set() for _edge in edges] for face_index, face in enumerate(faces): for edge in face.edges(): edge_index = index_for(edge, edges) if edge_index is not None: edge_faces[edge_index].add(face_index) # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 vertex_edges: list[set[int]] = [set() for _vertex in vertices] for edge_index, edge in enumerate(edges): for vertex in edge.vertices(): vertex_index = index_for(vertex, vertices) if vertex_index is not None: vertex_edges[vertex_index].add(edge_index) def edge_signature(edge_index: int) -> str: # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 edge = edges[edge_index] return ":".join(( str(edge.geom_type).split(".")[-1].lower(), f"{float(edge.length):.6f}", str(len(edge_faces[edge_index])), )) # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 # author 传入的 blind-depth 文字猜测“贯穿”。 face_edge_indexes: list[set[int]] = [] face_geometries: list[dict[str, Any]] = [] for index, face in enumerate(faces): bbox = face.bounding_box() center = face.center() normal = face.normal_at() boundary_edge_indexes = [ edge_index for edge in face.edges() if (edge_index := index_for(edge, edges)) is not None ] geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), } if geometry["surface_type"] == "plane": plane_normal, plane_offset = canonical_plane_signature( (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), ) geometry["plane_normal"] = list(plane_normal) geometry["plane_offset_mm"] = plane_offset boundary_loops: list[list[list[float]]] = [] for wire in face.wires(): samples: list[list[float]] = [] for edge in wire.edges(): curve_type = str(edge.geom_type).split(".")[-1].lower() fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] for fraction in fractions: point = edge.position_at(fraction) value = [float(point.X), float(point.Y), float(point.Z)] if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: samples.append(value) if len(samples) >= 3: boundary_loops.append(samples) if boundary_loops: geometry["boundary_loops_mm"] = boundary_loops elif geometry["surface_type"] in {"cylinder", "cone"}: axis = face.axis_of_rotation if axis is None: # Build123d can omit this optional OCC property for valid # swept rotational faces. Keep their generic B-rep record # so a selector-free workflow remains executable; do not # invent axis/radius evidence for an axis-based selector. pass else: direction = axis.direction origin = axis.position geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None if geometry["surface_type"] == "cone": boundary_radii: list[float] = [] for edge in face.edges(): if str(edge.geom_type).split(".")[-1].lower() != "circle": continue try: boundary_radii.append(float(edge.radius)) except ValueError: continue geometry["boundary_radii_mm"] = sorted(boundary_radii) geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None # ``through`` alone describes a cylinder spanning two opposed # planar faces. That applies to both a through bore and the # outside wall of a cylindrical extrusion. Classify the B-rep # face by its oriented normal so downstream acceptance claims # can prove holes without mistaking an exterior wall for one. unit_axis = (direction.X, direction.Y, direction.Z) radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) radial_length = sum(component * component for component in radial) ** 0.5 if geometry["surface_type"] == "cylinder": # OCC can report a cylinder surface with ``radius=None`` # after a non-planar-side Boolean cut. The face centre is # still on that cylinder, so its perpendicular distance to # the rotation axis is an equivalent measured radius. Do # not fail an otherwise valid build merely because that # optional OCC convenience property is absent. if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): geometry["radius_mm"] = float(raw_cylinder_radius) elif radial_length > 1e-9: geometry["radius_mm"] = radial_length if radial_length > 1e-9: normal_components = (normal.X, normal.Y, normal.Z) alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length geometry["radial_normal_alignment"] = alignment geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" else: geometry["cylinder_role"] = "unknown" face_edge_indexes.append(set(boundary_edge_indexes)) face_geometries.append(geometry) records.append(TopologyRecord( record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, geometry=geometry, )) plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] def directly_linked_planes(face_index: int) -> list[int]: return [ plane_index for plane_index in plane_indexes if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) ] def same_inner_rotational_channel(first: int, second: int) -> bool: """Whether two inner rotational faces share one B-rep bore channel.""" if not face_edge_indexes[first].intersection(face_edge_indexes[second]): return False left, right = face_geometries[first], face_geometries[second] if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": return False left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): return False try: left_direction = tuple(float(value) for value in left_axis) right_direction = tuple(float(value) for value in right_axis) offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) except (TypeError, ValueError): return False alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) if abs(alignment) < 1.0 - 1e-6: return False axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 inner_rotational_indexes = [ index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" ] def channel_plane_indexes(start: int) -> list[int]: """Collect endpoint planes through joined, co-axial inner faces. A countersink or counterbore splits a physical bore into a cone and a cylinder. The cylinder has only one direct planar neighbour, so direct adjacency alone cannot prove that the complete channel exits the part. Traverse shared B-rep edges only across co-axial inner rotational faces, then inspect the channel's actual plane ends. """ pending = [start] visited: set[int] = set() endpoints: set[int] = set() while pending: index = pending.pop() if index in visited: continue visited.add(index) endpoints.update(directly_linked_planes(index)) pending.extend( candidate for candidate in inner_rotational_indexes if candidate not in visited and same_inner_rotational_channel(index, candidate) ) return sorted(endpoints) def spans_opposed_planes(linked: list[int], axis: Any) -> bool: if not isinstance(axis, list) or len(axis) != 3: return False try: direction = tuple(float(value) for value in axis) except (TypeError, ValueError): return False return any( sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) for first in linked for second in linked if first < second ) for index, geometry in enumerate(face_geometries): if geometry["surface_type"] != "cylinder": continue linked = directly_linked_planes(index) geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 for index, edge in enumerate(edges): bbox = edge.bounding_box() center = edge.center() vertices = edge.vertices() geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), "curve_type": str(edge.geom_type).split(".")[-1].lower(), "adjacent_face_count": len(edge_faces[index]), } if geometry["curve_type"] == "circle": # ``Edge.center()`` is a point on a periodic circle, not its # geometric centre. Preserve the OCC circle data separately # so a provenance-backed rotational selector can distinguish # concentric full circles at different axial locations. try: circle_center = edge.arc_center radius = float(edge.radius) values = (circle_center.X, circle_center.Y, circle_center.Z, radius) except (AttributeError, TypeError, ValueError): values = () if values and all(math.isfinite(float(value)) for value in values) and radius > 0: geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] geometry["radius_mm"] = radius if vertices: geometry["start_mm"] = list(vertices[0]) geometry["end_mm"] = list(vertices[-1]) records.append(TopologyRecord( record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, geometry=geometry, )) # 5. 导出顶点记录:含坐标与关联边数。 for index, vertex in enumerate(vertices): point = [vertex.X, vertex.Y, vertex.Z] records.append(TopologyRecord( record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, )) return records