Files
pythonocc-step-editor/scripts/verify_face_resize_semantics.py

382 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
from pathlib import Path
import subprocess
import sys
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
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
FACE_CASES = (
("size", "local", "width"),
("size", "keep_relations", "width"),
("size", "owning", "width"),
("size", "local", "height"),
("size", "keep_relations", "height"),
("size", "owning", "height"),
("area", "local", "width"),
("area", "owning", "width"),
("center", "local", "width"),
("center", "keep_relations", "width"),
("center", "owning", "width"),
("offset", "local", "width"),
("offset", "owning", "width"),
("offset", "push_pull", "width"),
("offset", "keep_relations", "width"),
)
def _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
face_width = float(info.get("local_face_width") or 0.0)
face_height = float(info.get("local_face_height") or 0.0)
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
if direct or swapped:
return face_id
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
def _plane_sizes(model: StepModel) -> list[tuple[float, float, float]]:
sizes: list[tuple[float, float, float]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
sizes.append((round(area, 6), round(width, 6), round(height, 6)))
return sorted(sizes)
def _plane_metrics(model: StepModel) -> list[tuple[int, float, float, float, tuple[float, float, float]]]:
metrics: list[tuple[int, float, float, float, tuple[float, float, float]]] = []
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
area = float(info.get("area") or 0.0)
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
metrics.append(
(
face_id,
area,
width,
height,
(float(center[0]), float(center[1]), float(center[2])),
)
)
return metrics
def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool:
for _area, face_width, face_height in _plane_sizes(model):
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
if direct or swapped:
return True
return False
def _has_plane_area(model: StepModel, area: float, tolerance: float) -> bool:
return any(abs(candidate_area - area) <= tolerance for _face_id, candidate_area, _w, _h, _c in _plane_metrics(model))
def _nearest_plane_center(
model: StepModel,
target: tuple[float, float, float],
) -> tuple[int, tuple[float, float, float], float, float, float]:
best: tuple[int, tuple[float, float, float], float, float, float] | None = None
for face_id, area, width, height, center in _plane_metrics(model):
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error, width, height)
if best is None:
raise SystemExit("no plane Face center could be measured")
return best
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _parse_vector3(text: str) -> tuple[float, float, float]:
parts = [part.strip() for part in text.replace("", ",").split(",")]
if len(parts) != 3:
raise SystemExit("--center-offset must be in X,Y,Z format")
try:
return float(parts[0]), float(parts[1]), float(parts[2])
except ValueError as exc:
raise SystemExit("--center-offset must contain numbers") from exc
def _run_all_cases(args: argparse.Namespace) -> int:
script = Path(__file__).resolve()
common = [
str(script),
str(args.model),
"--source-size",
str(args.source_size),
"--other-size",
str(args.other_size),
"--target-size",
str(args.target_size),
"--target-area",
str(args.target_area),
"--center-offset",
str(args.center_offset),
"--offset-distance",
str(args.offset_distance),
"--tolerance",
str(args.tolerance),
]
for property_name, strategy, axis in FACE_CASES:
print(f"\n=== Face case: property={property_name} strategy={strategy} axis={axis} ===", flush=True)
command = [
sys.executable,
*common,
"--property",
property_name,
"--strategy",
strategy,
"--axis",
axis,
]
completed = subprocess.run(command, check=False)
if completed.returncode != 0:
return completed.returncode
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--all", action="store_true", help="Run all Face semantic edit cases on fresh model loads.")
parser.add_argument("--property", default="size", choices=["size", "area", "center", "offset"])
parser.add_argument("--axis", default="width", choices=["width", "height"])
parser.add_argument("--source-size", type=float, default=10.0)
parser.add_argument("--other-size", type=float, default=10.0)
parser.add_argument("--target-size", type=float, default=15.0)
parser.add_argument("--target-area", type=float, default=144.0)
parser.add_argument("--center-offset", default="2,0,3")
parser.add_argument("--offset-distance", type=float, default=1.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull", "keep_relations"])
parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args()
if args.all:
return _run_all_cases(args)
model = StepModel.load(Path(args.model))
face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance)
before = model.stats()
current_info = model.face_info(face_id)
if args.strategy == "push_pull" and args.property != "offset":
raise SystemExit("--strategy push_pull is only valid with --property offset")
if args.strategy == "keep_relations" and args.property not in {"size", "center", "offset"}:
raise SystemExit("--strategy keep_relations is only valid with --property size/center/offset")
if args.property == "size" and args.strategy == "local":
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
expected_strategy = f"local-face-{args.axis}-only-deform"
elif args.property == "size" and args.strategy == "keep_relations":
plan = model.face_size_local_resize_keep_relations_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local_keep_relations(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "size":
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "area" and args.strategy == "local":
plan = model.face_area_local_resize_plan(face_id, args.target_area)
result = model.resize_face_area_local(face_id, args.target_area)
expected_strategy = "local-face-area-only-deform"
elif args.property == "area":
plan = model.face_area_scale_plan(face_id, args.target_area)
result = model.resize_face_area(face_id, args.target_area)
expected_strategy = "uniform-scale-face-area-fallback"
elif args.property == "center" and args.strategy in {"local", "keep_relations"}:
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
if args.strategy == "keep_relations":
plan = model.face_center_local_move_keep_relations_plan(face_id, target_center)
result = model.move_face_center_local_keep_relations(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
else:
plan = model.face_center_local_move_plan(face_id, target_center)
result = model.move_face_center_local(face_id, target_center)
expected_strategy = "local-face-only-deform"
elif args.property == "center":
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
offset = _parse_vector3(args.center_offset)
target_center = (
float(current_center[0]) + offset[0],
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
plan = model.face_center_owning_translation_plan(face_id, target_center)
result = model.move_face_center_owning(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
elif args.property == "offset" and args.strategy == "local":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_local_plan(face_id, args.offset_distance)
result = model.move_face_plane_offset_local(face_id, args.offset_distance)
expected_strategy = "local-face-plane-offset-deform"
elif args.property == "offset" and args.strategy == "owning":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.face_plane_offset_owning_translation_plan(face_id, args.offset_distance)
result = model.translate_face_plane_offset_owning(face_id, args.offset_distance)
expected_strategy = "translate-owning-shape-from-plane-offset"
elif args.property == "offset" and args.strategy == "push_pull":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_plan(face_id, args.offset_distance)
result = model.push_pull_face(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face"
else:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_keep_relations_plan(face_id, args.offset_distance)
result = model.push_pull_face_keep_relations(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face-keep-relations"
resolved_strategy = str(plan.get("resize_strategy", ""))
if args.property == "offset" and args.strategy == "push_pull":
resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
if args.strategy == "keep_relations":
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-relations plan should record requested relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "ready":
raise SystemExit(f"keep-relations plan should be ready on cube Face: {plan}")
if "Planar relation check: ok" not in result:
raise SystemExit(f"keep-relations result should include planar relation check, got: {result}")
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:
raise SystemExit(f"solid count changed: before={before.solids}, after={after.solids}")
if after.faces < before.faces:
raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}")
extra_lines: list[str] = []
if args.property == "size":
width = args.target_size if args.axis == "width" else args.other_size
height = args.other_size if args.axis == "width" else args.target_size
if not _has_plane_size(model, width, height, args.tolerance):
raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}")
extra_lines.append(f"target_size_pair=({width:.6f}, {height:.6f})")
elif args.property == "area":
if not _has_plane_area(model, args.target_area, args.tolerance):
raise SystemExit(f"no resulting plane Face near area {args.target_area:g}")
extra_lines.append(f"target_area={args.target_area:.6f}")
elif args.property == "center":
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
else:
verified_face, center, center_error, width, height = _nearest_plane_center(model, target_center)
if center_error > args.tolerance:
raise SystemExit(
f"no resulting plane Face center near offset target {target_center}: nearest={center}, error={center_error:g}"
)
extra_lines.append(f"offset_distance={args.offset_distance:.6f}")
extra_lines.append(f"target_center={target_center}")
extra_lines.append(f"verified_face={verified_face} center={center} center_error={center_error:.6g}")
extra_lines.append(f"verified_size=({width:.6f}, {height:.6f})")
print(f"model={Path(args.model)}")
print(f"face_id={face_id}")
print(f"property={args.property}")
print(f"strategy={args.strategy}")
print(f"axis={args.axis}")
print(f"resolved_strategy={resolved_strategy}")
print(f"rebuild_mode={plan.get('owning_face_size_rebuild_mode', '')}")
print(f"before={before}")
print(f"after={after}")
print(f"plane_sizes={_plane_sizes(model)}")
for line in extra_lines:
print(line)
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())