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

340 lines
14 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
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_through_hole_model(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(30.0, 20.0, 8.0).Shape()
axis = gp_Ax2(gp_Pnt(15.0, 10.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 10.0).Shape()
cut = BRepAlgoAPI_Cut(plate, cutter)
shape = _finalize_boolean_result(cut, "verify through-hole model cut")
_write_step(shape, path)
def _write_blind_hole_model(path: Path) -> None:
block = BRepPrimAPI_MakeBox(30.0, 20.0, 10.0).Shape()
axis = gp_Ax2(gp_Pnt(15.0, 10.0, 10.0), gp_Dir(0.0, 0.0, -1.0))
cutter = BRepPrimAPI_MakeCylinder(axis, 3.0, 6.0).Shape()
cut = BRepAlgoAPI_Cut(block, cutter)
shape = _finalize_boolean_result(cut, "verify blind-hole model cut")
_write_step(shape, path)
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 _hole_face_ids(model: StepModel, *, blind: bool | None = None) -> 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
if str(info.get("feature_guess", "")) != "hole/groove candidate":
continue
span = float(info.get("angular_span") or 0.0)
if span < math.tau * 0.92:
continue
if blind is not None:
is_blind = info.get("cylinder_end_type") == "blind"
if is_blind != blind:
continue
face_ids.append(face_id)
return face_ids
def _first_hole_face(model: StepModel, *, blind: bool | None = None) -> int:
candidates = _hole_face_ids(model, blind=blind)
if not candidates:
kind = "blind " if blind else ""
raise SystemExit(f"no {kind}near-full cylindrical hole face was recognized")
return candidates[0]
def _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"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 _diameter(model: StepModel, face_id: int) -> float:
value = model.face_info(face_id).get("diameter")
if value is None:
raise SystemExit(f"diameter is missing on Face {face_id}")
return float(value)
def _volume(model: StepModel) -> float:
value = model.geometry_stats().get("volume")
if not isinstance(value, (int, float)):
raise SystemExit("model volume is unavailable")
return float(value)
def _depth(model: StepModel, face_id: int) -> float:
info = model.face_info(face_id)
feature = model.feature_info(face_id)
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None
probe = float(info.get("hole_depth_estimate") or 1.0)
plan = model.cylindrical_depth_plan(face_id, probe, bottom_face_id=bottom_face_id)
value = plan.get("current_depth")
if value is None:
raise SystemExit(f"depth is missing on Face {face_id}")
return float(value)
def _nearest_hole_by_diameter(
model: StepModel,
target_diameter: float,
target_center: tuple[float, float, float] | None = None,
*,
blind: bool | None = None,
) -> tuple[int, float, tuple[float, float, float], float]:
best: tuple[int, float, tuple[float, float, float], float] | None = None
for face_id in _hole_face_ids(model, blind=blind):
diameter = _diameter(model, face_id)
center = _axis_center(model, face_id)
diameter_error = abs(diameter - target_diameter)
center_error = _distance(center, target_center) if target_center is not None else 0.0
error = diameter_error + center_error
if best is None or error < best[3]:
best = (face_id, diameter, center, error)
if best is None:
raise SystemExit("no cylindrical hole face remained after edit")
return best
def _nearest_blind_depth(model: StepModel, target_depth: float) -> tuple[int, float, float]:
best: tuple[int, float, float] | None = None
for face_id in _hole_face_ids(model, blind=True):
try:
depth = _depth(model, face_id)
except Exception:
continue
error = abs(depth - target_depth)
if best is None or error < best[2]:
best = (face_id, depth, error)
if best is None:
raise SystemExit("no measurable blind hole remained after edit")
return best
def _run_diameter_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_hole_diam_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=False)
before = model.stats()
center = _axis_center(model, face_id)
plan = model.cylindrical_resize_plan(face_id, target)
if plan["status"] == "blocked":
raise SystemExit(f"diameter plan was blocked: {plan['message']}")
result = model.resize_cylindrical_hole(face_id, target)
after = model.stats()
verified_face, diameter, verified_center, error = _nearest_hole_by_diameter(model, target, center, blind=False)
if abs(diameter - target) > tolerance:
raise SystemExit(f"diameter verification failed: target={target:g}, value={diameter:g}, error={error:g}")
print("mode=diameter")
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:.6f} value={diameter:.6f} center={verified_center} error={abs(diameter - target):.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_axis_center_case(offset: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_hole_axis_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=False)
before = model.stats()
current_center = _axis_center(model, face_id)
target_center = (current_center[0] + offset, current_center[1], current_center[2])
current_diameter = _diameter(model, face_id)
plan = model.cylindrical_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_hole_axis(face_id, target_center)
after = model.stats()
verified_face, diameter, center, _ = _nearest_hole_by_diameter(model, current_diameter, target_center, blind=False)
center_error = _distance(center, target_center)
if center_error > tolerance or abs(diameter - current_diameter) > tolerance:
raise SystemExit(
f"axis_center verification failed: target={target_center}, center={center}, "
f"center_error={center_error:g}, diameter={diameter:g}"
)
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} diameter={diameter:.6f} error={center_error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_suppress_case(tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_hole_suppress_") as temp_dir:
model_path = Path(temp_dir) / "through_hole.step"
_write_through_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=False)
before = model.stats()
before_volume = _volume(model)
plan = model.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise SystemExit(f"suppress plan was blocked: {plan['message']}")
result = model.suppress_cylindrical_hole(face_id)
after = model.stats()
after_volume = _volume(model)
remaining_holes = _hole_face_ids(model)
full_box_volume = 30.0 * 20.0 * 8.0
removed_hole_volume = math.pi * 3.0 * 3.0 * 8.0
volume_tolerance = max(tolerance * full_box_volume, 1e-3)
if after.solids != 1:
raise SystemExit(f"suppress verification failed: expected one solid, got {after.solids}")
if remaining_holes:
raise SystemExit(f"suppress verification failed: hole faces still detected: {remaining_holes}")
if abs(after_volume - full_box_volume) > volume_tolerance:
raise SystemExit(
f"suppress volume verification failed: target={full_box_volume:g}, "
f"value={after_volume:g}, tolerance={volume_tolerance:g}"
)
if after_volume - before_volume < removed_hole_volume * 0.95:
raise SystemExit(
f"suppress recovered too little volume: expected_about={removed_hole_volume:g}, "
f"value={after_volume - before_volume:g}"
)
print("mode=suppress")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"before_volume={before_volume:.6f}")
print(f"after_volume={after_volume:.6f}")
print(f"full_box_volume={full_box_volume:.6f}")
print(f"remaining_hole_faces={remaining_holes}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_blind_depth_case(target: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_blind_depth_") as temp_dir:
model_path = Path(temp_dir) / "blind_hole.step"
_write_blind_hole_model(model_path)
model = StepModel.load(model_path)
face_id = _first_hole_face(model, blind=True)
feature = model.feature_info(face_id)
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
bottom_face_id = int(bottom_face_ids[0]) if bottom_face_ids else None
before = model.stats()
current_depth = _depth(model, face_id)
plan = model.cylindrical_depth_plan(face_id, target, bottom_face_id=bottom_face_id)
if plan["status"] == "blocked":
raise SystemExit(f"blind_depth plan was blocked: {plan['message']}")
result = model.resize_cylindrical_depth(face_id, target, bottom_face_id=bottom_face_id)
after = model.stats()
verified_face, depth, error = _nearest_blind_depth(model, target)
if error > tolerance:
raise SystemExit(f"blind_depth verification failed: target={target:g}, value={depth:g}, error={error:g}")
print("mode=blind_depth")
print(f"source_face={face_id}")
print(f"bottom_face={bottom_face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"current_depth={current_depth:.6f}")
print(f"verified_face={verified_face}")
print(f"target={target:.6f} value={depth:.6f} error={error:.6g}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify cylindrical hole edit operations.")
parser.add_argument(
"--mode",
default="all",
choices=[
"all",
"diameter",
"diameter_shrink",
"axis_center",
"suppress",
"blind_depth",
"blind_depth_shallow",
],
help="Hole edit mode to verify.",
)
parser.add_argument("--diameter", type=float, default=8.0)
parser.add_argument("--diameter-shrink", type=float, default=4.0)
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
parser.add_argument("--blind-depth", type=float, default=8.0)
parser.add_argument("--blind-depth-shallow", type=float, default=4.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
cases = (
[
("diameter", args.diameter),
("diameter_shrink", args.diameter_shrink),
("axis_center", args.axis_center),
("suppress", None),
("blind_depth", args.blind_depth),
("blind_depth_shallow", args.blind_depth_shallow),
]
if args.mode == "all"
else [(args.mode, None if args.mode == "suppress" else getattr(args, args.mode.replace("-", "_")))]
)
for mode, target in cases:
if mode in {"diameter", "diameter_shrink"}:
_run_diameter_case(float(target), args.tolerance)
elif mode == "axis_center":
_run_axis_center_case(float(target), args.tolerance)
elif mode == "suppress":
_run_suppress_case(args.tolerance)
elif mode in {"blind_depth", "blind_depth_shallow"}:
_run_blind_depth_case(float(target), args.tolerance)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
if __name__ == "__main__":
raise SystemExit(main())