feat: 完善 STEP/B-Rep 一级关系参数化编辑

This commit is contained in:
2026-08-07 18:08:32 +08:00
parent eef9efcc1e
commit 12250603dd
26 changed files with 4254 additions and 1270 deletions
+108
View File
@@ -4,12 +4,18 @@ import argparse
from collections import Counter
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GeomAbs import GeomAbs_Plane
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 _dir_tuple, _tuple_dot, _tuple_normalized, _tuple_or_none, _tuple_sub
from step_editor.model import StepModel
from verify_edge_round_chamfer import _first_editable_line_edge, _write_box_model
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
@@ -38,6 +44,93 @@ def _count_near(values: list[float], target: float, tolerance: float) -> int:
return sum(1 for value in values if abs(value - target) <= tolerance)
def _planar_face_normal(model: StepModel, face_id: int) -> tuple[float, float, float] | None:
surf = BRepAdaptor_Surface(model.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
return _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction()))
def _edge_axis(model: StepModel, edge_id: int) -> tuple[float, float, float]:
info = model.edge_info(edge_id)
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None
if axis is None:
raise SystemExit(f"edge {edge_id} has no stable line direction")
return axis
def _assert_cube_push_pull_planar_constraints(
model: StepModel,
target_length: float,
tolerance: float,
result: str,
) -> None:
if "Planar relation check: ok" not in result:
raise SystemExit(f"end-face push/pull result should include a passing planar relation check: {result}")
relation_tolerance = max(tolerance, 1e-4)
target_edge_ids = _line_edge_ids_near_length(model, target_length, relation_tolerance)
if len(target_edge_ids) != 4:
raise SystemExit(f"end-face push/pull should leave four target-length edges, got {target_edge_ids}")
for edge_id in target_edge_ids:
axis = _edge_axis(model, edge_id)
adjacent_face_ids = tuple(int(item) for item in model.edge_info(edge_id).get("adjacent_face_ids", ()))
normals: list[tuple[int, tuple[float, float, float]]] = []
for face_id in adjacent_face_ids:
normal = _planar_face_normal(model, face_id)
if normal is not None:
normals.append((face_id, normal))
if len(normals) != 2:
raise SystemExit(f"target edge {edge_id} should still have two planar adjacent faces, got {adjacent_face_ids}")
side_axis_dots = [abs(_tuple_dot(normal, axis)) for _face_id, normal in normals]
if any(value > relation_tolerance for value in side_axis_dots):
raise SystemExit(f"target edge {edge_id} side faces are no longer parallel to the edge: {side_axis_dots}")
side_pair_dot = abs(_tuple_dot(normals[0][1], normals[1][1]))
if side_pair_dot > relation_tolerance:
raise SystemExit(f"target edge {edge_id} adjacent side faces are no longer perpendicular: {side_pair_dot:g}")
def _verify_nonorthogonal_push_pull_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_push_pull_guard_") as temp_dir:
model_path = Path(temp_dir) / "chamfered_box.step"
_write_box_model(model_path)
model = StepModel.load(model_path)
source_edge_id = _first_editable_line_edge(model)
model.chamfer_edge(source_edge_id, 1.0)
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
normals: list[tuple[float, float, float]] = []
for face_id in tuple(int(item) for item in info.get("adjacent_face_ids", ())):
normal = _planar_face_normal(model, face_id)
if normal is not None:
normals.append(normal)
if len(normals) != 2:
continue
side_dot = abs(_tuple_dot(normals[0], normals[1]))
if not 0.2 < side_dot < 0.9:
continue
current_length = float(info.get("length") or 0.0)
plan = model.general_edge_length_plan(
edge_id,
current_length + 2.0,
anchor_mode="keep-start",
strategy_mode="move-edge-end-plane-by-push-pull",
)
message = str(plan.get("message") or "")
blockers = str(plan.get("edge_length_planar_constraint_blockers") or "")
if plan.get("status") != "blocked":
raise SystemExit(f"non-orthogonal chamfer Edge push/pull should be blocked: {plan}")
if "neither parallel nor perpendicular" not in f"{message} {blockers}":
raise SystemExit(f"non-orthogonal chamfer Edge should explain the planar relation blocker: {plan}")
print(f"non_orthogonal_push_pull_guard=edge {edge_id}, side_dot={side_dot:.6f}")
return
raise SystemExit("no non-orthogonal chamfer Edge was found for push/pull guard verification")
def _assert_cube_edge_intent_geometry(
*,
source_length: float,
@@ -129,6 +222,12 @@ def main() -> int:
expected_strategy = strategy
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready":
raise SystemExit(
"end-face push/pull plan should expose a ready planar relation constraint; "
f"status={plan.get('edge_length_planar_constraint_status')}, "
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
)
result = model.resize_general_edge_length(
edge_id,
@@ -162,6 +261,15 @@ def main() -> int:
lengths=lengths,
tolerance=max(args.tolerance, 1e-5),
)
if strategy == "move-edge-end-plane-by-push-pull":
_assert_cube_push_pull_planar_constraints(
model,
args.target_length,
max(args.tolerance, 1e-5),
result,
)
if args.anchor == "keep-start":
_verify_nonorthogonal_push_pull_guard()
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")