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

449 lines
20 KiB
Python

from __future__ import annotations
import argparse
import math
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
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 _finalize_boolean_result
from step_editor.model import StepModel
from step_editor.step_io import _write_step
def _write_half_round_slot_model(path: Path) -> None:
box = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape()
# A cylinder centered on the top face cuts a half-round groove open to Z+.
cutter_axis = gp_Ax2(gp_Pnt(-5.0, 10.0, 10.0), gp_Dir(1.0, 0.0, 0.0))
cutter = BRepPrimAPI_MakeCylinder(cutter_axis, 3.0, 40.0).Shape()
cut = BRepAlgoAPI_Cut(box, cutter)
shape = _finalize_boolean_result(cut, "verify slot model cut")
_write_step(shape, path)
def _write_obround_slot_model(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(40.0, 24.0, 6.0).Shape()
axis_1 = gp_Ax2(gp_Pnt(15.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(25.0, 12.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape()
cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape()
connector = BRepPrimAPI_MakeBox(gp_Pnt(15.0, 9.0, -1.0), 10.0, 6.0, 8.0).Shape()
fuse_1 = BRepAlgoAPI_Fuse(cylinder_1, connector)
tool = _finalize_boolean_result(fuse_1, "verify obround slot tool fuse")
fuse_2 = BRepAlgoAPI_Fuse(tool, cylinder_2)
tool = _finalize_boolean_result(fuse_2, "verify obround slot tool second fuse")
cut = BRepAlgoAPI_Cut(plate, tool)
shape = _finalize_boolean_result(cut, "verify obround slot model cut")
_write_step(shape, path)
def _slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
if feature.get("slot_kind") != "partial-cylindrical-groove":
continue
span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0)
if 1e-6 < span < math.tau * 0.92:
face_ids.append(face_id)
return face_ids
def _first_slot_face(model: StepModel) -> int:
candidates = _slot_face_ids(model)
if not candidates:
raise SystemExit("no partial cylindrical slot face was recognized")
return candidates[0]
def _slot_metric(model: StepModel, face_id: int, key: str) -> float:
feature = model.feature_info(face_id)
info = model.face_info(face_id)
value = feature.get(key)
if value is None:
value = info.get(key)
if value is None:
raise SystemExit(f"slot metric {key} is missing on Face {face_id}")
return float(value)
def _slot_axis_center(model: StepModel, face_id: int) -> tuple[float, float, float]:
info = model.face_info(face_id)
axis_point = info.get("axis_point")
axis = info.get("axis")
v_range = info.get("same_domain_v_range") or info.get("v_range")
if not isinstance(axis_point, tuple) or not isinstance(axis, tuple) or not isinstance(v_range, tuple):
raise SystemExit(f"slot axis center data is missing on Face {face_id}")
v_mid = (float(v_range[0]) + float(v_range[1])) * 0.5
return (
float(axis_point[0]) + float(axis[0]) * v_mid,
float(axis_point[1]) + float(axis[1]) * v_mid,
float(axis_point[2]) + float(axis[2]) * v_mid,
)
def _distance(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return math.sqrt(
(left[0] - right[0]) ** 2
+ (left[1] - right[1]) ** 2
+ (left[2] - right[2]) ** 2
)
def _nearest_metric(model: StepModel, key: str, target: float) -> tuple[int, float, float]:
best: tuple[int, float, float] | None = None
for face_id in _slot_face_ids(model):
value = _slot_metric(model, face_id, key)
error = abs(value - target)
if best is None or error < best[2]:
best = (face_id, value, error)
if best is None:
raise SystemExit("no slot face remained after edit")
return best
def _nearest_axis_center(model: StepModel, target: tuple[float, float, float]) -> tuple[int, tuple[float, float, float], float]:
best: tuple[int, tuple[float, float, float], float] | None = None
for face_id in _slot_face_ids(model):
center = _slot_axis_center(model, face_id)
error = _distance(center, target)
if best is None or error < best[2]:
best = (face_id, center, error)
if best is None:
raise SystemExit("no slot face remained after axis move")
return best
def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, float]]:
rows: list[tuple[int, int, float, float]] = []
seen_pairs: set[tuple[int, int]] = set()
for face_id in _slot_face_ids(model):
info = model.face_info(face_id)
diameter = float(info.get("diameter") or 0.0)
if diameter <= 1e-9:
continue
feature = model.feature_info(face_id)
try:
cutter_plan = model._bounded_cylinder_cutter_plan(face_id, diameter, feature)
pair_plan = model._paired_obround_slot_plan(face_id, diameter, feature, cutter_plan)
except Exception:
continue
pair_face_id = pair_plan.get("slot_pair_face_id")
center_distance = pair_plan.get("slot_pair_axis_distance")
if pair_face_id in {None, ""} or center_distance in {None, ""}:
continue
pair_key = tuple(sorted((face_id, int(pair_face_id))))
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
rows.append((face_id, int(pair_face_id), float(center_distance) + diameter, float(center_distance)))
return rows
def _nearest_obround_total_length(model: StepModel, target_total_length: float) -> tuple[int, int, float, float, float]:
best: tuple[int, int, float, float, float] | None = None
for face_id, pair_face_id, total_length, center_distance in _obround_total_lengths(model):
error = abs(total_length - target_total_length)
if best is None or error < best[4]:
best = (face_id, pair_face_id, total_length, center_distance, error)
if best is None:
raise SystemExit("no paired obround slot ends were recognized")
return best
def _nearest_obround_axis_pair(
model: StepModel,
target_center_1: tuple[float, float, float],
target_center_2: tuple[float, float, float],
) -> tuple[int, int, tuple[float, float, float], tuple[float, float, float], float]:
best: tuple[int, int, tuple[float, float, float], tuple[float, float, float], float] | None = None
for face_id, pair_face_id, _total_length, _center_distance in _obround_total_lengths(model):
center_1 = _slot_axis_center(model, face_id)
center_2 = _slot_axis_center(model, pair_face_id)
direct_error = max(_distance(center_1, target_center_1), _distance(center_2, target_center_2))
swapped_error = max(_distance(center_1, target_center_2), _distance(center_2, target_center_1))
if swapped_error < direct_error:
error = swapped_error
ordered_1, ordered_2 = center_2, center_1
else:
error = direct_error
ordered_1, ordered_2 = center_1, center_2
if best is None or error < best[4]:
best = (face_id, pair_face_id, ordered_1, ordered_2, error)
if best is None:
raise SystemExit("no paired obround slot ends were recognized after axis move")
return best
def _slot_candidate_summary(model: StepModel) -> list[tuple[int, float, float, str]]:
rows: list[tuple[int, float, float, str]] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
span = float(feature.get("slot_angular_span") or info.get("angular_span") or 0.0)
diameter = float(info.get("diameter") or feature.get("diameter") or 0.0)
rows.append((face_id, round(diameter, 6), round(span, 6), str(feature.get("slot_kind") or "")))
return rows
def _run_case(mode: str, target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_slot_") as temp_dir:
model_path = Path(temp_dir) / "half_round_slot.step"
_write_half_round_slot_model(model_path)
model = StepModel.load(model_path)
face_id = _first_slot_face(model)
before = model.stats()
if mode == "width":
metric_key = "slot_chord_width_estimate"
plan = model.cylindrical_slot_resize_plan(face_id, target, "width")
result = model.resize_cylindrical_slot_width(face_id, target)
elif mode == "depth":
metric_key = "slot_sagitta_depth_estimate"
plan = model.cylindrical_slot_resize_plan(face_id, target, "depth")
result = model.resize_cylindrical_slot_depth(face_id, target)
elif mode == "arc_length":
metric_key = "slot_arc_length_estimate"
plan = model.cylindrical_slot_resize_plan(face_id, target, "arc_length")
result = model.resize_cylindrical_slot_arc_length(face_id, target)
elif mode == "angular_span":
metric_key = "slot_angular_span"
plan = model.cylindrical_slot_angular_span_plan(face_id, target)
result = model.resize_cylindrical_slot_angular_span(face_id, target)
elif mode == "axis_center":
current_center = _slot_axis_center(model, face_id)
target_center = (current_center[0], current_center[1] + target, current_center[2])
plan = model.cylindrical_slot_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise SystemExit(f"axis_center plan was blocked: {plan['message']}")
result = model.move_cylindrical_slot_axis(face_id, target_center)
after = model.stats()
verified_face, center, error = _nearest_axis_center(model, target_center)
if error > tolerance:
raise SystemExit(
f"axis_center verification failed: target={target_center}, nearest={center}, "
f"error={error:g}; candidates={_slot_candidate_summary(model)}"
)
print("mode=axis_center")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target={target_center} value={center} error={error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
return
else:
raise SystemExit(f"unsupported mode: {mode}")
if plan["status"] == "blocked":
raise SystemExit(f"{mode} plan was blocked: {plan['message']}")
after = model.stats()
verified_face, value, error = _nearest_metric(model, metric_key, target)
if error > tolerance:
raise SystemExit(
f"{mode} verification failed: target={target:g}, nearest={value:g}, error={error:g}; "
f"candidates={_slot_candidate_summary(model)}"
)
print(f"mode={mode}")
print(f"source_face={face_id}")
print(f"strategy={plan.get('slot_resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"verified_face={verified_face}")
print(f"target={target:.6f} value={value:.6f} error={error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_total_length_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_obround_") as temp_dir:
model_path = Path(temp_dir) / "obround_slot.step"
_write_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0)
before = model.stats()
plan = model.cylindrical_slot_total_length_plan(face_id, target)
if plan["status"] == "blocked":
raise SystemExit(f"total_length plan was blocked: {plan['message']}")
result = model.resize_cylindrical_slot_total_length(face_id, target)
after = model.stats()
verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length(model, target)
if error > tolerance:
raise SystemExit(
f"total_length verification failed: target={target:g}, nearest={total_length:g}, "
f"error={error:g}; pairs={_obround_total_lengths(model)}"
)
print("mode=total_length")
print(f"source_face={face_id}")
print(f"source_pair_face={pair_face_id}")
print(f"strategy={plan.get('slot_resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_total_length={current_total_length:.6f}")
print(f"current_center_distance={current_center_distance:.6f}")
print(f"verified_face={verified_face}")
print(f"verified_pair_face={verified_pair}")
print(f"target={target:.6f} value={total_length:.6f} center_distance={center_distance:.6f} error={error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_center_distance_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_obround_center_distance_") as temp_dir:
model_path = Path(temp_dir) / "obround_slot.step"
_write_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0)
slot_diameter = current_total_length - current_center_distance
target_total_length = target + slot_diameter
before = model.stats()
plan = model.cylindrical_slot_center_distance_plan(face_id, target)
if plan["status"] == "blocked":
raise SystemExit(f"center_distance plan was blocked: {plan['message']}")
result = model.resize_cylindrical_slot_center_distance(face_id, target)
after = model.stats()
verified_face, verified_pair, total_length, center_distance, error = _nearest_obround_total_length(
model,
target_total_length,
)
center_error = abs(center_distance - target)
if center_error > tolerance:
raise SystemExit(
f"center_distance verification failed: target={target:g}, nearest={center_distance:g}, "
f"error={center_error:g}; pairs={_obround_total_lengths(model)}"
)
print("mode=center_distance")
print(f"source_face={face_id}")
print(f"source_pair_face={pair_face_id}")
print(f"strategy={plan.get('slot_resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_total_length={current_total_length:.6f}")
print(f"current_center_distance={current_center_distance:.6f}")
print(f"slot_diameter={slot_diameter:.6f}")
print(f"verified_face={verified_face}")
print(f"verified_pair_face={verified_pair}")
print(
f"target_center_distance={target:.6f} value={center_distance:.6f} "
f"total_length={total_length:.6f} total_length_error={error:.6g} error={center_error:.6g}"
)
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_obround_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_obround_axis_") as temp_dir:
model_path = Path(temp_dir) / "obround_slot.step"
_write_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_id, pair_face_id, current_total_length, current_center_distance, _ = _nearest_obround_total_length(model, 16.0)
current_center = _slot_axis_center(model, face_id)
pair_center = _slot_axis_center(model, pair_face_id)
target_center = (current_center[0], current_center[1] + offset, current_center[2])
target_pair_center = (pair_center[0], pair_center[1] + offset, pair_center[2])
before = model.stats()
plan = model.cylindrical_slot_axis_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise SystemExit(f"obround_axis_center plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "paired-obround-slot-axis-prism":
raise SystemExit(f"obround_axis_center did not choose paired strategy: {plan.get('resize_strategy')}")
result = model.move_cylindrical_slot_axis(face_id, target_center)
after = model.stats()
verified_face, verified_pair, center_1, center_2, error = _nearest_obround_axis_pair(
model,
target_center,
target_pair_center,
)
if error > tolerance:
raise SystemExit(
f"obround_axis_center verification failed: target=({target_center}, {target_pair_center}), "
f"nearest=({center_1}, {center_2}), error={error:g}; pairs={_obround_total_lengths(model)}"
)
print("mode=obround_axis_center")
print(f"source_face={face_id}")
print(f"source_pair_face={pair_face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_total_length={current_total_length:.6f}")
print(f"current_center_distance={current_center_distance:.6f}")
print(f"verified_face={verified_face}")
print(f"verified_pair_face={verified_pair}")
print(f"target=({target_center}, {target_pair_center}) value=({center_1}, {center_2}) error={error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify partial cylindrical slot resize operations.")
parser.add_argument(
"--mode",
default="all",
choices=[
"all",
"width",
"depth",
"arc_length",
"angular_span",
"axis_center",
"obround_axis_center",
"total_length",
"center_distance",
],
help="Slot metric to verify.",
)
parser.add_argument("--width", type=float, default=8.0)
parser.add_argument("--depth", type=float, default=4.0)
parser.add_argument("--arc-length", type=float, default=12.0)
parser.add_argument("--angular-span", type=float, default=2.2)
parser.add_argument("--axis-center", type=float, default=1.0, help="Radial axis-center offset to verify.")
parser.add_argument("--obround-axis-center", type=float, default=1.0, help="Obround slot axis-center offset to verify.")
parser.add_argument("--total-length", type=float, default=20.0)
parser.add_argument("--center-distance", type=float, default=14.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
cases = (
[
("width", args.width),
("depth", args.depth),
("arc_length", args.arc_length),
("angular_span", args.angular_span),
("axis_center", args.axis_center),
("obround_axis_center", args.obround_axis_center),
("total_length", args.total_length),
("center_distance", args.center_distance),
]
if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
)
for mode, target in cases:
if mode == "total_length":
_run_total_length_case(float(target), args.tolerance)
elif mode == "center_distance":
_run_center_distance_case(float(target), args.tolerance)
elif mode == "obround_axis_center":
_run_obround_axis_center_case(float(target), args.tolerance)
else:
_run_case(mode, float(target), args.tolerance)
return 0
if __name__ == "__main__":
raise SystemExit(main())