feat: 完善 Face 一级关系编辑和稳定性校验
This commit is contained in:
@@ -572,10 +572,15 @@ def _verify_embedded_reference_radius_isolated_window_job(path: Path) -> None:
|
||||
)
|
||||
if not result.get("message"):
|
||||
raise SystemExit("isolated embedded reference-radius edit did not return a completion message")
|
||||
if probe.model is model:
|
||||
raise SystemExit("successful isolated embedded reference-radius edit should load the edited model")
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(probe.model, 30.963756532)
|
||||
circles = probe.model._conical_face_circle_boundaries(
|
||||
if probe.model is not model:
|
||||
raise SystemExit("isolated embedded reference-radius edit should not replace the window model off the UI thread")
|
||||
edited_model = result.get("after_model")
|
||||
if not isinstance(edited_model, StepModel):
|
||||
raise SystemExit(f"isolated embedded reference-radius edit should return after_model: {result}")
|
||||
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
|
||||
raise SystemExit("isolated embedded reference-radius edit should return prebuilt display polydata")
|
||||
edited_face_id, edited_info, _score = _best_cone_face_by_angle(edited_model, 30.963756532)
|
||||
circles = edited_model._conical_face_circle_boundaries(
|
||||
edited_face_id,
|
||||
tuple(float(value) for value in edited_info["axis_point"]),
|
||||
_unit(tuple(float(value) for value in edited_info["axis"])),
|
||||
@@ -738,10 +743,13 @@ def main() -> int:
|
||||
)
|
||||
if not result.get("message"):
|
||||
raise SystemExit("isolated cone semi-angle edit did not return a completion message")
|
||||
if probe.model is model:
|
||||
raise SystemExit("successful isolated cone semi-angle edit should load the edited model")
|
||||
|
||||
edited_model = probe.model
|
||||
if probe.model is not model:
|
||||
raise SystemExit("isolated cone semi-angle edit should not replace the window model off the UI thread")
|
||||
edited_model = result.get("after_model")
|
||||
if not isinstance(edited_model, StepModel):
|
||||
raise SystemExit(f"isolated cone semi-angle edit should return after_model: {result}")
|
||||
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
|
||||
raise SystemExit("isolated cone semi-angle edit should return prebuilt display polydata")
|
||||
best_angle = _best_cone_angle_degrees(edited_model)
|
||||
if best_angle is None:
|
||||
raise SystemExit("isolated cone semi-angle edit should keep an analytic cone Face")
|
||||
|
||||
@@ -220,12 +220,65 @@ def _verify_missing_first_level_neighbor_blocks_plan() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _verify_result_check_rejects_degraded_first_level_topology() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cyl_topology_result_guard_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "through_hole.step"
|
||||
_write_through_hole_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_hole_face(model, blind=False)
|
||||
plan = model.cylindrical_resize_plan(face_id, 8.0)
|
||||
|
||||
healthy = model._attach_cylindrical_first_level_result_check( # noqa: SLF001
|
||||
{"matched": True, "face_id": face_id},
|
||||
plan,
|
||||
)
|
||||
_assert(
|
||||
healthy.get("first_level_topology_matched") is True,
|
||||
f"healthy result topology should pass: {healthy}",
|
||||
)
|
||||
minimums = dict(healthy.get("first_level_topology_minimums") or {})
|
||||
_assert(
|
||||
int(minimums.get("adjacent_face_count", 0) or 0) >= 2,
|
||||
f"through-hole result guard should derive a direct-neighbor minimum: {healthy}",
|
||||
)
|
||||
|
||||
original_topology = model.cylindrical_feature_first_level_topology
|
||||
|
||||
def degraded_topology(target_face_id: int) -> dict[str, object]:
|
||||
topology = dict(original_topology(target_face_id))
|
||||
topology["cylindrical_feature_adjacent_face_ids"] = tuple(
|
||||
tuple(topology.get("cylindrical_feature_adjacent_face_ids", ()) or ())[:1]
|
||||
)
|
||||
topology["cylindrical_feature_adjacent_face_count"] = 1
|
||||
return topology
|
||||
|
||||
model.cylindrical_feature_first_level_topology = degraded_topology # type: ignore[method-assign]
|
||||
degraded = model._attach_cylindrical_first_level_result_check( # noqa: SLF001
|
||||
{"matched": True, "face_id": face_id},
|
||||
plan,
|
||||
)
|
||||
_assert(
|
||||
degraded.get("matched") is False,
|
||||
f"result topology with too few direct adjacent Faces should fail: {degraded}",
|
||||
)
|
||||
_assert(
|
||||
degraded.get("first_level_topology_matched") is False,
|
||||
f"degraded result should mark first-level topology mismatch: {degraded}",
|
||||
)
|
||||
detail = str(degraded.get("detail") or "")
|
||||
_assert(
|
||||
"first-level topology" in detail and "adjacent_face_count" in detail,
|
||||
f"degraded result should explain the weaker first-level topology: {degraded}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_verify_through_hole()
|
||||
_verify_blind_hole()
|
||||
_verify_half_round_slot()
|
||||
_verify_obround_slot_length_plan()
|
||||
_verify_missing_first_level_neighbor_blocks_plan()
|
||||
_verify_result_check_rejects_degraded_first_level_topology()
|
||||
print("cylindrical first-level topology verification passed")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
def _write_solid_cylinder(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeCylinder(3.0, 11.0).Shape(), path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder(path: Path) -> None:
|
||||
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
inner = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
3.0,
|
||||
80.0,
|
||||
).Shape()
|
||||
cut = BRepAlgoAPI_Cut(outer, inner)
|
||||
_write_step(_finalize_boolean_result(cut, "verify hollow cylinder height", use_glue=False), path)
|
||||
|
||||
|
||||
def _write_multi_hole_cylinder(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
for x_value in (-3.0, 3.0):
|
||||
cutter = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(x_value, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
1.2,
|
||||
80.0,
|
||||
).Shape()
|
||||
shape = _finalize_boolean_result(
|
||||
BRepAlgoAPI_Cut(shape, cutter),
|
||||
"verify multi-hole cylinder cut",
|
||||
use_glue=False,
|
||||
)
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _write_top_notched_cylinder(path: Path) -> None:
|
||||
cylinder = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
notch = BRepPrimAPI_MakeBox(gp_Pnt(2.0, -1.0, 60.0), gp_Pnt(4.5, 1.0, 90.0)).Shape()
|
||||
cut = BRepAlgoAPI_Cut(cylinder, notch)
|
||||
_write_step(_finalize_boolean_result(cut, "verify top-notched cylinder height", use_glue=False), path)
|
||||
|
||||
|
||||
def _first_cylinder_face(model: StepModel) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") == "cylinder":
|
||||
return face_id
|
||||
raise SystemExit("no cylindrical Face was found")
|
||||
|
||||
|
||||
def _top_planar_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = info.get("area_center") or info.get("bbox_center")
|
||||
if not isinstance(center, tuple) or len(center) != 3:
|
||||
continue
|
||||
z_value = float(center[2])
|
||||
if best is None or z_value > best[0]:
|
||||
best = (z_value, face_id)
|
||||
if best is None:
|
||||
raise SystemExit("no planar cap Face was found")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _bbox_height(model: StepModel) -> float:
|
||||
bbox_size = model.geometry_stats().get("bbox_size")
|
||||
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
|
||||
raise SystemExit(f"bbox_size is missing: {model.geometry_stats()}")
|
||||
return float(bbox_size[2])
|
||||
|
||||
|
||||
def _assert_height(model: StepModel, target_height: float, label: str) -> None:
|
||||
height = _bbox_height(model)
|
||||
if abs(height - target_height) > 1e-4:
|
||||
raise SystemExit(f"{label}: height should be {target_height:g}, got {height:g}")
|
||||
|
||||
|
||||
def _assert_radii(model: StepModel, expected: tuple[float, ...], label: str) -> None:
|
||||
radii: list[float] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
radius = float(info.get("radius") or 0.0)
|
||||
if radius <= 0.0:
|
||||
continue
|
||||
if not any(abs(radius - item) <= max(radius, item, 1.0) * 1e-5 for item in radii):
|
||||
radii.append(radius)
|
||||
radii.sort()
|
||||
expected_values = list(expected)
|
||||
if len(radii) != len(expected_values) or any(abs(a - b) > 1e-4 for a, b in zip(radii, expected_values)):
|
||||
raise SystemExit(f"{label}: radii should be {expected_values}, got {radii}")
|
||||
|
||||
|
||||
def _run_isolated_worker(
|
||||
input_path: Path,
|
||||
operation: str,
|
||||
args: list[object],
|
||||
label: str,
|
||||
*,
|
||||
timeout_seconds: float = 90.0,
|
||||
) -> tuple[StepModel, str, float]:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_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(input_path),
|
||||
"output_path": str(output_path),
|
||||
"operation": operation,
|
||||
"args": args,
|
||||
},
|
||||
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=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
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(
|
||||
f"{label}: isolated worker failed: returncode={completed.returncode}, "
|
||||
f"stdout={completed.stdout!r}, stderr={completed.stderr!r}, response={response}"
|
||||
)
|
||||
if elapsed > 45.0:
|
||||
raise SystemExit(f"{label}: isolated worker took too long: {elapsed:.3f}s")
|
||||
if not output_path.exists():
|
||||
raise SystemExit(f"{label}: isolated worker did not export output STEP")
|
||||
return StepModel.load(output_path), str(response.get("message") or ""), elapsed
|
||||
|
||||
|
||||
def _run_height_case(
|
||||
path: Path,
|
||||
target_height: float,
|
||||
label: str,
|
||||
expected_method: str | None = None,
|
||||
) -> StepModel:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cylinder_face(model)
|
||||
plan = model.cylindrical_height_plan(face_id, target_height)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: height plan should not be blocked: {plan}")
|
||||
if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) <= 0:
|
||||
raise SystemExit(f"{label}: height plan should carry cylindrical first-level topology: {plan}")
|
||||
result = model.resize_cylindrical_height(face_id, target_height)
|
||||
_assert_height(model, target_height, label)
|
||||
if "verified_face=" not in result:
|
||||
raise SystemExit(f"{label}: result should include cylindrical height verification: {result}")
|
||||
if expected_method is not None and expected_method not in result:
|
||||
raise SystemExit(f"{label}: result should use {expected_method}: {result}")
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"resize_cylindrical_height",
|
||||
[face_id, target_height],
|
||||
f"{label} isolated",
|
||||
)
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
if "verified_face=" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated result should include cylindrical height verification: {isolated_message}")
|
||||
if expected_method is not None and expected_method not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated result should use {expected_method}: {isolated_message}")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
return model
|
||||
|
||||
|
||||
def _run_owning_scale_case(path: Path, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cylinder_face(model)
|
||||
plan = model.cylindrical_height_owning_scale_plan(face_id, target_height)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: owning-scale plan should not be blocked: {plan}")
|
||||
result = model.resize_cylindrical_height_owning_scale(face_id, target_height)
|
||||
_assert_height(model, target_height, label)
|
||||
if "verified_axis_span=" not in result:
|
||||
raise SystemExit(f"{label}: result should include axis-span verification: {result}")
|
||||
print(f"{label} ok: {result}")
|
||||
|
||||
|
||||
def _run_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _top_planar_face(model)
|
||||
plan = model.push_pull_plan(face_id, distance)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: cap push/pull plan should not be blocked: {plan}")
|
||||
if plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(f"{label}: expected coaxial-tube cap recognition, got: {plan}")
|
||||
result = model.push_pull_face(face_id, distance)
|
||||
if "cylindrical cap analytic rebuild" not in result:
|
||||
raise SystemExit(f"{label}: cap push/pull should use analytic rebuild: {result}")
|
||||
_assert_height(model, target_height, label)
|
||||
_assert_radii(model, (3.0, 8.0), label)
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"push_pull_face",
|
||||
[face_id, distance],
|
||||
f"{label} isolated",
|
||||
)
|
||||
if "cylindrical cap analytic rebuild" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated cap push/pull should use analytic rebuild: {isolated_message}")
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
_assert_radii(isolated_model, (3.0, 8.0), f"{label} isolated")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
|
||||
|
||||
def _run_prismatic_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _top_planar_face(model)
|
||||
result = model.push_pull_face(face_id, distance)
|
||||
if "prismatic cap analytic rebuild" not in result:
|
||||
raise SystemExit(f"{label}: multi-hole cap push/pull should use prismatic rebuild: {result}")
|
||||
_assert_height(model, target_height, label)
|
||||
_assert_radii(model, (1.2, 8.0), label)
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"push_pull_face",
|
||||
[face_id, distance],
|
||||
f"{label} isolated",
|
||||
)
|
||||
if "prismatic cap analytic rebuild" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated multi-hole cap push/pull should use prismatic rebuild: {isolated_message}")
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
_assert_radii(isolated_model, (1.2, 8.0), f"{label} isolated")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
solid_path = root / "solid_cylinder.step"
|
||||
hollow_path = root / "hollow_cylinder.step"
|
||||
multi_hole_path = root / "multi_hole_cylinder.step"
|
||||
notch_path = root / "top_notched_cylinder.step"
|
||||
_write_solid_cylinder(solid_path)
|
||||
_write_hollow_cylinder(hollow_path)
|
||||
_write_multi_hole_cylinder(multi_hole_path)
|
||||
_write_top_notched_cylinder(notch_path)
|
||||
|
||||
_run_height_case(solid_path, 50.0, "solid cylinder side Face height", "analytic rebuild")
|
||||
_run_owning_scale_case(solid_path, 50.0, "solid cylinder owning-axis height")
|
||||
hollow_model = _run_height_case(hollow_path, 167.0, "hollow cylinder side Face height", "analytic rebuild")
|
||||
_assert_radii(hollow_model, (3.0, 8.0), "hollow cylinder side Face height")
|
||||
_run_cap_push_pull_case(hollow_path, 89.0, 167.0, "hollow cylinder cap Face large push/pull")
|
||||
_run_prismatic_cap_push_pull_case(
|
||||
multi_hole_path,
|
||||
89.0,
|
||||
167.0,
|
||||
"multi-hole cylinder cap Face large push/pull",
|
||||
)
|
||||
_run_height_case(notch_path, 98.0, "top-notched cylinder side Face height", "profile-prism")
|
||||
|
||||
print("cylindrical Face height resize ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -51,6 +51,36 @@ def _assert_push_pull_keeps_single_solid(path: Path, distance: float) -> str:
|
||||
stats = model.stats()
|
||||
if stats.solids != 1:
|
||||
raise SystemExit(f"holed planar Face push/pull should keep one solid: {stats}")
|
||||
if "inner_wires=" not in result:
|
||||
raise SystemExit(f"holed planar Face push/pull should report inner-wire verification: {result}")
|
||||
target_position = plan.get("target_plane_position")
|
||||
direction = plan.get("plane_direction") or plan.get("outward_direction")
|
||||
if not isinstance(target_position, (int, float)) or not isinstance(direction, tuple) or len(direction) != 3:
|
||||
raise SystemExit(f"holed planar Face push/pull plan should expose a target plane position: {plan}")
|
||||
best: tuple[float, int, dict[str, object]] | None = None
|
||||
for candidate_id in range(len(model.faces)):
|
||||
info = model.face_info(candidate_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
origin = info.get("plane_origin")
|
||||
if not isinstance(origin, tuple) or len(origin) != 3:
|
||||
continue
|
||||
actual_position = (
|
||||
float(origin[0]) * float(direction[0])
|
||||
+ float(origin[1]) * float(direction[1])
|
||||
+ float(origin[2]) * float(direction[2])
|
||||
)
|
||||
error = abs(actual_position - float(target_position))
|
||||
if best is None or error < best[0]:
|
||||
best = (error, candidate_id, info)
|
||||
if best is None:
|
||||
raise SystemExit("holed planar Face push/pull should leave a measurable target plane")
|
||||
_error, matched_face_id, matched_info = best
|
||||
if int(matched_info.get("inner_boundary_wires") or 0) < 1:
|
||||
raise SystemExit(
|
||||
"holed planar Face push/pull should preserve the target Face inner boundary: "
|
||||
f"matched_face={matched_face_id}, info={matched_info}, result={result}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ def main() -> int:
|
||||
scope_ids = tuple(plan.get("push_pull_scope_face_ids", ()))
|
||||
if set(scope_ids) != set(top_faces):
|
||||
raise SystemExit(f"expected push/pull scope {top_faces}, got {scope_ids}")
|
||||
scope_area = float(plan.get("push_pull_scope_area") or 0.0)
|
||||
if abs(scope_area - 100.0) > args.tolerance:
|
||||
raise SystemExit(f"expected push/pull scope area 100, got {scope_area:g}")
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"coplanar push/pull plan was blocked: {plan['message']}")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402
|
||||
|
||||
|
||||
class _Probe(WindowActionMixin):
|
||||
@@ -194,6 +195,183 @@ def main() -> int:
|
||||
after_model=edited_cube,
|
||||
),
|
||||
)
|
||||
rollback_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
rollback_before_stats = rollback_cube.stats()
|
||||
rollback_plan = rollback_cube.face_area_local_resize_plan(0, 144.0)
|
||||
rollback_plan["target_area"] = 999.0
|
||||
_expect_blocked(
|
||||
"Face model-layer rollback on failed target check",
|
||||
lambda: rollback_cube._run_checked_face_edit(
|
||||
rollback_plan,
|
||||
lambda: rollback_cube._apply_local_face_deform(rollback_plan),
|
||||
),
|
||||
)
|
||||
if rollback_cube.stats() != rollback_before_stats:
|
||||
raise SystemExit(
|
||||
"Face model-layer rollback should restore topology stats: "
|
||||
f"before={rollback_before_stats}, after={rollback_cube.stats()}"
|
||||
)
|
||||
if abs(float(rollback_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
|
||||
raise SystemExit("Face model-layer rollback should restore the original Face area")
|
||||
topology_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
topology_before_stats = topology_guard_cube.stats()
|
||||
topology_plan = topology_guard_cube.face_area_local_resize_plan(0, 144.0)
|
||||
original_topology_method = topology_guard_cube.face_first_level_topology
|
||||
|
||||
def apply_face_then_weaken_first_level_topology():
|
||||
topology_guard_cube._apply_local_face_deform(topology_plan)
|
||||
|
||||
def weak_topology(face_id: int) -> dict[str, object]:
|
||||
topology = dict(original_topology_method(face_id))
|
||||
topology["first_level_boundary_edge_count"] = 3
|
||||
topology["first_level_boundary_vertex_count"] = 3
|
||||
topology["first_level_adjacent_face_count"] = 1
|
||||
return topology
|
||||
|
||||
topology_guard_cube.face_first_level_topology = weak_topology
|
||||
|
||||
try:
|
||||
_expect_blocked(
|
||||
"Face model-layer rollback on weakened first-level topology",
|
||||
lambda: topology_guard_cube._run_checked_face_edit(
|
||||
topology_plan,
|
||||
apply_face_then_weaken_first_level_topology,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
topology_guard_cube.face_first_level_topology = original_topology_method
|
||||
if topology_guard_cube.stats() != topology_before_stats:
|
||||
raise SystemExit(
|
||||
"Face model-layer topology rollback should restore topology stats: "
|
||||
f"before={topology_before_stats}, after={topology_guard_cube.stats()}"
|
||||
)
|
||||
if abs(float(topology_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
|
||||
raise SystemExit("Face model-layer topology rollback should restore the original Face area")
|
||||
|
||||
fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
fact_before_stats = fact_guard_cube.stats()
|
||||
fact_plan = fact_guard_cube.face_area_local_resize_plan(0, 144.0)
|
||||
original_fact_method = fact_guard_cube.face_first_level_facts
|
||||
|
||||
def apply_face_then_weaken_first_level_facts():
|
||||
fact_guard_cube._apply_local_face_deform(fact_plan)
|
||||
|
||||
def weak_facts(face_id: int, scope: str = "auto") -> dict[str, object]:
|
||||
facts = dict(original_fact_method(face_id, scope=scope))
|
||||
facts["first_level_fact_boundary_edge_count"] = 1
|
||||
facts["first_level_fact_boundary_vertex_count"] = 1
|
||||
facts["first_level_fact_adjacent_face_count"] = 0
|
||||
facts["first_level_fact_ignored_relation_depths"] = ("second-level",)
|
||||
return facts
|
||||
|
||||
fact_guard_cube.face_first_level_facts = weak_facts
|
||||
|
||||
try:
|
||||
_expect_blocked(
|
||||
"Face model-layer rollback on weakened first-level fact graph",
|
||||
lambda: fact_guard_cube._run_checked_face_edit(
|
||||
fact_plan,
|
||||
apply_face_then_weaken_first_level_facts,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
fact_guard_cube.face_first_level_facts = original_fact_method
|
||||
if fact_guard_cube.stats() != fact_before_stats:
|
||||
raise SystemExit(
|
||||
"Face model-layer fact-graph rollback should restore topology stats: "
|
||||
f"before={fact_before_stats}, after={fact_guard_cube.stats()}"
|
||||
)
|
||||
if abs(float(fact_guard_cube.face_info(0).get("area") or 0.0) - 100.0) > 1e-5:
|
||||
raise SystemExit("Face model-layer fact-graph rollback should restore the original Face area")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_coplanar_area_") as temp_dir:
|
||||
split_path = Path(temp_dir) / "split_top_box.step"
|
||||
_write_split_top_box(split_path)
|
||||
split_model = StepModel.load(split_path)
|
||||
top_faces = _top_faces(split_model, 10.0, 2e-4)
|
||||
if len(top_faces) != 2:
|
||||
raise SystemExit(f"coplanar area guard expected two top Faces, got {top_faces}")
|
||||
split_plan = split_model.push_pull_plan(top_faces[0], 1.0)
|
||||
if split_plan["status"] == "blocked":
|
||||
raise SystemExit(f"coplanar area guard plan was blocked: {split_plan['message']}")
|
||||
original_scope_area = float(split_plan.get("push_pull_scope_area") or 0.0)
|
||||
if abs(original_scope_area - 100.0) > 2e-4:
|
||||
raise SystemExit(f"coplanar area guard expected source area 100, got {original_scope_area:g}")
|
||||
split_model.push_pull_face(top_faces[0], 1.0)
|
||||
split_plan["push_pull_scope_area"] = original_scope_area * 2.0
|
||||
_expect_blocked(
|
||||
"Face push/pull scope area mismatch",
|
||||
lambda: split_model._checked_face_edit_result_summary(split_plan),
|
||||
)
|
||||
|
||||
plan_fact_guard_cube = StepModel.load(PROJECT_ROOT / "assets" / "models" / "cube_10mm.step")
|
||||
plan_info = plan_fact_guard_cube.face_info(0)
|
||||
plan_center = plan_info.get("area_center") or plan_info.get("bbox_center")
|
||||
if not isinstance(plan_center, tuple) or len(plan_center) != 3:
|
||||
raise SystemExit(f"Face plan guard probe needs a stable center: {plan_info}")
|
||||
plan_width = float(plan_info.get("local_face_width") or 0.0)
|
||||
plan_height = float(plan_info.get("local_face_height") or 0.0)
|
||||
if plan_width <= 0 or plan_height <= 0:
|
||||
raise SystemExit(f"Face plan guard probe needs local width/height: {plan_info}")
|
||||
original_fact_plan_method = plan_fact_guard_cube._first_level_fact_plan_fields
|
||||
|
||||
def unavailable_fact_plan(face_id: int, scope: str) -> dict[str, object]:
|
||||
return {
|
||||
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
|
||||
"first_level_fact_source_model": scope,
|
||||
"first_level_fact_status": "unavailable",
|
||||
"first_level_fact_relation_depth": 1,
|
||||
"first_level_fact_relation_boundary": "shared-edge",
|
||||
"first_level_fact_scope": scope,
|
||||
"first_level_fact_subject_role": "selected Face",
|
||||
"first_level_fact_subject_face_ids": (face_id,),
|
||||
"first_level_fact_subject_face_count": 1,
|
||||
"first_level_fact_boundary_edge_ids": (),
|
||||
"first_level_fact_boundary_edge_count": 0,
|
||||
"first_level_fact_boundary_vertex_points": (),
|
||||
"first_level_fact_boundary_vertex_count": 0,
|
||||
"first_level_fact_adjacent_face_ids": (),
|
||||
"first_level_fact_adjacent_face_count": 0,
|
||||
"first_level_fact_included_face_ids": (face_id,),
|
||||
"first_level_fact_included_face_count": 1,
|
||||
"first_level_fact_role_groups": (),
|
||||
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
|
||||
"first_level_fact_summary": "test-only unavailable first-level fact graph",
|
||||
}
|
||||
|
||||
plan_fact_guard_cube._first_level_fact_plan_fields = unavailable_fact_plan
|
||||
try:
|
||||
local_plans = (
|
||||
(
|
||||
"Face local center plan",
|
||||
plan_fact_guard_cube.face_center_local_move_plan(
|
||||
0,
|
||||
(float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])),
|
||||
),
|
||||
),
|
||||
("Face local area plan", plan_fact_guard_cube.face_area_local_resize_plan(0, 144.0)),
|
||||
("Face local width plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_width * 1.2, "width")),
|
||||
("Face local height plan", plan_fact_guard_cube.face_size_local_resize_plan(0, plan_height * 1.2, "height")),
|
||||
("Face local plane offset plan", plan_fact_guard_cube.face_plane_offset_local_plan(0, 1.0)),
|
||||
)
|
||||
for label, plan in local_plans:
|
||||
if plan.get("status") != "blocked":
|
||||
raise SystemExit(f"{label} should be blocked when first-level facts are unavailable: {plan}")
|
||||
blocker_text = str(plan.get("message") or plan.get("blockers") or "")
|
||||
if "一级事实图" not in blocker_text:
|
||||
raise SystemExit(f"{label} should explain the first-level fact blocker: {plan}")
|
||||
|
||||
owning_plan = plan_fact_guard_cube.face_center_owning_translation_plan(
|
||||
0,
|
||||
(float(plan_center[0]) + 1.0, float(plan_center[1]), float(plan_center[2])),
|
||||
)
|
||||
if owning_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
"Owning-shape Face translation should not be blocked by local first-level fact guard: "
|
||||
f"{owning_plan}"
|
||||
)
|
||||
finally:
|
||||
plan_fact_guard_cube._first_level_fact_plan_fields = original_fact_plan_method
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_sphere_") as temp_dir:
|
||||
sphere_path = Path(temp_dir) / "sphere.step"
|
||||
_write_step(BRepPrimAPI_MakeSphere(5.0).Shape(), sphere_path)
|
||||
@@ -299,6 +477,35 @@ def main() -> int:
|
||||
after_model=edited_cone,
|
||||
),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_face_guard_cone_rollback_") as temp_dir:
|
||||
cone_path = Path(temp_dir) / "simple_cone.step"
|
||||
_write_step(BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape(), cone_path)
|
||||
rollback_cone = StepModel.load(cone_path)
|
||||
cone_face_id = _first_face_by_surface(rollback_cone, "cone")
|
||||
before_stats = rollback_cone.stats()
|
||||
before_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0)
|
||||
rollback_plan = rollback_cone.conical_reference_radius_plan(cone_face_id, 5.0)
|
||||
|
||||
def apply_cone_then_fail_target_check():
|
||||
applied = rollback_cone._apply_conical_analytic_rebuild_if_simple(rollback_plan)
|
||||
rollback_plan["target_reference_radius"] = 999.0
|
||||
return applied
|
||||
|
||||
_expect_blocked(
|
||||
"Conical Face model-layer rollback on failed target check",
|
||||
lambda: rollback_cone._run_checked_face_edit(rollback_plan, apply_cone_then_fail_target_check),
|
||||
)
|
||||
if rollback_cone.stats() != before_stats:
|
||||
raise SystemExit(
|
||||
"Conical Face model-layer rollback should restore topology stats: "
|
||||
f"before={before_stats}, after={rollback_cone.stats()}"
|
||||
)
|
||||
restored_radius = float(rollback_cone.face_info(cone_face_id).get("reference_radius") or 0.0)
|
||||
if abs(restored_radius - before_radius) > 1e-5:
|
||||
raise SystemExit(
|
||||
"Conical Face model-layer rollback should restore the original reference radius: "
|
||||
f"before={before_radius:g}, after={restored_radius:g}"
|
||||
)
|
||||
_expect_allowed(
|
||||
"Non-Face edit solid-count change only warns",
|
||||
lambda: probe._verified_edit_quality_warnings(
|
||||
|
||||
@@ -86,6 +86,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"planar Face on curved Solid disables local-only deformation",
|
||||
("verify_face_mixed_surface_guard.py",),
|
||||
),
|
||||
(
|
||||
"cylindrical side Face height edits are checked",
|
||||
("verify_cylindrical_height_resize.py",),
|
||||
),
|
||||
(
|
||||
"large stepped cylindrical cap push pull stays local",
|
||||
("verify_large_stepped_cap_push_pull.py",),
|
||||
),
|
||||
(
|
||||
"non-rectangular planar Face local edits",
|
||||
("verify_face_nonrectangular_local_edit.py",),
|
||||
@@ -102,6 +110,18 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"property editor keeps generic Face edits out of cylindrical features",
|
||||
("verify_property_editor_specs.py",),
|
||||
),
|
||||
(
|
||||
"Face UI isolation contract",
|
||||
("verify_face_ui_isolation_contract.py",),
|
||||
),
|
||||
(
|
||||
"first-level fact graph is exposed",
|
||||
("verify_first_level_fact_graph.py",),
|
||||
),
|
||||
(
|
||||
"feature recognition summary is exposed",
|
||||
("verify_feature_recognition_summary.py",),
|
||||
),
|
||||
(
|
||||
"Face edited values read back into property rows",
|
||||
("verify_face_property_readback.py",),
|
||||
|
||||
@@ -3,6 +3,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from OCC.Core.BRep import BRep_Tool
|
||||
from OCC.Core.TopAbs import TopAbs_VERTEX
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopoDS import topods
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
@@ -24,13 +29,21 @@ def _center(info: dict[str, object]) -> tuple[float, float, float]:
|
||||
|
||||
|
||||
def _top_plane_face(model: StepModel) -> int:
|
||||
return _extreme_plane_face(model, highest=True)
|
||||
|
||||
|
||||
def _bottom_plane_face(model: StepModel) -> int:
|
||||
return _extreme_plane_face(model, highest=False)
|
||||
|
||||
|
||||
def _extreme_plane_face(model: StepModel, *, highest: bool) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = _center(info)
|
||||
if best is None or center[2] > best[0]:
|
||||
if best is None or (center[2] > best[0] if highest else center[2] < best[0]):
|
||||
best = (center[2], face_id)
|
||||
if best is None:
|
||||
raise SystemExit("No planar Face found.")
|
||||
@@ -52,6 +65,33 @@ def _assert_close(actual: float, expected: float, label: str) -> None:
|
||||
raise SystemExit(f"{label}: expected {expected:g}, got {actual:g}")
|
||||
|
||||
|
||||
def _edge_vertex_points(model: StepModel, edge_id: int) -> list[tuple[float, float, float]]:
|
||||
points: list[tuple[float, float, float]] = []
|
||||
explorer = TopExp_Explorer(model.edges[edge_id], TopAbs_VERTEX)
|
||||
while explorer.More():
|
||||
vertex = topods.Vertex(explorer.Current())
|
||||
point = BRep_Tool.Pnt(vertex)
|
||||
item = (float(point.X()), float(point.Y()), float(point.Z()))
|
||||
if not any(
|
||||
abs(item[0] - existing[0]) <= TOLERANCE
|
||||
and abs(item[1] - existing[1]) <= TOLERANCE
|
||||
and abs(item[2] - existing[2]) <= TOLERANCE
|
||||
for existing in points
|
||||
):
|
||||
points.append(item)
|
||||
explorer.Next()
|
||||
return points
|
||||
|
||||
|
||||
def _assert_points_z(points: object, expected: float, label: str) -> None:
|
||||
if not isinstance(points, (tuple, list)) or len(points) == 0:
|
||||
raise SystemExit(f"{label}: expected non-empty 3D points, got {points!r}")
|
||||
for index, point in enumerate(points):
|
||||
if not isinstance(point, (tuple, list)) or len(point) != 3:
|
||||
raise SystemExit(f"{label}: point {index} is not a 3D coordinate: {point!r}")
|
||||
_assert_close(float(point[2]), expected, f"{label} point {index} Z")
|
||||
|
||||
|
||||
def _assert_first_level_topology(model: StepModel, face_id: int) -> dict[str, object]:
|
||||
topology = model.face_first_level_topology(face_id)
|
||||
if topology.get("topology_relation_depth") != 1:
|
||||
@@ -149,10 +189,34 @@ def main() -> int:
|
||||
_assert_close(value, 6.0, f"First-level side Face {index} center Z")
|
||||
_assert_close(z_values[-1], 12.0, "Moved source Face center Z")
|
||||
|
||||
moved_face_id = _top_plane_face(model)
|
||||
moved_topology = _assert_first_level_topology(model, moved_face_id)
|
||||
_assert_points_z(
|
||||
moved_topology.get("first_level_boundary_vertex_points"),
|
||||
12.0,
|
||||
"Moved Face first-level boundary Vertex",
|
||||
)
|
||||
for edge_id in moved_topology.get("first_level_boundary_edge_ids", ()):
|
||||
_assert_points_z(
|
||||
_edge_vertex_points(model, int(edge_id)),
|
||||
12.0,
|
||||
f"Moved Face first-level boundary Edge {edge_id}",
|
||||
)
|
||||
|
||||
bottom_face_id = _bottom_plane_face(model)
|
||||
bottom_topology = model.face_first_level_topology(bottom_face_id)
|
||||
_assert_points_z(
|
||||
bottom_topology.get("first_level_boundary_vertex_points"),
|
||||
0.0,
|
||||
"Second-level bottom Face boundary Vertex",
|
||||
)
|
||||
|
||||
print(f"model={DEFAULT_MODEL}")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"topology={topology}")
|
||||
print(f"z_values_after_local_move={z_values}")
|
||||
print(f"moved_face_id={moved_face_id}")
|
||||
print(f"moved_boundary_edges={moved_topology.get('first_level_boundary_edge_ids')}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
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.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
@@ -35,6 +38,54 @@ def _write_cylinder(path: Path) -> None:
|
||||
_write_step(cylinder, path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder(path: Path) -> None:
|
||||
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
inner = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
3.0,
|
||||
80.0,
|
||||
).Shape()
|
||||
cut = BRepAlgoAPI_Cut(outer, inner)
|
||||
_write_step(_finalize_boolean_result(cut, "verify hollow cylinder cut", use_glue=False), path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder_with_side_feature(path: Path) -> None:
|
||||
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
inner = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
3.0,
|
||||
80.0,
|
||||
).Shape()
|
||||
hollow = _finalize_boolean_result(BRepAlgoAPI_Cut(outer, inner), "verify complex hollow cylinder cut", use_glue=False)
|
||||
notch = BRepPrimAPI_MakeBox(gp_Pnt(5.0, -3.0, 20.0), gp_Pnt(12.0, 3.0, 35.0)).Shape()
|
||||
cut = BRepAlgoAPI_Cut(hollow, notch)
|
||||
_write_step(_finalize_boolean_result(cut, "verify complex hollow cylinder side notch", use_glue=False), path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder_with_top_boundary_feature(path: Path) -> None:
|
||||
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
inner = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
3.0,
|
||||
80.0,
|
||||
).Shape()
|
||||
hollow = _finalize_boolean_result(
|
||||
BRepAlgoAPI_Cut(outer, inner),
|
||||
"verify hollow cylinder top-boundary base cut",
|
||||
use_glue=False,
|
||||
)
|
||||
notch = BRepPrimAPI_MakeBox(gp_Pnt(4.0, -1.0, 60.0), gp_Pnt(5.5, 1.0, 90.0)).Shape()
|
||||
cut = BRepAlgoAPI_Cut(hollow, notch)
|
||||
_write_step(_finalize_boolean_result(cut, "verify hollow cylinder top-boundary notch", use_glue=False), path)
|
||||
|
||||
|
||||
def _write_solid_cylinder_with_top_boundary_feature(path: Path) -> None:
|
||||
cylinder = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
notch = BRepPrimAPI_MakeBox(gp_Pnt(2.0, -1.0, 60.0), gp_Pnt(4.5, 1.0, 90.0)).Shape()
|
||||
cut = BRepAlgoAPI_Cut(cylinder, notch)
|
||||
_write_step(_finalize_boolean_result(cut, "verify solid cylinder top-boundary notch", use_glue=False), path)
|
||||
|
||||
|
||||
def _first_planar_cap_face(model: StepModel) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
@@ -43,6 +94,37 @@ def _first_planar_cap_face(model: StepModel) -> int:
|
||||
raise SystemExit("no planar cylinder cap Face was found")
|
||||
|
||||
|
||||
def _top_planar_cap_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = info.get("area_center") or info.get("bbox_center")
|
||||
if not isinstance(center, tuple) or len(center) != 3:
|
||||
continue
|
||||
z_value = float(center[2])
|
||||
if best is None or z_value > best[0]:
|
||||
best = (z_value, face_id)
|
||||
if best is None:
|
||||
raise SystemExit("no top planar cylinder cap Face was found")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _cylinder_radii(model: StepModel) -> list[float]:
|
||||
radii: list[float] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
radius = float(info.get("radius") or 0.0)
|
||||
if radius <= 0.0:
|
||||
continue
|
||||
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii):
|
||||
radii.append(radius)
|
||||
return sorted(radii)
|
||||
|
||||
|
||||
def _assert_blocked(plan: dict[str, object], label: str) -> None:
|
||||
if plan.get("status") != "blocked":
|
||||
raise SystemExit(f"{label} should be blocked for a planar Face on a curved Solid: {plan}")
|
||||
@@ -131,15 +213,38 @@ def main() -> int:
|
||||
raise SystemExit("planar cylinder cap push/pull scope should remain available")
|
||||
|
||||
huge_push_plan = model.push_pull_plan(face_id, 50.0)
|
||||
if huge_push_plan.get("status") != "blocked":
|
||||
raise SystemExit(f"huge planar cylinder cap push/pull should be blocked early: {huge_push_plan}")
|
||||
if huge_push_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"huge simple cylinder cap push/pull should use analytic rebuild: {huge_push_plan}")
|
||||
old_height = float(huge_push_plan.get("cylindrical_cap_extension_old_height") or 0.0)
|
||||
new_height = float(huge_push_plan.get("cylindrical_cap_extension_new_height") or 0.0)
|
||||
if old_height <= 0.0 or new_height <= old_height * 3.0:
|
||||
raise SystemExit(f"huge cap push/pull should expose the blocked height growth: {huge_push_plan}")
|
||||
raise SystemExit(f"huge cap push/pull should expose the large height growth: {huge_push_plan}")
|
||||
if huge_push_plan.get("cylindrical_cap_extension_kind") != "solid-cylinder":
|
||||
raise SystemExit(f"huge simple cylinder cap push/pull should expose solid-cylinder kind: {huge_push_plan}")
|
||||
huge_message = str(huge_push_plan.get("message") or "")
|
||||
if "圆柱高度" not in huge_message and "cylindrical" not in huge_message.lower():
|
||||
raise SystemExit(f"huge cap push/pull should explain the cylinder-height risk: {huge_push_plan}")
|
||||
if "解析" not in huge_message and "analytic" not in huge_message.lower():
|
||||
raise SystemExit(f"huge cap push/pull should explain analytic rebuild: {huge_push_plan}")
|
||||
|
||||
large_model = StepModel.load(path)
|
||||
large_face_id = _first_planar_cap_face(large_model)
|
||||
large_result = large_model.push_pull_face(large_face_id, 50.0)
|
||||
if "analytic rebuild" not in large_result:
|
||||
raise SystemExit(f"large simple cylinder cap push/pull should use analytic rebuild: {large_result}")
|
||||
if abs(_bbox_height(large_model) - 58.0) > 1e-4:
|
||||
raise SystemExit(f"large simple cylinder cap push/pull should rebuild height to 58: {_bbox_height(large_model)}")
|
||||
|
||||
shrink_model = StepModel.load(path)
|
||||
shrink_face_id = _first_planar_cap_face(shrink_model)
|
||||
shrink_plan = shrink_model.push_pull_plan(shrink_face_id, -3.0)
|
||||
if shrink_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"simple cylinder cap inward push/pull should use analytic shrink: {shrink_plan}")
|
||||
if shrink_plan.get("cylindrical_cap_operation") != "retract":
|
||||
raise SystemExit(f"simple cylinder cap inward push/pull should expose retract operation: {shrink_plan}")
|
||||
shrink_result = shrink_model.push_pull_face(shrink_face_id, -3.0)
|
||||
if "analytic rebuild" not in shrink_result or "cap_operation=retract" not in shrink_result:
|
||||
raise SystemExit(f"simple cylinder cap inward push/pull should use analytic retract: {shrink_result}")
|
||||
if abs(_bbox_height(shrink_model) - 5.0) > 1e-4:
|
||||
raise SystemExit(f"simple cylinder cap inward push/pull should shrink height to 5: {_bbox_height(shrink_model)}")
|
||||
|
||||
before_height = _bbox_height(model)
|
||||
push_plan = model.push_pull_plan(face_id, 1.0)
|
||||
@@ -158,6 +263,275 @@ def main() -> int:
|
||||
f"face_id={face_id}, blocker={blocker}, height={before_height:g}->{after_height:g}, "
|
||||
f"push_pull={push_result}"
|
||||
)
|
||||
|
||||
hollow_path = Path(temp_dir) / "hollow_cylinder.step"
|
||||
_write_hollow_cylinder(hollow_path)
|
||||
hollow = StepModel.load(hollow_path)
|
||||
hollow_face_id = _top_planar_cap_face(hollow)
|
||||
hollow_plan = hollow.push_pull_plan(hollow_face_id, 89.0)
|
||||
if hollow_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"hollow cylinder cap push/pull should not be blocked: {hollow_plan}")
|
||||
if hollow_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(f"hollow cylinder cap should be recognized as coaxial-tube: {hollow_plan}")
|
||||
if abs(float(hollow_plan.get("cylindrical_cap_extension_inner_radius") or 0.0) - 3.0) > 1e-4:
|
||||
raise SystemExit(f"hollow cylinder cap should preserve inner radius in plan: {hollow_plan}")
|
||||
hollow_result = hollow.push_pull_face(hollow_face_id, 89.0)
|
||||
if "analytic rebuild" not in hollow_result or "coaxial-tube" not in hollow_result:
|
||||
raise SystemExit(f"hollow cylinder cap push/pull should use tube analytic rebuild: {hollow_result}")
|
||||
if abs(_bbox_height(hollow) - 167.0) > 1e-4:
|
||||
raise SystemExit(f"hollow cylinder cap push/pull should rebuild height to 167: {_bbox_height(hollow)}")
|
||||
radii = _cylinder_radii(hollow)
|
||||
if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-4 or abs(radii[1] - 8.0) > 1e-4:
|
||||
raise SystemExit(f"hollow cylinder cap push/pull should preserve inner/outer cylindrical walls: {radii}")
|
||||
print(f"hollow cylinder cap analytic push/pull ok: radii={radii}, result={hollow_result}")
|
||||
|
||||
hollow_shrink = StepModel.load(hollow_path)
|
||||
hollow_shrink_face_id = _top_planar_cap_face(hollow_shrink)
|
||||
hollow_shrink_plan = hollow_shrink.push_pull_plan(hollow_shrink_face_id, -39.0)
|
||||
if hollow_shrink_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"hollow cylinder cap inward push/pull should not be blocked: {hollow_shrink_plan}")
|
||||
if hollow_shrink_plan.get("cylindrical_cap_operation") != "retract":
|
||||
raise SystemExit(f"hollow cylinder cap inward push/pull should expose retract operation: {hollow_shrink_plan}")
|
||||
hollow_shrink_result = hollow_shrink.push_pull_face(hollow_shrink_face_id, -39.0)
|
||||
if "analytic rebuild" not in hollow_shrink_result or "coaxial-tube" not in hollow_shrink_result:
|
||||
raise SystemExit(f"hollow cylinder cap inward push/pull should use tube analytic shrink: {hollow_shrink_result}")
|
||||
if abs(_bbox_height(hollow_shrink) - 39.0) > 1e-4:
|
||||
raise SystemExit(f"hollow cylinder cap inward push/pull should shrink height to 39: {_bbox_height(hollow_shrink)}")
|
||||
shrink_radii = _cylinder_radii(hollow_shrink)
|
||||
if len(shrink_radii) != 2 or abs(shrink_radii[0] - 3.0) > 1e-4 or abs(shrink_radii[1] - 8.0) > 1e-4:
|
||||
raise SystemExit(f"hollow cylinder cap inward push/pull should preserve radii: {shrink_radii}")
|
||||
print(f"hollow cylinder cap analytic retraction ok: radii={shrink_radii}, result={hollow_shrink_result}")
|
||||
|
||||
complex_hollow_path = Path(temp_dir) / "hollow_cylinder_with_side_feature.step"
|
||||
_write_hollow_cylinder_with_side_feature(complex_hollow_path)
|
||||
complex_hollow = StepModel.load(complex_hollow_path)
|
||||
complex_hollow_face_id = _top_planar_cap_face(complex_hollow)
|
||||
complex_plan = complex_hollow.push_pull_plan(complex_hollow_face_id, 89.0)
|
||||
if complex_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"complex hollow cylinder cap push/pull should not be blocked: {complex_plan}")
|
||||
if complex_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(f"complex hollow cylinder cap should be recognized as coaxial-tube: {complex_plan}")
|
||||
if not complex_plan.get("cylindrical_cap_extension_uses_local_segment"):
|
||||
raise SystemExit(f"complex hollow cylinder cap should expose local extension segment: {complex_plan}")
|
||||
complex_result = complex_hollow.push_pull_face(complex_hollow_face_id, 89.0)
|
||||
if "local extension" not in complex_result or "coaxial-tube" not in complex_result:
|
||||
raise SystemExit(f"complex hollow cylinder cap should use local extension segment: {complex_result}")
|
||||
if abs(_bbox_height(complex_hollow) - 167.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"complex hollow cylinder cap push/pull should extend height to 167: {_bbox_height(complex_hollow)}"
|
||||
)
|
||||
complex_radii = _cylinder_radii(complex_hollow)
|
||||
if len(complex_radii) < 2 or abs(complex_radii[0] - 3.0) > 1e-4 or abs(complex_radii[-1] - 8.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"complex hollow cylinder cap should preserve inner/outer cylindrical walls: {complex_radii}"
|
||||
)
|
||||
print(
|
||||
"complex hollow cylinder cap local extension ok: "
|
||||
f"radii={complex_radii}, result={complex_result}"
|
||||
)
|
||||
|
||||
complex_shrink = StepModel.load(complex_hollow_path)
|
||||
complex_shrink_face_id = _top_planar_cap_face(complex_shrink)
|
||||
complex_shrink_plan = complex_shrink.push_pull_plan(complex_shrink_face_id, -20.0)
|
||||
if complex_shrink_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"complex hollow cylinder cap inward push/pull should not be blocked: {complex_shrink_plan}")
|
||||
if complex_shrink_plan.get("cylindrical_cap_operation") != "retract":
|
||||
raise SystemExit(f"complex hollow cylinder cap inward push/pull should expose retract: {complex_shrink_plan}")
|
||||
complex_shrink_result = complex_shrink.push_pull_face(complex_shrink_face_id, -20.0)
|
||||
if "local retraction" not in complex_shrink_result or "coaxial-tube" not in complex_shrink_result:
|
||||
raise SystemExit(f"complex hollow cylinder cap inward push/pull should use local retraction: {complex_shrink_result}")
|
||||
if abs(_bbox_height(complex_shrink) - 58.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"complex hollow cylinder cap inward push/pull should shrink height to 58: {_bbox_height(complex_shrink)}"
|
||||
)
|
||||
complex_shrink_radii = _cylinder_radii(complex_shrink)
|
||||
if (
|
||||
len(complex_shrink_radii) < 2
|
||||
or abs(complex_shrink_radii[0] - 3.0) > 1e-4
|
||||
or abs(complex_shrink_radii[-1] - 8.0) > 1e-4
|
||||
):
|
||||
raise SystemExit(
|
||||
f"complex hollow cylinder cap inward push/pull should preserve radii: {complex_shrink_radii}"
|
||||
)
|
||||
print(
|
||||
"complex hollow cylinder cap local retraction ok: "
|
||||
f"radii={complex_shrink_radii}, result={complex_shrink_result}"
|
||||
)
|
||||
|
||||
top_boundary_path = Path(temp_dir) / "hollow_cylinder_with_top_boundary_feature.step"
|
||||
_write_hollow_cylinder_with_top_boundary_feature(top_boundary_path)
|
||||
top_boundary = StepModel.load(top_boundary_path)
|
||||
top_boundary_face_id = _top_planar_cap_face(top_boundary)
|
||||
top_boundary_plan = top_boundary.push_pull_plan(top_boundary_face_id, 89.0)
|
||||
if top_boundary_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder cap push/pull should preserve extra first-level openings: {top_boundary_plan}"
|
||||
)
|
||||
if top_boundary_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder cap should be recognized as coaxial-tube: {top_boundary_plan}"
|
||||
)
|
||||
if top_boundary_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder cap should use cap profile prism method: {top_boundary_plan}"
|
||||
)
|
||||
if int(top_boundary_plan.get("cap_extra_adjacent_face_count") or 0) <= 0:
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder cap should expose extra adjacent faces: {top_boundary_plan}"
|
||||
)
|
||||
top_boundary_message = str(top_boundary_plan.get("message") or "")
|
||||
if "真实轮廓拉伸" not in top_boundary_message:
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder cap should explain profile-prism extension: {top_boundary_plan}"
|
||||
)
|
||||
try:
|
||||
top_boundary.push_pull_face(top_boundary_face_id, 89.0)
|
||||
except ValueError as exc:
|
||||
blocker_text = str(exc)
|
||||
if "解析重建" not in blocker_text and "OCCT" not in blocker_text:
|
||||
raise SystemExit(
|
||||
"top-boundary hollow cylinder large cap push/pull should explain the fast blocker: "
|
||||
f"{blocker_text}"
|
||||
)
|
||||
else:
|
||||
raise SystemExit(
|
||||
"top-boundary hollow cylinder large cap push/pull should be blocked instead of entering slow Boolean."
|
||||
)
|
||||
print(
|
||||
"top-boundary hollow cylinder cap large profile-prism blocker ok: "
|
||||
f"extra_adjacent={top_boundary_plan.get('cap_extra_adjacent_face_count')}, "
|
||||
f"distance=89"
|
||||
)
|
||||
|
||||
top_boundary_shallow_cut = StepModel.load(top_boundary_path)
|
||||
shallow_face_id = _top_planar_cap_face(top_boundary_shallow_cut)
|
||||
shallow_plan = top_boundary_shallow_cut.push_pull_plan(shallow_face_id, -10.0)
|
||||
if shallow_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder shallow inward push/pull should stay available: {shallow_plan}"
|
||||
)
|
||||
if shallow_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder shallow inward push/pull should use cap profile prism: {shallow_plan}"
|
||||
)
|
||||
shallow_result = top_boundary_shallow_cut.push_pull_face(shallow_face_id, -10.0)
|
||||
if "profile-prism retraction" not in shallow_result or "coaxial-tube" not in shallow_result:
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder shallow inward push/pull should use profile-prism retraction: {shallow_result}"
|
||||
)
|
||||
if abs(_bbox_height(top_boundary_shallow_cut) - 68.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
"top-boundary hollow cylinder shallow inward push/pull should lower height to 68: "
|
||||
f"{_bbox_height(top_boundary_shallow_cut)}"
|
||||
)
|
||||
shallow_top_face = _top_planar_cap_face(top_boundary_shallow_cut)
|
||||
shallow_info = top_boundary_shallow_cut.face_info(shallow_top_face)
|
||||
if int(shallow_info.get("boundary_wires") or 0) < 3:
|
||||
raise SystemExit(
|
||||
"top-boundary hollow cylinder shallow inward push/pull should preserve openings: "
|
||||
f"face_id={shallow_top_face}, info={shallow_info}"
|
||||
)
|
||||
print(
|
||||
"top-boundary hollow cylinder cap profile-prism shallow retraction ok: "
|
||||
f"result={shallow_result}"
|
||||
)
|
||||
|
||||
top_boundary_deep_cut = StepModel.load(top_boundary_path)
|
||||
deep_face_id = _top_planar_cap_face(top_boundary_deep_cut)
|
||||
deep_plan = top_boundary_deep_cut.push_pull_plan(deep_face_id, -20.0)
|
||||
if deep_plan.get("status") != "blocked":
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder deep inward push/pull should be blocked: {deep_plan}"
|
||||
)
|
||||
deep_message = str(deep_plan.get("message") or "")
|
||||
if "越过" not in deep_message or "内侧终点" not in deep_message:
|
||||
raise SystemExit(
|
||||
f"top-boundary hollow cylinder deep inward blocker should explain the retract limit: {deep_plan}"
|
||||
)
|
||||
print(
|
||||
"top-boundary hollow cylinder cap deep retraction blocker ok: "
|
||||
f"message={deep_message}"
|
||||
)
|
||||
|
||||
solid_top_boundary_path = Path(temp_dir) / "solid_cylinder_with_top_boundary_feature.step"
|
||||
_write_solid_cylinder_with_top_boundary_feature(solid_top_boundary_path)
|
||||
solid_top_boundary = StepModel.load(solid_top_boundary_path)
|
||||
solid_top_face_id = _top_planar_cap_face(solid_top_boundary)
|
||||
solid_extend_plan = solid_top_boundary.push_pull_plan(solid_top_face_id, 20.0)
|
||||
if solid_extend_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder cap outward push/pull should stay available: {solid_extend_plan}"
|
||||
)
|
||||
if solid_extend_plan.get("cylindrical_cap_extension_kind") != "solid-cylinder":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder cap should be recognized as solid-cylinder: {solid_extend_plan}"
|
||||
)
|
||||
if solid_extend_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder cap should use profile prism extension: {solid_extend_plan}"
|
||||
)
|
||||
solid_extend_result = solid_top_boundary.push_pull_face(solid_top_face_id, 20.0)
|
||||
if "profile-prism extension" not in solid_extend_result or "solid-cylinder" not in solid_extend_result:
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder cap should use profile-prism extension: {solid_extend_result}"
|
||||
)
|
||||
if abs(_bbox_height(solid_top_boundary) - 98.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder cap outward push/pull should extend height to 98: {_bbox_height(solid_top_boundary)}"
|
||||
)
|
||||
solid_extended_top = _top_planar_cap_face(solid_top_boundary)
|
||||
solid_extended_info = solid_top_boundary.face_info(solid_extended_top)
|
||||
if int(solid_extended_info.get("boundary_wires") or 0) < 2:
|
||||
raise SystemExit(
|
||||
"solid top-boundary cylinder cap outward push/pull should preserve the notch opening: "
|
||||
f"face_id={solid_extended_top}, info={solid_extended_info}"
|
||||
)
|
||||
print(
|
||||
"solid top-boundary cylinder cap profile-prism extension ok: "
|
||||
f"result={solid_extend_result}"
|
||||
)
|
||||
|
||||
solid_shallow_cut = StepModel.load(solid_top_boundary_path)
|
||||
solid_shallow_face_id = _top_planar_cap_face(solid_shallow_cut)
|
||||
solid_shallow_plan = solid_shallow_cut.push_pull_plan(solid_shallow_face_id, -10.0)
|
||||
if solid_shallow_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder shallow inward push/pull should stay available: {solid_shallow_plan}"
|
||||
)
|
||||
if solid_shallow_plan.get("cylindrical_cap_extension_method") != "cap-profile-prism":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder shallow inward push/pull should use profile prism: {solid_shallow_plan}"
|
||||
)
|
||||
solid_shallow_result = solid_shallow_cut.push_pull_face(solid_shallow_face_id, -10.0)
|
||||
if "profile-prism retraction" not in solid_shallow_result or "solid-cylinder" not in solid_shallow_result:
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder shallow inward push/pull should use profile-prism retraction: {solid_shallow_result}"
|
||||
)
|
||||
if abs(_bbox_height(solid_shallow_cut) - 68.0) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder shallow inward push/pull should lower height to 68: {_bbox_height(solid_shallow_cut)}"
|
||||
)
|
||||
print(
|
||||
"solid top-boundary cylinder cap profile-prism shallow retraction ok: "
|
||||
f"result={solid_shallow_result}"
|
||||
)
|
||||
|
||||
solid_deep_cut = StepModel.load(solid_top_boundary_path)
|
||||
solid_deep_face_id = _top_planar_cap_face(solid_deep_cut)
|
||||
solid_deep_plan = solid_deep_cut.push_pull_plan(solid_deep_face_id, -20.0)
|
||||
if solid_deep_plan.get("status") != "blocked":
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder deep inward push/pull should be blocked: {solid_deep_plan}"
|
||||
)
|
||||
solid_deep_message = str(solid_deep_plan.get("message") or "")
|
||||
if "越过" not in solid_deep_message or "内侧终点" not in solid_deep_message:
|
||||
raise SystemExit(
|
||||
f"solid top-boundary cylinder deep inward blocker should explain the retract limit: {solid_deep_plan}"
|
||||
)
|
||||
print(
|
||||
"solid top-boundary cylinder cap deep retraction blocker ok: "
|
||||
f"message={solid_deep_message}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ def main() -> int:
|
||||
if float(near_through.get("push_pull_inward_cut_ratio") or 0.0) < 0.85:
|
||||
raise SystemExit(f"near-through cut should report a high inward cut ratio: {near_through}")
|
||||
|
||||
large_outward = model.push_pull_plan(face_id, 11.0)
|
||||
_assert_plan("large outward push/pull", large_outward, "caution", "high")
|
||||
large_message = str(large_outward.get("message") or large_outward.get("warnings") or "")
|
||||
if "隔离子进程" not in large_message and "OCCT" not in large_message:
|
||||
raise SystemExit(f"large outward push/pull warning should explain isolated high-risk execution: {large_outward}")
|
||||
if float(large_outward.get("push_pull_distance_to_owning_axis_span_ratio") or 0.0) <= 1.0:
|
||||
raise SystemExit(f"large outward push/pull should report the owning-axis span ratio: {large_outward}")
|
||||
|
||||
through = model.push_pull_plan(face_id, -10.0)
|
||||
_assert_plan("through inward cut", through, "blocked", "blocked")
|
||||
message = str(through.get("message") or through.get("blockers") or "")
|
||||
|
||||
@@ -283,6 +283,8 @@ def main() -> int:
|
||||
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
|
||||
if "Face result check:" not in result:
|
||||
raise SystemExit(f"Face edit result message should include a result check, got: {result}")
|
||||
if "First-level check:" not in result:
|
||||
raise SystemExit(f"Planar Face edit result should include a first-level topology check, got: {result}")
|
||||
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
EXPECTED_FACE_ISOLATED_OPERATIONS = {
|
||||
"push_pull_face",
|
||||
"move_face_plane_offset_local",
|
||||
"resize_face_area_local",
|
||||
"resize_face_area",
|
||||
"resize_face_size_local",
|
||||
"resize_face_size_owning_scale",
|
||||
"move_face_center_local",
|
||||
"resize_shell_thickness",
|
||||
"resize_shell_thickness_owning_scale",
|
||||
"resize_cylindrical_height",
|
||||
"resize_cylindrical_boss_height",
|
||||
"resize_cylindrical_height_owning_scale",
|
||||
"resize_cone_reference_radius",
|
||||
"resize_cone_semi_angle",
|
||||
"resize_sphere_radius",
|
||||
"resize_torus_radius",
|
||||
}
|
||||
|
||||
|
||||
def _source_tree(relative_path: str) -> ast.Module:
|
||||
return ast.parse((PROJECT_ROOT / relative_path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise SystemExit(f"function {name!r} was not found")
|
||||
|
||||
|
||||
def _constant_string_set(node: ast.AST, variable_name: str) -> set[str]:
|
||||
for child in ast.walk(node):
|
||||
if not isinstance(child, ast.Assign):
|
||||
continue
|
||||
if not any(isinstance(target, ast.Name) and target.id == variable_name for target in child.targets):
|
||||
continue
|
||||
if not isinstance(child.value, (ast.Set, ast.Tuple, ast.List)):
|
||||
raise SystemExit(f"{variable_name} should be a literal collection")
|
||||
values: set[str] = set()
|
||||
for item in child.value.elts:
|
||||
if not isinstance(item, ast.Constant) or not isinstance(item.value, str):
|
||||
raise SystemExit(f"{variable_name} contains a non-string item: {ast.dump(item)}")
|
||||
values.add(item.value)
|
||||
return values
|
||||
raise SystemExit(f"{variable_name} assignment was not found")
|
||||
|
||||
|
||||
def _worker_operation_set(node: ast.AST) -> set[str]:
|
||||
operations: set[str] = set()
|
||||
for child in ast.walk(node):
|
||||
if not isinstance(child, ast.Compare):
|
||||
continue
|
||||
if not isinstance(child.left, ast.Name) or child.left.id != "operation":
|
||||
continue
|
||||
if len(child.ops) != 1 or not isinstance(child.ops[0], ast.Eq):
|
||||
continue
|
||||
if len(child.comparators) != 1:
|
||||
continue
|
||||
comparator = child.comparators[0]
|
||||
if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str):
|
||||
operations.add(comparator.value)
|
||||
return operations
|
||||
|
||||
|
||||
def _assert_same(label: str, actual: set[str], expected: set[str]) -> None:
|
||||
missing = sorted(expected - actual)
|
||||
extra = sorted(actual - expected)
|
||||
if missing or extra:
|
||||
raise SystemExit(f"{label} mismatch: missing={missing}, extra={extra}")
|
||||
|
||||
|
||||
PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
|
||||
"push_pull_face": "push_pull_face",
|
||||
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
|
||||
"resize_face_area_local": "resize_face_area_local",
|
||||
"resize_face_area": "resize_face_area",
|
||||
"resize_face_width_local": "resize_face_size_local",
|
||||
"resize_face_height_local": "resize_face_size_local",
|
||||
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
|
||||
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
|
||||
"move_selected_face_center_local": "move_face_center_local",
|
||||
"resize_shell_thickness": "resize_shell_thickness",
|
||||
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
|
||||
"resize_cylinder_height": "resize_cylindrical_height",
|
||||
"resize_boss_height": "resize_cylindrical_boss_height",
|
||||
"resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale",
|
||||
"resize_cone_reference_radius": "resize_cone_reference_radius",
|
||||
"resize_cone_semi_angle": "resize_cone_semi_angle",
|
||||
"resize_sphere_radius": "resize_sphere_radius",
|
||||
"resize_torus_major_radius": "resize_torus_radius",
|
||||
"resize_torus_minor_radius": "resize_torus_radius",
|
||||
}
|
||||
|
||||
|
||||
PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD = {
|
||||
# Pure owning-shape affine translations are intentionally lightweight and do
|
||||
# not enter the OCC rebuild/Boolean isolation worker.
|
||||
"move_selected_face_center",
|
||||
"move_selected_face_plane_position_by_translation",
|
||||
}
|
||||
|
||||
|
||||
ACTION_IMPLEMENTATION_METHOD = {
|
||||
"resize_face_width_local": "_resize_face_size_local",
|
||||
"resize_face_height_local": "_resize_face_size_local",
|
||||
"resize_face_width_owning_scale": "_resize_face_size_owning_scale",
|
||||
"resize_face_height_owning_scale": "_resize_face_size_owning_scale",
|
||||
"resize_torus_major_radius": "_resize_torus_radius",
|
||||
"resize_torus_minor_radius": "_resize_torus_radius",
|
||||
}
|
||||
|
||||
|
||||
def _property_spec_actions_for_keys(specs: list[dict[str, object]], keys: set[str]) -> set[str]:
|
||||
actions: set[str] = set()
|
||||
for spec in specs:
|
||||
key = str(spec.get("key", ""))
|
||||
if key not in keys:
|
||||
continue
|
||||
action = str(spec.get("action") or "")
|
||||
if action:
|
||||
actions.add(action)
|
||||
scope_modes = spec.get("scope_modes")
|
||||
if isinstance(scope_modes, dict):
|
||||
for mode in scope_modes.values():
|
||||
if not isinstance(mode, dict):
|
||||
continue
|
||||
action = str(mode.get("action") or "")
|
||||
if action:
|
||||
actions.add(action)
|
||||
return actions
|
||||
|
||||
|
||||
def _face_property_actions_from_specs() -> set[str]:
|
||||
from verify_property_editor_specs import _specs
|
||||
|
||||
plane_info = {
|
||||
"surface": "plane",
|
||||
"area": 100.0,
|
||||
"area_center": (5.0, 5.0, 0.0),
|
||||
"bbox_center": (5.0, 5.0, 0.0),
|
||||
"local_face_width": 10.0,
|
||||
"local_face_height": 10.0,
|
||||
"local_face_size_center": (5.0, 5.0, 0.0),
|
||||
"plane_origin": (0.0, 0.0, 0.0),
|
||||
"push_pull_outward_direction": (0.0, 0.0, 1.0),
|
||||
"normal": (0.0, 0.0, 1.0),
|
||||
"topology_relation_depth": 1,
|
||||
"topology_relation_status": "ready",
|
||||
"same_domain_face_count": 1,
|
||||
"first_level_boundary_edge_count": 4,
|
||||
"first_level_boundary_vertex_count": 4,
|
||||
"first_level_adjacent_face_count": 4,
|
||||
}
|
||||
shell_info = {
|
||||
**plane_info,
|
||||
"shell_region_status": "candidate",
|
||||
"shell_thickness_estimate": 2.0,
|
||||
"shell_current_thickness": 2.0,
|
||||
"shell_signed_thickness": 2.0,
|
||||
"shell_opposite_face_id": 2,
|
||||
"shell_overlap_ratio_estimate": 1.0,
|
||||
}
|
||||
generic_cylinder_info = {
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 6.283185307179586,
|
||||
"height_estimate": 11.0,
|
||||
"same_domain_height_estimate": 11.0,
|
||||
"area": 207.0,
|
||||
"area_center": (0.0, 0.0, 5.5),
|
||||
"bbox_center": (0.0, 0.0, 5.5),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
"axis_center": (0.0, 0.0, 5.5),
|
||||
}
|
||||
boss_info = {
|
||||
**generic_cylinder_info,
|
||||
"feature_guess": "boss/outer-round candidate",
|
||||
"height_estimate": 5.0,
|
||||
"same_domain_height_estimate": 5.0,
|
||||
"feature_start_end_face_ids": (1,),
|
||||
}
|
||||
cases = (
|
||||
(
|
||||
plane_info,
|
||||
{
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
},
|
||||
),
|
||||
(shell_info, {"shell_thickness_estimate"}),
|
||||
(generic_cylinder_info, {"cylinder_height"}),
|
||||
(boss_info, {"boss_height"}),
|
||||
(
|
||||
{
|
||||
"surface": "cone",
|
||||
"reference_radius": 5.0,
|
||||
"diameter": 10.0,
|
||||
"radius": 5.0,
|
||||
"semi_angle": 0.5,
|
||||
"area": 100.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
},
|
||||
{"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"},
|
||||
),
|
||||
(
|
||||
{
|
||||
"surface": "sphere",
|
||||
"radius": 5.0,
|
||||
"diameter": 10.0,
|
||||
"area": 100.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"center": (0.0, 0.0, 0.0),
|
||||
},
|
||||
{"sphere_radius", "sphere_diameter"},
|
||||
),
|
||||
(
|
||||
{
|
||||
"surface": "torus",
|
||||
"major_radius": 8.0,
|
||||
"minor_radius": 2.0,
|
||||
"major_diameter": 16.0,
|
||||
"minor_diameter": 4.0,
|
||||
"area": 100.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"center": (0.0, 0.0, 0.0),
|
||||
},
|
||||
{"torus_major_radius", "torus_major_diameter", "torus_minor_radius", "torus_minor_diameter"},
|
||||
),
|
||||
)
|
||||
actions: set[str] = set()
|
||||
for info, keys in cases:
|
||||
actions.update(_property_spec_actions_for_keys(_specs(info), keys))
|
||||
return actions
|
||||
|
||||
|
||||
def _method_isolation_operations(tree: ast.Module, method_name: str) -> set[str]:
|
||||
method = _function_node(tree, method_name)
|
||||
operations: set[str] = set()
|
||||
for child in ast.walk(method):
|
||||
if not isinstance(child, ast.Call):
|
||||
continue
|
||||
callee = child.func
|
||||
if not isinstance(callee, ast.Attribute) or callee.attr != "_isolation_for_plan":
|
||||
continue
|
||||
if len(child.args) < 2:
|
||||
raise SystemExit(f"{method_name} calls _isolation_for_plan without an operation argument")
|
||||
operation_arg = child.args[1]
|
||||
if not isinstance(operation_arg, ast.Constant) or not isinstance(operation_arg.value, str):
|
||||
raise SystemExit(f"{method_name} should pass a literal isolated operation name")
|
||||
operations.add(operation_arg.value)
|
||||
return operations
|
||||
|
||||
|
||||
def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None:
|
||||
property_actions = _face_property_actions_from_specs()
|
||||
covered = set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) | PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD
|
||||
missing = sorted(property_actions - covered)
|
||||
stale = sorted(set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) - property_actions)
|
||||
if missing or stale:
|
||||
raise SystemExit(f"Face property action contract mismatch: missing={missing}, stale={stale}")
|
||||
|
||||
for action, isolated_operation in sorted(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION.items()):
|
||||
if isolated_operation not in EXPECTED_FACE_ISOLATED_OPERATIONS:
|
||||
raise SystemExit(f"{action} maps to non-worker isolated operation {isolated_operation!r}")
|
||||
method_name = ACTION_IMPLEMENTATION_METHOD.get(action, action)
|
||||
actual_operations = _method_isolation_operations(actions_tree, method_name)
|
||||
if isolated_operation not in actual_operations:
|
||||
raise SystemExit(
|
||||
f"{action} should enter isolated operation {isolated_operation!r} through {method_name}; "
|
||||
f"actual={sorted(actual_operations)}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
worker_execute = _function_node(_source_tree("step_editor/isolated_edit_worker.py"), "_execute")
|
||||
worker_ops = _worker_operation_set(worker_execute)
|
||||
_assert_same("isolated worker operations", worker_ops, EXPECTED_FACE_ISOLATED_OPERATIONS)
|
||||
|
||||
actions_tree = _source_tree("step_editor/window_actions.py")
|
||||
isolation_func = _function_node(actions_tree, "_isolation_for_plan")
|
||||
ui_ops = _constant_string_set(isolation_func, "isolated_face_operations")
|
||||
_assert_same("UI isolation operations", ui_ops, EXPECTED_FACE_ISOLATED_OPERATIONS)
|
||||
_assert_same("UI/worker isolation operation contract", ui_ops, worker_ops)
|
||||
_assert_property_face_actions_are_isolated(actions_tree)
|
||||
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
|
||||
class _Probe(WindowActionMixin):
|
||||
pass
|
||||
|
||||
probe = _Probe()
|
||||
for risk in ("low", "medium", "high"):
|
||||
for operation in sorted(EXPECTED_FACE_ISOLATED_OPERATIONS):
|
||||
isolation = probe._isolation_for_plan(
|
||||
{"risk": risk},
|
||||
operation,
|
||||
[1, 2, 3],
|
||||
timeout_seconds=7.5,
|
||||
)
|
||||
if isolation is None:
|
||||
raise SystemExit(f"{operation}/{risk} should request isolated execution")
|
||||
if isolation.get("operation") != operation:
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong operation: {isolation}")
|
||||
if isolation.get("args") != [1, 2, 3]:
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong args: {isolation}")
|
||||
if float(isolation.get("timeout_seconds") or 0.0) != 7.5:
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong timeout: {isolation}")
|
||||
if str(isolation.get("reason") or "") != f"{risk}-risk-face-occ-edit":
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong reason: {isolation}")
|
||||
|
||||
if probe._isolation_for_plan({"risk": "blocked"}, "push_pull_face", [0, 1]) is not None:
|
||||
raise SystemExit("blocked plans should not enter isolated execution")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "resize_cylindrical_hole", [0, 20]) is not None:
|
||||
raise SystemExit("non-Face feature operations should not be covered by the Face isolation contract")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "unknown_operation", []) is not None:
|
||||
raise SystemExit("unknown operations should not enter isolated execution")
|
||||
|
||||
from step_editor.model import StepModel
|
||||
|
||||
complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
||||
if complex_path.exists():
|
||||
model = StepModel.load(complex_path)
|
||||
complex_face_id = 1
|
||||
complex_probe = _Probe()
|
||||
complex_probe.model = model
|
||||
complex_probe.step_path = complex_path
|
||||
complex_probe.selected_face_id = complex_face_id
|
||||
complex_probe.current_info_values = model.quick_face_info(complex_face_id)
|
||||
checks = (
|
||||
("area local", lambda: complex_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0), "blocked"),
|
||||
(
|
||||
"width local",
|
||||
lambda: complex_probe._quick_blocked_face_size_local_plan(complex_face_id, 50.0, "width"),
|
||||
"blocked",
|
||||
),
|
||||
(
|
||||
"offset local",
|
||||
lambda: complex_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0),
|
||||
"blocked",
|
||||
),
|
||||
(
|
||||
"center local",
|
||||
lambda: complex_probe._quick_blocked_face_center_local_plan(complex_face_id, (0.0, 50.0, 0.0)),
|
||||
"blocked",
|
||||
),
|
||||
(
|
||||
"shell thickness",
|
||||
lambda: complex_probe._deferred_shell_thickness_plan_if_needed(complex_face_id, 10.0),
|
||||
"caution",
|
||||
),
|
||||
)
|
||||
for label, callback, expected_status in checks:
|
||||
started = time.perf_counter()
|
||||
plan = callback()
|
||||
elapsed = time.perf_counter() - started
|
||||
if plan is None:
|
||||
raise SystemExit(f"{label} complex Face UI preflight should return a quick plan")
|
||||
if elapsed > 0.05:
|
||||
raise SystemExit(f"{label} complex Face UI preflight should be quick, elapsed={elapsed:.3f}s")
|
||||
if str(plan.get("status")) != expected_status:
|
||||
raise SystemExit(f"{label} complex Face UI preflight returned wrong status: {plan}")
|
||||
if expected_status == "blocked" and not plan.get("ui_quick_blocked_local_face_plan"):
|
||||
raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}")
|
||||
if label == "shell thickness" and not plan.get("ui_deferred_shell_thickness_plan"):
|
||||
raise SystemExit(f"{label} should defer full shell-thickness planning: {plan}")
|
||||
|
||||
uncached_probe = _Probe()
|
||||
uncached_probe.model = model
|
||||
uncached_probe.step_path = complex_path
|
||||
uncached_probe.selected_face_id = complex_face_id
|
||||
uncached_probe.current_info_values = {}
|
||||
uncached_checks = (
|
||||
("area local uncached", lambda: uncached_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0)),
|
||||
(
|
||||
"offset local uncached",
|
||||
lambda: uncached_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0),
|
||||
),
|
||||
)
|
||||
for label, callback in uncached_checks:
|
||||
started = time.perf_counter()
|
||||
plan = callback()
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed > 0.05:
|
||||
raise SystemExit(f"{label} should use quick_face_info fallback, elapsed={elapsed:.3f}s")
|
||||
if plan is None or plan.get("status") != "blocked":
|
||||
raise SystemExit(f"{label} should still return a quick blocked plan: {plan}")
|
||||
if not plan.get("ui_quick_blocked_local_face_plan"):
|
||||
raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}")
|
||||
|
||||
multi_boundary_face_id = 594
|
||||
multi_boundary_probe = _Probe()
|
||||
multi_boundary_probe.model = model
|
||||
multi_boundary_probe.step_path = complex_path
|
||||
multi_boundary_probe.selected_face_id = multi_boundary_face_id
|
||||
multi_boundary_probe.current_info_values = model.quick_face_info(multi_boundary_face_id)
|
||||
started = time.perf_counter()
|
||||
inward_plan = multi_boundary_probe._push_pull_plan_for_action(multi_boundary_face_id, -1.0)
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed > 0.05:
|
||||
raise SystemExit(f"multi-boundary inward push/pull UI preflight should be quick, elapsed={elapsed:.3f}s")
|
||||
if inward_plan.get("status") != "blocked":
|
||||
raise SystemExit(f"multi-boundary inward push/pull UI preflight should be blocked: {inward_plan}")
|
||||
if not inward_plan.get("ui_quick_blocked_push_pull_plan"):
|
||||
raise SystemExit(f"multi-boundary inward push/pull should be marked as a quick blocker: {inward_plan}")
|
||||
message = str(inward_plan.get("message") or "")
|
||||
if "二级关系" not in message or "通用 OCCT 布尔" not in message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary inward push/pull blocker should explain topology depth and Boolean risk: "
|
||||
f"{inward_plan}"
|
||||
)
|
||||
for required in ("Face 594", "内边界", "一级边界 Edge", "一级相邻 Face"):
|
||||
if required not in message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary inward push/pull blocker should include readable topology evidence "
|
||||
f"{required!r}: {inward_plan}"
|
||||
)
|
||||
diagnostics = multi_boundary_probe._edit_failure_diagnostics(
|
||||
{
|
||||
"operation_name": "推拉平面",
|
||||
"target": f"Face {multi_boundary_face_id}",
|
||||
"parameters": {
|
||||
"surface": inward_plan.get("surface"),
|
||||
"semantic_distance": -1.0,
|
||||
"current_plane_position": inward_plan.get("current_plane_position"),
|
||||
"target_plane_position": inward_plan.get("target_plane_position"),
|
||||
"edit_strategy_label": inward_plan.get("edit_strategy_label"),
|
||||
"push_pull_message": inward_plan.get("message"),
|
||||
"push_pull_risk": inward_plan.get("risk"),
|
||||
"selected_boundary_wires": inward_plan.get("selected_boundary_wires"),
|
||||
"selected_inner_boundary_wires": inward_plan.get("selected_inner_boundary_wires"),
|
||||
"selected_has_inner_boundaries": inward_plan.get("selected_has_inner_boundaries"),
|
||||
"same_domain_face_count": inward_plan.get("same_domain_face_count"),
|
||||
"first_level_boundary_edge_count": inward_plan.get("first_level_boundary_edge_count"),
|
||||
"first_level_boundary_vertex_count": inward_plan.get("first_level_boundary_vertex_count"),
|
||||
"first_level_adjacent_face_count": inward_plan.get("first_level_adjacent_face_count"),
|
||||
"first_level_topology_note": inward_plan.get("first_level_topology_note"),
|
||||
"topology_ignored_relation_note": inward_plan.get("topology_ignored_relation_note"),
|
||||
"planar_cap_extension_kind": inward_plan.get("planar_cap_extension_kind"),
|
||||
"planar_cap_extension_method": inward_plan.get("planar_cap_extension_method"),
|
||||
},
|
||||
"isolation": {
|
||||
"operation": "push_pull_face",
|
||||
"args": [multi_boundary_face_id, -1.0],
|
||||
"timeout_seconds": 180.0,
|
||||
"reason": "high-risk-face-occ-edit",
|
||||
},
|
||||
}
|
||||
)
|
||||
for required in ("诊断信息", "操作: 推拉平面 / Face 594", "一级关系证据", "可能原因", "二级", "隔离保护"):
|
||||
if required not in diagnostics:
|
||||
raise SystemExit(f"edit failure diagnostics should include {required!r}: {diagnostics}")
|
||||
if multi_boundary_probe._isolation_for_plan(inward_plan, "push_pull_face", [multi_boundary_face_id, -1.0]) is not None:
|
||||
raise SystemExit(f"blocked multi-boundary inward push/pull should not enter isolated execution: {inward_plan}")
|
||||
|
||||
print(
|
||||
"Face UI isolation contract ok: "
|
||||
f"{len(EXPECTED_FACE_ISOLATED_OPERATIONS)} operations are shared by UI and worker."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeTorus
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
for path in (PROJECT_ROOT, SCRIPTS_DIR):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.ui_helpers import INFO_LABELS
|
||||
|
||||
from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402
|
||||
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _top_planar_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = info.get("area_center")
|
||||
if not isinstance(center, tuple) or len(center) != 3:
|
||||
continue
|
||||
z_value = float(center[2])
|
||||
if best is None or z_value > best[0]:
|
||||
best = (z_value, face_id)
|
||||
if best is None:
|
||||
raise AssertionError("no planar Face was found")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _first_planar_face_with_inner_boundary(model: StepModel) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") == "plane" and int(info.get("inner_boundary_wires", 0) or 0) > 0:
|
||||
return face_id
|
||||
raise AssertionError("no planar Face with an inner boundary was found")
|
||||
|
||||
|
||||
def _first_surface_face(model: StepModel, surface: str) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == surface:
|
||||
return face_id
|
||||
raise AssertionError(f"no {surface} Face was found")
|
||||
|
||||
|
||||
def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]) -> None:
|
||||
summary = str(info.get("recognition_summary") or "")
|
||||
candidate = str(info.get("recognition_candidate") or "")
|
||||
confidence = str(info.get("recognition_confidence") or "")
|
||||
risk = str(info.get("recognition_risk") or "")
|
||||
decision = str(info.get("recognition_decision") or "")
|
||||
evidence_keys = set(str(item) for item in tuple(info.get("recognition_evidence_keys") or ()))
|
||||
evidence = str(info.get("recognition_evidence") or "")
|
||||
score = int(info.get("recognition_score", -1))
|
||||
|
||||
_assert(summary, f"{label}: recognition_summary is missing")
|
||||
_assert(candidate, f"{label}: recognition_candidate is missing")
|
||||
_assert(confidence in {"unchecked", "none", "low", "medium", "high"}, f"{label}: bad confidence {confidence!r}")
|
||||
_assert(risk in {"low", "medium", "high", "blocked"}, f"{label}: bad risk {risk!r}")
|
||||
_assert(0 <= score <= 100, f"{label}: bad recognition_score {score!r}")
|
||||
_assert(
|
||||
decision in {"高可信候选", "可尝试候选", "需人工确认", "不建议自动修改", "已阻止"},
|
||||
f"{label}: bad recognition_decision {decision!r}",
|
||||
)
|
||||
_assert(required_keys <= evidence_keys, f"{label}: evidence keys missing {required_keys - evidence_keys}")
|
||||
_assert(evidence, f"{label}: recognition_evidence is missing")
|
||||
_assert(candidate in summary, f"{label}: summary should mention candidate {candidate!r}: {summary!r}")
|
||||
_assert(f"评分={score}" in summary, f"{label}: summary should mention score {score}: {summary!r}")
|
||||
_assert(decision in summary, f"{label}: summary should mention decision {decision!r}: {summary!r}")
|
||||
|
||||
|
||||
def _verify_planar_summary(root: Path) -> None:
|
||||
path = root / "box.step"
|
||||
_write_step(BRepPrimAPI_MakeBox(10.0, 8.0, 6.0).Shape(), path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _top_planar_face(model)
|
||||
quick_info = model.quick_face_info(face_id)
|
||||
full_info = model.feature_info(face_id)
|
||||
_assert_summary(quick_info, "quick planar Face", {"surface", "boundary_edges"})
|
||||
_assert_summary(full_info, "feature planar Face", {"surface", "boundary_edges", "first_level_topology"})
|
||||
|
||||
|
||||
def _verify_hole_summary(root: Path) -> None:
|
||||
path = root / "through_hole.step"
|
||||
_write_through_hole_model(path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_hole_face(model, blind=False)
|
||||
info = model.feature_info(face_id)
|
||||
_assert(info.get("feature_guess") == "hole/groove candidate", f"hole was not recognized: {info}")
|
||||
_assert_summary(info, "through-hole feature", {"surface", "material_votes", "first_level_topology"})
|
||||
_assert(info.get("resize_status") == "ready", f"hole diameter resize should be ready: {info}")
|
||||
_assert(info.get("recognition_risk") != "blocked", f"ready hole candidate should not be globally blocked: {info}")
|
||||
_assert(info.get("recognition_decision") != "已阻止", f"ready hole candidate should not be marked blocked: {info}")
|
||||
_assert(not str(info.get("recognition_blockers") or ""), f"ready hole blockers should stay empty: {info}")
|
||||
ready_actions = str(info.get("recognition_ready_actions") or "")
|
||||
limited_actions = str(info.get("recognition_limited_actions") or "")
|
||||
_assert("孔/槽/圆柱直径" in ready_actions, f"hole ready actions should include diameter resize: {info}")
|
||||
_assert("盲孔/盲槽深度" in limited_actions, f"through-hole limited actions should include blind depth: {info}")
|
||||
limitations = str(info.get("recognition_limitations") or "")
|
||||
_assert(limitations, f"ready hole should still explain unsupported sub-capabilities: {info}")
|
||||
_assert("受限能力" in str(info.get("recognition_summary") or ""), f"hole summary should show limitations: {info}")
|
||||
_assert("可改:" in str(info.get("recognition_summary") or ""), f"hole summary should show ready actions: {info}")
|
||||
|
||||
|
||||
def _verify_holed_planar_summary(root: Path) -> None:
|
||||
path = root / "holed_plate.step"
|
||||
_write_through_hole_model(path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_planar_face_with_inner_boundary(model)
|
||||
info = model.feature_info(face_id)
|
||||
_assert_summary(info, "holed planar Face", {"surface", "boundary_edges", "first_level_topology"})
|
||||
_assert(info.get("local_face_deform_ready") is False, f"holed Face should block local deformation: {info}")
|
||||
_assert(info.get("recognition_risk") != "blocked", f"holed Face should keep push/pull available: {info}")
|
||||
_assert(info.get("recognition_decision") != "已阻止", f"holed Face should not be globally blocked: {info}")
|
||||
_assert(not str(info.get("recognition_blockers") or ""), f"holed Face blockers should stay empty: {info}")
|
||||
ready_actions = str(info.get("recognition_ready_actions") or "")
|
||||
limited_actions = str(info.get("recognition_limited_actions") or "")
|
||||
_assert("平面推拉" in ready_actions, f"holed Face should expose push/pull as ready: {info}")
|
||||
_assert(
|
||||
"当前面局部尺寸/中心/偏移" in limited_actions,
|
||||
f"holed Face should expose local deformation as limited: {info}",
|
||||
)
|
||||
|
||||
|
||||
def _verify_slot_summary(root: Path) -> None:
|
||||
path = root / "half_round_slot.step"
|
||||
_write_half_round_slot_model(path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_slot_face(model)
|
||||
info = model.feature_info(face_id)
|
||||
_assert(info.get("slot_status") == "candidate", f"slot was not recognized: {info}")
|
||||
_assert_summary(info, "half-round slot feature", {"surface", "material_votes", "slot_geometry"})
|
||||
|
||||
|
||||
def _verify_torus_summary(root: Path) -> None:
|
||||
path = root / "torus.step"
|
||||
_write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_surface_face(model, "torus")
|
||||
info = model.feature_info(face_id)
|
||||
_assert(info.get("feature_type") == "环面候选", f"torus was not recognized safely: {info}")
|
||||
_assert(info.get("feature_highlight_face_ids") == (face_id,), f"torus highlight should stay on source Face: {info}")
|
||||
_assert_summary(info, "torus feature", {"surface", "boundary_edges"})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for key in (
|
||||
"recognition_summary",
|
||||
"recognition_candidate",
|
||||
"recognition_confidence",
|
||||
"recognition_risk",
|
||||
"recognition_score",
|
||||
"recognition_decision",
|
||||
"recognition_evidence",
|
||||
"recognition_ready_actions",
|
||||
"recognition_limited_actions",
|
||||
"recognition_blockers",
|
||||
"recognition_limitations",
|
||||
):
|
||||
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_recognition_summary_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
_verify_planar_summary(root)
|
||||
_verify_holed_planar_summary(root)
|
||||
_verify_hole_summary(root)
|
||||
_verify_slot_summary(root)
|
||||
_verify_torus_summary(root)
|
||||
|
||||
print("feature recognition summary ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
for path in (PROJECT_ROOT, SCRIPTS_DIR):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.ui_helpers import INFO_LABELS
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402
|
||||
from verify_shell_thickness_resize import _first_shell_face, _write_plate_model # noqa: E402
|
||||
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
|
||||
|
||||
|
||||
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
||||
|
||||
|
||||
class _FactProbe(WindowStateMixin):
|
||||
def __init__(self, model: StepModel) -> None:
|
||||
self.model = model
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def _center(info: dict[str, object]) -> tuple[float, float, float]:
|
||||
value = info.get("area_center") or info.get("bbox_center")
|
||||
if not isinstance(value, tuple) or len(value) != 3:
|
||||
raise AssertionError(f"Face has no stable center: {info}")
|
||||
return float(value[0]), float(value[1]), float(value[2])
|
||||
|
||||
|
||||
def _number(value: object, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _top_plane_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = _center(info)
|
||||
if best is None or center[2] > best[0]:
|
||||
best = (center[2], face_id)
|
||||
if best is None:
|
||||
raise AssertionError("No planar Face found.")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _assert_common_facts(facts: dict[str, object], label: str) -> None:
|
||||
_assert(facts.get("first_level_fact_model") == "STEP/B-Rep first-level fact graph", f"{label}: bad model")
|
||||
_assert(facts.get("first_level_fact_status") == "ready", f"{label}: bad status {facts}")
|
||||
_assert(facts.get("first_level_fact_relation_depth") == 1, f"{label}: bad relation depth")
|
||||
_assert(facts.get("first_level_fact_relation_boundary") == "shared-edge", f"{label}: bad boundary")
|
||||
_assert(
|
||||
int(facts.get("first_level_fact_subject_face_count", 0) or 0) >= 1,
|
||||
f"{label}: missing subject Faces",
|
||||
)
|
||||
_assert(
|
||||
int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) >= 1,
|
||||
f"{label}: missing boundary Edges",
|
||||
)
|
||||
_assert(
|
||||
int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) >= 1,
|
||||
f"{label}: missing first-level adjacent Faces",
|
||||
)
|
||||
ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ())
|
||||
_assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing")
|
||||
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing")
|
||||
|
||||
|
||||
def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None:
|
||||
_assert_common_facts(plan, label)
|
||||
_assert(plan.get("first_level_fact_scope") == scope, f"{label}: wrong fact scope {plan}")
|
||||
_assert(
|
||||
int(plan.get("first_level_fact_included_face_count", 0) or 0) >=
|
||||
int(plan.get("first_level_fact_subject_face_count", 0) or 0),
|
||||
f"{label}: included facts should cover subject Faces",
|
||||
)
|
||||
|
||||
|
||||
def _verify_planar_face_facts() -> None:
|
||||
model = StepModel.load(DEFAULT_MODEL)
|
||||
face_id = _top_plane_face(model)
|
||||
info = model.face_info(face_id)
|
||||
facts = model.face_first_level_facts(face_id, scope="face")
|
||||
_assert_common_facts(facts, "cube planar Face")
|
||||
_assert(facts.get("first_level_fact_scope") == "face", f"cube planar Face: wrong scope {facts}")
|
||||
_assert(int(facts.get("first_level_fact_subject_face_count", 0) or 0) == 1, f"cube subject count: {facts}")
|
||||
_assert(int(facts.get("first_level_fact_boundary_edge_count", 0) or 0) == 4, f"cube boundary count: {facts}")
|
||||
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}")
|
||||
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}")
|
||||
_assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}")
|
||||
|
||||
probe = _FactProbe(model)
|
||||
fields = probe._face_first_level_selection_fields(face_id)
|
||||
_assert(fields.get("first_level_fact_summary"), f"selection fields should expose fact summary: {fields}")
|
||||
enriched = model.quick_face_info(face_id)
|
||||
enriched.update(fields)
|
||||
enriched.update(model._recognition_summary_fields(enriched)) # noqa: SLF001
|
||||
_assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"summary missed facts: {enriched}")
|
||||
|
||||
center = _center(info)
|
||||
area = _number(info.get("area"), 100.0)
|
||||
width = _number(info.get("local_face_width"), 10.0)
|
||||
plans = (
|
||||
(
|
||||
model.face_center_local_move_plan(face_id, (center[0], center[1], center[2] + 2.0)),
|
||||
"cube Face center local move plan",
|
||||
),
|
||||
(
|
||||
model.face_center_owning_translation_plan(face_id, (center[0], center[1], center[2] + 2.0)),
|
||||
"cube Face center owning translation plan",
|
||||
),
|
||||
(model.face_area_local_resize_plan(face_id, area * 1.2), "cube Face area local resize plan"),
|
||||
(model.face_area_scale_plan(face_id, area * 1.2), "cube Face area owning scale plan"),
|
||||
(model.face_size_local_resize_plan(face_id, width * 1.2, "width"), "cube Face width local resize plan"),
|
||||
(model.face_size_owning_scale_plan(face_id, width * 1.2, "width"), "cube Face width owning scale plan"),
|
||||
(model.face_plane_offset_local_plan(face_id, 2.0), "cube Face plane offset local plan"),
|
||||
(model.face_plane_offset_owning_translation_plan(face_id, 2.0), "cube Face plane offset owning plan"),
|
||||
(model.push_pull_plan(face_id, 2.0), "cube Face push-pull plan"),
|
||||
)
|
||||
for plan, label in plans:
|
||||
_assert_plan_facts(plan, label, "face")
|
||||
|
||||
|
||||
def _verify_shell_plan_facts(root: Path) -> None:
|
||||
shell_path = root / "thin_plate.step"
|
||||
_write_plate_model(shell_path)
|
||||
model = StepModel.load(shell_path)
|
||||
face_id = _first_shell_face(model, 2.0, 2e-4)
|
||||
plans = (
|
||||
(model.shell_thickness_plan(face_id, 3.0), "thin wall thickness local plan"),
|
||||
(model.shell_thickness_owning_scale_plan(face_id, 3.0), "thin wall thickness owning plan"),
|
||||
)
|
||||
for plan, label in plans:
|
||||
_assert_plan_facts(plan, label, "face")
|
||||
|
||||
|
||||
def _verify_cylindrical_facts(root: Path) -> None:
|
||||
hole_path = root / "through_hole.step"
|
||||
_write_through_hole_model(hole_path)
|
||||
hole_model = StepModel.load(hole_path)
|
||||
hole_face_id = _first_hole_face(hole_model, blind=False)
|
||||
hole_facts = hole_model.face_first_level_facts(hole_face_id, scope="cylindrical-feature")
|
||||
_assert_common_facts(hole_facts, "through-hole cylinder")
|
||||
_assert(
|
||||
hole_facts.get("first_level_fact_scope") == "cylindrical-feature",
|
||||
f"through-hole cylinder: wrong scope {hole_facts}",
|
||||
)
|
||||
role_groups = tuple(hole_facts.get("first_level_fact_role_groups") or ())
|
||||
_assert(any(isinstance(item, dict) and item.get("role") == "cylindrical-side" for item in role_groups), role_groups)
|
||||
_assert(any(isinstance(item, dict) and item.get("role") == "end/opening" for item in role_groups), role_groups)
|
||||
|
||||
probe = _FactProbe(hole_model)
|
||||
fields = probe._cylindrical_first_level_selection_fields(hole_face_id)
|
||||
enriched = hole_model.feature_info(hole_face_id)
|
||||
enriched.update(fields)
|
||||
enriched.update(hole_model._recognition_summary_fields(enriched)) # noqa: SLF001
|
||||
_assert("一级事实=" in str(enriched.get("recognition_summary") or ""), f"hole summary missed facts: {enriched}")
|
||||
|
||||
diameter = _number(hole_model.face_info(hole_face_id).get("diameter"), 6.0)
|
||||
plan = hole_model.cylindrical_resize_plan(hole_face_id, diameter * 1.2)
|
||||
_assert_plan_facts(plan, "through-hole diameter resize plan", "cylindrical-feature")
|
||||
|
||||
|
||||
def _verify_slot_facts(root: Path) -> None:
|
||||
slot_path = root / "half_round_slot.step"
|
||||
_write_half_round_slot_model(slot_path)
|
||||
model = StepModel.load(slot_path)
|
||||
face_id = _first_slot_face(model)
|
||||
facts = model.face_first_level_facts(face_id, scope="cylindrical-feature")
|
||||
_assert_common_facts(facts, "half-round slot")
|
||||
role_groups = tuple(facts.get("first_level_fact_role_groups") or ())
|
||||
_assert(any(isinstance(item, dict) and item.get("role") == "slot-boundary" for item in role_groups), role_groups)
|
||||
|
||||
feature = model.feature_info(face_id)
|
||||
width = _number(feature.get("slot_width"), _number(model.face_info(face_id).get("diameter"), 6.0))
|
||||
plan = model.cylindrical_slot_resize_plan(face_id, width * 1.1, "width")
|
||||
_assert_plan_facts(plan, "half-round slot width resize plan", "cylindrical-feature")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for key in (
|
||||
"first_level_fact_model",
|
||||
"first_level_fact_status",
|
||||
"first_level_fact_summary",
|
||||
"first_level_fact_subject_face_count",
|
||||
"first_level_fact_adjacent_face_count",
|
||||
):
|
||||
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
|
||||
|
||||
_verify_planar_face_facts()
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_first_level_facts_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
_verify_shell_plan_facts(root)
|
||||
_verify_cylindrical_facts(root)
|
||||
_verify_slot_facts(root)
|
||||
|
||||
print("first-level fact graph ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -330,6 +330,8 @@ def _run_blind_depth_case(target: float, tolerance: float) -> None:
|
||||
verified_face, depth, error = _nearest_blind_depth(model, target)
|
||||
if error > tolerance:
|
||||
raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}")
|
||||
if "verified_face=" not in result:
|
||||
raise SystemExit(f"blind depth operation should report internal result verification: {result}")
|
||||
print("mode=blind_depth")
|
||||
print(f"source_face={face_id}")
|
||||
print(f"bottom_face={bottom_face_id}")
|
||||
|
||||
@@ -6,7 +6,9 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
|
||||
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -15,6 +17,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
@@ -207,6 +210,17 @@ def _write_torus_model(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeTorus(8.0, 2.0).Shape(), path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder_model(path: Path) -> None:
|
||||
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
inner = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
3.0,
|
||||
80.0,
|
||||
).Shape()
|
||||
cut = BRepAlgoAPI_Cut(outer, inner)
|
||||
_write_step(_finalize_boolean_result(cut, "verify isolated hollow cylinder cut", use_glue=False), path)
|
||||
|
||||
|
||||
def _first_face_by_surface(model: StepModel, surface: str) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == surface:
|
||||
@@ -214,6 +228,23 @@ def _first_face_by_surface(model: StepModel, surface: str) -> int:
|
||||
raise SystemExit(f"no {surface} Face was recognized")
|
||||
|
||||
|
||||
def _top_planar_face(model: StepModel) -> int:
|
||||
best: tuple[float, int] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
center = info.get("area_center") or info.get("bbox_center")
|
||||
if not isinstance(center, tuple) or len(center) != 3:
|
||||
continue
|
||||
z_value = float(center[2])
|
||||
if best is None or z_value > best[0]:
|
||||
best = (z_value, face_id)
|
||||
if best is None:
|
||||
raise SystemExit("no top planar Face was recognized")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
@@ -270,7 +301,78 @@ def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float
|
||||
print(f"matched_torus_radii={matches}")
|
||||
|
||||
|
||||
def _assert_hollow_cylinder_height(label: str, model: StepModel, target_height: float) -> None:
|
||||
bbox_size = model.geometry_stats().get("bbox_size")
|
||||
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
|
||||
raise SystemExit(f"{label}: output bbox_size is missing")
|
||||
height = float(bbox_size[2])
|
||||
if abs(height - target_height) > 1e-4:
|
||||
raise SystemExit(f"{label}: hollow cylinder height should be {target_height:g}, got {height:g}")
|
||||
radii: list[float] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
radius = float(info.get("radius") or 0.0)
|
||||
if radius <= 0.0:
|
||||
continue
|
||||
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii):
|
||||
radii.append(radius)
|
||||
radii.sort()
|
||||
if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-4 or abs(radii[1] - 8.0) > 1e-4:
|
||||
raise SystemExit(f"{label}: hollow cylinder should preserve inner/outer radii, got {radii}")
|
||||
print(f"matched_hollow_cylinder_height={height:g}, radii={radii}")
|
||||
|
||||
|
||||
def _assert_hollow_cylinder_extension(label: str, model: StepModel) -> None:
|
||||
_assert_hollow_cylinder_height(label, model, 167.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
push_pull_probe = StepModel.load(DEFAULT_MODEL)
|
||||
large_push_plan = push_pull_probe.push_pull_plan(0, 11.0)
|
||||
if large_push_plan.get("status") == "blocked" or str(large_push_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"large push/pull should be allowed as an isolated high-risk edit: {large_push_plan}")
|
||||
_run_worker_case(
|
||||
label="large push/pull current Face",
|
||||
operation="push_pull_face",
|
||||
args=[0, 11.0],
|
||||
validator=lambda label, model: _assert_plane_position(label, model, 11.0),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_hollow_cylinder_verify_") as temp_dir:
|
||||
hollow_path = Path(temp_dir) / "hollow_cylinder.step"
|
||||
_write_hollow_cylinder_model(hollow_path)
|
||||
hollow_probe = StepModel.load(hollow_path)
|
||||
hollow_face_id = _top_planar_face(hollow_probe)
|
||||
hollow_plan = hollow_probe.push_pull_plan(hollow_face_id, 89.0)
|
||||
if hollow_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(f"hollow cylinder cap should use coaxial-tube plan in worker test: {hollow_plan}")
|
||||
_run_worker_case(
|
||||
label="large hollow cylinder cap push/pull",
|
||||
operation="push_pull_face",
|
||||
args=[hollow_face_id, 89.0],
|
||||
validator=_assert_hollow_cylinder_extension,
|
||||
input_path=hollow_path,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="hollow cylinder cap inward push/pull",
|
||||
operation="push_pull_face",
|
||||
args=[hollow_face_id, -39.0],
|
||||
validator=lambda label, model: _assert_hollow_cylinder_height(label, model, 39.0),
|
||||
input_path=hollow_path,
|
||||
)
|
||||
hollow_height_probe = StepModel.load(hollow_path)
|
||||
hollow_cylinder_face_id = _first_face_by_surface(hollow_height_probe, "cylinder")
|
||||
height_plan = hollow_height_probe.cylindrical_height_plan(hollow_cylinder_face_id, 167.0)
|
||||
if height_plan.get("status") == "blocked":
|
||||
raise SystemExit(f"hollow cylinder side Face height edit should be isolated, not blocked: {height_plan}")
|
||||
_run_worker_case(
|
||||
label="large hollow cylinder side Face height",
|
||||
operation="resize_cylindrical_height",
|
||||
args=[hollow_cylinder_face_id, 167.0],
|
||||
validator=_assert_hollow_cylinder_extension,
|
||||
input_path=hollow_path,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面偏移(当前面)",
|
||||
operation="move_face_plane_offset_local",
|
||||
@@ -384,8 +486,30 @@ def main() -> int:
|
||||
def __init__(self) -> None:
|
||||
self.model = StepModel.load(DEFAULT_MODEL)
|
||||
self.step_path = DEFAULT_MODEL
|
||||
self.current_info_values = self.model.face_info(0)
|
||||
|
||||
probe = _IsolatedJobProbe()
|
||||
action_plan = probe._push_pull_plan_for_action(0, 11.0)
|
||||
if action_plan.get("status") == "blocked" or str(action_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"UI push/pull action plan should use the model-layer high-risk plan: {action_plan}")
|
||||
if float(action_plan.get("push_pull_distance_to_owning_axis_span_ratio") or 0.0) <= 1.0:
|
||||
raise SystemExit(f"UI push/pull action plan should expose model-layer span ratio: {action_plan}")
|
||||
|
||||
complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
||||
if complex_path.exists():
|
||||
complex_probe = _IsolatedJobProbe()
|
||||
complex_probe.model = StepModel.load(complex_path)
|
||||
complex_probe.step_path = complex_path
|
||||
complex_probe.selected_face_id = 1
|
||||
complex_probe.current_info_values = complex_probe.model.quick_face_info(1)
|
||||
complex_plan = complex_probe._push_pull_plan_for_action(1, 89.0)
|
||||
if not complex_plan.get("ui_deferred_model_plan"):
|
||||
raise SystemExit(f"complex holed cap UI plan should defer full model plan: {complex_plan}")
|
||||
if str(complex_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"deferred complex holed cap UI plan should be high risk: {complex_plan}")
|
||||
if complex_probe._isolation_for_plan(complex_plan, "push_pull_face", [1, 89.0]) is None:
|
||||
raise SystemExit(f"deferred complex holed cap UI plan should request isolated execution: {complex_plan}")
|
||||
|
||||
if probe._isolated_edit_command(Path("request.json")) != [
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -445,9 +569,18 @@ def main() -> int:
|
||||
)
|
||||
if "隔离子进程" not in str(result.get("message", "")):
|
||||
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
|
||||
after_model = result.get("after_model")
|
||||
if not isinstance(after_model, StepModel):
|
||||
raise SystemExit(f"isolated window job should return after_model for UI-thread installation: {result}")
|
||||
if probe.model.stats().solids != 1:
|
||||
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
|
||||
if not _logical_region_has_width(probe.model, 0, 25.0):
|
||||
if _logical_region_has_width(probe.model, 0, 25.0):
|
||||
raise SystemExit("isolated window job replaced the window model before returning to the UI thread")
|
||||
if after_model.stats().solids != 1:
|
||||
raise SystemExit(f"isolated window after_model changed solid count unexpectedly: {after_model.stats()}")
|
||||
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
|
||||
raise SystemExit("isolated window job should return prebuilt display polydata")
|
||||
if not _logical_region_has_width(after_model, 0, 25.0):
|
||||
raise SystemExit("isolated window job did not preserve the original logical Face ID on the edited width")
|
||||
print("isolated window job ok")
|
||||
|
||||
@@ -474,8 +607,17 @@ def main() -> int:
|
||||
)
|
||||
if "隔离子进程" not in str(owning_result.get("message", "")):
|
||||
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
|
||||
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
|
||||
if not _logical_region_has_width(owning_probe.model, 0, 25.0):
|
||||
owning_after_model = owning_result.get("after_model")
|
||||
if not isinstance(owning_after_model, StepModel):
|
||||
raise SystemExit(
|
||||
f"isolated owning window job should return after_model for UI-thread installation: {owning_result}"
|
||||
)
|
||||
if _logical_region_has_width(owning_probe.model, 0, 25.0):
|
||||
raise SystemExit("isolated owning window job replaced the window model before returning to the UI thread")
|
||||
if owning_result.get("model_polydata") is None or owning_result.get("edge_polydata") is None:
|
||||
raise SystemExit("isolated owning window job should return prebuilt display polydata")
|
||||
_assert_face_width("面宽(整体窗口任务)", owning_after_model, 25.0)
|
||||
if not _logical_region_has_width(owning_after_model, 0, 25.0):
|
||||
raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
|
||||
print("isolated owning window job ok")
|
||||
_run_main_worker_entry_case()
|
||||
|
||||
@@ -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())
|
||||
@@ -9,6 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
from step_editor.ui_helpers import _format_value
|
||||
|
||||
|
||||
class _PropertySpecProbe(WindowStateMixin):
|
||||
@@ -315,6 +316,10 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
relation_depths_text = _format_value(("second-level", "third-level", "deeper"))
|
||||
if "second-level" not in relation_depths_text or "deeper" not in relation_depths_text:
|
||||
raise SystemExit(f"relation-depth tuple should render as text: {relation_depths_text!r}")
|
||||
|
||||
plane_info = {
|
||||
"surface": "plane",
|
||||
"area": 100.0,
|
||||
@@ -485,6 +490,68 @@ def main() -> int:
|
||||
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
|
||||
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
|
||||
|
||||
cylinder_topology_info = {
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "hole/groove candidate",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 6.283185307179586,
|
||||
"area": 188.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
"axis_center": (0.0, 0.0, 5.0),
|
||||
"topology_relation_depth": 1,
|
||||
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
|
||||
"topology_relation_status": "ready",
|
||||
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
||||
"cylindrical_feature_side_face_count": 1,
|
||||
"cylindrical_feature_boundary_edge_count": 2,
|
||||
"cylindrical_feature_boundary_vertex_count": 4,
|
||||
"cylindrical_feature_adjacent_face_count": 2,
|
||||
"cylindrical_feature_end_face_count": 2,
|
||||
"cylindrical_feature_opening_face_count": 2,
|
||||
"first_level_topology_note": "Cylindrical side Faces=1, boundary Edges=2, boundary Vertices=4, direct adjacent Faces=2.",
|
||||
}
|
||||
cylinder_topology_specs = _specs(cylinder_topology_info)
|
||||
cylinder_topology_spec = _spec(cylinder_topology_specs, "cylindrical_feature_first_level_topology")
|
||||
cylinder_topology_text = str(cylinder_topology_spec.get("current_text") or "")
|
||||
for fragment in ("侧壁 Face 1 个", "边界 Edge 2 条", "共享边相邻 Face 2 个"):
|
||||
if fragment not in cylinder_topology_text:
|
||||
raise SystemExit(f"cylindrical feature topology row should explain first-level counts: {cylinder_topology_spec}")
|
||||
if "二级、三级" not in str(cylinder_topology_spec.get("disabled_tip") or ""):
|
||||
raise SystemExit(f"cylindrical feature topology tip should document ignored deeper topology: {cylinder_topology_spec}")
|
||||
cylinder_feature_probe = _PropertySpecProbe()
|
||||
cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info)
|
||||
cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info)
|
||||
_spec(cylinder_feature_rows, "cylindrical_feature_first_level_topology")
|
||||
|
||||
generic_cylinder_specs = _specs(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 6.283185307179586,
|
||||
"height_estimate": 11.0,
|
||||
"same_domain_height_estimate": 11.0,
|
||||
"area": 207.0,
|
||||
"area_center": (0.0, 0.0, 5.5),
|
||||
"bbox_center": (0.0, 0.0, 5.5),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
"axis_center": (0.0, 0.0, 5.5),
|
||||
}
|
||||
)
|
||||
generic_height = _spec(generic_cylinder_specs, "cylinder_height")
|
||||
if generic_height.get("scope_default") != "owning":
|
||||
raise SystemExit(f"generic cylinder height should default to owning-axis resize: {generic_height}")
|
||||
if generic_height.get("action") != "resize_cylindrical_height_owning_scale":
|
||||
raise SystemExit(f"generic cylinder height should use owning-axis action by default: {generic_height}")
|
||||
if generic_height.get("scope_text") != "调整整个特征":
|
||||
raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}")
|
||||
|
||||
slot_keys = _spec_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
@@ -510,26 +577,31 @@ def main() -> int:
|
||||
"slot/half-hole feature",
|
||||
)
|
||||
|
||||
boss_keys = _spec_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "boss/outer-round candidate",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 6.283185307179586,
|
||||
"height_estimate": 5.0,
|
||||
"same_domain_height_estimate": 5.0,
|
||||
"area": 188.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
"axis_center": (0.0, 0.0, 5.0),
|
||||
"feature_start_end_face_ids": (1,),
|
||||
}
|
||||
)
|
||||
boss_info = {
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "boss/outer-round candidate",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 6.283185307179586,
|
||||
"height_estimate": 5.0,
|
||||
"same_domain_height_estimate": 5.0,
|
||||
"area": 188.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"axis": (0.0, 0.0, 1.0),
|
||||
"axis_point": (0.0, 0.0, 0.0),
|
||||
"axis_center": (0.0, 0.0, 5.0),
|
||||
"feature_start_end_face_ids": (1,),
|
||||
}
|
||||
boss_specs = _specs(boss_info)
|
||||
boss_keys = {str(spec.get("key", "")) for spec in boss_specs}
|
||||
_assert_no_generic_face_leak(boss_keys, "boss feature")
|
||||
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
|
||||
boss_height = _spec(boss_specs, "boss_height")
|
||||
if boss_height.get("scope_default") != "owning":
|
||||
raise SystemExit(f"boss height should default to owning-axis resize: {boss_height}")
|
||||
if boss_height.get("action") != "resize_cylindrical_height_owning_scale":
|
||||
raise SystemExit(f"boss height should use owning-axis action by default: {boss_height}")
|
||||
|
||||
fillet_keys = _spec_keys(
|
||||
{
|
||||
@@ -609,6 +681,28 @@ def main() -> int:
|
||||
if surface_spec.get("current_text") != "圆锥面 / 拔模面":
|
||||
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}")
|
||||
|
||||
low_recognition_specs = _specs(
|
||||
{
|
||||
"surface": "sphere",
|
||||
"radius": 5.0,
|
||||
"diameter": 10.0,
|
||||
"area": 100.0,
|
||||
"area_center": (0.0, 0.0, 0.0),
|
||||
"bbox_center": (0.0, 0.0, 0.0),
|
||||
"center": (0.0, 0.0, 0.0),
|
||||
"recognition_score": 24,
|
||||
"recognition_confidence": "low",
|
||||
"recognition_risk": "medium",
|
||||
"recognition_blockers": "ambiguous analytic surface",
|
||||
}
|
||||
)
|
||||
for key in ("sphere_radius", "sphere_diameter"):
|
||||
spec = _spec(low_recognition_specs, key)
|
||||
if spec.get("enabled"):
|
||||
raise SystemExit(f"{key} should be read-only when analytic surface recognition is low: {spec}")
|
||||
if "ambiguous analytic surface" not in str(spec.get("disabled_tip") or ""):
|
||||
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
|
||||
|
||||
_assert_target_change_detection()
|
||||
_assert_holed_plane_local_scopes_disabled()
|
||||
_assert_no_legacy_face_source_terms()
|
||||
|
||||
Reference in New Issue
Block a user