feat: 完善参数化编辑语义和槽孔中心距
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCone, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
|
||||
|
||||
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
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
def _write_cone_model(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeCone(4.0, 2.0, 10.0).Shape()
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _write_sphere_model(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeSphere(5.0).Shape()
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _write_torus_model(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeTorus(8.0, 2.0).Shape()
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _first_face_by_surface(model: StepModel, surface: str) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
if model.face_info(face_id).get("surface") == surface:
|
||||
return face_id
|
||||
raise SystemExit(f"no {surface} Face was recognized")
|
||||
|
||||
|
||||
def _nearest_sphere_radius(model: StepModel, target_radius: float) -> tuple[int, float]:
|
||||
best: tuple[int, float, float] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "sphere":
|
||||
continue
|
||||
radius = float(info.get("radius") or 0.0)
|
||||
score = abs(radius - target_radius)
|
||||
if best is None or score < best[2]:
|
||||
best = (face_id, radius, score)
|
||||
if best is None:
|
||||
raise SystemExit("no sphere Face remained after edit")
|
||||
return best[0], best[1]
|
||||
|
||||
|
||||
def _nearest_torus_radii(
|
||||
model: StepModel,
|
||||
target_major: float,
|
||||
target_minor: float,
|
||||
) -> tuple[int, float, float]:
|
||||
best: tuple[int, float, float, float] | None = None
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "torus":
|
||||
continue
|
||||
major = float(info.get("major_radius") or 0.0)
|
||||
minor = float(info.get("minor_radius") or 0.0)
|
||||
score = abs(major - target_major) + abs(minor - target_minor)
|
||||
if best is None or score < best[3]:
|
||||
best = (face_id, major, minor, score)
|
||||
if best is None:
|
||||
raise SystemExit("no torus Face remained after edit")
|
||||
return best[0], best[1], best[2]
|
||||
|
||||
|
||||
def _has_thin_cap_diameter(model: StepModel, target_diameter: float, tolerance: float) -> bool:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
size = info.get("bbox_size")
|
||||
if not isinstance(size, tuple) or len(size) != 3:
|
||||
continue
|
||||
dx, dy, dz = (float(size[0]), float(size[1]), float(size[2]))
|
||||
if dz > max(tolerance * 10.0, 1e-5):
|
||||
continue
|
||||
if abs(dx - target_diameter) <= tolerance and abs(dy - target_diameter) <= tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _bbox_z_size(model: StepModel) -> float:
|
||||
size = model.geometry_stats().get("bbox_size")
|
||||
if not isinstance(size, tuple) or len(size) != 3:
|
||||
raise SystemExit("model bbox_size is missing")
|
||||
return float(size[2])
|
||||
|
||||
|
||||
def _run_cone_case(target_reference_radius: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cone_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "cone.step"
|
||||
_write_cone_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_face_by_surface(model, "cone")
|
||||
before = model.stats()
|
||||
info = model.face_info(face_id)
|
||||
current_reference_radius = float(info.get("reference_radius") or 0.0)
|
||||
current_top_radius = 2.0
|
||||
scale = target_reference_radius / current_reference_radius
|
||||
target_top_radius = current_top_radius * scale
|
||||
plan = model.conical_reference_radius_plan(face_id, target_reference_radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"cone plan was blocked: {plan['message']}")
|
||||
result = model.resize_conical_reference_radius(face_id, target_reference_radius)
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"cone resize changed solid count: before={before.solids}, after={after.solids}")
|
||||
if abs(_bbox_z_size(model) - 10.0) > tolerance:
|
||||
raise SystemExit(f"cone height changed unexpectedly: z_size={_bbox_z_size(model):g}")
|
||||
if not _has_thin_cap_diameter(model, target_reference_radius * 2.0, tolerance):
|
||||
raise SystemExit(f"cone bottom cap diameter was not resized to {target_reference_radius * 2.0:g}")
|
||||
if not _has_thin_cap_diameter(model, target_top_radius * 2.0, tolerance):
|
||||
raise SystemExit(f"cone top cap diameter was not resized to {target_top_radius * 2.0:g}")
|
||||
print("mode=cone_reference_radius")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"current_reference_radius={current_reference_radius:.6f} target_reference_radius={target_reference_radius:.6f}")
|
||||
print(f"target_top_radius={target_top_radius:.6f}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_sphere_case(target_radius: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_sphere_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "sphere.step"
|
||||
_write_sphere_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_face_by_surface(model, "sphere")
|
||||
before = model.stats()
|
||||
current_radius = float(model.face_info(face_id).get("radius") or 0.0)
|
||||
plan = model.spherical_radius_plan(face_id, target_radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"sphere plan was blocked: {plan['message']}")
|
||||
result = model.resize_spherical_radius(face_id, target_radius)
|
||||
after = model.stats()
|
||||
verified_face, radius = _nearest_sphere_radius(model, target_radius)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"sphere resize changed solid count: before={before.solids}, after={after.solids}")
|
||||
if abs(radius - target_radius) > tolerance:
|
||||
raise SystemExit(f"sphere radius verification failed: target={target_radius:g}, value={radius:g}")
|
||||
print("mode=sphere_radius")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"current_radius={current_radius:.6f} target_radius={target_radius:.6f}")
|
||||
print(f"verified_face={verified_face} value={radius:.6f}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_torus_case(mode: str, target_radius: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"geom_param_torus_{mode}_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "torus.step"
|
||||
_write_torus_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_face_by_surface(model, "torus")
|
||||
before = model.stats()
|
||||
info = model.face_info(face_id)
|
||||
current_major = float(info.get("major_radius") or 0.0)
|
||||
current_minor = float(info.get("minor_radius") or 0.0)
|
||||
current = current_major if mode == "major" else current_minor
|
||||
scale = target_radius / current
|
||||
target_major = current_major * scale
|
||||
target_minor = current_minor * scale
|
||||
plan = model.toroidal_radius_plan(face_id, target_radius, mode)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"torus {mode} plan was blocked: {plan['message']}")
|
||||
result = model.resize_toroidal_radius(face_id, target_radius, mode)
|
||||
after = model.stats()
|
||||
verified_face, major, minor = _nearest_torus_radii(model, target_major, target_minor)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"torus {mode} resize changed solid count: before={before.solids}, after={after.solids}")
|
||||
if abs(major - target_major) > tolerance or abs(minor - target_minor) > tolerance:
|
||||
raise SystemExit(
|
||||
f"torus {mode} verification failed: target=({target_major:g}, {target_minor:g}), "
|
||||
f"value=({major:g}, {minor:g})"
|
||||
)
|
||||
print(f"mode=torus_{mode}_radius")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"current_major={current_major:.6f} current_minor={current_minor:.6f}")
|
||||
print(f"target_major={target_major:.6f} target_minor={target_minor:.6f}")
|
||||
print(f"verified_face={verified_face} major={major:.6f} minor={minor:.6f}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify analytic curved surface resize operations.")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="all",
|
||||
choices=["all", "cone", "sphere", "torus_major", "torus_minor"],
|
||||
)
|
||||
parser.add_argument("--cone-reference-radius", type=float, default=5.0)
|
||||
parser.add_argument("--sphere-radius", type=float, default=6.25)
|
||||
parser.add_argument("--torus-major-radius", type=float, default=10.0)
|
||||
parser.add_argument("--torus-minor-radius", type=float, default=3.0)
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
args = parser.parse_args()
|
||||
|
||||
modes = ["cone", "sphere", "torus_major", "torus_minor"] if args.mode == "all" else [args.mode]
|
||||
for mode in modes:
|
||||
if mode == "cone":
|
||||
_run_cone_case(args.cone_reference_radius, args.tolerance)
|
||||
elif mode == "sphere":
|
||||
_run_sphere_case(args.sphere_radius, args.tolerance)
|
||||
elif mode == "torus_major":
|
||||
_run_torus_case("major", args.torus_major_radius, args.tolerance)
|
||||
elif mode == "torus_minor":
|
||||
_run_torus_case("minor", args.torus_minor_radius, args.tolerance)
|
||||
else:
|
||||
raise SystemExit(f"unsupported mode: {mode}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import 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_boss_model(path: Path) -> None:
|
||||
base = BRepPrimAPI_MakeBox(30.0, 20.0, 6.0).Shape()
|
||||
axis = gp_Ax2(gp_Pnt(15.0, 10.0, 6.0), gp_Dir(0.0, 0.0, 1.0))
|
||||
boss = BRepPrimAPI_MakeCylinder(axis, 3.0, 5.0).Shape()
|
||||
fuse = BRepAlgoAPI_Fuse(base, boss)
|
||||
shape = _finalize_boolean_result(fuse, "verify boss model fuse", use_glue=False)
|
||||
_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 _boss_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
|
||||
if str(info.get("feature_guess", "")) != "boss/outer-round candidate":
|
||||
continue
|
||||
span = float(info.get("angular_span") or 0.0)
|
||||
if span < math.tau * 0.92:
|
||||
continue
|
||||
face_ids.append(face_id)
|
||||
return face_ids
|
||||
|
||||
|
||||
def _first_boss_face(model: StepModel) -> int:
|
||||
candidates = _boss_face_ids(model)
|
||||
if not candidates:
|
||||
raise SystemExit("no near-full cylindrical boss 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 _height(model: StepModel, face_id: int) -> float:
|
||||
feature = model.feature_info(face_id)
|
||||
value = feature.get("same_domain_height_estimate")
|
||||
if value is None:
|
||||
value = model.face_info(face_id).get("height_estimate")
|
||||
if value is None:
|
||||
raise SystemExit(f"height is missing on Face {face_id}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _nearest_boss(
|
||||
model: StepModel,
|
||||
target_diameter: float,
|
||||
target_center: tuple[float, float, float] | None = None,
|
||||
) -> tuple[int, float, float, tuple[float, float, float], float]:
|
||||
best: tuple[int, float, float, tuple[float, float, float], float] | None = None
|
||||
for face_id in _boss_face_ids(model):
|
||||
diameter = _diameter(model, face_id)
|
||||
height = _height(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
|
||||
score = diameter_error + center_error
|
||||
if best is None or score < best[4]:
|
||||
best = (face_id, diameter, height, center, score)
|
||||
if best is None:
|
||||
raise SystemExit("no cylindrical boss face remained after edit")
|
||||
return best
|
||||
|
||||
|
||||
def _run_diameter_case(target: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_boss_diam_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "boss.step"
|
||||
_write_boss_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_boss_face(model)
|
||||
before = model.stats()
|
||||
center = _axis_center(model, face_id)
|
||||
current_height = _height(model, face_id)
|
||||
plan = model.cylindrical_boss_resize_plan(face_id, target)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"diameter plan was blocked: {plan['message']}")
|
||||
result = model.resize_cylindrical_boss(face_id, target)
|
||||
after = model.stats()
|
||||
verified_face, diameter, height, verified_center, _ = _nearest_boss(model, target, center)
|
||||
error = abs(diameter - target)
|
||||
height_error = abs(height - current_height)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"diameter changed solid count: before={before.solids}, after={after.solids}")
|
||||
if error > tolerance:
|
||||
raise SystemExit(f"diameter verification failed: target={target:g}, value={diameter:g}, error={error:g}")
|
||||
if height_error > tolerance:
|
||||
raise SystemExit(
|
||||
f"diameter changed boss height: before={current_height:g}, after={height:g}, error={height_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} height={height:.6f} center={verified_center} error={error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_axis_center_case(offset: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_boss_axis_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "boss.step"
|
||||
_write_boss_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_boss_face(model)
|
||||
before = model.stats()
|
||||
current_center = _axis_center(model, face_id)
|
||||
current_diameter = _diameter(model, face_id)
|
||||
current_height = _height(model, face_id)
|
||||
target_center = (current_center[0] + offset, current_center[1], current_center[2])
|
||||
plan = model.cylindrical_boss_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_boss_axis(face_id, target_center)
|
||||
after = model.stats()
|
||||
verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter, target_center)
|
||||
center_error = _distance(center, target_center)
|
||||
height_error = abs(height - current_height)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"axis_center changed solid count: before={before.solids}, after={after.solids}")
|
||||
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}"
|
||||
)
|
||||
if height_error > tolerance:
|
||||
raise SystemExit(
|
||||
f"axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error: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} height={height:.6f} error={center_error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_height_case(target: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_boss_height_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "boss.step"
|
||||
_write_boss_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_boss_face(model)
|
||||
before = model.stats()
|
||||
current_diameter = _diameter(model, face_id)
|
||||
plan = model.cylindrical_boss_height_plan(face_id, target)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"height plan was blocked: {plan['message']}")
|
||||
result = model.resize_cylindrical_boss_height(face_id, target)
|
||||
after = model.stats()
|
||||
verified_face, diameter, height, center, _ = _nearest_boss(model, current_diameter)
|
||||
error = abs(height - target)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"height changed solid count: before={before.solids}, after={after.solids}")
|
||||
if error > tolerance:
|
||||
raise SystemExit(f"height verification failed: target={target:g}, value={height:g}, error={error:g}")
|
||||
print("mode=height")
|
||||
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={height:.6f} diameter={diameter:.6f} center={center} error={error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify cylindrical boss edit operations.")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="all",
|
||||
choices=["all", "diameter", "diameter_shrink", "height", "axis_center"],
|
||||
help="Boss 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("--height", type=float, default=7.0)
|
||||
parser.add_argument("--axis-center", type=float, default=2.0, help="Axis-center X offset to verify.")
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
args = parser.parse_args()
|
||||
|
||||
cases = (
|
||||
[
|
||||
("diameter", args.diameter),
|
||||
("diameter_shrink", args.diameter_shrink),
|
||||
("height", args.height),
|
||||
("axis_center", args.axis_center),
|
||||
]
|
||||
if args.mode == "all"
|
||||
else [(args.mode, 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 == "height":
|
||||
_run_height_case(float(target), args.tolerance)
|
||||
elif mode == "axis_center":
|
||||
_run_axis_center_case(float(target), args.tolerance)
|
||||
else:
|
||||
raise SystemExit(f"unsupported mode: {mode}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -35,11 +35,23 @@ def _length_distribution(model: StepModel) -> dict[float, int]:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify cube edge-length local deformation.")
|
||||
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()
|
||||
|
||||
@@ -50,12 +62,25 @@ def main() -> int:
|
||||
|
||||
edge_id = edge_ids[0]
|
||||
before = model.stats()
|
||||
plan = model.general_edge_length_plan(edge_id, args.target_length, anchor_mode=args.anchor)
|
||||
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", ""))
|
||||
if strategy != "local-edge-only-deform":
|
||||
raise SystemExit(f"expected local-edge-only-deform, got {strategy or '<none>'}")
|
||||
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)
|
||||
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))
|
||||
@@ -65,6 +90,7 @@ def main() -> int:
|
||||
|
||||
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')}")
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCC.Core.GeomAbs import GeomAbs_Line
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.TopoDS import TopoDS_Shape, topods
|
||||
from OCC.Extend.TopologyUtils import TopologyExplorer
|
||||
|
||||
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_builder_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
def _write_box_model(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _first_line_edge(shape: TopoDS_Shape, minimum_length: float = 5.0) -> TopoDS_Shape:
|
||||
for edge in TopologyExplorer(shape, ignore_orientation=True).edges():
|
||||
curve = BRepAdaptor_Curve(edge)
|
||||
if curve.GetType() != GeomAbs_Line:
|
||||
continue
|
||||
props = GProp_GProps()
|
||||
brepgprop.LinearProperties(edge, props)
|
||||
if props.Mass() >= minimum_length:
|
||||
return topods.Edge(edge)
|
||||
raise SystemExit("no line edge found in generated box")
|
||||
|
||||
|
||||
def _write_filleted_box_model(path: Path, radius: float) -> None:
|
||||
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
|
||||
maker = BRepFilletAPI_MakeFillet(shape)
|
||||
maker.Add(float(radius), _first_line_edge(shape))
|
||||
result = _finalize_builder_result(maker, "verify source box fillet")
|
||||
_write_step(result, path)
|
||||
|
||||
|
||||
def _first_editable_line_edge(model: StepModel) -> int:
|
||||
candidates: list[tuple[float, int]] = []
|
||||
for edge_id in range(len(model.edges)):
|
||||
info = model.edge_info(edge_id)
|
||||
if info.get("curve") != "line":
|
||||
continue
|
||||
if int(info.get("adjacent_face_count") or 0) < 2:
|
||||
continue
|
||||
length = float(info.get("length") or 0.0)
|
||||
if length <= 1e-9:
|
||||
continue
|
||||
candidates.append((-length, edge_id))
|
||||
if not candidates:
|
||||
raise SystemExit("no editable line Edge was recognized")
|
||||
candidates.sort()
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
def _first_adjacent_reference_face(model: StepModel, edge_id: int) -> int:
|
||||
adjacent_face_ids = tuple(model.edge_info(edge_id).get("adjacent_face_ids") or ())
|
||||
if not adjacent_face_ids:
|
||||
raise SystemExit(f"Edge {edge_id} has no adjacent Face for chamfer reference")
|
||||
return int(adjacent_face_ids[0])
|
||||
|
||||
|
||||
def _cylindrical_faces_near_radius(
|
||||
model: StepModel,
|
||||
radius: float,
|
||||
tolerance: float,
|
||||
*,
|
||||
require_fillet_guess: bool = False,
|
||||
) -> list[tuple[int, float, float, str]]:
|
||||
matches: 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
|
||||
value = float(info.get("radius") or 0.0)
|
||||
if abs(value - radius) > tolerance:
|
||||
continue
|
||||
guess = str(info.get("feature_guess", ""))
|
||||
if require_fillet_guess and guess != "round/fillet candidate":
|
||||
continue
|
||||
matches.append((face_id, value, float(info.get("angular_span") or 0.0), guess))
|
||||
return matches
|
||||
|
||||
|
||||
def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: float) -> int:
|
||||
matches = _cylindrical_faces_near_radius(model, radius, tolerance, require_fillet_guess=True)
|
||||
if matches:
|
||||
return matches[0][0]
|
||||
loose_matches = _cylindrical_faces_near_radius(model, radius, tolerance)
|
||||
detail = ", ".join(
|
||||
f"Face {face_id}: radius={value:g}, span={span:g}, guess={guess}"
|
||||
for face_id, value, span, guess in loose_matches[:8]
|
||||
)
|
||||
raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or '<none>'}")
|
||||
|
||||
|
||||
def _run_fillet_case(radius: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_edge_fillet_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "box.step"
|
||||
_write_box_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
before = model.stats()
|
||||
plan = model.edge_fillet_plan(edge_id, radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"fillet plan was blocked: {plan['message']}")
|
||||
result = model.fillet_edge(edge_id, radius)
|
||||
after = model.stats()
|
||||
matches = _cylindrical_faces_near_radius(model, radius, tolerance)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"fillet changed solid count: before={before.solids}, after={after.solids}")
|
||||
if not matches:
|
||||
raise SystemExit(f"fillet verification failed: no cylindrical face near radius {radius:g}")
|
||||
print("mode=fillet")
|
||||
print(f"edge_id={edge_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"target_radius={radius:.6f}")
|
||||
print(f"matched_faces={matches}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _verify_chamfer_topology(
|
||||
mode: str,
|
||||
edge_id: int,
|
||||
plan: dict[str, object],
|
||||
before,
|
||||
after,
|
||||
result: str,
|
||||
extra_lines: list[str],
|
||||
) -> None:
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"{mode} changed solid count: before={before.solids}, after={after.solids}")
|
||||
if after.faces <= before.faces:
|
||||
raise SystemExit(f"{mode} did not add a visible planar face: before={before.faces}, after={after.faces}")
|
||||
if after.edges <= before.edges:
|
||||
raise SystemExit(f"{mode} did not add expected boundary edges: before={before.edges}, after={after.edges}")
|
||||
print(f"mode={mode}")
|
||||
print(f"edge_id={edge_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
for line in extra_lines:
|
||||
print(line)
|
||||
print(f"face_delta={after.faces - before.faces} edge_delta={after.edges - before.edges}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def _run_chamfer_case(distance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_edge_chamfer_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "box.step"
|
||||
_write_box_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
before = model.stats()
|
||||
plan = model.edge_chamfer_plan(edge_id, distance)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"chamfer plan was blocked: {plan['message']}")
|
||||
result = model.chamfer_edge(edge_id, distance)
|
||||
after = model.stats()
|
||||
_verify_chamfer_topology(
|
||||
"chamfer",
|
||||
edge_id,
|
||||
plan,
|
||||
before,
|
||||
after,
|
||||
result,
|
||||
[f"target_distance={distance:.6f}"],
|
||||
)
|
||||
|
||||
|
||||
def _run_asymmetric_chamfer_case(distance1: float, distance2: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_edge_asym_chamfer_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "box.step"
|
||||
_write_box_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
reference_face_id = _first_adjacent_reference_face(model, edge_id)
|
||||
before = model.stats()
|
||||
plan = model.edge_asymmetric_chamfer_plan(edge_id, distance1, distance2, reference_face_id)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"asymmetric chamfer plan was blocked: {plan['message']}")
|
||||
result = model.chamfer_edge_asymmetric(edge_id, distance1, distance2, reference_face_id)
|
||||
after = model.stats()
|
||||
_verify_chamfer_topology(
|
||||
"asymmetric_chamfer",
|
||||
edge_id,
|
||||
plan,
|
||||
before,
|
||||
after,
|
||||
result,
|
||||
[
|
||||
f"target_distance1={distance1:.6f}",
|
||||
f"target_distance2={distance2:.6f}",
|
||||
f"reference_face_id={reference_face_id}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _run_distance_angle_chamfer_case(distance: float, angle_degrees: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_edge_da_chamfer_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "box.step"
|
||||
_write_box_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
reference_face_id = _first_adjacent_reference_face(model, edge_id)
|
||||
before = model.stats()
|
||||
plan = model.edge_distance_angle_chamfer_plan(edge_id, distance, angle_degrees, reference_face_id)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"distance-angle chamfer plan was blocked: {plan['message']}")
|
||||
result = model.chamfer_edge_distance_angle(edge_id, distance, angle_degrees, reference_face_id)
|
||||
after = model.stats()
|
||||
_verify_chamfer_topology(
|
||||
"distance_angle_chamfer",
|
||||
edge_id,
|
||||
plan,
|
||||
before,
|
||||
after,
|
||||
result,
|
||||
[
|
||||
f"target_distance={distance:.6f}",
|
||||
f"target_angle_degrees={angle_degrees:.6f}",
|
||||
f"reference_face_id={reference_face_id}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _run_existing_fillet_case(source_radius: float, target_radius: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "filleted_box.step"
|
||||
_write_filleted_box_model(model_path, source_radius)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
|
||||
before = model.stats()
|
||||
plan = model.existing_fillet_resize_plan(face_id, target_radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"existing fillet plan was blocked: {plan['message']}")
|
||||
result = model.resize_existing_fillet(face_id, target_radius)
|
||||
after = model.stats()
|
||||
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance)
|
||||
old_matches = _cylindrical_faces_near_radius(model, source_radius, tolerance)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"existing fillet resize changed solid count: before={before.solids}, after={after.solids}")
|
||||
if not matches:
|
||||
raise SystemExit(f"existing fillet verification failed: no cylindrical face near radius {target_radius:g}")
|
||||
if old_matches and abs(source_radius - target_radius) > tolerance:
|
||||
raise SystemExit(f"existing fillet still has old radius matches: {old_matches}")
|
||||
print("mode=existing_fillet")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"source_radius={source_radius:.6f} target_radius={target_radius:.6f}")
|
||||
print(f"matched_faces={matches}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="all",
|
||||
choices=["all", "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"],
|
||||
help="Edge rounding/chamfering edit mode to verify.",
|
||||
)
|
||||
parser.add_argument("--fillet-radius", type=float, default=1.0)
|
||||
parser.add_argument("--chamfer-distance", type=float, default=1.0)
|
||||
parser.add_argument("--asymmetric-distance1", type=float, default=0.8)
|
||||
parser.add_argument("--asymmetric-distance2", type=float, default=1.2)
|
||||
parser.add_argument("--distance-angle-distance", type=float, default=1.0)
|
||||
parser.add_argument("--distance-angle-degrees", type=float, default=45.0)
|
||||
parser.add_argument("--source-fillet-radius", type=float, default=1.0)
|
||||
parser.add_argument("--target-fillet-radius", type=float, default=1.5)
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
args = parser.parse_args()
|
||||
|
||||
modes = (
|
||||
["fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"]
|
||||
if args.mode == "all"
|
||||
else [args.mode]
|
||||
)
|
||||
for mode in modes:
|
||||
if mode == "fillet":
|
||||
_run_fillet_case(args.fillet_radius, args.tolerance)
|
||||
elif mode == "chamfer":
|
||||
_run_chamfer_case(args.chamfer_distance)
|
||||
elif mode == "asymmetric_chamfer":
|
||||
_run_asymmetric_chamfer_case(args.asymmetric_distance1, args.asymmetric_distance2)
|
||||
elif mode == "distance_angle_chamfer":
|
||||
_run_distance_angle_chamfer_case(args.distance_angle_distance, args.distance_angle_degrees)
|
||||
elif mode == "existing_fillet":
|
||||
_run_existing_fillet_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
|
||||
else:
|
||||
raise SystemExit(f"unsupported mode: {mode}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire
|
||||
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Elips, 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.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
def _write_ellipse_face_model(path: Path) -> None:
|
||||
edge = BRepBuilderAPI_MakeEdge(
|
||||
gp_Elips(gp_Ax2(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)), 5.0, 2.0)
|
||||
).Edge()
|
||||
wire = BRepBuilderAPI_MakeWire(edge).Wire()
|
||||
face = BRepBuilderAPI_MakeFace(wire).Face()
|
||||
_write_step(face, path)
|
||||
|
||||
|
||||
def _ellipse_edge_ids(model: StepModel) -> list[int]:
|
||||
return [edge_id for edge_id in range(len(model.edges)) if model.edge_info(edge_id).get("curve") == "ellipse"]
|
||||
|
||||
|
||||
def _run_case(axis_kind: str, target: float, expected_other: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"geom_param_ellipse_{axis_kind}_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "ellipse.step"
|
||||
_write_ellipse_face_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
edge_ids = _ellipse_edge_ids(model)
|
||||
if not edge_ids:
|
||||
raise SystemExit("no ellipse Edge was recognized")
|
||||
edge_id = edge_ids[0]
|
||||
before = model.stats()
|
||||
plan = model.ellipse_edge_axis_radius_plan(edge_id, target, axis_kind=axis_kind)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"{axis_kind} radius plan was blocked: {plan['message']}")
|
||||
expected_strategy = f"ellipse-edge-{axis_kind}-axis-affine"
|
||||
if plan.get("resize_strategy") != expected_strategy:
|
||||
raise SystemExit(f"expected {expected_strategy}, got {plan.get('resize_strategy')}")
|
||||
result = model.resize_ellipse_edge_axis_radius(edge_id, target, axis_kind=axis_kind)
|
||||
after = model.stats()
|
||||
sampled = model._ellipse_edge_axis_radius_sampled_result(plan)
|
||||
if sampled is None:
|
||||
raise SystemExit("could not sample the edited ellipse-like Edge")
|
||||
verified_edge, value, other, error = sampled
|
||||
other_error = abs(other - expected_other)
|
||||
if error > tolerance:
|
||||
raise SystemExit(f"{axis_kind} radius verification failed: target={target:g}, value={value:g}, error={error:g}")
|
||||
if other_error > tolerance:
|
||||
raise SystemExit(
|
||||
f"{axis_kind} radius changed the other radius too much: expected={expected_other:g}, "
|
||||
f"value={other:g}, error={other_error:g}"
|
||||
)
|
||||
|
||||
print(f"mode={axis_kind}")
|
||||
print(f"source_edge={edge_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"verified_edge={verified_edge}")
|
||||
print(f"target={target:.6f} value={value:.6f} error={error:.6g}")
|
||||
print(f"other_radius={other:.6f} other_error={other_error:.6g}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify ellipse Edge major/minor radius resize operations.")
|
||||
parser.add_argument("--mode", default="all", choices=["all", "major", "minor"])
|
||||
parser.add_argument("--major-radius", type=float, default=7.5)
|
||||
parser.add_argument("--minor-radius", type=float, default=3.0)
|
||||
parser.add_argument("--tolerance", type=float, default=5e-3)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode in {"all", "major"}:
|
||||
_run_case("major", args.major_radius, expected_other=2.0, tolerance=args.tolerance)
|
||||
if args.mode in {"all", "minor"}:
|
||||
_run_case("minor", args.minor_radius, expected_other=5.0, tolerance=args.tolerance)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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 _first_plane_face_near_size(model: StepModel, width: float, height: float, tolerance: float) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
face_width = float(info.get("local_face_width") or 0.0)
|
||||
face_height = float(info.get("local_face_height") or 0.0)
|
||||
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
|
||||
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
|
||||
if direct or swapped:
|
||||
return face_id
|
||||
raise SystemExit(f"no plane Face near {width:g} x {height:g}")
|
||||
|
||||
|
||||
def _plane_sizes(model: StepModel) -> list[tuple[float, float, float]]:
|
||||
sizes: list[tuple[float, float, float]] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
area = float(info.get("area") or 0.0)
|
||||
width = float(info.get("local_face_width") or 0.0)
|
||||
height = float(info.get("local_face_height") or 0.0)
|
||||
sizes.append((round(area, 6), round(width, 6), round(height, 6)))
|
||||
return sorted(sizes)
|
||||
|
||||
|
||||
def _has_plane_size(model: StepModel, width: float, height: float, tolerance: float) -> bool:
|
||||
for _area, face_width, face_height in _plane_sizes(model):
|
||||
direct = abs(face_width - width) <= tolerance and abs(face_height - height) <= tolerance
|
||||
swapped = abs(face_width - height) <= tolerance and abs(face_height - width) <= tolerance
|
||||
if direct or swapped:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify Face resize semantics on the cube test model.")
|
||||
parser.add_argument("model", nargs="?", default=str(DEFAULT_MODEL), help="STEP model path.")
|
||||
parser.add_argument("--axis", default="width", choices=["width", "height"])
|
||||
parser.add_argument("--source-size", type=float, default=10.0)
|
||||
parser.add_argument("--other-size", type=float, default=10.0)
|
||||
parser.add_argument("--target-size", type=float, default=15.0)
|
||||
parser.add_argument("--strategy", default="local", choices=["local", "owning"])
|
||||
parser.add_argument("--tolerance", type=float, default=1e-5)
|
||||
args = parser.parse_args()
|
||||
|
||||
model = StepModel.load(Path(args.model))
|
||||
face_id = _first_plane_face_near_size(model, args.source_size, args.other_size, args.tolerance)
|
||||
before = model.stats()
|
||||
|
||||
if args.strategy == "local":
|
||||
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
|
||||
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
|
||||
expected_strategy = f"local-face-{args.axis}-only-deform"
|
||||
else:
|
||||
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
|
||||
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
|
||||
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
|
||||
|
||||
resolved_strategy = str(plan.get("resize_strategy", ""))
|
||||
if resolved_strategy != expected_strategy:
|
||||
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
|
||||
|
||||
after = model.stats()
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"solid count changed: before={before.solids}, after={after.solids}")
|
||||
if after.faces < before.faces:
|
||||
raise SystemExit(f"face count decreased: before={before.faces}, after={after.faces}")
|
||||
|
||||
width = args.target_size if args.axis == "width" else args.other_size
|
||||
height = args.other_size if args.axis == "width" else args.target_size
|
||||
if not _has_plane_size(model, width, height, args.tolerance):
|
||||
raise SystemExit(f"no resulting plane Face near {width:g} x {height:g}")
|
||||
|
||||
print(f"model={Path(args.model)}")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={args.strategy}")
|
||||
print(f"axis={args.axis}")
|
||||
print(f"resolved_strategy={resolved_strategy}")
|
||||
print(f"rebuild_mode={plan.get('owning_face_size_rebuild_mode', '')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"plane_sizes={_plane_sizes(model)}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,339 @@
|
||||
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())
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
|
||||
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
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
def _write_plate_model(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.feature_info(face_id)
|
||||
if info.get("surface") != "plane":
|
||||
continue
|
||||
if info.get("shell_region_status") != "candidate":
|
||||
continue
|
||||
current = float(info.get("shell_thickness_estimate") or 0.0)
|
||||
if abs(current - thickness) > tolerance:
|
||||
continue
|
||||
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
|
||||
candidates.append((confidence_rank, face_id))
|
||||
if not candidates:
|
||||
raise SystemExit(f"no shell thickness candidate near {thickness:g}")
|
||||
candidates.sort()
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
def _bounds(model: StepModel) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]:
|
||||
info = model.geometry_stats()
|
||||
return (
|
||||
tuple(float(value) for value in info["bbox_min"]),
|
||||
tuple(float(value) for value in info["bbox_max"]),
|
||||
tuple(float(value) for value in info["bbox_size"]),
|
||||
)
|
||||
|
||||
|
||||
def _thickness_axis(size: tuple[float, float, float]) -> int:
|
||||
return min(range(3), key=lambda index: size[index])
|
||||
|
||||
|
||||
def _verify_local_bounds(
|
||||
before_min: tuple[float, float, float],
|
||||
before_max: tuple[float, float, float],
|
||||
after_min: tuple[float, float, float],
|
||||
after_max: tuple[float, float, float],
|
||||
axis: int,
|
||||
target: float,
|
||||
tolerance: float,
|
||||
) -> None:
|
||||
after_size = after_max[axis] - after_min[axis]
|
||||
if abs(after_size - target) > tolerance:
|
||||
raise SystemExit(f"local shell thickness failed: target={target:g}, value={after_size:g}")
|
||||
min_unchanged = abs(after_min[axis] - before_min[axis]) <= tolerance
|
||||
max_unchanged = abs(after_max[axis] - before_max[axis]) <= tolerance
|
||||
if not (min_unchanged or max_unchanged):
|
||||
raise SystemExit(
|
||||
"local shell thickness did not keep either opposite side fixed: "
|
||||
f"before=({before_min[axis]:g}, {before_max[axis]:g}), "
|
||||
f"after=({after_min[axis]:g}, {after_max[axis]:g})"
|
||||
)
|
||||
|
||||
|
||||
def _verify_owning_bounds(
|
||||
before_min: tuple[float, float, float],
|
||||
before_max: tuple[float, float, float],
|
||||
after_min: tuple[float, float, float],
|
||||
after_max: tuple[float, float, float],
|
||||
axis: int,
|
||||
target: float,
|
||||
tolerance: float,
|
||||
) -> None:
|
||||
after_size = after_max[axis] - after_min[axis]
|
||||
if abs(after_size - target) > tolerance:
|
||||
raise SystemExit(f"owning shell thickness failed: target={target:g}, value={after_size:g}")
|
||||
before_center = (before_min[axis] + before_max[axis]) * 0.5
|
||||
after_center = (after_min[axis] + after_max[axis]) * 0.5
|
||||
if abs(after_center - before_center) > tolerance:
|
||||
raise SystemExit(
|
||||
"owning shell thickness did not keep the thickness center fixed: "
|
||||
f"before_center={before_center:g}, after_center={after_center:g}"
|
||||
)
|
||||
|
||||
|
||||
def _run_case(mode: str, source_thickness: float, target_thickness: float, tolerance: float) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"geom_param_shell_{mode}_") as temp_dir:
|
||||
model_path = Path(temp_dir) / "plate.step"
|
||||
_write_plate_model(model_path)
|
||||
model = StepModel.load(model_path)
|
||||
face_id = _first_shell_face(model, source_thickness, tolerance)
|
||||
before = model.stats()
|
||||
before_min, before_max, before_size = _bounds(model)
|
||||
axis = _thickness_axis(before_size)
|
||||
|
||||
if mode == "local":
|
||||
plan = model.shell_thickness_plan(face_id, target_thickness)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"local shell plan was blocked: {plan['message']}")
|
||||
result = model.resize_shell_thickness(face_id, target_thickness)
|
||||
elif mode == "owning":
|
||||
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"owning shell plan was blocked: {plan['message']}")
|
||||
result = model.resize_shell_thickness_owning_scale(face_id, target_thickness)
|
||||
else:
|
||||
raise SystemExit(f"unsupported mode: {mode}")
|
||||
|
||||
after = model.stats()
|
||||
after_min, after_max, after_size = _bounds(model)
|
||||
if after.solids != before.solids:
|
||||
raise SystemExit(f"{mode} shell thickness changed solid count: before={before.solids}, after={after.solids}")
|
||||
if mode == "local":
|
||||
_verify_local_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
|
||||
else:
|
||||
_verify_owning_bounds(before_min, before_max, after_min, after_max, axis, target_thickness, tolerance)
|
||||
|
||||
print(f"mode={mode}")
|
||||
print(f"face_id={face_id}")
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"before_bbox_size={before_size}")
|
||||
print(f"after_bbox_size={after_size}")
|
||||
print(f"axis={axis}")
|
||||
print(f"source_thickness={source_thickness:.6f} target_thickness={target_thickness:.6f}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.")
|
||||
parser.add_argument("--mode", default="all", choices=["all", "local", "owning"])
|
||||
parser.add_argument("--source-thickness", type=float, default=2.0)
|
||||
parser.add_argument("--target-thickness", type=float, default=3.0)
|
||||
parser.add_argument("--tolerance", type=float, default=2e-4)
|
||||
args = parser.parse_args()
|
||||
|
||||
modes = ["local", "owning"] if args.mode == "all" else [args.mode]
|
||||
for mode in modes:
|
||||
_run_case(mode, args.source_thickness, args.target_thickness, args.tolerance)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,448 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user