feat: 增强一级关系识别与保持关系策略
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user