feat: 完善 Face 一级关系编辑和稳定性校验
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.TopAbs import TopAbs_WIRE
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
|
||||
|
||||
def _wire_count(face) -> int:
|
||||
count = 0
|
||||
explorer = TopExp_Explorer(face, TopAbs_WIRE)
|
||||
while explorer.More():
|
||||
count += 1
|
||||
explorer.Next()
|
||||
return count
|
||||
|
||||
|
||||
def _adjacent_cylinder_radii(model: StepModel, face_id: int) -> list[float]:
|
||||
adjacent_ids: list[int] = []
|
||||
for edge_id in model._face_boundary_edge_ids(face_id):
|
||||
for adjacent_id in model._edge_adjacent_face_ids(edge_id):
|
||||
if adjacent_id == face_id or adjacent_id in adjacent_ids:
|
||||
continue
|
||||
adjacent_ids.append(adjacent_id)
|
||||
|
||||
radii: list[float] = []
|
||||
for adjacent_id in adjacent_ids:
|
||||
surf = BRepAdaptor_Surface(model.faces[adjacent_id])
|
||||
if surf.GetType() != GeomAbs_Cylinder:
|
||||
continue
|
||||
radius = float(surf.Cylinder().Radius())
|
||||
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii):
|
||||
radii.append(radius)
|
||||
return sorted(radii)
|
||||
|
||||
|
||||
def _large_stepped_cap_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id, face in enumerate(model.faces):
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() != GeomAbs_Plane:
|
||||
continue
|
||||
if _wire_count(face) != 2:
|
||||
continue
|
||||
radii = _adjacent_cylinder_radii(model, face_id)
|
||||
if len(radii) != 2:
|
||||
continue
|
||||
if abs(radii[0] - 4.1) > 1e-4 or abs(radii[1] - 15.0) > 1e-4:
|
||||
continue
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
center = props.CentreOfMass()
|
||||
score = abs(center.X() - 120.0) + abs(center.Y() + 39.0) + abs(center.Z() + 131.0)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, face_id)
|
||||
if best is None:
|
||||
raise SystemExit("large stepped cap Face was not found in geom_extract.step")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _large_multi_boundary_cap_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id, face in enumerate(model.faces):
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() != GeomAbs_Plane:
|
||||
continue
|
||||
if _wire_count(face) != 6:
|
||||
continue
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
area = float(props.Mass())
|
||||
if area < 20000.0:
|
||||
continue
|
||||
center = props.CentreOfMass()
|
||||
score = abs(center.Y() + 57.5) + abs(center.Z() + 249.7) * 0.02
|
||||
if best is None or score < best[0]:
|
||||
best = (score, face_id)
|
||||
if best is None:
|
||||
raise SystemExit("large multi-boundary cap Face was not found in geom_extract.step")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _face_topology_counts(model: StepModel, face_id: int) -> dict[str, int]:
|
||||
topology = model.face_first_level_topology(face_id)
|
||||
info = model.quick_face_info(face_id)
|
||||
return {
|
||||
"boundary_edges": int(topology.get("first_level_boundary_edge_count", 0) or 0),
|
||||
"boundary_vertices": int(topology.get("first_level_boundary_vertex_count", 0) or 0),
|
||||
"adjacent_faces": int(topology.get("first_level_adjacent_face_count", 0) or 0),
|
||||
"inner_wires": int(info.get("inner_boundary_wires", 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
def _assert_topology_preserved(
|
||||
label: str,
|
||||
before: dict[str, int],
|
||||
after: dict[str, int],
|
||||
*,
|
||||
min_inner_wires: int | None = None,
|
||||
) -> None:
|
||||
for key in ("boundary_edges", "boundary_vertices", "adjacent_faces"):
|
||||
if after[key] < before[key]:
|
||||
raise SystemExit(f"{label} lost first-level {key}: before={before}, after={after}")
|
||||
expected_inner_wires = before["inner_wires"] if min_inner_wires is None else min_inner_wires
|
||||
if after["inner_wires"] < expected_inner_wires:
|
||||
raise SystemExit(f"{label} lost inner boundary wires: before={before}, after={after}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
||||
if not path.exists():
|
||||
print("large stepped cap fixture is absent; skipping")
|
||||
return 0
|
||||
|
||||
model = StepModel.load(path)
|
||||
face_id = _large_stepped_cap_face(model)
|
||||
logical_id = model.face_region_logical_id(face_id)
|
||||
before_topology = _face_topology_counts(model, face_id)
|
||||
plan = model.push_pull_plan(face_id, 89.0)
|
||||
if plan.get("cylindrical_cap_extension_kind") != "coaxial-stepped-cap":
|
||||
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
|
||||
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
|
||||
raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}")
|
||||
|
||||
started = time.perf_counter()
|
||||
result = model.push_pull_face(face_id, 89.0)
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed > 30.0:
|
||||
raise SystemExit(f"large stepped cap push/pull took too long: {elapsed:.3f}s; result={result}")
|
||||
if "local shell rebuild" not in result or "coaxial-stepped-cap" not in result:
|
||||
raise SystemExit(f"large stepped cap push/pull should report local shell rebuild: {result}")
|
||||
stats = model.stats()
|
||||
if stats.solids != 1:
|
||||
raise SystemExit(f"large stepped cap push/pull should keep one Solid: {stats}")
|
||||
if "actual=50" not in result or "inner_wires=1" not in result:
|
||||
raise SystemExit(f"large stepped cap push/pull should verify target cap and inner wire: {result}")
|
||||
retained_ids = model.face_ids_for_logical_id(logical_id)
|
||||
if len(retained_ids) != 1:
|
||||
raise SystemExit(f"large stepped cap logical Face should map to one new cap: {retained_ids}")
|
||||
retained_info = model.quick_face_info(retained_ids[0])
|
||||
retained_origin = retained_info.get("plane_origin")
|
||||
retained_outward = retained_info.get("push_pull_outward_direction") or retained_info.get("normal")
|
||||
retained_position = (
|
||||
sum(float(retained_origin[index]) * float(retained_outward[index]) for index in range(3))
|
||||
if isinstance(retained_origin, (tuple, list))
|
||||
and isinstance(retained_outward, (tuple, list))
|
||||
and len(retained_origin) == 3
|
||||
and len(retained_outward) == 3
|
||||
else None
|
||||
)
|
||||
if retained_position is None or abs(retained_position - 50.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"large stepped cap logical Face should follow the moved cap at 50: "
|
||||
f"ids={retained_ids}, position={retained_position}"
|
||||
)
|
||||
after_topology = _face_topology_counts(model, retained_ids[0])
|
||||
_assert_topology_preserved("large stepped cap", before_topology, after_topology)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="verify_large_stepped_cap_isolated_") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
output_path = temp_root / "output.step"
|
||||
request_path = temp_root / "request.json"
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(path),
|
||||
"output_path": str(output_path),
|
||||
"operation": "push_pull_face",
|
||||
"args": [face_id, 89.0],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)],
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60.0,
|
||||
check=False,
|
||||
)
|
||||
isolated_elapsed = time.perf_counter() - started
|
||||
response_path = request_path.with_suffix(".response.json")
|
||||
response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {}
|
||||
if completed.returncode != 0 or not response.get("ok"):
|
||||
raise SystemExit(
|
||||
"large stepped cap isolated worker failed: "
|
||||
f"returncode={completed.returncode}, stdout={completed.stdout!r}, "
|
||||
f"stderr={completed.stderr!r}, response={response}"
|
||||
)
|
||||
isolated_message = str(response.get("message") or "")
|
||||
if isolated_elapsed > 30.0:
|
||||
raise SystemExit(
|
||||
f"large stepped cap isolated worker took too long: {isolated_elapsed:.3f}s; "
|
||||
f"message={isolated_message}"
|
||||
)
|
||||
if not output_path.exists():
|
||||
raise SystemExit("large stepped cap isolated worker did not export output STEP")
|
||||
if "local shell rebuild" not in isolated_message or "actual=50" not in isolated_message:
|
||||
raise SystemExit(f"large stepped cap isolated worker used the wrong path: {isolated_message}")
|
||||
|
||||
multi_model = StepModel.load(path)
|
||||
multi_face_id = _large_multi_boundary_cap_face(multi_model)
|
||||
multi_logical_id = multi_model.face_region_logical_id(multi_face_id)
|
||||
multi_before_topology = _face_topology_counts(multi_model, multi_face_id)
|
||||
multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5)
|
||||
if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9:
|
||||
raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}")
|
||||
if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9:
|
||||
raise SystemExit(f"multi-boundary cap should target 92: {multi_plan}")
|
||||
if multi_plan.get("planar_cap_extension_kind") != "multi-boundary-planar-cap":
|
||||
raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}")
|
||||
if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
|
||||
raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}")
|
||||
started = time.perf_counter()
|
||||
multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0)
|
||||
multi_inward_elapsed = time.perf_counter() - started
|
||||
if multi_inward_elapsed > 5.0:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward push/pull plan should be quick: {multi_inward_elapsed:.3f}s"
|
||||
)
|
||||
if multi_inward_plan.get("status") != "blocked":
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward push/pull should be blocked until second-level propagation exists: "
|
||||
f"{multi_inward_plan}"
|
||||
)
|
||||
multi_inward_message = str(multi_inward_plan.get("message") or "")
|
||||
if "二级关系" not in multi_inward_message or "通用 OCCT 布尔" not in multi_inward_message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward blocker should explain topology depth and slow Boolean risk: "
|
||||
f"{multi_inward_plan}"
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
multi_result = multi_model.push_pull_face(multi_face_id, 34.5)
|
||||
multi_elapsed = time.perf_counter() - started
|
||||
if multi_elapsed > 15.0:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap push/pull took too long: {multi_elapsed:.3f}s; result={multi_result}"
|
||||
)
|
||||
if "boundary-shell rebuild" not in multi_result:
|
||||
raise SystemExit(f"multi-boundary cap push/pull should report boundary-shell rebuild: {multi_result}")
|
||||
multi_stats = multi_model.stats()
|
||||
if multi_stats.solids != 1:
|
||||
raise SystemExit(f"multi-boundary cap push/pull should keep one Solid: {multi_stats}")
|
||||
if "actual=92" not in multi_result or "inner_wires=5" not in multi_result:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap push/pull should verify target cap and inner wires: {multi_result}"
|
||||
)
|
||||
multi_retained_ids = multi_model.face_ids_for_logical_id(multi_logical_id)
|
||||
if len(multi_retained_ids) != 1:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap logical Face should map to one moved cap: {multi_retained_ids}"
|
||||
)
|
||||
multi_retained_info = multi_model.quick_face_info(multi_retained_ids[0])
|
||||
multi_origin = multi_retained_info.get("plane_origin")
|
||||
multi_outward = multi_retained_info.get("push_pull_outward_direction") or multi_retained_info.get("normal")
|
||||
multi_position = (
|
||||
sum(float(multi_origin[index]) * float(multi_outward[index]) for index in range(3))
|
||||
if isinstance(multi_origin, (tuple, list))
|
||||
and isinstance(multi_outward, (tuple, list))
|
||||
and len(multi_origin) == 3
|
||||
and len(multi_outward) == 3
|
||||
else None
|
||||
)
|
||||
if multi_position is None or abs(multi_position - 92.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap logical Face should follow the moved cap at 92: "
|
||||
f"ids={multi_retained_ids}, position={multi_position}"
|
||||
)
|
||||
if int(multi_retained_info.get("inner_boundary_wires", 0) or 0) < 5:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap logical Face should keep inner boundary wires: {multi_retained_info}"
|
||||
)
|
||||
multi_after_topology = _face_topology_counts(multi_model, multi_retained_ids[0])
|
||||
_assert_topology_preserved(
|
||||
"multi-boundary cap",
|
||||
multi_before_topology,
|
||||
multi_after_topology,
|
||||
min_inner_wires=5,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
output_path = temp_root / "output.step"
|
||||
request_path = temp_root / "request.json"
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(path),
|
||||
"output_path": str(output_path),
|
||||
"operation": "push_pull_face",
|
||||
"args": [multi_face_id, 34.5],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)],
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60.0,
|
||||
check=False,
|
||||
)
|
||||
multi_isolated_elapsed = time.perf_counter() - started
|
||||
response_path = request_path.with_suffix(".response.json")
|
||||
response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {}
|
||||
if completed.returncode != 0 or not response.get("ok"):
|
||||
raise SystemExit(
|
||||
"large multi-boundary cap isolated worker failed: "
|
||||
f"returncode={completed.returncode}, stdout={completed.stdout!r}, "
|
||||
f"stderr={completed.stderr!r}, response={response}"
|
||||
)
|
||||
multi_isolated_message = str(response.get("message") or "")
|
||||
if multi_isolated_elapsed > 30.0:
|
||||
raise SystemExit(
|
||||
f"large multi-boundary cap isolated worker took too long: {multi_isolated_elapsed:.3f}s; "
|
||||
f"message={multi_isolated_message}"
|
||||
)
|
||||
if not output_path.exists():
|
||||
raise SystemExit("large multi-boundary cap isolated worker did not export output STEP")
|
||||
if "boundary-shell rebuild" not in multi_isolated_message or "actual=92" not in multi_isolated_message:
|
||||
raise SystemExit(
|
||||
f"large multi-boundary cap isolated worker used the wrong path: {multi_isolated_message}"
|
||||
)
|
||||
|
||||
print(
|
||||
"large stepped cap push/pull ok: "
|
||||
f"face_id={face_id}, elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
|
||||
f"topology_before={before_topology}, topology_after={after_topology}, result={result}"
|
||||
)
|
||||
print(
|
||||
"large multi-boundary cap push/pull ok: "
|
||||
f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, "
|
||||
f"isolated_elapsed={multi_isolated_elapsed:.3f}s, "
|
||||
f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user