Files
pythonocc-step-editor/scripts/verify_edge_round_chamfer.py

992 lines
50 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_MakeChamfer, 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 _line_edge_endpoint_records(shape: TopoDS_Shape) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
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)
start = curve.Value(curve.FirstParameter())
end = curve.Value(curve.LastParameter())
records.append(
{
"edge": topods.Edge(edge),
"length": float(props.Mass()),
"start": (round(start.X(), 6), round(start.Y(), 6), round(start.Z(), 6)),
"end": (round(end.X(), 6), round(end.Y(), 6), round(end.Z(), 6)),
}
)
records.sort(key=lambda item: (float(item["length"]), item["start"], item["end"]))
return records
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 _write_chamfered_box_model(path: Path, distance: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
maker = BRepFilletAPI_MakeChamfer(shape)
maker.Add(float(distance), _first_line_edge(shape))
result = _finalize_builder_result(maker, "verify source box chamfer")
_write_step(result, path)
def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if not records:
raise SystemExit("no line edges found in generated box")
first = records[0]
first_endpoints = {first["start"], first["end"]}
second = None
for candidate in records[1:]:
if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints:
second = candidate
break
if second is None:
raise SystemExit("no adjacent line Edge pair found for chained fillet source")
maker = BRepFilletAPI_MakeFillet(shape)
maker.Add(float(radius), first["edge"])
maker.Add(float(radius), second["edge"])
result = _finalize_builder_result(maker, "verify source box fillet chain")
_write_step(result, path)
def _write_transitive_chained_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if len(records) < 6:
raise SystemExit("not enough line edges found in generated box for transitive fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for record in records[:6]:
maker.Add(float(radius), record["edge"])
result = _finalize_builder_result(maker, "verify source box transitive fillet chain")
_write_step(result, path)
def _write_complex_same_radius_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
complex_chain_edge_indices = (0, 1, 2, 4, 9)
if len(records) <= max(complex_chain_edge_indices):
raise SystemExit("not enough line edges found in generated box for complex fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for index in complex_chain_edge_indices:
maker.Add(float(radius), records[index]["edge"])
result = _finalize_builder_result(maker, "verify source box complex same-radius fillet chain")
_write_step(result, path)
def _write_mixed_radius_filleted_box_model(path: Path, radius1: float, radius2: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if not records:
raise SystemExit("no line edges found in generated box")
first = records[0]
first_endpoints = {first["start"], first["end"]}
second = None
for candidate in records[1:]:
if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints:
second = candidate
break
if second is None:
raise SystemExit("no adjacent line Edge pair found for mixed-radius fillet source")
maker = BRepFilletAPI_MakeFillet(shape)
maker.Add(float(radius1), first["edge"])
maker.Add(float(radius2), second["edge"])
result = _finalize_builder_result(maker, "verify source box mixed-radius fillet chain")
_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 _existing_chamfer_faces_near_distance(
model: StepModel,
distance: float,
tolerance: float,
) -> list[tuple[int, float, str]]:
matches: list[tuple[int, float, str]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("existing_chamfer_status") != "candidate":
continue
value = float(info.get("existing_chamfer_distance_estimate") or 0.0)
if abs(value - distance) <= tolerance:
matches.append((face_id, value, str(info.get("feature_type") or "")))
return matches
def _first_existing_chamfer_face(model: StepModel, distance: float, tolerance: float) -> int:
matches = _existing_chamfer_faces_near_distance(model, distance, tolerance)
if matches:
return matches[0][0]
detail = ", ".join(
f"Face {face_id}: distance={value:g}, type={feature_type}"
for face_id, value, feature_type in _existing_chamfer_faces_near_distance(model, distance, max(tolerance, distance))
)
raise SystemExit(f"no existing chamfer candidate near distance {distance: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 _run_existing_fillet_arc_length_case(
source_radius: float,
target_arc_length: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_arc_") 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)
source_info = model.feature_info(face_id)
angular_span = float(
source_info.get("existing_fillet_angular_span")
or source_info.get("angular_span")
or 0.0
)
if angular_span <= 1e-6:
raise SystemExit(f"existing fillet has no stable angular span: {source_info}")
target_radius = float(target_arc_length) / angular_span
before = model.stats()
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise SystemExit(f"existing fillet arc-length plan was blocked: {plan['message']}")
_assert_existing_fillet_plan_topology(plan)
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance)
if not matches:
raise SystemExit(
f"existing fillet arc-length verification failed: no cylindrical face near radius {target_radius:g}"
)
verified_face_id = matches[0][0]
verified_info = model.feature_info(verified_face_id)
verified_arc = float(
verified_info.get("existing_fillet_arc_length_estimate")
or target_radius * angular_span
)
arc_error = abs(verified_arc - target_arc_length)
if after.solids != before.solids:
raise SystemExit(
f"existing fillet arc-length resize changed solid count: before={before.solids}, after={after.solids}"
)
if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4):
raise SystemExit(
f"existing fillet arc-length verification failed: "
f"target={target_arc_length:g}, value={verified_arc:g}, error={arc_error:g}"
)
if "Existing fillet result check" not in result:
raise SystemExit(f"existing fillet arc-length result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing fillet arc-length result did not verify first-level topology: {result}")
print("mode=existing_fillet_arc_length")
def _run_existing_chamfer_case(source_distance: float, target_distance: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_chamfer_") as temp_dir:
model_path = Path(temp_dir) / "chamfered_box.step"
_write_chamfered_box_model(model_path, source_distance)
model = StepModel.load(model_path)
face_id = _first_existing_chamfer_face(model, source_distance, tolerance)
source_info = model.feature_info(face_id)
if source_info.get("feature_type") != "已有倒角平面候选":
raise SystemExit(f"existing chamfer should use a clear feature type: {source_info}")
ready_actions = str(source_info.get("recognition_ready_actions") or "")
if "已有倒角距离" not in ready_actions:
raise SystemExit(f"existing chamfer should expose distance as ready action: {source_info}")
if "当前面面内长度" in ready_actions or "平面拉伸" in ready_actions:
raise SystemExit(f"existing chamfer should not leak generic Face edits: {source_info}")
editable_candidates = model.editable_feature_candidates(limit=20, detailed=False)
editable_chamfers = [
item
for item in editable_candidates
if item.get("operation_key") == "inspect_existing_chamfer" and item.get("target_id") == face_id
]
if not editable_chamfers:
raise SystemExit(f"editable scan did not expose existing chamfer distance: {editable_candidates}")
plane_leaks = [
item
for item in editable_candidates
if item.get("operation_key") == "push_pull_plane" and item.get("target_id") == face_id
]
if plane_leaks:
raise SystemExit(f"editable scan leaked existing chamfer as generic plane edit: {plane_leaks}")
before = model.stats()
plan = model.existing_chamfer_resize_plan(face_id, target_distance)
if plan.get("status") == "blocked":
raise SystemExit(f"existing chamfer plan was blocked: {plan['message']}")
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing chamfer plan should expose first-level depth: {plan}")
result = model.resize_existing_chamfer(face_id, target_distance)
after = model.stats()
matches = _existing_chamfer_faces_near_distance(model, target_distance, max(tolerance, target_distance * 0.03))
old_matches = _existing_chamfer_faces_near_distance(model, source_distance, max(tolerance, source_distance * 0.02))
if after.solids != before.solids:
raise SystemExit(f"existing chamfer resize changed solid count: before={before.solids}, after={after.solids}")
if not matches:
raise SystemExit(f"existing chamfer verification failed: no planar chamfer near distance {target_distance:g}")
if old_matches:
raise SystemExit(f"existing chamfer still has old distance matches: {old_matches}")
if "Existing chamfer result check" not in result:
raise SystemExit(f"existing chamfer result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing chamfer result did not verify first-level topology: {result}")
print("mode=existing_chamfer")
print(f"face_id={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"target_distance={target_distance:.6f}")
print(f"matched_faces={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_resize_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_") as temp_dir:
model_path = Path(temp_dir) / "fillet_chain_box.step"
_write_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ())
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ())
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"same-radius fillet chain should be editable but high risk at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "same-radius-chain-candidate":
raise SystemExit(f"same-radius fillet chain status is unclear: {feature}")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if feature.get("recognition_decision") == "已阻止" or recognition_blockers:
raise SystemExit(f"same-radius fillet chain should not be globally blocked: {feature}")
if "已有圆角半径" not in recognition_ready_actions:
raise SystemExit(f"same-radius fillet chain should expose existing fillet radius as ready: {feature}")
if "已有圆角半径" in recognition_limited_actions:
raise SystemExit(f"same-radius fillet chain should not list existing fillet radius as limited: {feature}")
if len(chain_face_ids) < 2 or not chain_adjacent_face_ids:
raise SystemExit(f"fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
raise SystemExit(
"connected fillet faces should not be counted as support faces: "
f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}"
)
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
if plan.get("status") == "blocked":
raise SystemExit(f"same-radius existing fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"fillet chain plan should retain chain face ids: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"fillet chain plan should resize every chain face: {plan}")
scan_candidates = model.editable_feature_candidates(limit=20, detailed=False)
chain_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if not chain_candidates:
raise SystemExit(f"same-radius fillet chain should be listed as an editable fillet operation: {scan_candidates}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"same-radius fillet chain result should contain target fillet faces: matches={matches}, result={result}")
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain result should report chain result checks: {result}")
print("mode=existing_fillet_chain")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"chain_adjacent_face_ids={chain_adjacent_face_ids}")
print(f"support_face_ids={support_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_arc_length_case(
source_radius: float,
target_arc_length: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_arc_") as temp_dir:
model_path = Path(temp_dir) / "fillet_chain_box.step"
_write_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
source_info = model.feature_info(face_id)
chain_face_ids = tuple(source_info.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"same-radius fillet chain source should expose connected faces: {source_info}")
angular_span = float(
source_info.get("existing_fillet_angular_span")
or source_info.get("angular_span")
or 0.0
)
if angular_span <= 1e-6:
raise SystemExit(f"same-radius fillet chain has no stable angular span: {source_info}")
target_radius = float(target_arc_length) / angular_span
before = model.stats()
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise SystemExit(f"same-radius fillet chain arc-length plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain arc-length should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"same-radius fillet chain arc-length should resize every chain face: {plan}")
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(
f"same-radius fillet chain arc-length result should contain target fillet faces: "
f"matches={matches}, result={result}"
)
arc_errors: list[tuple[int, float, float]] = []
for verified_face_id, _radius, _span, _guess in matches[: len(chain_face_ids)]:
verified_info = model.feature_info(verified_face_id)
verified_arc = float(
verified_info.get("existing_fillet_arc_length_estimate")
or target_radius * angular_span
)
arc_error = abs(verified_arc - target_arc_length)
arc_errors.append((verified_face_id, verified_arc, arc_error))
if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4):
raise SystemExit(
f"same-radius fillet chain arc-length verification failed: "
f"target={target_arc_length:g}, face={verified_face_id}, value={verified_arc:g}, error={arc_error:g}"
)
if after.solids != before.solids:
raise SystemExit(
f"same-radius fillet chain arc-length resize changed solid count: "
f"before={before.solids}, after={after.solids}"
)
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain arc-length result should report chain checks: {result}")
print("mode=existing_fillet_chain_arc_length")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"target_arc_length={target_arc_length:.6f}")
print(f"target_radius={target_radius:.6f}")
print(f"matches={matches}")
print(f"arc_errors={arc_errors}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_transitive_chain_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_transitive_chain_") as temp_dir:
model_path = Path(temp_dir) / "transitive_fillet_chain_box.step"
_write_transitive_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
chain_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "same-radius-chain-candidate":
chain_rows.append((candidate_face_id, chain_face_ids, feature))
chain_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not chain_rows:
raise SystemExit("transitive same-radius fillet chain source did not expose an editable chain")
face_id, chain_face_ids, feature = chain_rows[0]
if len(chain_face_ids) != 4:
raise SystemExit(
"transitive same-radius fillet chain should include every connected chain Face, "
f"expected 4, got {chain_face_ids}: {feature}"
)
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"transitive same-radius fillet chain should be a high-risk editable candidate: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan.get("status") == "blocked":
raise SystemExit(f"transitive same-radius fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"transitive same-radius fillet chain should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"transitive same-radius fillet chain should resize the full connected chain: {plan}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"transitive same-radius fillet chain result should contain target fillet faces: {matches}")
if "required_matches=4" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"transitive same-radius fillet chain result should report four target checks: {result}")
print("mode=existing_fillet_transitive_chain")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_mixed_radius_chain_guard_case(
source_radius: float,
adjacent_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_mixed_chain_") as temp_dir:
model_path = Path(temp_dir) / "mixed_radius_fillet_chain_box.step"
_write_mixed_radius_filleted_box_model(model_path, source_radius, adjacent_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ())
mixed_radius_face_ids = tuple(feature.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ())
radius_rows = tuple(feature.get("feature_existing_fillet_adjacent_radius_rows") or ())
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ())
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"mixed-radius fillet chain should be blocked at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "variable-radius-chain-candidate":
raise SystemExit(f"mixed-radius fillet chain should expose variable-radius status: {feature}")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if feature.get("recognition_decision") != "已阻止" or "变半径" not in recognition_blockers:
raise SystemExit(f"mixed-radius fillet recognition summary should explain the blocker: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"mixed-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"mixed-radius chain should list existing fillet radius as a limited action: {feature}")
if len(chain_face_ids) < 2 or not chain_adjacent_face_ids or not mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
raise SystemExit(
"connected mixed-radius fillet faces should not be counted as support faces: "
f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}"
)
if not any(abs(float(row[1]) - adjacent_radius) <= tolerance for row in radius_rows if len(row) >= 2):
raise SystemExit(f"mixed-radius fillet should record adjacent radii: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked":
raise SystemExit(f"mixed-radius existing fillet resize should be blocked before geometry execution: {plan}")
if "变半径" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}":
raise SystemExit(f"mixed-radius blocker should explain the unsupported capability: {plan}")
if tuple(plan.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ()) != mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet plan should retain mixed-radius face ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, detailed=False)
leaked_chain_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if leaked_chain_candidates:
raise SystemExit(
f"mixed-radius fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}"
)
print("mode=existing_fillet_mixed_radius_chain_guard")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"mixed_radius_face_ids={mixed_radius_face_ids}")
print(f"adjacent_radius_rows={radius_rows}")
print(f"support_face_ids={support_face_ids}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_complex_same_radius_chain_guard_case(
source_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_complex_same_chain_") as temp_dir:
model_path = Path(temp_dir) / "complex_same_radius_fillet_chain_box.step"
_write_complex_same_radius_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
blocked_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "complex-same-radius-chain-candidate":
blocked_rows.append((candidate_face_id, chain_face_ids, feature))
blocked_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not blocked_rows:
raise SystemExit("complex same-radius fillet chain was not recognized as blocked")
face_id, chain_face_ids, feature = blocked_rows[0]
blockers = str(feature.get("existing_fillet_blockers") or "")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if len(chain_face_ids) <= 4:
raise SystemExit(f"complex same-radius fillet chain should contain more than 4 faces: {feature}")
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain should be blocked at recognition level: {feature}")
if "复杂长链" not in blockers or "2 到 4" not in blockers:
raise SystemExit(f"complex same-radius blocker should explain the supported chain size: {feature}")
if feature.get("recognition_decision") != "已阻止" or "复杂长链" not in recognition_blockers:
raise SystemExit(f"complex same-radius chain should be globally blocked: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"complex same-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"complex same-radius chain should list existing fillet radius as limited: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
plan_blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked" or plan.get("risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain resize should be blocked: {plan}")
if "复杂长链" not in f"{message} {plan_blockers}" or "2 到 4" not in f"{message} {plan_blockers}":
raise SystemExit(f"complex same-radius fillet chain plan should explain the limit: {plan}")
if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"complex same-radius fillet chain plan should retain full chain ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, detailed=False)
leaked_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if leaked_candidates:
raise SystemExit(
f"complex same-radius fillet chain should not be listed as editable: {leaked_candidates}"
)
print("mode=existing_fillet_complex_same_radius_chain_guard")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(message.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",
"existing_fillet_arc_length",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_chain_guard",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
],
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("--adjacent-fillet-radius", type=float, default=1.8)
parser.add_argument("--target-fillet-radius", type=float, default=1.5)
parser.add_argument("--target-fillet-arc-length", type=float, default=2.356194490192345)
parser.add_argument("--source-chamfer-distance", type=float, default=1.5)
parser.add_argument("--target-chamfer-distance", type=float, default=2.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
modes = (
[
"fillet",
"chamfer",
"asymmetric_chamfer",
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
]
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)
elif mode == "existing_fillet_arc_length":
_run_existing_fillet_arc_length_case(
args.source_fillet_radius,
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_chamfer":
_run_existing_chamfer_case(
args.source_chamfer_distance,
args.target_chamfer_distance,
args.tolerance,
)
elif mode in {"existing_fillet_chain", "existing_fillet_chain_guard"}:
_run_existing_fillet_chain_resize_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
elif mode == "existing_fillet_chain_arc_length":
_run_existing_fillet_chain_arc_length_case(
args.source_fillet_radius,
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_transitive_chain":
_run_existing_fillet_transitive_chain_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_complex_same_radius_chain_guard":
_run_existing_fillet_complex_same_radius_chain_guard_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_mixed_radius_chain_guard":
_run_existing_fillet_mixed_radius_chain_guard_case(
args.source_fillet_radius,
args.adjacent_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
if __name__ == "__main__":
raise SystemExit(main())