diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 9fce3d51..c633da3a 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -33,6 +33,11 @@ from .runtime_types import ( TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature, ) +from .topology_export import ( + body_geometry as _body_geometry_impl, + surface_geometry as _surface_geometry_impl, + topology_records as _topology_records_impl, +) def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -1926,294 +1931,15 @@ class Build123dGeometryAdapter: @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), - } + # 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。 + return _body_geometry_impl(body) @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()), - } + # 曲面结果的独立拓扑摘要(实现见 topology_export)。 + return _surface_geometry_impl(surface) @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 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 + # 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。 + return _topology_records_impl(body, feature_id, body_id) diff --git a/backend/engine/cdsl_engine/topology_export.py b/backend/engine/cdsl_engine/topology_export.py new file mode 100644 index 00000000..8b6301d3 --- /dev/null +++ b/backend/engine/cdsl_engine/topology_export.py @@ -0,0 +1,311 @@ +"""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