feat: 增强一级关系识别与保持关系策略

This commit is contained in:
2026-08-10 14:56:50 +08:00
parent 12250603dd
commit 83c44265ba
24 changed files with 3460 additions and 241 deletions
+4
View File
@@ -42,6 +42,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Edge length move end plane, fixed start point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-start"),
),
(
"Edge length keep first-level planar relations, fixed start point",
("verify_edge_length_resize.py", "--strategy", "keep-first-level-planar-relations", "--anchor", "keep-start"),
),
(
"Edge length move end plane, fixed end point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-end"),
@@ -72,6 +72,18 @@ def _assert_edge_topology(model: StepModel, edge_id: int) -> dict[str, object]:
int(topology.get("first_level_edge_count", 0) or 0) == 5,
f"cube Edge first-level Edge set should contain selected Edge + 4 neighbors: {topology}",
)
_assert(
topology.get("first_level_planar_relation_status") == "ready",
f"cube Edge should expose first-level planar relation facts: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"cube Edge should have two incident Faces parallel to the Edge direction: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"cube Edge incident Faces should be perpendicular to each other: {topology}",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"missing ignored depths: {topology}")
return topology
@@ -93,6 +105,16 @@ def _assert_edge_facts(facts: dict[str, object], label: str) -> None:
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 2, f"{label}: bad adjacent Faces")
_assert(int(facts.get("first_level_fact_included_edge_count", 0) or 0) == 5, f"{label}: bad included Edges")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: missing summary")
_assert(facts.get("first_level_planar_relation_status") == "ready", f"{label}: missing planar relation facts")
_assert(
int(facts.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"{label}: expected incident Faces parallel to selected Edge direction",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"{label}: expected perpendicular incident Face relation",
)
_assert("一级平面关系" in str(facts.get("first_level_fact_summary") or ""), f"{label}: missing relation summary")
def _assert_plan_facts(plan: dict[str, object], label: str) -> None:
@@ -111,6 +133,9 @@ def main() -> int:
"edge_first_level_topology",
"first_level_vertex_count",
"first_level_adjacent_edge_count",
"first_level_planar_relation_summary",
"first_level_planar_relation_parallel_count",
"first_level_planar_relation_perpendicular_count",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
+90
View File
@@ -58,6 +58,7 @@ def _strategy_label(strategy: str) -> str:
"auto": "自动选择",
"local-edge-only-deform": "只改当前Edge",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直",
"keep-first-level-planar-relations": "保持一级平面关系",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径",
"scale-owning-shape-from-edge": "缩放所属",
}.get(strategy, strategy)
@@ -133,6 +134,7 @@ def _verify_line_edge_intent_specs() -> None:
_assert_edge_length_scope(length_spec, "auto", "自动", enabled=True)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=True)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=True)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=True)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -140,6 +142,12 @@ def _verify_line_edge_intent_specs() -> None:
enabled=False,
)
_assert_edge_length_scope(length_spec, "scale-owning-shape-from-edge", "缩放所属", enabled=True)
constrained_specs = _specs_for_edge(info, strategy="keep-first-level-planar-relations", anchor="keep-start")
constrained_length_spec = _spec(constrained_specs, "length")
_assert(
str(constrained_length_spec.get("scope_default")) == "keep-first-level-planar-relations",
"line Edge should honor the explicit keep-planar-relations modeling intent",
)
anchor_spec = _spec(specs, "edge_length_anchor_mode")
_assert(str(anchor_spec.get("label")) == "长度基准", "line Edge should expose a length anchor row")
print("line Edge modeling-intent specs ok")
@@ -171,6 +179,7 @@ def _verify_circle_edge_intent_specs() -> None:
)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=False)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=False)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=False)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -195,6 +204,7 @@ def _verify_edge_length_plan_intents() -> None:
expectations = (
("local-edge-only-deform", "keep-start", "local-edge-only-deform", "相邻面会自然变斜"),
("move-edge-end-plane-by-push-pull", "keep-start", "move-edge-end-plane-by-push-pull", "正方体这类模型会更像变成长方体"),
("keep-first-level-planar-relations", "keep-start", "move-edge-end-plane-by-push-pull", "保持一级平面关系"),
("scale-owning-shape-from-edge", "center", "scale-owning-shape-from-edge", "其它尺寸会跟随变化"),
)
for strategy, anchor, expected_strategy, impact_fragment in expectations:
@@ -206,6 +216,28 @@ def _verify_edge_length_plan_intents() -> None:
)
impact = str(plan.get("edge_length_impact_summary") or "")
_assert(impact_fragment in impact, f"{strategy} impact summary should explain result: {impact}")
if expected_strategy == "move-edge-end-plane-by-push-pull":
_assert(
plan.get("edge_length_planar_constraint_status") == "ready",
f"end-face push/pull should expose ready planar constraints: {plan}",
)
_assert(
"端面推拉平面约束" in str(plan.get("edge_length_planar_constraint_summary") or ""),
f"end-face push/pull should explain its planar constraint guard: {plan}",
)
_assert(
int(plan.get("edge_length_planar_constraint_perpendicular_count", 0) or 0) >= 1,
f"end-face push/pull should detect perpendicular side-face relations: {plan}",
)
if strategy == "keep-first-level-planar-relations":
_assert(
plan.get("edge_length_planar_relation_constraint_requested") is True,
f"explicit keep-planar-relations strategy should be preserved in the plan: {plan}",
)
_assert(
str(plan.get("edge_length_strategy_label")) == "保持一级平面关系",
f"explicit keep-planar-relations strategy should keep a user-facing label: {plan}",
)
blocked = model.general_edge_length_plan(
edge_id,
15.0,
@@ -214,13 +246,71 @@ def _verify_edge_length_plan_intents() -> None:
)
_assert(str(blocked.get("status")) == "blocked", "moving an end plane while fixing center should be blocked")
_assert("不能使用固定中心" in str(blocked.get("message") or ""), f"blocked message should explain center conflict: {blocked}")
blocked_keep_relations = model.general_edge_length_plan(
edge_id,
15.0,
anchor_mode="center",
strategy_mode="keep-first-level-planar-relations",
)
_assert(
str(blocked_keep_relations.get("status")) == "blocked",
"keeping first-level planar relations while fixing center should be blocked",
)
_assert(
"保持一级平面关系" in str(blocked_keep_relations.get("message") or ""),
f"blocked keep-planar-relations plan should name the requested constraint: {blocked_keep_relations}",
)
print("Edge modeling-intent plans ok")
def _verify_edge_coordinate_move_plan_semantics() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_id = _line_edge_ids_near_length(model, 10.0, 1e-5)[0]
info = model.edge_info(edge_id)
start = info.get("start_point")
center = info.get("length_center")
if not isinstance(start, (tuple, list)) or len(start) != 3:
raise AssertionError(f"line Edge lacks a stable start point: {info}")
if not isinstance(center, (tuple, list)) or len(center) != 3:
raise AssertionError(f"line Edge lacks a stable center: {info}")
endpoint_plan = model.edge_endpoint_move_plan(
edge_id,
"start",
(float(start[0]) - 1.0, float(start[1]), float(start[2])),
)
_assert(str(endpoint_plan.get("status")) != "blocked", f"endpoint move should be available: {endpoint_plan}")
_assert(str(endpoint_plan.get("edge_endpoint_label")) == "起点", f"endpoint label should be Chinese: {endpoint_plan}")
_assert(
str(endpoint_plan.get("edit_strategy_label")) == "Edge端点局部移动",
f"endpoint move should expose a user-facing strategy label: {endpoint_plan}",
)
_assert(
"不是端面整体推拉" in str(endpoint_plan.get("edit_semantics") or ""),
f"endpoint move should explain that it is local deformation: {endpoint_plan}",
)
center_plan = model.edge_center_move_plan(
edge_id,
(float(center[0]), float(center[1]), float(center[2]) + 1.0),
)
_assert(str(center_plan.get("status")) != "blocked", f"center move should be available: {center_plan}")
_assert(
str(center_plan.get("edit_strategy_label")) == "Edge整体局部移动",
f"center move should expose a user-facing strategy label: {center_plan}",
)
_assert(
"不是移动整个端面" in str(center_plan.get("edit_semantics") or ""),
f"center move should explain that it is local deformation: {center_plan}",
)
print("Edge coordinate move plan semantics ok")
def main() -> int:
_verify_line_edge_intent_specs()
_verify_circle_edge_intent_specs()
_verify_edge_length_plan_intents()
_verify_edge_coordinate_move_plan_semantics()
print("Edge modeling-intent spec suite passed.")
return 0
+57
View File
@@ -25,10 +25,14 @@ from verify_edge_round_chamfer import (
_cylindrical_faces_near_radius,
_first_adjacent_reference_face,
_first_editable_line_edge,
_first_existing_chamfer_face,
_first_existing_fillet_face,
_verify_chamfer_topology,
_write_box_model,
_write_chamfered_box_model,
_write_chained_filleted_box_model,
_write_filleted_box_model,
_existing_chamfer_faces_near_distance,
)
from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model
from verify_hole_resize import (
@@ -267,6 +271,57 @@ def _verify_existing_fillet_isolated(temp_dir: Path) -> None:
print(f"isolated existing fillet ok: face={face_id}, matches={matches[:3]}")
def _verify_existing_fillet_chain_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_fillet_chain.step"
output_path = temp_dir / "existing_fillet_chain_out.step"
request_path = temp_dir / "existing_fillet_chain_request.json"
source_radius = 1.0
target_radius = 1.5
_write_chained_filleted_box_model(input_path, source_radius)
model = StepModel.load(input_path)
face_id = _first_existing_fillet_face(model, source_radius, 2e-4)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"isolated existing fillet chain source did not expose a chain: {feature}")
_write_request(request_path, input_path, output_path, "resize_existing_fillet", [face_id, target_radius])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _cylindrical_faces_near_radius(result_model, target_radius, 2e-4)
old_matches = _cylindrical_faces_near_radius(result_model, source_radius, 2e-4)
if len(matches) < len(chain_face_ids) or old_matches:
raise SystemExit(
"isolated existing fillet chain failed: "
f"matches={matches}, old_matches={old_matches}, chain={chain_face_ids}, response={response}"
)
_assert_one_solid(result_model, "isolated existing fillet chain")
print(f"isolated existing fillet chain ok: face={face_id}, chain={chain_face_ids}, matches={matches[:4]}")
def _verify_existing_chamfer_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_chamfer.step"
output_path = temp_dir / "existing_chamfer_out.step"
request_path = temp_dir / "existing_chamfer_request.json"
source_distance = 1.5
target_distance = 2.0
_write_chamfered_box_model(input_path, source_distance)
model = StepModel.load(input_path)
face_id = _first_existing_chamfer_face(model, source_distance, 2e-4)
_write_request(request_path, input_path, output_path, "resize_existing_chamfer", [face_id, target_distance])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _existing_chamfer_faces_near_distance(result_model, target_distance, target_distance * 0.03)
old_matches = _existing_chamfer_faces_near_distance(result_model, source_distance, source_distance * 0.02)
if not matches or old_matches:
raise SystemExit(
f"isolated existing chamfer failed: matches={matches}, old_matches={old_matches}, response={response}"
)
_assert_one_solid(result_model, "isolated existing chamfer")
print(f"isolated existing chamfer ok: face={face_id}, matches={matches[:3]}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_isolated_") as temp:
temp_dir = Path(temp)
@@ -278,6 +333,8 @@ def main() -> int:
_verify_edge_fillet_isolated(temp_dir)
_verify_edge_chamfers_isolated(temp_dir)
_verify_existing_fillet_isolated(temp_dir)
_verify_existing_fillet_chain_isolated(temp_dir)
_verify_existing_chamfer_isolated(temp_dir)
print("Edge isolated edit suite passed.")
return 0
+13 -1
View File
@@ -168,7 +168,11 @@ def _assert_cube_edge_intent_geometry(
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
elif strategy in {"move-edge-end-plane-by-push-pull", "scale-owning-shape-from-edge"}:
elif strategy in {
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
}:
if target_count != 4 or source_count != 8:
raise SystemExit(
f"{strategy} should resize the whole cube span in the selected Edge direction; "
@@ -195,6 +199,7 @@ def main() -> int:
"auto",
"local-edge-only-deform",
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
],
help="Requested Edge length edit semantics.",
@@ -220,6 +225,8 @@ def main() -> int:
expected_strategy = args.expect_strategy or args.strategy
if expected_strategy == "auto":
expected_strategy = strategy
if expected_strategy == "keep-first-level-planar-relations":
expected_strategy = "move-edge-end-plane-by-push-pull"
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":
@@ -228,6 +235,11 @@ def main() -> int:
f"status={plan.get('edge_length_planar_constraint_status')}, "
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
)
if args.strategy == "keep-first-level-planar-relations":
if plan.get("edge_length_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-planar-relations plan should preserve explicit constraint intent: {plan}")
if "保持一级平面关系" not in str(plan.get("edge_length_strategy_label") or ""):
raise SystemExit(f"keep-planar-relations plan should use a clear user-facing label: {plan}")
result = model.resize_general_edge_length(
edge_id,
+486 -24
View File
@@ -7,7 +7,7 @@ import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet
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
@@ -71,6 +71,14 @@ def _write_filleted_box_model(path: Path, radius: float) -> None:
_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)
@@ -92,6 +100,52 @@ def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
_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)):
@@ -151,6 +205,33 @@ def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: floa
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}")
@@ -394,17 +475,69 @@ def _run_existing_fillet_arc_length_case(
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"source_radius={source_radius:.6f}")
print(f"target_arc_length={target_arc_length:.6f} target_radius={target_radius:.6f}")
print(f"verified_face={verified_face_id} verified_arc_length={verified_arc:.6f} error={arc_error:.6g}")
print(f"target_distance={target_distance:.6f}")
print(f"matched_faces={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None:
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)
@@ -414,17 +547,19 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
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") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"fillet chain should be blocked at recognition level: {feature}")
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 "圆角链" not in recognition_blockers:
raise SystemExit(f"fillet chain recognition summary should explain the blocker: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"fillet chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"fillet chain should list existing fillet radius as a limited action: {feature}")
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):
@@ -435,30 +570,312 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
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"existing fillet chain resize should be blocked before geometry execution: {plan}")
if "圆角链" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}":
raise SystemExit(f"fillet chain blocker should explain the unsupported capability: {plan}")
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"fillet chain should not be listed as an editable fillet operation: {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_chain_guard")
print("mode=existing_fillet_mixed_radius_chain_guard")
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"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(
@@ -472,7 +889,13 @@ def main() -> int:
"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.",
)
@@ -483,8 +906,11 @@ def main() -> int:
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()
@@ -496,7 +922,12 @@ def main() -> int:
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_fillet_chain_guard",
"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]
@@ -518,8 +949,39 @@ def main() -> int:
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_chain_guard":
_run_existing_fillet_chain_guard_case(args.source_fillet_radius, args.target_fillet_radius, 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
+15 -2
View File
@@ -54,6 +54,7 @@ EXPECTED_EDGE_ISOLATED_OPERATIONS = {
"move_circular_edge_axis_center",
"resize_ellipse_edge_axis_radius",
"resize_existing_fillet",
"resize_existing_chamfer",
"fillet_edge",
"chamfer_edge",
"chamfer_edge_asymmetric",
@@ -122,8 +123,6 @@ def _assert_same(label: str, actual: set[str], expected: set[str]) -> None:
PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"push_pull_face": "push_pull_face",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"resize_face_area_local": "resize_face_area_local",
"resize_face_area": "resize_face_area",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
@@ -139,6 +138,7 @@ PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_existing_chamfer": "resize_existing_chamfer",
}
@@ -277,6 +277,19 @@ def _face_property_actions_from_specs() -> set[str]:
(shell_info, {"shell_thickness_estimate"}),
(generic_cylinder_info, {"cylinder_height"}),
(boss_info, {"boss_height"}),
(
{
**plane_info,
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.12132,
"feature_existing_chamfer_support_face_ids": (0, 5),
"feature_existing_chamfer_long_edge_ids": (8,),
},
{"existing_chamfer_distance_estimate"},
),
(
{
"surface": "cone",
+92 -1
View File
@@ -17,9 +17,14 @@ from step_editor.step_io import _write_step
from step_editor.ui_helpers import INFO_LABELS
from verify_hole_resize import _first_hole_face, _write_blind_hole_model, _write_through_hole_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_cross_obround_slot_model, _write_half_round_slot_model # noqa: E402
from verify_boss_resize import _first_boss_face, _write_boss_model # noqa: E402
from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402
from verify_edge_round_chamfer import ( # noqa: E402
_first_existing_fillet_face,
_write_mixed_radius_filleted_box_model,
)
from verify_shell_thickness_resize import _first_open_shell_wall_face, _write_open_thin_wall_box_model # noqa: E402
def _assert(condition: bool, message: str) -> None:
@@ -288,6 +293,89 @@ def _verify_ellipse_edge_scan_entries(root: Path) -> None:
raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}")
def _verify_complex_slot_guard(root: Path) -> None:
path = root / "cross_obround_slot.step"
_write_cross_obround_slot_model(path)
model = StepModel.load(path)
blocked_slot_faces: 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" and feature.get("slot_status") == "blocked":
blocked_slot_faces.append(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
_assert("交叉槽" in blockers or "多槽组" in blockers, f"complex slot blocker is unclear: {feature}")
_assert("复杂槽" in str(feature.get("feature_type") or ""), f"complex slot label is unclear: {feature}")
_assert(blocked_slot_faces, "cross obround slot should be recognized as blocked complex slots")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in blocked_slot_faces and item.get("operation_key") in forbidden_actions
]
_assert(not leaked, f"complex slot should not leak editable scan entries: {leaked}")
def _verify_mixed_radius_fillet_chain_guard(root: Path) -> None:
path = root / "mixed_radius_fillet_chain.step"
_write_mixed_radius_filleted_box_model(path, 1.0, 1.8)
model = StepModel.load(path)
face_id = _first_existing_fillet_face(model, 1.0, 2e-4)
feature = model.feature_info(face_id)
_assert(feature.get("existing_fillet_status") == "blocked", f"mixed-radius fillet chain should be blocked: {feature}")
_assert(
feature.get("existing_fillet_chain_status") == "variable-radius-chain-candidate",
f"mixed-radius fillet chain should expose variable-radius status: {feature}",
)
blockers = str(feature.get("recognition_blockers") or feature.get("existing_fillet_blockers") or "")
_assert("变半径" in blockers, f"mixed-radius fillet blocker should mention variable radius: {feature}")
_assert(feature.get("recognition_decision") == "已阻止", f"mixed-radius fillet should be globally blocked: {feature}")
_assert(
"已有圆角半径" not in str(feature.get("recognition_ready_actions") or ""),
f"mixed-radius fillet chain should not be ready-editable: {feature}",
)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
candidates = model.editable_feature_candidates(limit=40, detailed=False)
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
_assert(not leaked, f"mixed-radius fillet chain should not leak editable scan entries: {leaked}")
def _verify_open_shell_context_summary(root: Path) -> None:
path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(path)
model = StepModel.load(path)
face_id = _first_open_shell_wall_face(model, 2.0, 2e-4)
info = model.feature_info(face_id)
_assert(info.get("open_shell_context_status") == "limited", f"open shell context should be limited: {info}")
_assert_summary(info, "open shell context", {"surface", "first_level_topology", "open_shell_context"})
_assert(
"完整抽壳/开口面编辑" in str(info.get("recognition_limited_actions") or ""),
f"open shell should list full shell/opening edit as limited: {info}",
)
_assert(
"完整抽壳/开口面" in str(info.get("recognition_limitations") or ""),
f"open shell limitation should explain unsupported complete shell edit: {info}",
)
_assert(
"壳体厚度" in str(info.get("recognition_ready_actions") or ""),
f"open shell wall should keep local shell thickness ready: {info}",
)
def main() -> int:
for key in (
"recognition_summary",
@@ -318,6 +406,9 @@ def main() -> int:
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_ellipse_edge_scan_entries(root)
_verify_complex_slot_guard(root)
_verify_mixed_radius_fillet_chain_guard(root)
_verify_open_shell_context_summary(root)
print("feature recognition summary ok")
return 0
+17
View File
@@ -80,6 +80,7 @@ def _assert_common_facts(facts: dict[str, object], label: str) -> None:
ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing")
_assert("first_level_planar_relation_summary" in facts, f"{label}: planar relation summary is missing")
def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None:
@@ -104,6 +105,22 @@ def _verify_planar_face_facts() -> None:
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}")
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}")
_assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}")
_assert(
facts.get("first_level_planar_relation_status") == "ready",
f"cube planar Face should expose planar relation facts: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_count", 0) or 0) == 4,
f"cube top Face should have four subject-adjacent planar relations: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) == 4,
f"cube top Face should have four perpendicular adjacent side Faces: {facts}",
)
_assert(
"一级平面关系" in str(facts.get("first_level_fact_summary") or ""),
f"cube planar Face fact summary should include planar relation facts: {facts}",
)
probe = _FactProbe(model)
fields = probe._face_first_level_selection_fields(face_id)
+57
View File
@@ -146,6 +146,62 @@ def assert_rectangular_feature_ui(
raise AssertionError(f"unexpected rectangular feature UI parameters: {actual}")
def find_multistep_transition_face(model: StepModel) -> tuple[int, dict[str, object]]:
matches: list[tuple[int, dict[str, object]]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("multistep_prismatic_status") != "blocked":
continue
center = info.get("area_center")
z_value = float(center[2]) if isinstance(center, (list, tuple)) and len(center) >= 3 else -1.0
matches.append((face_id, info | {"_z_sort": z_value}))
if not matches:
raise AssertionError("expected at least one blocked multistep transition face")
matches.sort(key=lambda item: float(item[1].get("_z_sort", -1.0)), reverse=True)
face_id, info = matches[0]
info.pop("_z_sort", None)
return face_id, info
def assert_multistep_transition_guard(model: StepModel) -> None:
face_id, info = find_multistep_transition_face(model)
blockers = str(info.get("multistep_prismatic_blockers") or info.get("recognition_blockers") or "")
if "多台阶" not in blockers or "暂不支持" not in blockers:
raise AssertionError(f"multistep transition blocker should explain unsupported scope: {info}")
if "复杂多台阶凸台" not in str(info.get("feature_type")):
raise AssertionError(f"unexpected multistep transition label: {info.get('feature_type')}")
if feature_dimension_rows(model, face_id, info) != ():
raise AssertionError("blocked multistep transition face should not expose editable dimensions")
target_center = info.get("area_center")
if not isinstance(target_center, (list, tuple)) or len(target_center) != 3:
raise AssertionError(f"multistep transition face lacks a stable center: {info}")
center_target = (float(target_center[0]) + 0.5, float(target_center[1]), float(target_center[2]))
plans = (
model.push_pull_plan(face_id, 1.0),
model.shell_thickness_plan(face_id, 2.5),
model.face_size_local_resize_plan(face_id, 9.0, "width"),
model.face_center_local_move_plan(face_id, center_target),
)
for plan in plans:
if plan.get("status") != "blocked":
raise AssertionError(f"multistep transition plan should be blocked: {plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if "多台阶" not in message or "暂不支持" not in message:
raise AssertionError(f"multistep transition plan should explain unsupported scope: {plan}")
leaked_entries = [
item
for item in model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("target_id", -1)) == face_id
and str(item.get("operation_key") or "") in {"push_pull_plane", "resize_shell_thickness"}
]
if leaked_entries:
raise AssertionError(
f"blocked multistep transition face should not leak editable scan entries: {leaked_entries}"
)
def assert_prismatic_feature_display_labels() -> None:
state = object.__new__(WindowStateMixin)
if state._feature_display_label("矩形口袋候选") != "矩形槽/口袋":
@@ -432,6 +488,7 @@ def main() -> int:
multistep_path = temp_path / "rectangular-multistep-boss.step"
write_rectangular_multistep_boss_model(multistep_path)
multistep_model = StepModel.load(multistep_path)
assert_multistep_transition_guard(multistep_model)
multistep_face_id, multistep_info = find_prismatic_semantics(multistep_model, "additive-boss")
assert_prismatic_sizes(multistep_info, 6.0, 4.0, 2.0)
assert_rectangular_feature_ui(
+119
View File
@@ -788,6 +788,8 @@ def main() -> int:
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"slot_status": "candidate",
"slot_kind": "partial-cylindrical-groove",
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
@@ -815,6 +817,27 @@ def main() -> int:
("槽宽", "槽深", "轴心"),
"slot/half-hole feature",
)
blocked_slot_specs = _display_specs(
{
**slot_info,
"slot_status": "blocked",
"slot_blockers": "交叉槽/多槽组暂未实现稳定修改。",
"recognition_risk": "blocked",
"recognition_blockers": "交叉槽/多槽组暂未实现稳定修改。",
}
)
_assert_keys_absent(
blocked_slot_specs,
(
"slot_axis_center",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_arc_length_estimate",
"slot_angular_span_degrees",
"slot_open_angle_degrees",
),
"blocked complex slot display specs",
)
boss_info = {
"surface": "cylinder",
@@ -878,6 +901,94 @@ def main() -> int:
("工程特征", "倒圆角", "重新倒圆"),
"existing fillet feature",
)
fillet_arc_local = _scope_mode(fillet_specs, "existing_fillet_arc_length_estimate", "local")
if fillet_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"existing fillet arc length should rebuild existing fillet: {fillet_arc_local}")
if fillet_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"existing fillet arc length should transform to target radius: {fillet_arc_local}")
chain_fillet_info = dict(fillet_info)
chain_fillet_info.update(
{
"feature_type": "简单等半径圆角链候选",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_chain_adjacent_face_ids": (11,),
"existing_fillet_chain_status": "same-radius-chain-candidate",
"existing_fillet_status": "candidate",
"existing_fillet_risk": "high",
}
)
chain_fillet_specs = _specs(chain_fillet_info)
chain_fillet_keys = {str(spec.get("key", "")) for spec in chain_fillet_specs}
_assert_contains(
chain_fillet_keys,
{"existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"},
"same-radius existing fillet chain feature",
)
chain_arc_local = _scope_mode(chain_fillet_specs, "existing_fillet_arc_length_estimate", "local")
if chain_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"same-radius fillet chain arc length should rebuild the chain: {chain_arc_local}")
if chain_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"same-radius fillet chain arc length should transform to target radius: {chain_arc_local}")
blocked_fillet_info = dict(fillet_info)
blocked_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角与不同半径的圆角面直接相连,属于变半径圆角链。",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_mixed_radius_chain_face_ids": (11,),
}
)
blocked_fillet_keys = _spec_keys(blocked_fillet_info)
if "existing_fillet_radius_estimate" in blocked_fillet_keys or "existing_fillet_arc_length_estimate" in blocked_fillet_keys:
raise SystemExit(f"blocked fillet chain should not expose editable fillet dimensions: {blocked_fillet_keys}")
complex_same_radius_fillet_info = dict(fillet_info)
complex_same_radius_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角链包含至少 5 个同半径圆角面;复杂长链暂未实现稳定重建。",
"existing_fillet_chain_status": "complex-same-radius-chain-candidate",
"feature_existing_fillet_chain_face_ids": (10, 11, 12, 13, 14),
"feature_existing_fillet_same_radius_chain_face_ids": (11, 12, 13, 14),
}
)
complex_same_radius_fillet_keys = _spec_keys(complex_same_radius_fillet_info)
if (
"existing_fillet_radius_estimate" in complex_same_radius_fillet_keys
or "existing_fillet_arc_length_estimate" in complex_same_radius_fillet_keys
):
raise SystemExit(
"complex same-radius fillet chain should not expose editable fillet dimensions: "
f"{complex_same_radius_fillet_keys}"
)
chamfer_info = {
"kind": "feature",
"surface": "plane",
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.121320343559643,
"feature_existing_chamfer_support_face_ids": (1, 2),
"feature_existing_chamfer_long_edge_ids": (10, 11),
"local_face_width": 10.0,
"local_face_height": 2.121320343559643,
"area_center": (0.75, 0.75, 5.0),
"face_center_position": (0.75, 0.75, 5.0),
}
chamfer_specs = _specs(chamfer_info)
chamfer_keys = {str(spec.get("key", "")) for spec in chamfer_specs}
if "existing_chamfer_distance_estimate" not in chamfer_keys:
raise SystemExit(f"existing chamfer should expose distance: {chamfer_keys}")
for leaked_key in ("local_face_width", "local_face_height", "face_center_position", "face_target_normal_position"):
if leaked_key in chamfer_keys:
raise SystemExit(f"existing chamfer should not expose generic Face edit {leaked_key}: {chamfer_keys}")
_assert_label(chamfer_specs, "existing_chamfer_distance_estimate", "倒角距离")
chamfer_distance_spec = _spec(chamfer_specs, "existing_chamfer_distance_estimate")
if chamfer_distance_spec.get("action") != "resize_existing_chamfer":
raise SystemExit(f"existing chamfer distance should call resize_existing_chamfer: {chamfer_distance_spec}")
analytic_cases = (
(
@@ -1002,6 +1113,14 @@ def main() -> int:
{"length", "edge_first_level_topology"},
"line Edge",
)
keep_relation_mode = _scope_mode(edge_specs, "length", "keep-first-level-planar-relations")
if str(keep_relation_mode.get("label") or "") != "保持关系":
raise SystemExit(f"line Edge should expose a compact keep-relations scope: {keep_relation_mode}")
if not bool(keep_relation_mode.get("enabled")):
raise SystemExit(f"line Edge keep-relations scope should be enabled: {keep_relation_mode}")
keep_relation_tip = f"{keep_relation_mode.get('enabled_tip', '')} {keep_relation_mode.get('range_hint', '')}"
if "一级平面" not in keep_relation_tip or "平行/垂直" not in keep_relation_tip:
raise SystemExit(f"line Edge keep-relations scope should explain the planar relation constraint: {keep_relation_tip}")
_assert_contains(
{str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "line", "length": 10.0})},
{"length", "edge_length_anchor_mode"},
+1 -1
View File
@@ -12,7 +12,7 @@ SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge fillet/chamfer and existing fillet rebuild checks",
"Edge fillet/chamfer plus existing fillet/chamfer rebuild checks",
("verify_edge_round_chamfer.py",),
),
(
+124 -2
View File
@@ -5,7 +5,9 @@ from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.gp import gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
@@ -20,6 +22,29 @@ def _write_plate_model(path: Path) -> None:
_write_step(shape, path)
def _fuse_shapes(left, right):
op = BRepAlgoAPI_Fuse(left, right)
op.SetFuzzyValue(1e-6)
op.Build()
if not op.IsDone():
raise RuntimeError("open shell fixture fuse failed")
return op.Shape()
def _write_open_thin_wall_box_model(path: Path) -> None:
bottom = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
walls = (
BRepPrimAPI_MakeBox(gp_Pnt(0.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(28.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 0.0, 2.0), 26.0, 2.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 18.0, 2.0), 26.0, 2.0, 10.0).Shape(),
)
shape = bottom
for wall in walls:
shape = _fuse_shapes(shape, wall)
_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)):
@@ -258,9 +283,101 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _first_open_shell_wall_face(model: StepModel, thickness: float, tolerance: float) -> int:
open_shell_faces: list[int] = []
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("open_shell_context_status") == "limited":
open_shell_faces.append(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
if info.get("open_shell_context_status") != "limited":
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 open_shell_faces:
raise SystemExit("open thin-wall box should expose an open_shell limited context")
if not candidates:
raise SystemExit(f"no open shell wall face with thickness near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_no_open_shell_context(model: StepModel, label: str) -> None:
leaked = [
face_id
for face_id in range(len(model.faces))
if model.feature_info(face_id).get("open_shell_context_status") == "limited"
]
if leaked:
raise SystemExit(f"{label} should not be recognized as open shell context: {leaked}")
def _run_open_shell_context_case(source_thickness: float, target_thickness: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_open_shell_context_") as temp_dir:
root = Path(temp_dir)
open_path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(open_path)
model = StepModel.load(open_path)
face_id = _first_open_shell_wall_face(model, source_thickness, tolerance)
info = model.feature_info(face_id)
limited_actions = str(info.get("recognition_limited_actions") or "")
if "完整抽壳/开口面编辑" not in limited_actions:
raise SystemExit(f"open shell should show full shell/opening edit as limited: {info}")
ready_actions = str(info.get("recognition_ready_actions") or "")
if "壳体厚度" not in ready_actions:
raise SystemExit(f"open shell wall should keep local thickness editable: {info}")
evidence_keys = tuple(info.get("recognition_evidence_keys") or ())
if "open_shell_context" not in evidence_keys:
raise SystemExit(f"open shell recognition evidence should be explicit: {info}")
local_plan = model.shell_thickness_plan(face_id, target_thickness)
if local_plan.get("status") == "blocked":
raise SystemExit(f"open shell local thickness should remain available: {local_plan}")
if local_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in local plan: {local_plan}")
if "完整抽壳/开口面" not in str(local_plan.get("message") or ""):
raise SystemExit(f"open shell local plan should explain the limitation: {local_plan}")
owning_plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if owning_plan.get("status") == "blocked":
raise SystemExit(f"open shell owning thickness should remain available: {owning_plan}")
if owning_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in owning plan: {owning_plan}")
editable_candidates = model.editable_feature_candidates(limit=30, detailed=False)
shell_entries = [
item
for item in editable_candidates
if item.get("operation_key") == "resize_shell_thickness" and int(item.get("target_id", -1)) == face_id
]
if not shell_entries:
raise SystemExit(f"open shell wall should be visible as editable shell thickness candidate: {editable_candidates}")
shell_note = str(shell_entries[0].get("note") or "")
if "完整抽壳/开口面" not in shell_note:
raise SystemExit(f"open shell candidate should explain full shell limitation: {shell_entries[0]}")
closed_path = root / "closed_plate.step"
_write_plate_model(closed_path)
closed_model = StepModel.load(closed_path)
_assert_no_open_shell_context(closed_model, "closed plate")
print("open_shell_context=limited")
print(f"face_id={face_id}")
print(f"limited_actions={limited_actions}")
print(f"local_plan_status={local_plan.get('status')}")
print(f"owning_plan_status={owning_plan.get('status')}")
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("--mode", default="all", choices=["all", "local", "owning", "open-shell-context"])
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)
@@ -268,7 +385,12 @@ def main() -> int:
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)
if mode == "open-shell-context":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
else:
_run_case(mode, args.source_thickness, args.target_thickness, args.tolerance)
if args.mode == "all":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
return 0
+100 -6
View File
@@ -45,6 +45,32 @@ def _write_obround_slot_model(path: Path) -> None:
_write_step(shape, path)
def _write_cross_obround_slot_model(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(40.0, 40.0, 6.0).Shape()
def capsule_tool_x() -> object:
axis_1 = gp_Ax2(gp_Pnt(14.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(26.0, 20.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(14.0, 17.0, -1.0), 12.0, 6.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot x tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot x tool second fuse")
def capsule_tool_y() -> object:
axis_1 = gp_Ax2(gp_Pnt(20.0, 14.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(20.0, 26.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(17.0, 14.0, -1.0), 6.0, 12.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot y tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot y tool second fuse")
cut_x = _finalize_boolean_result(BRepAlgoAPI_Cut(plate, capsule_tool_x()), "verify cross slot x cut")
cut_xy = _finalize_boolean_result(BRepAlgoAPI_Cut(cut_x, capsule_tool_y()), "verify cross slot y cut")
_write_step(cut_xy, path)
def _slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
@@ -151,6 +177,69 @@ def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, floa
return rows
def _complex_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" and feature.get("slot_status") == "blocked":
face_ids.append(face_id)
return face_ids
def _run_complex_slot_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cross_slot_guard_") as temp_dir:
model_path = Path(temp_dir) / "cross_obround_slot.step"
_write_cross_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_ids = _complex_slot_face_ids(model)
if len(face_ids) < 4:
raise SystemExit(f"cross obround slot should expose blocked slot ends, got {face_ids}")
face_id = face_ids[0]
feature = model.feature_info(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
if "交叉槽" not in blockers and "多槽组" not in blockers and "复杂草图槽" not in blockers:
raise SystemExit(f"cross slot blocker should explain the unsupported reason: {feature}")
if "复杂槽" not in str(feature.get("feature_type") or ""):
raise SystemExit(f"cross slot should be labeled as a complex slot: {feature.get('feature_type')}")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in face_ids and item.get("operation_key") in forbidden_actions
]
if leaked:
raise SystemExit(f"cross slot should not leak editable slot entries: {leaked}")
width_plan = model.cylindrical_slot_resize_plan(face_id, 7.0, "width")
angle_plan = model.cylindrical_slot_angular_span_plan(face_id, math.pi * 0.75)
current_center = _slot_axis_center(model, face_id)
axis_plan = model.cylindrical_slot_axis_move_plan(face_id, (current_center[0] + 1.0, current_center[1], current_center[2]))
total_plan = model.cylindrical_slot_total_length_plan(face_id, 16.0)
for label, plan in (
("width", width_plan),
("angle", angle_plan),
("axis", axis_plan),
("total_length", total_plan),
):
if plan.get("status") != "blocked":
raise SystemExit(f"cross slot {label} plan should be blocked: {plan}")
text = f"{plan.get('message')} {plan.get('blockers')}"
if "交叉槽" not in text and "多槽组" not in text and "复杂草图槽" not in text:
raise SystemExit(f"cross slot {label} plan should explain unsupported complex slot: {plan}")
print(f"complex_slot_guard=blocked_faces {face_ids}")
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):
@@ -404,6 +493,7 @@ def main() -> int:
"obround_axis_center",
"total_length",
"center_distance",
"complex_slot_guard",
],
help="Slot metric to verify.",
)
@@ -418,8 +508,8 @@ def main() -> int:
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
cases = (
[
if args.mode == "all":
cases = [
("width", args.width),
("depth", args.depth),
("arc_length", args.arc_length),
@@ -428,12 +518,16 @@ def main() -> int:
("obround_axis_center", args.obround_axis_center),
("total_length", args.total_length),
("center_distance", args.center_distance),
("complex_slot_guard", 0.0),
]
if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
)
elif args.mode == "complex_slot_guard":
cases = [("complex_slot_guard", 0.0)]
else:
cases = [(args.mode, getattr(args, args.mode.replace("-", "_")))]
for mode, target in cases:
if mode == "total_length":
if mode == "complex_slot_guard":
_run_complex_slot_guard()
elif mode == "total_length":
_run_total_length_case(float(target), args.tolerance)
elif mode == "center_distance":
_run_center_distance_case(float(target), args.tolerance)