340 lines
14 KiB
Python
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())
|