344 lines
15 KiB
Python
344 lines
15 KiB
Python
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 _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None:
|
|
if plan.get("topology_relation_depth") != 1:
|
|
raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}")
|
|
if plan.get("topology_relation_status") != "ready":
|
|
raise SystemExit(f"existing fillet plan topology should be ready: {plan}")
|
|
if plan.get("first_level_topology_status") != "ready":
|
|
raise SystemExit(f"existing fillet first-level guard should be ready: {plan}")
|
|
if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) < 1:
|
|
raise SystemExit(f"existing fillet plan should expose boundary Edges: {plan}")
|
|
if int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0) < 2:
|
|
raise SystemExit(f"existing fillet plan should expose direct support Faces: {plan}")
|
|
ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ())
|
|
if "second-level" not in ignored or "third-level" not in ignored:
|
|
raise SystemExit(f"existing fillet plan should document ignored deeper topology: {plan}")
|
|
|
|
|
|
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 "Edge blend result check" not in result:
|
|
raise SystemExit(f"fillet result did not report execution-layer result check: {result}")
|
|
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 "Edge blend result check" not in result:
|
|
raise SystemExit(f"{mode} result did not report execution-layer result check: {result}")
|
|
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']}")
|
|
_assert_existing_fillet_plan_topology(plan)
|
|
support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ()))
|
|
if len(support_face_ids) < 2:
|
|
raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}")
|
|
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}")
|
|
if "Existing fillet result check" not in result:
|
|
raise SystemExit(f"existing fillet result did not report result check: {result}")
|
|
if "first_level_topology_matched=True" not in result:
|
|
raise SystemExit(f"existing fillet result did not verify first-level topology: {result}")
|
|
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"support_face_ids={support_face_ids}")
|
|
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())
|