feat: 完善 Edge 一级编辑与 CAD 建模语义
This commit is contained in:
+285
-14
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user