from __future__ import annotations import argparse from pathlib import Path import sys import tempfile from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox 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 from step_editor.step_io import _write_step def _write_plate_model(path: Path) -> None: shape = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape() _write_step(shape, path) def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int: candidates: list[tuple[int, int]] = [] for face_id in range(len(model.faces)): info = model.feature_info(face_id) if info.get("surface") != "plane": continue if info.get("shell_region_status") != "candidate": continue current = float(info.get("shell_thickness_estimate") or 0.0) if abs(current - thickness) > tolerance: continue confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3) candidates.append((confidence_rank, face_id)) if not candidates: raise SystemExit(f"no shell thickness candidate near {thickness:g}") candidates.sort() return candidates[0][1] def _bounds(model: StepModel) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]: info = model.geometry_stats() return ( tuple(float(value) for value in info["bbox_min"]), tuple(float(value) for value in info["bbox_max"]), tuple(float(value) for value in info["bbox_size"]), ) def _thickness_axis(size: tuple[float, float, float]) -> int: return min(range(3), key=lambda index: size[index]) def _face_center(model: StepModel, face_id: int) -> tuple[float, float, float]: info = model.face_info(face_id) center = info.get("area_center") or info.get("bbox_center") if not isinstance(center, tuple) or len(center) != 3: raise SystemExit(f"Face {face_id} lacks a stable center") return float(center[0]), float(center[1]), float(center[2]) def _face_area(model: StepModel, face_id: int) -> float: return float(model.face_info(face_id).get("area") or 0.0) 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 _vector(value: object, label: str) -> tuple[float, float, float]: if not isinstance(value, tuple) or len(value) != 3: raise SystemExit(f"{label} should be a 3D vector, got {value!r}") return float(value[0]), float(value[1]), float(value[2]) def _axis_affine_point( point: tuple[float, float, float], axis_point: tuple[float, float, float], axis_direction: tuple[float, float, float], scale: float, ) -> tuple[float, float, float]: length = (axis_direction[0] ** 2 + axis_direction[1] ** 2 + axis_direction[2] ** 2) ** 0.5 if length <= 1e-12: raise SystemExit("owning shell thickness plan produced a zero axis direction") direction = (axis_direction[0] / length, axis_direction[1] / length, axis_direction[2] / length) relative = (point[0] - axis_point[0], point[1] - axis_point[1], point[2] - axis_point[2]) along = relative[0] * direction[0] + relative[1] * direction[1] + relative[2] * direction[2] axial = (direction[0] * along, direction[1] * along, direction[2] * along) rest = (relative[0] - axial[0], relative[1] - axial[1], relative[2] - axial[2]) return ( axis_point[0] + rest[0] + axial[0] * scale, axis_point[1] + rest[1] + axial[1] * scale, axis_point[2] + rest[2] + axial[2] * scale, ) def _assert_logical_face_retained( model: StepModel, logical_id: int, expected_center: tuple[float, float, float], expected_area: float, tolerance: float, label: str, ) -> int: matches = model.face_ids_for_logical_id(logical_id) if not matches: raise SystemExit(f"{label} did not retain logical Face {logical_id}") resolved = model.resolve_face_selection_id(logical_id) if resolved is None or resolved not in matches: raise SystemExit(f"{label} logical Face {logical_id} did not resolve into matches {matches}") info = model.face_info(resolved) if info.get("surface") != "plane": raise SystemExit(f"{label} retained logical Face should be planar, got {info.get('surface')}") center = _face_center(model, resolved) if _distance(center, expected_center) > tolerance: raise SystemExit(f"{label} retained Face center should be {expected_center}, got {center}") area = _face_area(model, resolved) if abs(area - expected_area) > tolerance: raise SystemExit(f"{label} retained Face area should be {expected_area:g}, got {area:g}") return resolved def _verify_local_bounds( before_min: tuple[float, float, float], before_max: tuple[float, float, float], after_min: tuple[float, float, float], after_max: tuple[float, float, float], axis: int, target: float, tolerance: float, ) -> None: after_size = after_max[axis] - after_min[axis] if abs(after_size - target) > tolerance: raise SystemExit(f"local shell thickness failed: target={target:g}, value={after_size:g}") min_unchanged = abs(after_min[axis] - before_min[axis]) <= tolerance max_unchanged = abs(after_max[axis] - before_max[axis]) <= tolerance if not (min_unchanged or max_unchanged): raise SystemExit( "local shell thickness did not keep either opposite side fixed: " f"before=({before_min[axis]:g}, {before_max[axis]:g}), " f"after=({after_min[axis]:g}, {after_max[axis]:g})" ) def _verify_owning_bounds( before_min: tuple[float, float, float], before_max: tuple[float, float, float], after_min: tuple[float, float, float], after_max: tuple[float, float, float], axis: int, target: float, tolerance: float, ) -> None: after_size = after_max[axis] - after_min[axis] if abs(after_size - target) > tolerance: raise SystemExit(f"owning shell thickness failed: target={target:g}, value={after_size:g}") before_center = (before_min[axis] + before_max[axis]) * 0.5 after_center = (after_min[axis] + after_max[axis]) * 0.5 if abs(after_center - before_center) > tolerance: raise SystemExit( "owning shell thickness did not keep the thickness center fixed: " f"before_center={before_center:g}, after_center={after_center:g}" ) def _run_case(mode: str, source_thickness: float, target_thickness: float, tolerance: float) -> None: with tempfile.TemporaryDirectory(prefix=f"geom_param_shell_{mode}_") as temp_dir: model_path = Path(temp_dir) / "plate.step" _write_plate_model(model_path) model = StepModel.load(model_path) face_id = _first_shell_face(model, source_thickness, tolerance) logical_id = model.face_region_logical_id(face_id) before_face_center = _face_center(model, face_id) before_face_area = _face_area(model, face_id) before = model.stats() before_min, before_max, before_size = _bounds(model) axis = _thickness_axis(before_size) if mode == "local": plan = model.shell_thickness_plan(face_id, target_thickness) if plan["status"] == "blocked": raise SystemExit(f"local shell plan was blocked: {plan['message']}") outward = _vector(plan.get("outward_direction"), "local shell outward_direction") distance = float(plan.get("push_pull_distance", 0.0)) expected_logical_center = ( before_face_center[0] + outward[0] * distance, before_face_center[1] + outward[1] * distance, before_face_center[2] + outward[2] * distance, ) result = model.resize_shell_thickness(face_id, target_thickness) elif mode == "owning": plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness) if plan["status"] == "blocked": raise SystemExit(f"owning shell plan was blocked: {plan['message']}") expected_logical_center = _axis_affine_point( before_face_center, _vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"), _vector(plan.get("affine_axis_direction"), "owning shell affine_axis_direction"), float(plan.get("affine_scale", 1.0)), ) result = model.resize_shell_thickness_owning_scale(face_id, target_thickness) else: raise SystemExit(f"unsupported mode: {mode}") after = model.stats() after_min, after_max, after_size = _bounds(model) if after.solids != before.solids: raise SystemExit(f"{mode} shell thickness changed solid count: before={before.solids}, after={after.solids}") if mode == "local": _verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) else: _verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance) retained_face_id = _assert_logical_face_retained( model, logical_id, expected_logical_center, before_face_area, tolerance, f"{mode} shell thickness", ) print(f"mode={mode}") print(f"face_id={face_id}") print(f"logical_face_id={logical_id}") print(f"retained_logical_face={retained_face_id}") print(f"strategy={plan.get('resize_strategy')}") print(f"before={before}") print(f"after={after}") print(f"before_bbox_size={before_size}") print(f"after_bbox_size={after_size}") print(f"axis={axis}") print(f"source_thickness={source_thickness:.6f} target_thickness={target_thickness:.6f}") print(result.encode("ascii", "backslashreplace").decode("ascii")) def main() -> int: parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.") parser.add_argument("--mode", default="all", choices=["all", "local", "owning"]) parser.add_argument("--source-thickness", type=float, default=2.0) parser.add_argument("--target-thickness", type=float, default=3.0) parser.add_argument("--tolerance", type=float, default=2e-4) args = parser.parse_args() modes = ["local", "owning"] if args.mode == "all" else [args.mode] for mode in modes: _run_case(mode, args.source_thickness, args.target_thickness, args.tolerance) return 0 if __name__ == "__main__": raise SystemExit(main())