feat: 完善 Edge 一级编辑与 CAD 建模语义

This commit is contained in:
2026-08-05 18:06:15 +08:00
parent ed1faf51d8
commit 57d75541c3
19 changed files with 2365 additions and 367 deletions
+285 -14
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import math
from pathlib import Path
import time
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
@@ -114,6 +115,10 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._face_first_level_fact_cache: dict[tuple[int, str], dict[str, object]] = {}
self._edge_vertex_points_cache: dict[int, tuple[tuple[float, float, float], ...]] = {}
self._edge_vertex_key_edge_ids_cache: dict[tuple[int, int, int, int, int], set[int]] | None = None
self._edge_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._edge_first_level_fact_cache: dict[int, dict[str, object]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -198,6 +203,10 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._edge_vertex_points_cache.clear()
self._edge_vertex_key_edge_ids_cache = None
self._edge_first_level_topology_cache.clear()
self._edge_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
@@ -904,6 +913,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
max_depth: int = 3,
max_scan_faces: int = 72,
max_features: int = 10,
time_budget_seconds: float | None = None,
lightweight: bool = False,
) -> list[dict[str, object]]:
"""Detect editable feature candidates near the selected face.
@@ -914,8 +925,73 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
if max_features <= 0:
return []
source_info = self.feature_info(face_id)
deadline = None
if time_budget_seconds is not None and time_budget_seconds > 0:
deadline = time.monotonic() + float(time_budget_seconds)
def budget_expired() -> bool:
return deadline is not None and time.monotonic() >= deadline
def selection_candidate_info(candidate_id: int) -> dict[str, object]:
cached = self.cached_feature_info(candidate_id)
if cached is not None:
return cached
info = self.quick_face_info(candidate_id)
surface = str(info.get("surface", "") or "")
result = dict(info)
result.setdefault("kind", "feature")
result.setdefault("feature_source_face_id", candidate_id)
result.setdefault("feature_face_ids", (candidate_id,))
result.setdefault("feature_highlight_face_ids", (candidate_id,))
if surface == "cylinder":
angular_span = _float_or_none(result.get("angular_span"))
is_partial = (
not _is_effectively_full_cylinder(result)
and angular_span is not None
and angular_span < math.tau * 0.92
)
if is_partial:
result.setdefault("feature_type", "槽/半孔候选")
result.setdefault("feature_guess", "cylindrical face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段只读取半径、直径、轴线和大致高度;点击具体参数时会重新确认槽/半孔语义。",
)
else:
result.setdefault("feature_type", "圆柱面候选")
result.setdefault("feature_guess", "cylindrical face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段只读取半径、直径、轴线和大致高度;点击具体参数时会重新确认孔、凸台或圆角语义。",
)
elif surface in {"cone", "sphere", "torus"}:
result.setdefault("feature_type", f"{surface} 面候选")
result.setdefault("feature_edit_actions", "轻量探测阶段只读取解析曲面参数;点击具体参数时会重新生成完整编辑计划。")
elif surface == "plane":
result["feature_type"] = "相邻平面 Face 候选"
result.setdefault("feature_guess", "adjacent plane face")
result.setdefault(
"feature_edit_actions",
"轻量探测阶段显示共享边的相邻平面 Face;点击具体参数时会切换到该 Face 并重新生成完整编辑计划。",
)
else:
result.setdefault("feature_type", "相邻几何候选")
result.setdefault("feature_edit_actions", "轻量探测阶段只显示相邻关系;暂不把它作为可修改特征。")
result.setdefault(
"feature_mode",
"相邻特征轻量探测:为保证旋转、悬停和点选流畅,选择时只读取直接邻域的基础几何;完整识别留到执行具体修改时再计算。",
)
result["associated_feature_scan_mode"] = "lightweight"
try:
result.update(self._recognition_summary_fields(result))
except Exception:
pass
return result
source_info = selection_candidate_info(face_id) if lightweight else self.feature_info(face_id)
source_area = max(float(source_info.get("area", 0.0) or 0.0), 1e-12)
source_feature_faces = set(_int_values(source_info.get("feature_face_ids"))) or {face_id}
visited = {face_id}
@@ -923,12 +999,16 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
candidate_hops: dict[int, int] = {}
while frontier and len(visited) < max_scan_faces:
if budget_expired():
break
current_id, depth = frontier.pop(0)
if depth >= max_depth:
continue
edge_ids = self._face_boundary_edge_ids(current_id)
neighbors = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, current_id)) - {current_id})
for neighbor_id in neighbors:
if budget_expired():
break
candidate_hops[neighbor_id] = min(candidate_hops.get(neighbor_id, depth + 1), depth + 1)
if neighbor_id in visited or len(visited) >= max_scan_faces:
continue
@@ -946,28 +1026,38 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
results: list[dict[str, object]] = []
seen_features: set[tuple[str, frozenset[int]]] = set()
for candidate_id, hop_count in sorted(candidate_hops.items(), key=lambda item: (item[1], item[0])):
if budget_expired():
break
if candidate_id in source_feature_faces:
continue
try:
info = self.feature_info(candidate_id)
info = selection_candidate_info(candidate_id) if lightweight else self.feature_info(candidate_id)
except Exception:
continue
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
feature_type = str(info.get("feature_type", "") or "")
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"cone", "sphere", "torus"}
or (
surface == "cylinder"
and feature_guess
in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
if lightweight:
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"plane", "cylinder", "cone", "sphere", "torus"}
)
if surface == "plane" and hop_count > 1:
is_semantic = False
else:
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"cone", "sphere", "torus"}
or (
surface == "cylinder"
and feature_guess
in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
)
)
)
if not is_semantic:
continue
identity_face_ids = _int_values(info.get("feature_face_ids"))
@@ -982,11 +1072,15 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
continue
seen_features.add(identity)
related = dict(info)
association_label = ""
if lightweight and surface == "plane":
association_label = f"相邻平面 Face {candidate_id}"
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_label": association_label,
"association_priority": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
@@ -2869,6 +2963,183 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
matches.append(face_id)
return matches
def _edge_vertex_points(self, edge_id: int) -> tuple[tuple[float, float, float], ...]:
if edge_id < 0 or edge_id >= len(self.edges):
return ()
cached = self._edge_vertex_points_cache.get(edge_id)
if cached is not None:
return tuple(cached)
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
points = tuple(points_by_key[key] for key in sorted(points_by_key))
self._edge_vertex_points_cache[edge_id] = points
return points
def _edge_vertex_key_edge_ids(self) -> dict[tuple[int, int, int, int, int], set[int]]:
cached = self._edge_vertex_key_edge_ids_cache
if cached is not None:
return cached
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
mapping: dict[tuple[int, int, int, int, int], set[int]] = {}
for edge_id in range(len(self.edges)):
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
solid_id = self._edge_solid_id(edge_id)
for point in self._edge_vertex_points(edge_id):
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
mapping.setdefault(key, set()).add(edge_id)
self._edge_vertex_key_edge_ids_cache = mapping
return mapping
def _edge_shared_vertex_adjacent_edge_ids(self, edge_id: int) -> tuple[int, ...]:
if edge_id < 0 or edge_id >= len(self.edges):
return ()
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
solid_id = self._edge_solid_id(edge_id)
mapping = self._edge_vertex_key_edge_ids()
adjacent: set[int] = set()
for point in self._edge_vertex_points(edge_id):
key = (int(part_id), int(solid_id), *self._local_point_key(point, tolerance))
adjacent.update(mapping.get(key, set()))
adjacent.discard(edge_id)
return tuple(sorted(item for item in adjacent if 0 <= item < len(self.edges)))
def edge_first_level_topology(self, edge_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for an Edge."""
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
cached = self._edge_first_level_topology_cache.get(edge_id)
if cached is not None:
return dict(cached)
source_part_id = self.edge_part_ids[edge_id] if edge_id < len(self.edge_part_ids) else -1
source_solid_id = self._edge_solid_id(edge_id)
endpoint_points = self._edge_vertex_points(edge_id)
adjacent_edge_ids = self._edge_shared_vertex_adjacent_edge_ids(edge_id)
adjacent_face_ids = tuple(self._edge_adjacent_face_ids(edge_id))
adjacent_surface_types = tuple((face_id, self.face_surface_kind(face_id)) for face_id in adjacent_face_ids)
adjacent_face_boundary_edges: set[int] = set()
shared_edges_by_face: list[dict[str, object]] = []
for face_id in adjacent_face_ids:
boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
adjacent_face_boundary_edges.update(boundary_edge_ids)
shared_edges_by_face.append(
{
"face_id": face_id,
"edge_ids": (edge_id,),
"edge_count": 1,
"surface": self.face_surface_kind(face_id),
"face_boundary_edge_ids": boundary_edge_ids,
}
)
first_level_edge_ids = tuple(sorted({edge_id, *adjacent_edge_ids}))
topology = {
"topology_relation_model": "STEP/B-Rep edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected Edge + endpoint Vertices + shared-endpoint Edges + direct incident Faces",
"topology_relation_boundary": "shared-vertex/shared-face",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Edge first-level topology stops at endpoint Vertices, shared-endpoint Edges, "
"and Faces that directly use the selected Edge. Edges/Faces reached through those Faces "
"are second-level or deeper and are not propagated automatically in this stage."
),
"source_edge_id": edge_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"selected_edge_id": edge_id,
"selected_edge_ids": (edge_id,),
"selected_edge_count": 1,
"first_level_vertex_points": endpoint_points,
"first_level_vertex_count": len(endpoint_points),
"first_level_adjacent_edge_ids": adjacent_edge_ids,
"first_level_adjacent_edge_count": len(adjacent_edge_ids),
"first_level_edge_ids": first_level_edge_ids,
"first_level_edge_count": len(first_level_edge_ids),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": adjacent_surface_types,
"first_level_shared_edges_by_face": tuple(shared_edges_by_face),
"first_level_adjacent_face_boundary_edge_ids": tuple(sorted(adjacent_face_boundary_edges)),
"first_level_adjacent_face_boundary_edge_count": len(adjacent_face_boundary_edges),
"first_level_topology_note": (
f"Selected Edge 1, endpoint Vertex {len(endpoint_points)}, "
f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
self._edge_first_level_topology_cache[edge_id] = dict(topology)
return dict(topology)
def edge_first_level_facts(self, edge_id: int) -> dict[str, object]:
"""Return a unified first-level fact graph for Edge recognition and edit plans."""
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
cached = self._edge_first_level_fact_cache.get(edge_id)
if cached is not None:
return dict(cached)
topology = self.edge_first_level_topology(edge_id)
endpoint_points = tuple(topology.get("first_level_vertex_points") or ())
adjacent_edge_ids = tuple(_int_values(topology.get("first_level_adjacent_edge_ids")))
adjacent_face_ids = tuple(_int_values(topology.get("first_level_adjacent_face_ids")))
included_edge_ids = tuple(_int_values(topology.get("first_level_edge_ids"))) or (edge_id,)
ignored_depths = tuple(topology.get("topology_ignored_relation_depths") or ("second-level", "third-level"))
role_groups = (
{"role": "selected-edge", "edge_ids": (edge_id,), "count": 1},
{"role": "endpoint-vertices", "vertex_points": endpoint_points, "count": len(endpoint_points)},
{"role": "shared-endpoint-edges", "edge_ids": adjacent_edge_ids, "count": len(adjacent_edge_ids)},
{"role": "direct-incident-faces", "face_ids": adjacent_face_ids, "count": len(adjacent_face_ids)},
)
facts = {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_source_model": "edge",
"first_level_fact_status": "ready",
"first_level_fact_relation_depth": 1,
"first_level_fact_relation_boundary": "shared-vertex/shared-face",
"first_level_fact_scope": "edge",
"first_level_fact_subject_role": "selected Edge",
"first_level_fact_subject_edge_ids": (edge_id,),
"first_level_fact_subject_edge_count": 1,
"first_level_fact_boundary_edge_ids": (edge_id,),
"first_level_fact_boundary_edge_count": 1,
"first_level_fact_boundary_vertex_points": endpoint_points,
"first_level_fact_boundary_vertex_count": len(endpoint_points),
"first_level_fact_adjacent_edge_ids": adjacent_edge_ids,
"first_level_fact_adjacent_edge_count": len(adjacent_edge_ids),
"first_level_fact_adjacent_face_ids": adjacent_face_ids,
"first_level_fact_adjacent_face_count": len(adjacent_face_ids),
"first_level_fact_adjacent_surface_types": tuple(topology.get("first_level_adjacent_surface_types") or ()),
"first_level_fact_shared_edges_by_face": tuple(topology.get("first_level_shared_edges_by_face") or ()),
"first_level_fact_included_edge_ids": included_edge_ids,
"first_level_fact_included_edge_count": len(included_edge_ids),
"first_level_fact_included_face_ids": adjacent_face_ids,
"first_level_fact_included_face_count": len(adjacent_face_ids),
"first_level_fact_role_groups": role_groups,
"first_level_fact_ignored_relation_depths": ignored_depths,
"first_level_fact_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"first_level_fact_summary": (
f"selected Edge 1, endpoint Vertex {len(endpoint_points)}, "
f"shared-endpoint Edge {len(adjacent_edge_ids)}, direct adjacent Face {len(adjacent_face_ids)}; "
"deeper relations are recorded as future propagation targets, not edited automatically."
),
}
self._edge_first_level_fact_cache[edge_id] = dict(facts)
return dict(facts)
def edge_info(self, edge_id: int) -> dict[str, object]:
if edge_id in self._edge_info_cache:
return dict(self._edge_info_cache[edge_id])