292 lines
13 KiB
Python
292 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
|
|
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
|
from OCC.Core.GeomAbs import GeomAbs_Plane
|
|
|
|
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.geometry_utils import _dir_tuple, _tuple_dot, _tuple_normalized, _tuple_or_none, _tuple_sub
|
|
from step_editor.model import StepModel
|
|
from verify_edge_round_chamfer import _first_editable_line_edge, _write_box_model
|
|
|
|
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 _planar_face_normal(model: StepModel, face_id: int) -> tuple[float, float, float] | None:
|
|
surf = BRepAdaptor_Surface(model.faces[face_id])
|
|
if surf.GetType() != GeomAbs_Plane:
|
|
return None
|
|
return _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction()))
|
|
|
|
|
|
def _edge_axis(model: StepModel, edge_id: int) -> tuple[float, float, float]:
|
|
info = model.edge_info(edge_id)
|
|
start = _tuple_or_none(info.get("start_point"))
|
|
end = _tuple_or_none(info.get("end_point"))
|
|
axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None
|
|
if axis is None:
|
|
raise SystemExit(f"edge {edge_id} has no stable line direction")
|
|
return axis
|
|
|
|
|
|
def _assert_cube_push_pull_planar_constraints(
|
|
model: StepModel,
|
|
target_length: float,
|
|
tolerance: float,
|
|
result: str,
|
|
) -> None:
|
|
if "Planar relation check: ok" not in result:
|
|
raise SystemExit(f"end-face push/pull result should include a passing planar relation check: {result}")
|
|
relation_tolerance = max(tolerance, 1e-4)
|
|
target_edge_ids = _line_edge_ids_near_length(model, target_length, relation_tolerance)
|
|
if len(target_edge_ids) != 4:
|
|
raise SystemExit(f"end-face push/pull should leave four target-length edges, got {target_edge_ids}")
|
|
for edge_id in target_edge_ids:
|
|
axis = _edge_axis(model, edge_id)
|
|
adjacent_face_ids = tuple(int(item) for item in model.edge_info(edge_id).get("adjacent_face_ids", ()))
|
|
normals: list[tuple[int, tuple[float, float, float]]] = []
|
|
for face_id in adjacent_face_ids:
|
|
normal = _planar_face_normal(model, face_id)
|
|
if normal is not None:
|
|
normals.append((face_id, normal))
|
|
if len(normals) != 2:
|
|
raise SystemExit(f"target edge {edge_id} should still have two planar adjacent faces, got {adjacent_face_ids}")
|
|
side_axis_dots = [abs(_tuple_dot(normal, axis)) for _face_id, normal in normals]
|
|
if any(value > relation_tolerance for value in side_axis_dots):
|
|
raise SystemExit(f"target edge {edge_id} side faces are no longer parallel to the edge: {side_axis_dots}")
|
|
side_pair_dot = abs(_tuple_dot(normals[0][1], normals[1][1]))
|
|
if side_pair_dot > relation_tolerance:
|
|
raise SystemExit(f"target edge {edge_id} adjacent side faces are no longer perpendicular: {side_pair_dot:g}")
|
|
|
|
|
|
def _verify_nonorthogonal_push_pull_guard() -> None:
|
|
with tempfile.TemporaryDirectory(prefix="geom_param_edge_push_pull_guard_") as temp_dir:
|
|
model_path = Path(temp_dir) / "chamfered_box.step"
|
|
_write_box_model(model_path)
|
|
model = StepModel.load(model_path)
|
|
source_edge_id = _first_editable_line_edge(model)
|
|
model.chamfer_edge(source_edge_id, 1.0)
|
|
|
|
for edge_id in range(len(model.edges)):
|
|
info = model.edge_info(edge_id)
|
|
if info.get("curve") != "line":
|
|
continue
|
|
normals: list[tuple[float, float, float]] = []
|
|
for face_id in tuple(int(item) for item in info.get("adjacent_face_ids", ())):
|
|
normal = _planar_face_normal(model, face_id)
|
|
if normal is not None:
|
|
normals.append(normal)
|
|
if len(normals) != 2:
|
|
continue
|
|
side_dot = abs(_tuple_dot(normals[0], normals[1]))
|
|
if not 0.2 < side_dot < 0.9:
|
|
continue
|
|
current_length = float(info.get("length") or 0.0)
|
|
plan = model.general_edge_length_plan(
|
|
edge_id,
|
|
current_length + 2.0,
|
|
anchor_mode="keep-start",
|
|
strategy_mode="move-edge-end-plane-by-push-pull",
|
|
)
|
|
message = str(plan.get("message") or "")
|
|
blockers = str(plan.get("edge_length_planar_constraint_blockers") or "")
|
|
if plan.get("status") != "blocked":
|
|
raise SystemExit(f"non-orthogonal chamfer Edge push/pull should be blocked: {plan}")
|
|
if "neither parallel nor perpendicular" not in f"{message} {blockers}":
|
|
raise SystemExit(f"non-orthogonal chamfer Edge should explain the planar relation blocker: {plan}")
|
|
print(f"non_orthogonal_push_pull_guard=edge {edge_id}, side_dot={side_dot:.6f}")
|
|
return
|
|
raise SystemExit("no non-orthogonal chamfer Edge was found for push/pull guard verification")
|
|
|
|
|
|
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>'}")
|
|
if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready":
|
|
raise SystemExit(
|
|
"end-face push/pull plan should expose a ready planar relation constraint; "
|
|
f"status={plan.get('edge_length_planar_constraint_status')}, "
|
|
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
|
|
)
|
|
|
|
result = model.resize_general_edge_length(
|
|
edge_id,
|
|
args.target_length,
|
|
anchor_mode=args.anchor,
|
|
strategy_mode=args.strategy,
|
|
)
|
|
if "First-level topology check" not in result:
|
|
raise SystemExit(f"Edge length result should include first-level topology check: {result}")
|
|
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),
|
|
)
|
|
if strategy == "move-edge-end-plane-by-push-pull":
|
|
_assert_cube_push_pull_planar_constraints(
|
|
model,
|
|
args.target_length,
|
|
max(args.tolerance, 1e-5),
|
|
result,
|
|
)
|
|
if args.anchor == "keep-start":
|
|
_verify_nonorthogonal_push_pull_guard()
|
|
|
|
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())
|