Files
cdsl-cad/backend/engine/cdsl_engine/topology_export.py
T

352 lines
18 KiB
Python

"""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 OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
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())
# build123d may wrap a vertex extracted from a solid in a distinct OCC
# handle. Builder FirstShape/LastShape vertex history is only comparable
# with the final result's native explorer handles, so retain those exact
# handles for selector records. Faces and edges keep their established
# build123d export path.
result_vertices: list[Any] = []
explorer = TopExp_Explorer(body.wrapped, TopAbs_VERTEX)
while explorer.More():
candidate = explorer.Current()
if not any(candidate.IsSame(existing) for existing in result_vertices):
result_vertices.append(candidate)
explorer.Next()
result_edges: list[Any] = []
explorer = TopExp_Explorer(body.wrapped, TopAbs_EDGE)
while explorer.More():
candidate = explorer.Current()
if not any(candidate.IsSame(existing) for existing in result_edges):
result_edges.append(candidate)
explorer.Next()
def exact_incident_edge_count(vertex: Any) -> int:
count = 0
for edge in result_edges:
edge_vertices: list[Any] = []
edge_explorer = TopExp_Explorer(edge, TopAbs_VERTEX)
while edge_explorer.More():
candidate = edge_explorer.Current()
if not any(candidate.IsSame(existing) for existing in edge_vertices):
edge_vertices.append(candidate)
edge_explorer.Next()
if any(vertex.IsSame(candidate) for candidate in edge_vertices):
count += 1
return count
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(result_vertices):
point_value = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex))
point = [point_value.X(), point_value.Y(), point_value.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": exact_incident_edge_count(vertex)},
))
return records