368 lines
18 KiB
Python
368 lines
18 KiB
Python
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"),
|
||
("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 in {"push_pull", "keep_relations"} and args.property != "offset":
|
||
raise SystemExit("--strategy push_pull/keep_relations 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"
|
||
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.property == "offset" and 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())
|