feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""CAD STP parsing + GT extraction.
|
||||
|
||||
This package depends on ``cadquery-ocp`` (OCP) for OpenCascade access. If
|
||||
OCP is not installed the modules will raise :class:`ImportError` at import
|
||||
time only when actually used; pure point-cloud workflows do not need it.
|
||||
"""
|
||||
|
||||
from .stp_parser import StepShape, load_step
|
||||
from .mesh import tessellate_to_trimesh
|
||||
from .gt_extractor import (
|
||||
GroundTruth,
|
||||
extract_ground_truth,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"StepShape",
|
||||
"load_step",
|
||||
"tessellate_to_trimesh",
|
||||
"GroundTruth",
|
||||
"extract_ground_truth",
|
||||
]
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Extract Ground Truth geometry from a parsed STEP shape.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
|
||||
1. Walk every circular edge in the shape (``iter_circular_edges``).
|
||||
2. Cluster by radius:
|
||||
- radii inside ``locating_hole_radius_mm`` → candidate locating holes
|
||||
- radii ≥ ``central_hole_radius_min_mm`` → candidate central hole
|
||||
3. Discard duplicates (front + back face of the same hole share centre and
|
||||
axis up to a sign).
|
||||
4. Find a set of four locating holes whose centres form a near-rectangle:
|
||||
pairwise sort by axis-distance, then verify diagonals roughly equal.
|
||||
5. Order the four locating holes consistently (anti-clockwise when viewed
|
||||
from +x looking down −x). This ordering is the canonical pairing for
|
||||
Kabsch later.
|
||||
6. Define:
|
||||
- ``C`` = mean of the four locating-hole centres (== diagonal
|
||||
intersection for a rectangle)
|
||||
- ``n`` = panel normal = unit axis of the locating circles, signed
|
||||
so that ``n_x > 0``
|
||||
- reference line, plane, longitudinal mid-plane in :class:`ReferenceFrame`
|
||||
|
||||
This GT can be used as
|
||||
- the gold standard the measurement is compared against, AND
|
||||
- a prior law that bootstraps point-cloud-side hole detection (we know the
|
||||
panel normal a priori).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..exceptions import CADParseError
|
||||
from ..structures import Hole, HoleSet, PanelPlane, ReferenceFrame
|
||||
from .stp_parser import StepShape, iter_circular_edges
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundTruth:
|
||||
"""All CAD-derived reference values used as the GT for validation."""
|
||||
|
||||
holes: HoleSet
|
||||
plane: PanelPlane
|
||||
frame: ReferenceFrame
|
||||
raw_circles: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"holes": {
|
||||
"locating": [
|
||||
{
|
||||
"center": list(h.center),
|
||||
"radius_mm": h.radius_mm,
|
||||
"fit_residual_rms_mm": h.fit_residual_rms_mm,
|
||||
"role": h.role,
|
||||
}
|
||||
for h in self.holes.locating
|
||||
],
|
||||
"central": (
|
||||
None
|
||||
if self.holes.central is None
|
||||
else {
|
||||
"center": list(self.holes.central.center),
|
||||
"radius_mm": self.holes.central.radius_mm,
|
||||
"fit_residual_rms_mm": self.holes.central.fit_residual_rms_mm,
|
||||
"role": self.holes.central.role,
|
||||
}
|
||||
),
|
||||
},
|
||||
"plane": {
|
||||
"normal": list(self.plane.normal),
|
||||
"point_on_plane": list(self.plane.point_on_plane),
|
||||
"d_offset": self.plane.d_offset,
|
||||
"inlier_ratio": self.plane.inlier_ratio,
|
||||
"inlier_residual_rms_mm": self.plane.inlier_residual_rms_mm,
|
||||
},
|
||||
"frame": {
|
||||
"center_C": list(self.frame.center_C),
|
||||
"line_direction": list(self.frame.line_direction),
|
||||
"reference_plane_z": self.frame.reference_plane_z,
|
||||
"longitudinal_mid_y": self.frame.longitudinal_mid_y,
|
||||
},
|
||||
"raw_circles": self.raw_circles,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_ground_truth(
|
||||
step: StepShape,
|
||||
cad_cfg: dict[str, Any],
|
||||
) -> GroundTruth:
|
||||
"""Extract GT from a parsed STEP file using the ``cad`` config block."""
|
||||
r_lo, r_hi = cad_cfg["locating_hole_radius_mm"]
|
||||
r_central_min = float(cad_cfg["central_hole_radius_min_mm"])
|
||||
rect_aspect_tol = float(cad_cfg.get("rectangle_aspect_tol", 0.15))
|
||||
|
||||
raw: list[dict[str, Any]] = []
|
||||
locating: list[dict[str, Any]] = []
|
||||
central: list[dict[str, Any]] = []
|
||||
|
||||
for center, axis, radius, _edge in iter_circular_edges(step.shape):
|
||||
item = {
|
||||
"center": center,
|
||||
"axis": axis,
|
||||
"radius": radius,
|
||||
}
|
||||
raw.append(item)
|
||||
if r_lo <= radius <= r_hi:
|
||||
locating.append(item)
|
||||
elif radius >= r_central_min:
|
||||
central.append(item)
|
||||
|
||||
if not locating:
|
||||
raise CADParseError(
|
||||
f"no circular edge with radius in [{r_lo}, {r_hi}] mm; "
|
||||
"adjust cad.locating_hole_radius_mm to match your panel"
|
||||
)
|
||||
|
||||
locating = _dedupe_circles(locating, r_tol=0.05, c_tol=0.5)
|
||||
central = _dedupe_circles(central, r_tol=0.5, c_tol=2.0)
|
||||
|
||||
four = _select_rectangle(locating, rect_aspect_tol=rect_aspect_tol)
|
||||
four_ordered = _order_rectangle(four)
|
||||
|
||||
panel_normal = _consensus_axis(four_ordered)
|
||||
panel_normal = _enforce_nx_positive(panel_normal)
|
||||
|
||||
centers = np.asarray([c["center"] for c in four_ordered], dtype=np.float64)
|
||||
C = centers.mean(axis=0)
|
||||
|
||||
holes = HoleSet(
|
||||
locating=tuple( # type: ignore[arg-type]
|
||||
Hole(
|
||||
center=tuple(c["center"]),
|
||||
radius_mm=float(c["radius"]),
|
||||
fit_residual_rms_mm=0.0,
|
||||
role="locating",
|
||||
)
|
||||
for c in four_ordered
|
||||
),
|
||||
central=(
|
||||
Hole(
|
||||
center=tuple(central[0]["center"]),
|
||||
radius_mm=float(central[0]["radius"]),
|
||||
fit_residual_rms_mm=0.0,
|
||||
role="central",
|
||||
)
|
||||
if central
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
plane = PanelPlane(
|
||||
normal=tuple(panel_normal),
|
||||
point_on_plane=tuple(C),
|
||||
d_offset=float(-np.dot(panel_normal, C)),
|
||||
inlier_ratio=1.0,
|
||||
inlier_residual_rms_mm=0.0,
|
||||
)
|
||||
|
||||
frame = ReferenceFrame(
|
||||
center_C=tuple(C),
|
||||
line_direction=(1.0, 0.0, 0.0),
|
||||
reference_plane_z=float(C[2]),
|
||||
longitudinal_mid_y=float(C[1]),
|
||||
)
|
||||
|
||||
return GroundTruth(holes=holes, plane=plane, frame=frame, raw_circles=raw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _dedupe_circles(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
r_tol: float,
|
||||
c_tol: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop circles that share centre and radius (front/back face copies)."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
c = np.asarray(it["center"])
|
||||
r = it["radius"]
|
||||
is_dup = False
|
||||
for kept in out:
|
||||
if abs(kept["radius"] - r) <= r_tol:
|
||||
if np.linalg.norm(np.asarray(kept["center"]) - c) <= c_tol:
|
||||
is_dup = True
|
||||
break
|
||||
if not is_dup:
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def _select_rectangle(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
rect_aspect_tol: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Pick four items whose centres look most like a rectangle.
|
||||
|
||||
For tractability we (a) require ≥ 4 candidates, (b) if exactly 4, return
|
||||
them, (c) otherwise enumerate all 4-subsets and pick the one with the
|
||||
smallest "rectangleness" residual.
|
||||
"""
|
||||
if len(items) < 4:
|
||||
raise CADParseError(
|
||||
f"only {len(items)} locating-hole candidates found; need ≥ 4"
|
||||
)
|
||||
if len(items) == 4:
|
||||
return items
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
best_score = float("inf")
|
||||
best: list[dict[str, Any]] | None = None
|
||||
for combo in combinations(items, 4):
|
||||
s = _rectangle_score(combo)
|
||||
if s < best_score:
|
||||
best_score = s
|
||||
best = list(combo)
|
||||
assert best is not None
|
||||
if best_score > rect_aspect_tol:
|
||||
# Still accept the best — the tolerance is informational; warn via
|
||||
# raw_circles in the GT report.
|
||||
pass
|
||||
return best
|
||||
|
||||
|
||||
def _rectangle_score(items) -> float:
|
||||
"""0 = perfect rectangle. Larger = worse."""
|
||||
c = np.asarray([i["center"] for i in items], dtype=np.float64)
|
||||
# Use distances between the 4 centres. For a rectangle, the 6 pairwise
|
||||
# distances are: two "long" sides equal, two "short" sides equal, two
|
||||
# diagonals equal.
|
||||
from itertools import combinations
|
||||
|
||||
d = sorted(np.linalg.norm(c[i] - c[j]) for i, j in combinations(range(4), 2))
|
||||
if d[0] < 1e-6:
|
||||
return 1e6
|
||||
s1, s2 = d[0], d[1] # short sides
|
||||
l1, l2 = d[2], d[3] # long sides
|
||||
g1, g2 = d[4], d[5] # diagonals
|
||||
score = (
|
||||
abs(s1 - s2) / max(s1, s2)
|
||||
+ abs(l1 - l2) / max(l1, l2)
|
||||
+ abs(g1 - g2) / max(g1, g2)
|
||||
)
|
||||
return score
|
||||
|
||||
|
||||
def _order_rectangle(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return four hole dicts in a canonical (-y/-z, +y/-z, +y/+z, -y/+z) order.
|
||||
|
||||
The ordering is taken in the y-z plane (panel normal is along ±x). This
|
||||
matches how a panel is normally described: bottom-left, bottom-right,
|
||||
top-right, top-left when viewed from +x looking down −x.
|
||||
"""
|
||||
c = np.asarray([i["center"] for i in items], dtype=np.float64)
|
||||
cy = c[:, 1].mean()
|
||||
cz = c[:, 2].mean()
|
||||
quadrant: dict[tuple[bool, bool], int] = {}
|
||||
for idx, p in enumerate(c):
|
||||
key = (p[1] >= cy, p[2] >= cz)
|
||||
quadrant[key] = idx
|
||||
order = [
|
||||
(False, False),
|
||||
(True, False),
|
||||
(True, True),
|
||||
(False, True),
|
||||
]
|
||||
if not all(q in quadrant for q in order):
|
||||
# Geometry is degenerate; fall back to angular sort.
|
||||
ang = np.arctan2(c[:, 2] - cz, c[:, 1] - cy)
|
||||
idxs = np.argsort(ang)
|
||||
return [items[i] for i in idxs]
|
||||
return [items[quadrant[q]] for q in order]
|
||||
|
||||
|
||||
def _consensus_axis(items: list[dict[str, Any]]) -> np.ndarray:
|
||||
"""Average and re-normalise the circle axis directions."""
|
||||
axes = np.asarray([i["axis"] for i in items], dtype=np.float64)
|
||||
# Flip any axes that disagree with the first.
|
||||
ref = axes[0]
|
||||
for k in range(1, len(axes)):
|
||||
if np.dot(axes[k], ref) < 0:
|
||||
axes[k] = -axes[k]
|
||||
n = axes.mean(axis=0)
|
||||
n /= max(np.linalg.norm(n), 1e-12)
|
||||
return n
|
||||
|
||||
|
||||
def _enforce_nx_positive(n: np.ndarray) -> np.ndarray:
|
||||
if n[0] < 0:
|
||||
return -n
|
||||
return n
|
||||
|
||||
|
||||
__all__ = ["GroundTruth", "extract_ground_truth"]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tessellation of a TopoDS_Shape into a (V, F) triangle mesh.
|
||||
|
||||
The output is a small dataclass — we do not import ``trimesh`` until the
|
||||
caller actually asks for it, to keep the core dependency surface minimal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .stp_parser import StepShape, _import_ocp, iter_faces
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriangleMesh:
|
||||
"""Vertex / face arrays.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
vertices : ``(V, 3) float64``
|
||||
faces : ``(F, 3) int64`` (vertex indices)
|
||||
face_normals : ``(F, 3) float64``
|
||||
"""
|
||||
|
||||
vertices: np.ndarray
|
||||
faces: np.ndarray
|
||||
face_normals: np.ndarray
|
||||
|
||||
@property
|
||||
def n_vertices(self) -> int:
|
||||
return int(self.vertices.shape[0])
|
||||
|
||||
@property
|
||||
def n_faces(self) -> int:
|
||||
return int(self.faces.shape[0])
|
||||
|
||||
def to_trimesh(self):
|
||||
"""Return a ``trimesh.Trimesh`` (lazy import)."""
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"trimesh is required for to_trimesh(); "
|
||||
"install with: pip install 'coupler-panel-validation[cad]'"
|
||||
) from exc
|
||||
return trimesh.Trimesh(
|
||||
vertices=self.vertices,
|
||||
faces=self.faces,
|
||||
face_normals=self.face_normals,
|
||||
process=False,
|
||||
)
|
||||
|
||||
|
||||
def tessellate_to_trimesh(
|
||||
step: StepShape,
|
||||
*,
|
||||
linear_deflection_mm: float = 0.5,
|
||||
angular_deflection_rad: float = 0.1,
|
||||
) -> TriangleMesh:
|
||||
"""Tessellate a :class:`StepShape` into a single combined triangle mesh.
|
||||
|
||||
Each face is tessellated independently (with the location of the edge
|
||||
transferred onto the vertices), then concatenated. Triangle winding is
|
||||
flipped where the OCP face is reversed, so :attr:`face_normals` is the
|
||||
*outward* normal in the assembly frame.
|
||||
"""
|
||||
occ = _import_ocp()
|
||||
BRep_Tool = occ["BRep_Tool"]
|
||||
TopLoc_Location = occ["TopLoc_Location"]
|
||||
BRepMesh_IncrementalMesh = occ["BRepMesh_IncrementalMesh"]
|
||||
|
||||
BRepMesh_IncrementalMesh(
|
||||
step.shape,
|
||||
float(linear_deflection_mm),
|
||||
False,
|
||||
float(angular_deflection_rad),
|
||||
True,
|
||||
)
|
||||
|
||||
all_v: list[np.ndarray] = []
|
||||
all_f: list[np.ndarray] = []
|
||||
all_n: list[np.ndarray] = []
|
||||
v_offset = 0
|
||||
|
||||
for face in iter_faces(step.shape):
|
||||
loc = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, loc)
|
||||
if triangulation is None:
|
||||
continue
|
||||
|
||||
trsf = loc.Transformation()
|
||||
n_nodes = triangulation.NbNodes()
|
||||
n_tri = triangulation.NbTriangles()
|
||||
if n_nodes == 0 or n_tri == 0:
|
||||
continue
|
||||
|
||||
v = np.empty((n_nodes, 3), dtype=np.float64)
|
||||
for i in range(n_nodes):
|
||||
p = triangulation.Node(i + 1).Transformed(trsf)
|
||||
v[i, 0] = p.X()
|
||||
v[i, 1] = p.Y()
|
||||
v[i, 2] = p.Z()
|
||||
|
||||
f = np.empty((n_tri, 3), dtype=np.int64)
|
||||
is_reversed = face.Orientation() == 1 # TopAbs_REVERSED
|
||||
for j in range(n_tri):
|
||||
tri = triangulation.Triangle(j + 1)
|
||||
i1, i2, i3 = tri.Get()
|
||||
if is_reversed:
|
||||
f[j] = (i3 - 1, i2 - 1, i1 - 1)
|
||||
else:
|
||||
f[j] = (i1 - 1, i2 - 1, i3 - 1)
|
||||
|
||||
# Outward face normal per triangle.
|
||||
a = v[f[:, 0]]
|
||||
b = v[f[:, 1]]
|
||||
c = v[f[:, 2]]
|
||||
normals = np.cross(b - a, c - a)
|
||||
norms = np.linalg.norm(normals, axis=1, keepdims=True)
|
||||
norms[norms < 1e-12] = 1.0
|
||||
normals = normals / norms
|
||||
|
||||
all_v.append(v)
|
||||
all_f.append(f + v_offset)
|
||||
all_n.append(normals)
|
||||
v_offset += n_nodes
|
||||
|
||||
if not all_v:
|
||||
raise RuntimeError(
|
||||
"tessellation produced no triangles; "
|
||||
"try a finer linear_deflection_mm"
|
||||
)
|
||||
|
||||
vertices = np.concatenate(all_v, axis=0)
|
||||
faces = np.concatenate(all_f, axis=0)
|
||||
face_normals = np.concatenate(all_n, axis=0)
|
||||
return TriangleMesh(vertices=vertices, faces=faces, face_normals=face_normals)
|
||||
|
||||
|
||||
__all__ = ["TriangleMesh", "tessellate_to_trimesh"]
|
||||
@@ -0,0 +1,219 @@
|
||||
"""STP file loading via OCP (OpenCascade Python bindings).
|
||||
|
||||
The OCP import is deferred so the rest of the package does not require
|
||||
OpenCascade to be installed.
|
||||
|
||||
The parser exposes a thin :class:`StepShape` wrapper that gives the rest
|
||||
of the package what it needs: the underlying ``TopoDS_Shape`` for
|
||||
tessellation and topology traversal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..exceptions import CADParseError
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepShape:
|
||||
"""Result of loading a STEP file.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
shape : ``OCP.TopoDS.TopoDS_Shape``
|
||||
The root shape (assembly compound or single solid).
|
||||
source : ``Path``
|
||||
Source file path, for debugging.
|
||||
"""
|
||||
|
||||
shape: Any
|
||||
source: Path
|
||||
|
||||
|
||||
def _import_ocp() -> dict[str, Any]:
|
||||
"""Import the OCP modules we need. Raises a clean error if missing."""
|
||||
try:
|
||||
from OCP.STEPControl import STEPControl_Reader # type: ignore
|
||||
from OCP.IFSelect import IFSelect_RetDone # type: ignore
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Shape # type: ignore
|
||||
from OCP.TopAbs import ( # type: ignore
|
||||
TopAbs_FACE,
|
||||
TopAbs_EDGE,
|
||||
TopAbs_VERTEX,
|
||||
)
|
||||
from OCP.TopExp import TopExp_Explorer # type: ignore
|
||||
from OCP.TopLoc import TopLoc_Location # type: ignore
|
||||
from OCP.BRep import BRep_Tool # type: ignore
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh # type: ignore
|
||||
from OCP.BRepGProp import BRepGProp # type: ignore
|
||||
from OCP.GProp import GProp_GProps # type: ignore
|
||||
from OCP.GeomAbs import GeomAbs_Circle # type: ignore
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface # type: ignore
|
||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec # type: ignore
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"OCP (cadquery-ocp) is required to parse STEP files. "
|
||||
"Install with: pip install 'coupler-panel-validation[cad]'"
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"STEPControl_Reader": STEPControl_Reader,
|
||||
"IFSelect_RetDone": IFSelect_RetDone,
|
||||
"TopoDS": TopoDS,
|
||||
"TopoDS_Shape": TopoDS_Shape,
|
||||
"TopAbs_FACE": TopAbs_FACE,
|
||||
"TopAbs_EDGE": TopAbs_EDGE,
|
||||
"TopAbs_VERTEX": TopAbs_VERTEX,
|
||||
"TopExp_Explorer": TopExp_Explorer,
|
||||
"TopLoc_Location": TopLoc_Location,
|
||||
"BRep_Tool": BRep_Tool,
|
||||
"BRepMesh_IncrementalMesh": BRepMesh_IncrementalMesh,
|
||||
"BRepGProp": BRepGProp,
|
||||
"GProp_GProps": GProp_GProps,
|
||||
"GeomAbs_Circle": GeomAbs_Circle,
|
||||
"BRepAdaptor_Curve": BRepAdaptor_Curve,
|
||||
"BRepAdaptor_Surface": BRepAdaptor_Surface,
|
||||
"gp_Pnt": gp_Pnt,
|
||||
"gp_Dir": gp_Dir,
|
||||
"gp_Vec": gp_Vec,
|
||||
}
|
||||
|
||||
|
||||
def load_step(path: str | Path) -> StepShape:
|
||||
"""Read a STEP file and return its root :class:`TopoDS_Shape`.
|
||||
|
||||
Raises :class:`CADParseError` on read failure.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise CADParseError(f"STEP file not found: {p}")
|
||||
|
||||
occ = _import_ocp()
|
||||
reader = occ["STEPControl_Reader"]()
|
||||
status = reader.ReadFile(str(p))
|
||||
if status != occ["IFSelect_RetDone"]:
|
||||
raise CADParseError(f"OCP failed to read {p} (status={status})")
|
||||
n_roots = reader.TransferRoots()
|
||||
if n_roots <= 0:
|
||||
raise CADParseError(f"no roots transferred from {p}")
|
||||
|
||||
shape = reader.OneShape()
|
||||
return StepShape(shape=shape, source=p)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Topology iteration helpers (used by gt_extractor)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _shape_key(shape) -> int:
|
||||
"""Stable identity key for a TopoDS_Shape across OCP versions.
|
||||
|
||||
OCP 7.8+ removed ``HashCode``. We fall back to the underlying TShape's
|
||||
Python id, which is stable per OpenCASCADE topology object.
|
||||
"""
|
||||
try:
|
||||
return shape.HashCode(2**31 - 1)
|
||||
except AttributeError:
|
||||
try:
|
||||
return id(shape.TShape())
|
||||
except Exception:
|
||||
return id(shape)
|
||||
|
||||
|
||||
def iter_faces(shape: Any):
|
||||
"""Yield every TopoDS_Face inside ``shape``.
|
||||
|
||||
Two visits of the *same underlying surface with opposite orientations*
|
||||
are treated as distinct because the orientation governs the outward
|
||||
normal of the produced triangulation.
|
||||
"""
|
||||
occ = _import_ocp()
|
||||
TopoDS = occ["TopoDS"]
|
||||
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
|
||||
while explorer.More():
|
||||
raw = explorer.Current()
|
||||
try:
|
||||
yield TopoDS.Face_s(raw)
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
|
||||
|
||||
def iter_circular_edges(shape: Any):
|
||||
"""Yield ``(center_xyz, axis_xyz, radius_mm, edge)`` for every circular
|
||||
edge in ``shape``. Duplicates (same circle on two adjacent faces) are
|
||||
NOT removed here — downstream ``_dedupe_circles`` does that by
|
||||
geometry (centre + radius).
|
||||
"""
|
||||
occ = _import_ocp()
|
||||
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_EDGE"])
|
||||
TopoDS = occ["TopoDS"]
|
||||
BRepAdaptor_Curve = occ["BRepAdaptor_Curve"]
|
||||
GeomAbs_Circle = occ["GeomAbs_Circle"]
|
||||
while explorer.More():
|
||||
raw = explorer.Current()
|
||||
try:
|
||||
edge = TopoDS.Edge_s(raw)
|
||||
curve = BRepAdaptor_Curve(edge)
|
||||
if curve.GetType() == GeomAbs_Circle:
|
||||
circ = curve.Circle()
|
||||
loc = circ.Location()
|
||||
ax = circ.Axis().Direction()
|
||||
radius = float(circ.Radius())
|
||||
center = (float(loc.X()), float(loc.Y()), float(loc.Z()))
|
||||
axis = (float(ax.X()), float(ax.Y()), float(ax.Z()))
|
||||
yield center, axis, radius, edge
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
|
||||
|
||||
def iter_cylindrical_faces(shape: Any):
|
||||
"""Yield ``(center, axis, radius, face)`` for every cylinder face.
|
||||
|
||||
Cylinder faces are typically the *walls* of through-holes; their
|
||||
radii match the circular edges that bound them.
|
||||
"""
|
||||
occ = _import_ocp()
|
||||
TopoDS = occ["TopoDS"]
|
||||
BRepAdaptor_Surface = occ["BRepAdaptor_Surface"]
|
||||
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
|
||||
while explorer.More():
|
||||
raw = explorer.Current()
|
||||
try:
|
||||
face = TopoDS.Face_s(raw)
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if int(surf.GetType()) == 1:
|
||||
cyl = surf.Cylinder()
|
||||
loc = cyl.Location()
|
||||
ax = cyl.Axis().Direction()
|
||||
radius = float(cyl.Radius())
|
||||
yield (
|
||||
(float(loc.X()), float(loc.Y()), float(loc.Z())),
|
||||
(float(ax.X()), float(ax.Y()), float(ax.Z())),
|
||||
radius,
|
||||
face,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
|
||||
|
||||
def face_area(face: Any) -> float:
|
||||
occ = _import_ocp()
|
||||
props = occ["GProp_GProps"]()
|
||||
occ["BRepGProp"].SurfaceProperties_s(face, props)
|
||||
return float(props.Mass())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StepShape",
|
||||
"load_step",
|
||||
"iter_faces",
|
||||
"iter_circular_edges",
|
||||
"face_area",
|
||||
]
|
||||
Reference in New Issue
Block a user