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", "owning", "width"), ("size", "local", "height"), ("size", "owning", "height"), ("area", "local", "width"), ("area", "owning", "width"), ("center", "local", "width"), ("center", "owning", "width"), ("offset", "local", "width"), ("offset", "owning", "width"), ("offset", "push_pull", "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"]) 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.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": 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 == "local": 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_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" 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_plan(face_id, args.offset_distance) result = model.push_pull_face(face_id, args.offset_distance) expected_strategy = "push-pull-planar-face" 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 ''}") if "Face result check:" not in result: raise SystemExit(f"Face edit result message should include a result 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())