2026-08-04 18:15:29 +08:00
|
|
|
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
|
2026-08-19 10:28:09 +08:00
|
|
|
from step_editor.records import OperationRecord
|
2026-08-05 15:08:16 +08:00
|
|
|
from step_editor.window_actions import WindowActionMixin
|
2026-08-19 10:28:09 +08:00
|
|
|
from step_editor.window_state import WindowStateMixin
|
2026-08-05 15:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _WindowActionProbe(WindowActionMixin):
|
|
|
|
|
pass
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
|
2026-08-19 10:28:09 +08:00
|
|
|
class _WindowStateProbe(WindowStateMixin):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
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)
|
2026-08-10 16:51:52 +08:00
|
|
|
started = time.perf_counter()
|
2026-08-04 18:15:29 +08:00
|
|
|
plan = model.push_pull_plan(face_id, 89.0)
|
2026-08-10 16:51:52 +08:00
|
|
|
plan_elapsed = time.perf_counter() - started
|
|
|
|
|
if plan_elapsed > 5.0:
|
|
|
|
|
raise SystemExit(f"large stepped cap push/pull plan took too long: {plan_elapsed:.3f}s; plan={plan}")
|
2026-08-04 18:15:29 +08:00
|
|
|
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}")
|
2026-08-19 10:28:09 +08:00
|
|
|
stepped_isolation = _WindowActionProbe()._isolation_for_plan(plan, "push_pull_face", [face_id, 89.0])
|
|
|
|
|
if not stepped_isolation or stepped_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
|
|
|
|
|
raise SystemExit(f"large stepped cap should use the smooth UI background process: {stepped_isolation}")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-08-19 10:28:09 +08:00
|
|
|
started = time.perf_counter()
|
2026-08-04 18:15:29 +08:00
|
|
|
multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5)
|
2026-08-19 10:28:09 +08:00
|
|
|
multi_plan_elapsed = time.perf_counter() - started
|
|
|
|
|
if multi_plan_elapsed > 5.0:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap push/pull plan should be quick: {multi_plan_elapsed:.3f}s; plan={multi_plan}"
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
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}")
|
2026-08-19 10:28:09 +08:00
|
|
|
multi_isolation = _WindowActionProbe()._isolation_for_plan(multi_plan, "push_pull_face", [multi_face_id, 34.5])
|
|
|
|
|
if not multi_isolation or multi_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
|
|
|
|
|
raise SystemExit(f"multi-boundary cap should use the smooth UI background process: {multi_isolation}")
|
|
|
|
|
ui_probe = _WindowActionProbe()
|
|
|
|
|
ui_probe.model = multi_model
|
|
|
|
|
ui_probe.current_info_values = multi_model.quick_face_info(multi_face_id)
|
|
|
|
|
ui_plan = ui_probe._push_pull_plan_for_action(multi_face_id, 34.5)
|
|
|
|
|
if bool(ui_plan.get("ui_deferred_model_plan")):
|
|
|
|
|
raise SystemExit(f"large multi-boundary UI plan should use the fast local plan now: {ui_plan}")
|
|
|
|
|
if ui_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
|
|
|
|
|
raise SystemExit(f"large multi-boundary UI plan should use boundary-shell rebuild: {ui_plan}")
|
|
|
|
|
ui_isolation = ui_probe._isolation_for_plan(ui_plan, "push_pull_face", [multi_face_id, 34.5])
|
|
|
|
|
if not ui_isolation or ui_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
|
|
|
|
|
raise SystemExit(f"large multi-boundary UI plan should use the smooth UI background process: {ui_isolation}")
|
|
|
|
|
if ui_probe._edit_preflight_blocker({"parameters": ui_plan}) is not None:
|
|
|
|
|
raise SystemExit(f"large multi-boundary UI plan should not be blocked before editing: {ui_plan}")
|
2026-08-04 18:15:29 +08:00
|
|
|
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"
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
if multi_inward_plan.get("status") == "blocked":
|
2026-08-04 18:15:29 +08:00
|
|
|
raise SystemExit(
|
2026-08-05 15:08:16 +08:00
|
|
|
f"multi-boundary cap shallow inward push/pull should be allowed by boundary shell rebuild: "
|
2026-08-04 18:15:29 +08:00
|
|
|
f"{multi_inward_plan}"
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
if multi_inward_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap shallow inward should use boundary shell rebuild: {multi_inward_plan}"
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
multi_inward_message = str(multi_inward_plan.get("message") or "")
|
2026-08-05 15:08:16 +08:00
|
|
|
if "向内收缩" not in multi_inward_message and "重建侧壁" not in multi_inward_message:
|
2026-08-04 18:15:29 +08:00
|
|
|
raise SystemExit(
|
2026-08-05 15:08:16 +08:00
|
|
|
f"multi-boundary cap shallow inward message should explain boundary shell semantics: "
|
2026-08-04 18:15:29 +08:00
|
|
|
f"{multi_inward_plan}"
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
multi_inward_result_model = StepModel.load(path)
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
multi_inward_result = multi_inward_result_model.push_pull_face(multi_face_id, -1.0)
|
|
|
|
|
multi_inward_result_elapsed = time.perf_counter() - started
|
|
|
|
|
if multi_inward_result_elapsed > 15.0:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap shallow inward push/pull took too long: "
|
|
|
|
|
f"{multi_inward_result_elapsed:.3f}s; result={multi_inward_result}"
|
|
|
|
|
)
|
|
|
|
|
if "boundary-shell rebuild" not in multi_inward_result or "actual=56.5" not in multi_inward_result:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap shallow inward should move to 56.5 by boundary shell rebuild: {multi_inward_result}"
|
|
|
|
|
)
|
|
|
|
|
if multi_inward_result_model.stats().solids != 1:
|
|
|
|
|
raise SystemExit(f"multi-boundary cap shallow inward should keep one Solid: {multi_inward_result_model.stats()}")
|
|
|
|
|
inward_retained_ids = multi_inward_result_model.face_ids_for_logical_id(multi_logical_id)
|
|
|
|
|
if len(inward_retained_ids) != 1:
|
|
|
|
|
raise SystemExit(f"multi-boundary cap shallow inward logical Face should follow the moved cap: {inward_retained_ids}")
|
|
|
|
|
inward_info = multi_inward_result_model.quick_face_info(inward_retained_ids[0])
|
|
|
|
|
if int(inward_info.get("inner_boundary_wires", 0) or 0) < 5:
|
|
|
|
|
raise SystemExit(f"multi-boundary cap shallow inward should keep inner wires: {inward_info}")
|
|
|
|
|
|
|
|
|
|
multi_deep_inward_plan = multi_model.push_pull_plan(multi_face_id, -77.5)
|
|
|
|
|
if multi_deep_inward_plan.get("status") != "blocked":
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap deep inward push/pull should still be blocked: {multi_deep_inward_plan}"
|
|
|
|
|
)
|
|
|
|
|
multi_deep_inward_message = str(multi_deep_inward_plan.get("message") or "")
|
|
|
|
|
if "材料厚度" not in multi_deep_inward_message and "二级关系" not in multi_deep_inward_message:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"multi-boundary cap deep inward blocker should explain cut-through or topology depth: "
|
|
|
|
|
f"{multi_deep_inward_plan}"
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-08-19 10:28:09 +08:00
|
|
|
locator_probe = _WindowStateProbe()
|
|
|
|
|
locator_probe.model = multi_model
|
|
|
|
|
locator_record = OperationRecord(
|
|
|
|
|
summary="test",
|
|
|
|
|
detail="test",
|
|
|
|
|
operation_name="拉伸/切除平面",
|
|
|
|
|
target=f"Face {multi_face_id}",
|
|
|
|
|
parameters={
|
|
|
|
|
"part_id": multi_plan.get("part_id"),
|
|
|
|
|
"solid_id": multi_plan.get("solid_id"),
|
|
|
|
|
"surface": "plane",
|
|
|
|
|
"outward_direction": multi_plan.get("outward_direction") or multi_plan.get("plane_direction"),
|
|
|
|
|
"target_plane_position": multi_plan.get("target_plane_position"),
|
|
|
|
|
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
|
|
|
|
|
},
|
|
|
|
|
result_message=multi_result,
|
|
|
|
|
target_kind="face",
|
|
|
|
|
target_id=multi_face_id,
|
|
|
|
|
target_logical_id=multi_logical_id,
|
|
|
|
|
)
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
resolved_after_edit = locator_probe._resolve_record_face_id(locator_record)
|
|
|
|
|
locator_elapsed = time.perf_counter() - started
|
|
|
|
|
if resolved_after_edit != multi_retained_ids[0] or locator_elapsed > 0.5:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"large multi-boundary operation history locator should use the fast result Face: "
|
|
|
|
|
f"resolved={resolved_after_edit}, expected={multi_retained_ids[0]}, elapsed={locator_elapsed:.3f}s"
|
|
|
|
|
)
|
|
|
|
|
no_hint_record = OperationRecord(
|
|
|
|
|
summary="test",
|
|
|
|
|
detail="test",
|
|
|
|
|
operation_name="拉伸/切除平面",
|
|
|
|
|
target=f"Face {multi_face_id}",
|
|
|
|
|
parameters=locator_record.parameters,
|
|
|
|
|
result_message="Planar face push/pull completed without result face hint.",
|
|
|
|
|
target_kind="face",
|
|
|
|
|
target_id=multi_face_id,
|
|
|
|
|
target_logical_id=multi_logical_id,
|
|
|
|
|
)
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
fallback_after_edit = locator_probe._record_plane_position_face_id(no_hint_record)
|
|
|
|
|
fallback_elapsed = time.perf_counter() - started
|
|
|
|
|
if fallback_after_edit != multi_retained_ids[0] or fallback_elapsed > 1.0:
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"large multi-boundary fallback locator should use lightweight plane positions: "
|
|
|
|
|
f"resolved={fallback_after_edit}, expected={multi_retained_ids[0]}, elapsed={fallback_elapsed:.3f}s"
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
isolated_output_model = StepModel.load(output_path)
|
|
|
|
|
probe = _WindowActionProbe()
|
|
|
|
|
direction = multi_plan.get("outward_direction") or multi_plan.get("plane_direction")
|
|
|
|
|
context = {
|
|
|
|
|
"operation_name": "拉伸/切除平面",
|
|
|
|
|
"target_kind": "face",
|
|
|
|
|
"target_id": multi_face_id,
|
|
|
|
|
"target_logical_id": multi_face_id,
|
|
|
|
|
"parameters": {
|
|
|
|
|
"part_id": multi_plan.get("part_id"),
|
|
|
|
|
"solid_id": multi_plan.get("solid_id"),
|
|
|
|
|
"surface": "plane",
|
|
|
|
|
"outward_direction": direction,
|
|
|
|
|
"current_plane_position": multi_plan.get("current_plane_position"),
|
|
|
|
|
"target_plane_position": multi_plan.get("target_plane_position"),
|
|
|
|
|
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
|
|
|
|
|
"resize_strategy": multi_plan.get("resize_strategy"),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
blocker = probe._face_target_integrity_blocker(isolated_output_model, context)
|
|
|
|
|
if blocker:
|
|
|
|
|
raise SystemExit(f"window-layer isolated target check should accept the moved cap: {blocker}")
|
|
|
|
|
probe._preserve_isolated_face_logical_id(isolated_output_model, context, multi_isolated_message)
|
|
|
|
|
retained_after_isolation = isolated_output_model.face_ids_for_logical_id(multi_face_id)
|
|
|
|
|
if not retained_after_isolation:
|
|
|
|
|
raise SystemExit("window-layer isolated logical Face retention lost the moved cap")
|
|
|
|
|
if not probe._face_target_plane_position_matches(
|
|
|
|
|
isolated_output_model,
|
|
|
|
|
retained_after_isolation,
|
|
|
|
|
float(multi_plan["target_plane_position"]),
|
|
|
|
|
direction,
|
|
|
|
|
float(multi_plan["bbox_diagonal"]),
|
|
|
|
|
):
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
f"window-layer isolated logical Face retention points at the wrong cap: {retained_after_isolation}"
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
"large stepped cap push/pull ok: "
|
2026-08-10 16:51:52 +08:00
|
|
|
f"face_id={face_id}, plan_elapsed={plan_elapsed:.3f}s, "
|
|
|
|
|
f"elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
|
2026-08-04 18:15:29 +08:00
|
|
|
f"topology_before={before_topology}, topology_after={after_topology}, result={result}"
|
|
|
|
|
)
|
|
|
|
|
print(
|
|
|
|
|
"large multi-boundary cap push/pull ok: "
|
2026-08-19 10:28:09 +08:00
|
|
|
f"face_id={multi_face_id}, plan_elapsed={multi_plan_elapsed:.3f}s, elapsed={multi_elapsed:.3f}s, "
|
2026-08-04 18:15:29 +08:00
|
|
|
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())
|