refactor(cdsl_engine): extract topology evidence export from the adapter
Phase 6 of the decoupling refactor (behavior-preserving move):
- topology_export.py: body_geometry / surface_geometry / topology_records
(~290 lines) moved verbatim out of build123d_adapter.py as module-level
functions; includes the circle_center_mm/radius_mm concentric-circle
evidence added by the lk_dev integration
- build123d_adapter.py: the three methods are now one-line forwards, so
kernel construction and topology evidence export live in separate files
and can evolve independently
No protocol change (GeometryAdapter untouched). Verified by:
- golden comparison: full-corpus analyze (2881 docs) + 12 complete
rebuilds -- zero field differences vs f79c287
- full test suite: 435 tests, failure set identical to baseline
This commit is contained in:
@@ -33,6 +33,11 @@ from .runtime_types import (
|
|||||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
||||||
canonical_plane_signature,
|
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:
|
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||||
@@ -1926,294 +1931,15 @@ class Build123dGeometryAdapter:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def body_geometry(body: Any) -> dict[str, Any]:
|
def body_geometry(body: Any) -> dict[str, Any]:
|
||||||
# 汇总主体基本几何信息:包围盒与体积。
|
# 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。
|
||||||
bbox = body.bounding_box()
|
return _body_geometry_impl(body)
|
||||||
# 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
|
@staticmethod
|
||||||
def surface_geometry(surface: Any) -> dict[str, Any]:
|
def surface_geometry(surface: Any) -> dict[str, Any]:
|
||||||
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
|
# 曲面结果的独立拓扑摘要(实现见 topology_export)。
|
||||||
bbox = surface.bounding_box()
|
return _surface_geometry_impl(surface)
|
||||||
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
|
@staticmethod
|
||||||
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
|
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
|
||||||
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
|
# 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。
|
||||||
records: list[TopologyRecord] = []
|
return _topology_records_impl(body, feature_id, body_id)
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user