Files
pythonocc-step-editor/scripts/verify_edge_length_resize.py
T

182 lines
7.6 KiB
Python
Raw Normal View History

from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
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"
def _edge_length(model: StepModel, edge_id: int) -> float:
return float(model.edge_info(edge_id).get("length", 0.0))
def _line_edge_ids_near_length(model: StepModel, length: float, tolerance: float) -> list[int]:
edge_ids: list[int] = []
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
if abs(float(info.get("length", 0.0)) - length) <= tolerance:
edge_ids.append(edge_id)
return edge_ids
def _length_distribution(model: StepModel) -> dict[float, int]:
counts = Counter(round(_edge_length(model, edge_id), 6) for edge_id in range(len(model.edges)))
return dict(sorted(counts.items()))
def _count_near(values: list[float], target: float, tolerance: float) -> int:
return sum(1 for value in values if abs(value - target) <= tolerance)
def _assert_cube_edge_intent_geometry(
*,
source_length: float,
target_length: float,
strategy: str,
anchor: str,
lengths: list[float],
tolerance: float,
) -> None:
target_count = _count_near(lengths, target_length, tolerance)
source_count = _count_near(lengths, source_length, tolerance)
delta = abs(target_length - source_length)
effective_anchor = "keep-start" if anchor == "auto" else anchor
if strategy == "local-edge-only-deform":
if target_count != 1:
raise SystemExit(
"local Edge deformation should only make the selected Edge reach the target length; "
f"target_count={target_count}, lengths={_length_distribution_from_values(lengths)}"
)
if effective_anchor == "center":
expected_slanted = (source_length * source_length + (delta * 0.5) * (delta * 0.5)) ** 0.5
slanted_count = _count_near(lengths, expected_slanted, tolerance)
if source_count != 7 or slanted_count != 4:
raise SystemExit(
"center-anchored local Edge deformation should move both endpoints equally; "
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
else:
expected_slanted = (source_length * source_length + delta * delta) ** 0.5
slanted_count = _count_near(lengths, expected_slanted, tolerance)
if source_count != 9 or slanted_count != 2:
raise SystemExit(
"one-end anchored local Edge deformation should move only the selected Edge endpoint; "
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
elif strategy in {"move-edge-end-plane-by-push-pull", "scale-owning-shape-from-edge"}:
if target_count != 4 or source_count != 8:
raise SystemExit(
f"{strategy} should resize the whole cube span in the selected Edge direction; "
f"target_count={target_count}, source_count={source_count}, "
f"lengths={_length_distribution_from_values(lengths)}"
)
def _length_distribution_from_values(values: list[float]) -> dict[float, int]:
counts = Counter(round(value, 6) for value in values)
return dict(sorted(counts.items()))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify cube edge-length resize semantics.")
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
parser.add_argument("--source-length", type=float, default=10.0, help="Current line edge length to search for.")
parser.add_argument("--target-length", type=float, default=15.0, help="Target edge length to apply.")
parser.add_argument("--anchor", default="keep-start", choices=["auto", "center", "keep-start", "keep-end"])
parser.add_argument(
"--strategy",
default="local-edge-only-deform",
choices=[
"auto",
"local-edge-only-deform",
"move-edge-end-plane-by-push-pull",
"scale-owning-shape-from-edge",
],
help="Requested Edge length edit semantics.",
)
parser.add_argument("--expect-strategy", default="", help="Expected resolved resize strategy.")
parser.add_argument("--tolerance", type=float, default=1e-5, help="Allowed target length error.")
args = parser.parse_args()
model = StepModel.load(Path(args.model))
edge_ids = _line_edge_ids_near_length(model, args.source_length, args.tolerance)
if not edge_ids:
raise SystemExit(f"no line edge near source length {args.source_length:g}")
edge_id = edge_ids[0]
before = model.stats()
plan = model.general_edge_length_plan(
edge_id,
args.target_length,
anchor_mode=args.anchor,
strategy_mode=args.strategy,
)
strategy = str(plan.get("resize_strategy", ""))
expected_strategy = args.expect_strategy or args.strategy
if expected_strategy == "auto":
expected_strategy = strategy
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
result = model.resize_general_edge_length(
edge_id,
args.target_length,
anchor_mode=args.anchor,
strategy_mode=args.strategy,
)
after = model.stats()
lengths = [_edge_length(model, item) for item in range(len(model.edges))]
nearest = min(lengths, key=lambda value: abs(value - args.target_length))
error = abs(nearest - args.target_length)
if error > args.tolerance:
raise SystemExit(f"target length check failed: nearest={nearest:g}, error={error:g}")
if (
Path(args.model).resolve() == DEFAULT_MODEL.resolve()
and strategy == "local-edge-only-deform"
and (after.faces != before.faces or after.edges != before.edges)
):
raise SystemExit(
"cube local Edge deformation should not split faces/edges; "
f"before faces/edges={before.faces}/{before.edges}, after={after.faces}/{after.edges}"
)
if Path(args.model).resolve() == DEFAULT_MODEL.resolve():
_assert_cube_edge_intent_geometry(
source_length=args.source_length,
target_length=args.target_length,
strategy=strategy,
anchor=args.anchor,
lengths=lengths,
tolerance=max(args.tolerance, 1e-5),
)
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")
print(f"requested_strategy={args.strategy}")
print(f"strategy={strategy}")
print(f"anchor_mode={args.anchor}")
print(f"start_move={plan.get('local_edge_deform_start_move')}")
print(f"end_move={plan.get('local_edge_deform_end_move')}")
print(f"before_faces={before.faces} before_edges={before.edges}")
print(f"after_faces={after.faces} after_edges={after.edges}")
print(f"topology_stable={after.faces == before.faces and after.edges == before.edges}")
print(f"nearest_length={nearest:.6f} target_error={error:.6g}")
print(f"length_distribution={_length_distribution(model)}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return 0
if __name__ == "__main__":
raise SystemExit(main())