feat: 完善参数化编辑语义和槽孔中心距

This commit is contained in:
2026-07-30 17:54:01 +08:00
parent c937e13d43
commit 27e4f7236c
22 changed files with 8769 additions and 668 deletions
+227
View File
@@ -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())
+255
View File
@@ -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())
+31 -5
View File
@@ -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')}")
+314
View File
@@ -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())
+90
View File
@@ -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())
+105
View File
@@ -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())
+339
View File
@@ -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())
+157
View File
@@ -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())
+448
View File
@@ -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())