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

This commit is contained in:
2026-08-07 18:08:32 +08:00
parent eef9efcc1e
commit 12250603dd
26 changed files with 4254 additions and 1270 deletions
+6 -12
View File
@@ -54,24 +54,18 @@ def main() -> int:
specs, _used = state._editable_property_specs(context)
rows = state._feature_context_property_specs(specs, context)
context_row = next((row for row in rows if row.get("key") == "feature_context_note"), None)
if context_row is None:
raise AssertionError("feature context should expose an associated-feature detection summary row")
context_text = str(context_row.get("current_text") or "")
context_scope = str(context_row.get("scope_text") or "")
if "相邻特征" not in context_text or "局部关联特征" not in context_text:
raise AssertionError(f"associated detection summary is not clear enough: {context_row}")
if "相邻特征" not in context_scope or "关联特征" not in context_scope:
raise AssertionError(f"associated detection summary should expose level and count: {context_row}")
if context_row is not None:
raise AssertionError("associated-feature detection summary should stay in diagnostics, not feature parameters")
editable = {
(str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action")))
for row in rows
if row.get("parameter_role") == "dimension" and row.get("source_face_id") is not None
}
expected = {
("凸台/外圆候选 · 直径", 394, "resize_boss"),
("凸台/外圆候选 · 高度", 394, "resize_cylindrical_height_owning_scale"),
("圆柱孔候选 · 直径", 1591, "resize_hole"),
("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"),
("凸台/外圆 · 直径", 394, "resize_boss"),
("凸台/外圆 · 高度", 394, "resize_cylindrical_height_owning_scale"),
("圆柱孔 · 直径", 1591, "resize_hole"),
("圆柱孔 · 盲孔/盲槽深度", 1591, "resize_hole_depth"),
}
if not expected.issubset(editable):
raise AssertionError(f"missing associated editable rows: {expected - editable}")
+4
View File
@@ -19,6 +19,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Boss diameter, height and axis local rebuilds",
("verify_boss_resize.py",),
),
(
"Rectangular boss/pocket size, depth, center and top-step edits",
("verify_prismatic_feature.py",),
),
(
"Property editor keeps generic Face edits out of boss features",
("verify_property_editor_specs.py",),
+108
View File
@@ -4,12 +4,18 @@ import argparse
from collections import Counter
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GeomAbs import GeomAbs_Plane
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.geometry_utils import _dir_tuple, _tuple_dot, _tuple_normalized, _tuple_or_none, _tuple_sub
from step_editor.model import StepModel
from verify_edge_round_chamfer import _first_editable_line_edge, _write_box_model
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
@@ -38,6 +44,93 @@ def _count_near(values: list[float], target: float, tolerance: float) -> int:
return sum(1 for value in values if abs(value - target) <= tolerance)
def _planar_face_normal(model: StepModel, face_id: int) -> tuple[float, float, float] | None:
surf = BRepAdaptor_Surface(model.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
return _tuple_normalized(_dir_tuple(surf.Plane().Axis().Direction()))
def _edge_axis(model: StepModel, edge_id: int) -> tuple[float, float, float]:
info = model.edge_info(edge_id)
start = _tuple_or_none(info.get("start_point"))
end = _tuple_or_none(info.get("end_point"))
axis = _tuple_normalized(_tuple_sub(end, start)) if start is not None and end is not None else None
if axis is None:
raise SystemExit(f"edge {edge_id} has no stable line direction")
return axis
def _assert_cube_push_pull_planar_constraints(
model: StepModel,
target_length: float,
tolerance: float,
result: str,
) -> None:
if "Planar relation check: ok" not in result:
raise SystemExit(f"end-face push/pull result should include a passing planar relation check: {result}")
relation_tolerance = max(tolerance, 1e-4)
target_edge_ids = _line_edge_ids_near_length(model, target_length, relation_tolerance)
if len(target_edge_ids) != 4:
raise SystemExit(f"end-face push/pull should leave four target-length edges, got {target_edge_ids}")
for edge_id in target_edge_ids:
axis = _edge_axis(model, edge_id)
adjacent_face_ids = tuple(int(item) for item in model.edge_info(edge_id).get("adjacent_face_ids", ()))
normals: list[tuple[int, tuple[float, float, float]]] = []
for face_id in adjacent_face_ids:
normal = _planar_face_normal(model, face_id)
if normal is not None:
normals.append((face_id, normal))
if len(normals) != 2:
raise SystemExit(f"target edge {edge_id} should still have two planar adjacent faces, got {adjacent_face_ids}")
side_axis_dots = [abs(_tuple_dot(normal, axis)) for _face_id, normal in normals]
if any(value > relation_tolerance for value in side_axis_dots):
raise SystemExit(f"target edge {edge_id} side faces are no longer parallel to the edge: {side_axis_dots}")
side_pair_dot = abs(_tuple_dot(normals[0][1], normals[1][1]))
if side_pair_dot > relation_tolerance:
raise SystemExit(f"target edge {edge_id} adjacent side faces are no longer perpendicular: {side_pair_dot:g}")
def _verify_nonorthogonal_push_pull_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_push_pull_guard_") as temp_dir:
model_path = Path(temp_dir) / "chamfered_box.step"
_write_box_model(model_path)
model = StepModel.load(model_path)
source_edge_id = _first_editable_line_edge(model)
model.chamfer_edge(source_edge_id, 1.0)
for edge_id in range(len(model.edges)):
info = model.edge_info(edge_id)
if info.get("curve") != "line":
continue
normals: list[tuple[float, float, float]] = []
for face_id in tuple(int(item) for item in info.get("adjacent_face_ids", ())):
normal = _planar_face_normal(model, face_id)
if normal is not None:
normals.append(normal)
if len(normals) != 2:
continue
side_dot = abs(_tuple_dot(normals[0], normals[1]))
if not 0.2 < side_dot < 0.9:
continue
current_length = float(info.get("length") or 0.0)
plan = model.general_edge_length_plan(
edge_id,
current_length + 2.0,
anchor_mode="keep-start",
strategy_mode="move-edge-end-plane-by-push-pull",
)
message = str(plan.get("message") or "")
blockers = str(plan.get("edge_length_planar_constraint_blockers") or "")
if plan.get("status") != "blocked":
raise SystemExit(f"non-orthogonal chamfer Edge push/pull should be blocked: {plan}")
if "neither parallel nor perpendicular" not in f"{message} {blockers}":
raise SystemExit(f"non-orthogonal chamfer Edge should explain the planar relation blocker: {plan}")
print(f"non_orthogonal_push_pull_guard=edge {edge_id}, side_dot={side_dot:.6f}")
return
raise SystemExit("no non-orthogonal chamfer Edge was found for push/pull guard verification")
def _assert_cube_edge_intent_geometry(
*,
source_length: float,
@@ -129,6 +222,12 @@ def main() -> int:
expected_strategy = strategy
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready":
raise SystemExit(
"end-face push/pull plan should expose a ready planar relation constraint; "
f"status={plan.get('edge_length_planar_constraint_status')}, "
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
)
result = model.resize_general_edge_length(
edge_id,
@@ -162,6 +261,15 @@ def main() -> int:
lengths=lengths,
tolerance=max(args.tolerance, 1e-5),
)
if strategy == "move-edge-end-plane-by-push-pull":
_assert_cube_push_pull_planar_constraints(
model,
args.target_length,
max(args.tolerance, 1e-5),
result,
)
if args.anchor == "keep-start":
_verify_nonorthogonal_push_pull_guard()
print(f"model={Path(args.model)}")
print(f"edge_id={edge_id}")
+188 -2
View File
@@ -41,6 +41,28 @@ def _first_line_edge(shape: TopoDS_Shape, minimum_length: float = 5.0) -> TopoDS
raise SystemExit("no line edge found in generated box")
def _line_edge_endpoint_records(shape: TopoDS_Shape) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
for edge in TopologyExplorer(shape, ignore_orientation=True).edges():
curve = BRepAdaptor_Curve(edge)
if curve.GetType() != GeomAbs_Line:
continue
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
start = curve.Value(curve.FirstParameter())
end = curve.Value(curve.LastParameter())
records.append(
{
"edge": topods.Edge(edge),
"length": float(props.Mass()),
"start": (round(start.X(), 6), round(start.Y(), 6), round(start.Z(), 6)),
"end": (round(end.X(), 6), round(end.Y(), 6), round(end.Z(), 6)),
}
)
records.sort(key=lambda item: (float(item["length"]), item["start"], item["end"]))
return records
def _write_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
maker = BRepFilletAPI_MakeFillet(shape)
@@ -49,6 +71,27 @@ def _write_filleted_box_model(path: Path, radius: float) -> None:
_write_step(result, path)
def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if not records:
raise SystemExit("no line edges found in generated box")
first = records[0]
first_endpoints = {first["start"], first["end"]}
second = None
for candidate in records[1:]:
if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints:
second = candidate
break
if second is None:
raise SystemExit("no adjacent line Edge pair found for chained fillet source")
maker = BRepFilletAPI_MakeFillet(shape)
maker.Add(float(radius), first["edge"])
maker.Add(float(radius), second["edge"])
result = _finalize_builder_result(maker, "verify source box fillet chain")
_write_step(result, path)
def _first_editable_line_edge(model: StepModel) -> int:
candidates: list[tuple[float, int]] = []
for edge_id in range(len(model.edges)):
@@ -299,12 +342,138 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_arc_length_case(
source_radius: float,
target_arc_length: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_arc_") as temp_dir:
model_path = Path(temp_dir) / "filleted_box.step"
_write_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
source_info = model.feature_info(face_id)
angular_span = float(
source_info.get("existing_fillet_angular_span")
or source_info.get("angular_span")
or 0.0
)
if angular_span <= 1e-6:
raise SystemExit(f"existing fillet has no stable angular span: {source_info}")
target_radius = float(target_arc_length) / angular_span
before = model.stats()
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise SystemExit(f"existing fillet arc-length plan was blocked: {plan['message']}")
_assert_existing_fillet_plan_topology(plan)
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance)
if not matches:
raise SystemExit(
f"existing fillet arc-length verification failed: no cylindrical face near radius {target_radius:g}"
)
verified_face_id = matches[0][0]
verified_info = model.feature_info(verified_face_id)
verified_arc = float(
verified_info.get("existing_fillet_arc_length_estimate")
or target_radius * angular_span
)
arc_error = abs(verified_arc - target_arc_length)
if after.solids != before.solids:
raise SystemExit(
f"existing fillet arc-length resize changed solid count: before={before.solids}, after={after.solids}"
)
if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4):
raise SystemExit(
f"existing fillet arc-length verification failed: "
f"target={target_arc_length:g}, value={verified_arc:g}, error={arc_error:g}"
)
if "Existing fillet result check" not in result:
raise SystemExit(f"existing fillet arc-length result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing fillet arc-length result did not verify first-level topology: {result}")
print("mode=existing_fillet_arc_length")
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(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_") as temp_dir:
model_path = Path(temp_dir) / "fillet_chain_box.step"
_write_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ())
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ())
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"fillet chain should be blocked at recognition level: {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 len(chain_face_ids) < 2 or not chain_adjacent_face_ids:
raise SystemExit(f"fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
raise SystemExit(
"connected fillet faces should not be counted as support faces: "
f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}"
)
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
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 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}")
scan_candidates = model.editable_feature_candidates(limit=20, 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}")
print("mode=existing_fillet_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"support_face_ids={support_face_ids}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.")
parser.add_argument(
"--mode",
default="all",
choices=["all", "fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"],
choices=[
"all",
"fillet",
"chamfer",
"asymmetric_chamfer",
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_fillet_chain_guard",
],
help="Edge rounding/chamfering edit mode to verify.",
)
parser.add_argument("--fillet-radius", type=float, default=1.0)
@@ -315,11 +484,20 @@ def main() -> int:
parser.add_argument("--distance-angle-degrees", type=float, default=45.0)
parser.add_argument("--source-fillet-radius", type=float, default=1.0)
parser.add_argument("--target-fillet-radius", type=float, default=1.5)
parser.add_argument("--target-fillet-arc-length", type=float, default=2.356194490192345)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
modes = (
["fillet", "chamfer", "asymmetric_chamfer", "distance_angle_chamfer", "existing_fillet"]
[
"fillet",
"chamfer",
"asymmetric_chamfer",
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_fillet_chain_guard",
]
if args.mode == "all"
else [args.mode]
)
@@ -334,6 +512,14 @@ def main() -> int:
_run_distance_angle_chamfer_case(args.distance_angle_distance, args.distance_angle_degrees)
elif mode == "existing_fillet":
_run_existing_fillet_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
elif mode == "existing_fillet_arc_length":
_run_existing_fillet_arc_length_case(
args.source_fillet_radius,
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_chain_guard":
_run_existing_fillet_chain_guard_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
+4 -1
View File
@@ -129,7 +129,10 @@ def main() -> int:
model.resize_face_area_local(face_id, 144.0)
for candidate_id, area, _width, _height, _center in _plane_metrics(model):
if abs(area - 144.0) <= 1e-5:
_assert_spec_readback(model, candidate_id, "area", 144.0)
info = model.face_info(candidate_id)
_assert_close("area diagnostic value", info.get("area"), 144.0)
if any(spec.get("key") == "area" for spec in _specs(candidate_id, info)):
raise SystemExit("area should stay a diagnostic result, not an editable property spec")
break
else:
raise SystemExit("no plane Face near area 144 after local area resize")
+139 -1
View File
@@ -16,8 +16,10 @@ from step_editor.model import StepModel
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_through_hole_model # noqa: E402
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_boss_resize import _first_boss_face, _write_boss_model # noqa: E402
from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402
def _assert(condition: bool, message: str) -> None:
@@ -57,6 +59,19 @@ def _first_surface_face(model: StepModel, surface: str) -> int:
raise AssertionError(f"no {surface} Face was found")
def _first_quick_candidate(
model: StepModel,
*,
feature_guess: str,
feature_type: str,
) -> tuple[int, dict[str, object]]:
for face_id in range(len(model.faces)):
info = model.quick_face_info(face_id)
if info.get("feature_guess") == feature_guess and info.get("feature_type") == feature_type:
return face_id, info
raise AssertionError(f"no quick {feature_type} was found")
def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]) -> None:
summary = str(info.get("recognition_summary") or "")
candidate = str(info.get("recognition_candidate") or "")
@@ -66,12 +81,18 @@ def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]
evidence_keys = set(str(item) for item in tuple(info.get("recognition_evidence_keys") or ()))
evidence = str(info.get("recognition_evidence") or "")
score = int(info.get("recognition_score", -1))
user_priority = int(info.get("recognition_user_priority", -1))
user_priority_label = str(info.get("recognition_user_priority_label") or "")
user_priority_reason = str(info.get("recognition_user_priority_reason") or "")
_assert(summary, f"{label}: recognition_summary is missing")
_assert(candidate, f"{label}: recognition_candidate is missing")
_assert(confidence in {"unchecked", "none", "low", "medium", "high"}, f"{label}: bad confidence {confidence!r}")
_assert(risk in {"low", "medium", "high", "blocked"}, f"{label}: bad risk {risk!r}")
_assert(0 <= score <= 100, f"{label}: bad recognition_score {score!r}")
_assert(1 <= user_priority <= 99, f"{label}: bad user priority {user_priority!r}")
_assert(user_priority_label, f"{label}: recognition_user_priority_label is missing")
_assert(user_priority_reason, f"{label}: recognition_user_priority_reason is missing")
_assert(
decision in {"高可信候选", "可尝试候选", "需人工确认", "不建议自动修改", "已阻止"},
f"{label}: bad recognition_decision {decision!r}",
@@ -79,6 +100,7 @@ def _assert_summary(info: dict[str, object], label: str, required_keys: set[str]
_assert(required_keys <= evidence_keys, f"{label}: evidence keys missing {required_keys - evidence_keys}")
_assert(evidence, f"{label}: recognition_evidence is missing")
_assert(candidate in summary, f"{label}: summary should mention candidate {candidate!r}: {summary!r}")
_assert("优先级=" in summary, f"{label}: summary should mention user priority: {summary!r}")
_assert(f"评分={score}" in summary, f"{label}: summary should mention score {score}: {summary!r}")
_assert(decision in summary, f"{label}: summary should mention decision {decision!r}: {summary!r}")
@@ -92,6 +114,7 @@ def _verify_planar_summary(root: Path) -> None:
full_info = model.feature_info(face_id)
_assert_summary(quick_info, "quick planar Face", {"surface", "boundary_edges"})
_assert_summary(full_info, "feature planar Face", {"surface", "boundary_edges", "first_level_topology"})
_assert(int(full_info.get("recognition_user_priority", 99)) == 10, f"planar Face should be first priority: {full_info}")
def _verify_hole_summary(root: Path) -> None:
@@ -105,6 +128,7 @@ def _verify_hole_summary(root: Path) -> None:
_assert(info.get("resize_status") == "ready", f"hole diameter resize should be ready: {info}")
_assert(info.get("recognition_risk") != "blocked", f"ready hole candidate should not be globally blocked: {info}")
_assert(info.get("recognition_decision") != "已阻止", f"ready hole candidate should not be marked blocked: {info}")
_assert(int(info.get("recognition_user_priority", 99)) == 20, f"hole should use hole priority: {info}")
_assert(not str(info.get("recognition_blockers") or ""), f"ready hole blockers should stay empty: {info}")
ready_actions = str(info.get("recognition_ready_actions") or "")
limited_actions = str(info.get("recognition_limited_actions") or "")
@@ -116,6 +140,64 @@ def _verify_hole_summary(root: Path) -> None:
_assert("可改:" in str(info.get("recognition_summary") or ""), f"hole summary should show ready actions: {info}")
def _verify_quick_cylinder_recognition(root: Path) -> None:
hole_path = root / "quick_through_hole.step"
_write_through_hole_model(hole_path)
hole_model = StepModel.load(hole_path)
_hole_face_id, hole_info = _first_quick_candidate(
hole_model,
feature_guess="hole/groove candidate",
feature_type="圆柱孔候选",
)
_assert_summary(hole_info, "quick through-hole feature", {"surface", "user_operation_priority"})
_assert(int(hole_info.get("recognition_user_priority", 99)) == 20, f"quick hole should use hole priority: {hole_info}")
_assert("孔/槽/圆柱直径" in str(hole_info.get("recognition_ready_actions") or ""), f"quick hole should expose diameter: {hole_info}")
blind_path = root / "quick_blind_hole_cached.step"
_write_blind_hole_model(blind_path)
blind_model = StepModel.load(blind_path)
blind_face_id = _first_hole_face(blind_model, blind=True)
cached_quick = blind_model.quick_face_info(blind_face_id)
_assert(
cached_quick.get("feature_type") == "圆柱孔候选",
f"quick hole should keep its feature label after face_info cache is populated: {cached_quick}",
)
_assert(cached_quick.get("cylinder_end_type") == "blind", f"quick blind hole should expose end type: {cached_quick}")
_assert(cached_quick.get("depth_status") == "ready", f"quick blind hole depth should be ready: {cached_quick}")
_assert(
"盲孔/盲槽深度" in str(cached_quick.get("recognition_ready_actions") or ""),
f"quick blind hole should expose depth in ready actions: {cached_quick}",
)
slot_path = root / "quick_half_round_slot.step"
_write_half_round_slot_model(slot_path)
slot_model = StepModel.load(slot_path)
_slot_face_id, slot_info = _first_quick_candidate(
slot_model,
feature_guess="hole/groove candidate",
feature_type="槽/半孔候选",
)
_assert_summary(slot_info, "quick half-round slot feature", {"surface", "slot_geometry", "user_operation_priority"})
_assert(int(slot_info.get("recognition_user_priority", 99)) == 30, f"quick slot should use slot priority: {slot_info}")
_assert(slot_info.get("slot_status") == "candidate", f"quick slot should expose slot candidate fields: {slot_info}")
boss_path = root / "quick_boss.step"
_write_boss_model(boss_path)
boss_model = StepModel.load(boss_path)
_boss_face_id, boss_info = _first_quick_candidate(
boss_model,
feature_guess="boss/outer-round candidate",
feature_type="凸台/外圆候选",
)
_assert_summary(boss_info, "quick boss feature", {"surface", "user_operation_priority"})
_assert(int(boss_info.get("recognition_user_priority", 99)) == 40, f"quick boss should use boss priority: {boss_info}")
ready_actions = str(boss_info.get("recognition_ready_actions") or "")
limited_actions = str(boss_info.get("recognition_limited_actions") or "")
_assert("圆柱凸台直径/高度/轴心" in ready_actions, f"quick boss should expose boss editing: {boss_info}")
_assert("孔/槽/圆柱直径" not in ready_actions, f"quick boss should not be exposed as hole resize: {boss_info}")
_assert("孔/槽/圆柱直径" not in limited_actions, f"quick boss should not show cross-feature hole limits: {boss_info}")
def _verify_holed_planar_summary(root: Path) -> None:
path = root / "holed_plate.step"
_write_through_hole_model(path)
@@ -144,6 +226,18 @@ def _verify_slot_summary(root: Path) -> None:
info = model.feature_info(face_id)
_assert(info.get("slot_status") == "candidate", f"slot was not recognized: {info}")
_assert_summary(info, "half-round slot feature", {"surface", "material_votes", "slot_geometry"})
_assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}")
def _verify_boss_summary(root: Path) -> None:
path = root / "boss.step"
_write_boss_model(path)
model = StepModel.load(path)
face_id = _first_boss_face(model)
info = model.feature_info(face_id)
_assert(info.get("feature_guess") == "boss/outer-round candidate", f"boss was not recognized: {info}")
_assert_summary(info, "boss feature", {"surface", "material_votes", "first_level_topology"})
_assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}")
def _verify_torus_summary(root: Path) -> None:
@@ -155,6 +249,43 @@ def _verify_torus_summary(root: Path) -> None:
_assert(info.get("feature_type") == "环面候选", f"torus was not recognized safely: {info}")
_assert(info.get("feature_highlight_face_ids") == (face_id,), f"torus highlight should stay on source Face: {info}")
_assert_summary(info, "torus feature", {"surface", "boundary_edges"})
_assert(int(info.get("recognition_user_priority", 0)) == 80, f"torus should be lower-priority analytic surface: {info}")
def _verify_user_priority_scan_order(root: Path) -> None:
path = root / "through_hole_scan.step"
_write_through_hole_model(path)
model = StepModel.load(path)
candidates = model.editable_feature_candidates(limit=12, detailed=False)
operations = tuple(str(item.get("operation_key")) for item in candidates)
_assert(operations, "editable feature scan returned no candidates")
_assert(operations[0] == "push_pull_plane", f"Face push/pull should be first in common-user scan order: {operations}")
if "resize_cylinder" in operations:
_assert(
operations.index("push_pull_plane") < operations.index("resize_cylinder"),
f"Face push/pull should rank before hole diameter: {operations}",
)
def _verify_ellipse_edge_scan_entries(root: Path) -> None:
path = root / "ellipse_edge_scan.step"
_write_ellipse_face_model(path)
model = StepModel.load(path)
candidates = model.editable_feature_candidates(limit=20, detailed=False)
operations = tuple(str(item.get("operation_key")) for item in candidates)
labels = {str(item.get("current_value_label")) for item in candidates}
_assert(
"resize_ellipse_edge_major_radius" in operations,
f"ellipse Edge scan should expose major radius instead of generic length: {operations}",
)
_assert(
"resize_ellipse_edge_minor_radius" in operations,
f"ellipse Edge scan should expose minor radius instead of generic length: {operations}",
)
_assert("major_radius" in labels and "minor_radius" in labels, f"ellipse Edge scan labels are unclear: {labels}")
for item in candidates:
if item.get("operation_key") == "resize_edge_length" and item.get("current_value_label") == "length":
raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}")
def main() -> int:
@@ -165,6 +296,9 @@ def main() -> int:
"recognition_risk",
"recognition_score",
"recognition_decision",
"recognition_user_priority",
"recognition_user_priority_label",
"recognition_user_priority_reason",
"recognition_evidence",
"recognition_ready_actions",
"recognition_limited_actions",
@@ -177,9 +311,13 @@ def main() -> int:
root = Path(temp_dir)
_verify_planar_summary(root)
_verify_holed_planar_summary(root)
_verify_quick_cylinder_recognition(root)
_verify_hole_summary(root)
_verify_slot_summary(root)
_verify_boss_summary(root)
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_ellipse_edge_scan_entries(root)
print("feature recognition summary ok")
return 0
@@ -47,6 +47,14 @@ def _verify_readme_mentions(readme: str) -> None:
"当前整体验证基线",
"不等于 CAD 级完成",
"Face 阶段的当前验收口径",
"参数化编辑路线图",
"用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚",
"`[x]` 已实现",
"`[~]` 部分实现/进行中",
"`[ ]` 未实现",
"STEP/B-Rep 参数化编辑主线",
"[不能修改 -> 立即说明原因]",
"[一级影响范围 -> 明确显示]",
"verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot",
@@ -70,10 +78,50 @@ def _verify_readme_mentions(readme: str) -> None:
_assert(script_name in readme, f"README should mention {stage_name} command script {script_name}")
def _verify_roadmap_scope(readme: str) -> None:
start_marker = "STEP/B-Rep 参数化编辑主线"
end_marker = "└── 8. 二级 / 三级关系"
start = readme.find(start_marker)
end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README roadmap should contain a 0~7 active scope before stage 8")
active_scope = readme[start:end]
deferred_scope = readme[end:]
required_active_fragments = (
"├── 0. 先让用户知道“能不能改”",
"│ ├── [x] [不能修改 -> 立即说明原因]",
"│ └── [x] [路线图 -> 验收脚本守门]",
"├── 1. 平面 Face,第一条主线",
"├── 2. 孔,第二条主线",
"├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征",
"├── 4. 凸台 / Boss,从切除特征扩展到加料特征",
"├── 5. 圆角 / 倒角,从主形体扩展到边修饰",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力",
)
for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
forbidden_active_fragments = (
"[Face 二级传播",
"[孔组 ->",
"多槽组 ->",
"二级传播 ->",
"三级传播 ->",
"二级 / 三级关系",
)
for fragment in forbidden_active_fragments:
_assert(fragment not in active_scope, f"README 0~7 roadmap should defer this to stage 8: {fragment}")
_assert("[Face 二级传播 -> 孔底/槽底/台阶联动]" in deferred_scope, "README should keep Face deeper propagation in stage 8")
_assert("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard")
def main() -> int:
readme = README_PATH.read_text(encoding="utf-8")
_verify_script_inventory()
_verify_readme_mentions(readme)
_verify_roadmap_scope(readme)
print("first-level acceptance docs ok")
return 0
+2 -1
View File
@@ -69,7 +69,8 @@ STAGES: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...] = (
QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Smoke test", ("main.py", "--smoke-test")),
("Property editor specs", ("verify_property_editor_specs.py",)),
("Property card editor UI", ("verify_property_card_editor_ui.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
("First-level fact graph", ("verify_first_level_fact_graph.py",)),
("Associated feature probe and display budget", ("verify_associated_features.py",)),
("First-level acceptance docs", ("verify_first_level_acceptance_docs.py",)),
+12 -12
View File
@@ -374,7 +374,7 @@ def main() -> int:
input_path=hollow_path,
)
_run_worker_case(
label="偏移变换(局部重建)",
label="偏移(局部重建)",
operation="move_face_plane_offset_local",
args=[0, 10.0],
validator=_assert_plane_position,
@@ -392,25 +392,25 @@ def main() -> int:
validator=lambda label, model: _assert_face_area(label, model, 400.0),
)
_run_worker_case(
label="U向尺寸(局部重建)",
label="面内长度(局部重建)",
operation="resize_face_size_local",
args=[0, 25.0, "width"],
validator=_assert_face_width,
)
_run_worker_case(
label="V向尺寸(局部重建)",
label="面内宽度(局部重建)",
operation="resize_face_size_local",
args=[0, 25.0, "height"],
validator=_assert_face_height,
)
_run_worker_case(
label="U向尺寸(缩放特征)",
label="面内长度(缩放特征)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "width"],
validator=lambda label, model: _assert_face_width(label, model, 25.0),
)
_run_worker_case(
label="V向尺寸(缩放特征)",
label="面内宽度(缩放特征)",
operation="resize_face_size_owning_scale",
args=[0, 25.0, "height"],
validator=lambda label, model: _assert_face_height(label, model, 25.0),
@@ -534,16 +534,16 @@ def main() -> int:
delattr(sys, "frozen")
for title in (
"U向尺寸(局部重建)",
"V向尺寸(局部重建)",
"U向尺寸(缩放特征)",
"V向尺寸(缩放特征)",
"面内长度(局部重建)",
"面内宽度(局部重建)",
"面内长度(缩放特征)",
"面内宽度(缩放特征)",
"壳体厚度(缩放特征)缩放所属对象",
):
if not probe._quick_edit_title_supports_isolation(title):
raise SystemExit(f"{title}: quick edit title should support isolated execution")
context = {
"operation_name": "U向尺寸(局部重建)",
"operation_name": "面内长度(局部重建)",
"target": "Face 0",
"parameters": {"part_id": 1, "face_id": 0},
"target_kind": "face",
@@ -596,7 +596,7 @@ def main() -> int:
owning_result = owning_probe._run_isolated_edit_job(
context={
**context,
"operation_name": "U向尺寸(缩放特征)",
"operation_name": "面内长度(缩放特征)",
},
isolation=owning_isolation,
snapshot=owning_probe.model.snapshot(),
@@ -616,7 +616,7 @@ def main() -> int:
raise SystemExit("isolated owning window job replaced the window model before returning to the UI thread")
if owning_result.get("model_polydata") is None or owning_result.get("edge_polydata") is None:
raise SystemExit("isolated owning window job should return prebuilt display polydata")
_assert_face_width("U向尺寸(整体窗口任务)", owning_after_model, 25.0)
_assert_face_width("面内长度(整体窗口任务)", owning_after_model, 25.0)
if not _logical_region_has_width(owning_after_model, 0, 25.0):
raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
print("isolated owning window job ok")
+426 -38
View File
@@ -12,9 +12,9 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.step_io import _write_step
from step_editor.model import StepModel
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
from step_editor.step_io import _write_step
DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
@@ -27,15 +27,36 @@ def close_to(value: object, expected: float, tolerance: float = 1e-6) -> bool:
return False
def find_feature(model: StepModel, feature_type: str) -> dict[str, object]:
matches = [model.feature_info(face_id) for face_id in range(len(model.faces))]
matches = [info for info in matches if info.get("feature_type") == feature_type]
def find_feature(model: StepModel, feature_type: str) -> tuple[int, dict[str, object]]:
matches = [
(face_id, model.feature_info(face_id))
for face_id in range(len(model.faces))
if model.feature_info(face_id).get("feature_type") == feature_type
]
if not matches:
observed = sorted({str(model.feature_info(face_id).get("feature_type")) for face_id in range(len(model.faces))})
raise AssertionError(f"expected {feature_type}, observed {observed}")
return matches[0]
def find_prismatic_semantics(model: StepModel, semantics: str) -> tuple[int, dict[str, object]]:
matches = [
(face_id, model.feature_info(face_id))
for face_id in range(len(model.faces))
if model.feature_info(face_id).get("prismatic_feature_semantics") == semantics
and model.feature_info(face_id).get("prismatic_profile_status") == "candidate"
]
if not matches:
observed = sorted(
{
str(model.feature_info(face_id).get("prismatic_feature_semantics"))
for face_id in range(len(model.faces))
}
)
raise AssertionError(f"expected prismatic semantics {semantics}, observed {observed}")
return matches[0]
def assert_prismatic_sizes(
info: dict[str, object], length: float, width: float, depth: float
) -> None:
@@ -49,6 +70,250 @@ def assert_prismatic_sizes(
raise AssertionError(f"{key}={info.get(key)!r}, expected {value}")
def assert_vector_close(
value: object,
expected: tuple[float, float, float],
tolerance: float = 2e-4,
) -> None:
if value is None:
raise AssertionError(f"missing vector, expected {expected}")
try:
actual = tuple(float(item) for item in value) # type: ignore[iteration-over-annotation]
except (TypeError, ValueError):
raise AssertionError(f"invalid vector {value!r}, expected {expected}") from None
if len(actual) != 3:
raise AssertionError(f"invalid vector {value!r}, expected {expected}")
errors = tuple(abs(actual_item - expected_item) for actual_item, expected_item in zip(actual, expected))
if max(errors) > tolerance:
raise AssertionError(f"vector={actual!r}, expected={expected!r}, errors={errors!r}")
def write_rectangular_pocket_model(path: Path) -> None:
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape()
_write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), path)
def write_rectangular_boss_model(path: Path) -> None:
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape()
_write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), path)
def write_rectangular_multistep_boss_model(path: Path) -> None:
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
lower = BRepPrimAPI_MakeBox(gp_Pnt(8.0, 5.0, 5.0), 14.0, 10.0, 3.0).Shape()
upper = BRepPrimAPI_MakeBox(gp_Pnt(12.0, 8.0, 8.0), 6.0, 4.0, 2.0).Shape()
_write_step(BRepAlgoAPI_Fuse(BRepAlgoAPI_Fuse(base, lower).Shape(), upper).Shape(), path)
def feature_dimension_rows(
model: StepModel,
face_id: int,
info: dict[str, object],
) -> tuple[tuple[str, str, str], ...]:
state = object.__new__(WindowStateMixin)
state.model = model
state.operation_in_progress = False
state.scan_in_progress = False
state.load_in_progress = False
state.selected_face_id = face_id
state.selected_edge_id = None
state.selected_kind = "feature"
state.selected_part_id = int(info["part_id"])
state.selected_solid_id = int(info["solid_id"])
specs, _used = state._editable_property_specs(info)
filtered = state._feature_property_specs(specs, info)
dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"]
return tuple(
(
str(spec.get("key")),
str(spec.get("label")),
str(spec.get("current_text")),
)
for spec in dimensions
)
def assert_rectangular_feature_ui(
model: StepModel,
face_id: int,
info: dict[str, object],
expected_rows: tuple[tuple[str, str, str], ...],
) -> None:
actual = feature_dimension_rows(model, face_id, info)
if actual != expected_rows:
raise AssertionError(f"unexpected rectangular feature UI parameters: {actual}")
def assert_prismatic_feature_display_labels() -> None:
state = object.__new__(WindowStateMixin)
if state._feature_display_label("矩形口袋候选") != "矩形槽/口袋":
raise AssertionError("rectangular pocket should be displayed as a rectangular slot/pocket")
if state._feature_display_label("矩形凸台候选") != "矩形凸台":
raise AssertionError("rectangular boss display label should not expose candidate wording")
def assert_shell_depth_edit(
model_path: Path,
feature_type: str,
target_depth: float,
tolerance: float = 2e-4,
) -> None:
model = StepModel.load(model_path)
face_id, info = find_feature(model, feature_type)
before = model.stats()
assert_prismatic_sizes(info, 10.0, 8.0, 3.0)
plan = model.shell_thickness_plan(face_id, target_depth)
if plan["status"] == "blocked":
raise AssertionError(f"{feature_type} depth plan was blocked: {plan['message']}")
result = model.resize_shell_thickness(face_id, target_depth)
after = model.stats()
if after.solids != before.solids:
raise AssertionError(f"{feature_type} depth edit changed solid count: before={before}, after={after}")
_verified_face_id, verified = find_feature(model, feature_type)
assert_prismatic_sizes(verified, 10.0, 8.0, target_depth)
error = abs(float(verified["prismatic_extrusion_estimate"]) - target_depth)
if error > tolerance:
raise AssertionError(
f"{feature_type} depth verification failed: target={target_depth:g}, "
f"value={verified['prismatic_extrusion_estimate']!r}, error={error:g}"
)
if "resize completed" not in result.lower():
raise AssertionError(f"{feature_type} depth result did not report completion: {result}")
def assert_rectangular_size_edit(
model_path: Path,
semantics: str,
axis: str,
target_size: float,
expected_length: float,
expected_width: float,
expected_depth: float = 3.0,
tolerance: float = 2e-4,
initial_length: float = 10.0,
initial_width: float = 8.0,
initial_depth: float = 3.0,
) -> None:
model = StepModel.load(model_path)
face_id, info = find_prismatic_semantics(model, semantics)
before = model.stats()
assert_prismatic_sizes(info, initial_length, initial_width, initial_depth)
axis_key = "height" if axis == "width" else "width"
plan = model.face_size_local_resize_plan(face_id, target_size, axis_key)
if plan["status"] == "blocked":
raise AssertionError(f"{semantics} {axis} plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "rectangular-prismatic-profile-rebuild":
raise AssertionError(f"unexpected {semantics} {axis} strategy: {plan.get('resize_strategy')}")
result = model.resize_face_size_local(face_id, target_size, axis_key)
after = model.stats()
if after.solids != before.solids:
raise AssertionError(f"{semantics} {axis} edit changed solid count: before={before}, after={after}")
_verified_face_id, verified = find_prismatic_semantics(model, semantics)
assert_prismatic_sizes(verified, expected_length, expected_width, expected_depth)
length_error = abs(float(verified["prismatic_length"]) - expected_length)
width_error = abs(float(verified["prismatic_width"]) - expected_width)
if length_error > tolerance or width_error > tolerance:
raise AssertionError(
f"{semantics} {axis} verification failed: "
f"length={verified['prismatic_length']!r}, width={verified['prismatic_width']!r}, "
f"expected=({expected_length:g}, {expected_width:g})"
)
if "rectangular prismatic feature size resize completed" not in result.lower():
raise AssertionError(f"{semantics} {axis} result did not report completion: {result}")
def assert_rectangular_center_edit(
model_path: Path,
semantics: str,
target_center: tuple[float, float, float],
expected_center: tuple[float, float, float],
expected_length: float = 10.0,
expected_width: float = 8.0,
expected_depth: float = 3.0,
initial_length: float = 10.0,
initial_width: float = 8.0,
initial_depth: float = 3.0,
) -> None:
model = StepModel.load(model_path)
face_id, info = find_prismatic_semantics(model, semantics)
before = model.stats()
assert_prismatic_sizes(info, initial_length, initial_width, initial_depth)
plan = model.face_center_local_move_plan(face_id, target_center)
if plan["status"] == "blocked":
raise AssertionError(f"{semantics} center move plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "rectangular-prismatic-center-rebuild":
raise AssertionError(f"unexpected {semantics} center move strategy: {plan.get('resize_strategy')}")
result = model.move_face_center_local(face_id, target_center)
after = model.stats()
if after.solids != before.solids:
raise AssertionError(f"{semantics} center move changed solid count: before={before}, after={after}")
_verified_face_id, verified = find_prismatic_semantics(model, semantics)
assert_prismatic_sizes(verified, expected_length, expected_width, expected_depth)
assert_vector_close(verified.get("area_center") or verified.get("bbox_center"), expected_center)
if "rectangular prismatic feature center move completed" not in result.lower():
raise AssertionError(f"{semantics} center move result did not report completion: {result}")
def assert_rectangular_depth_edit_by_semantics(
model_path: Path,
semantics: str,
target_depth: float,
expected_length: float,
expected_width: float,
initial_depth: float,
tolerance: float = 2e-4,
) -> None:
model = StepModel.load(model_path)
face_id, info = find_prismatic_semantics(model, semantics)
before = model.stats()
assert_prismatic_sizes(info, expected_length, expected_width, initial_depth)
plan = model.shell_thickness_plan(face_id, target_depth)
if plan["status"] == "blocked":
raise AssertionError(f"{semantics} depth plan was blocked: {plan['message']}")
result = model.resize_shell_thickness(face_id, target_depth)
after = model.stats()
if after.solids != before.solids:
raise AssertionError(f"{semantics} depth edit changed solid count: before={before}, after={after}")
_verified_face_id, verified = find_prismatic_semantics(model, semantics)
assert_prismatic_sizes(verified, expected_length, expected_width, target_depth)
error = abs(float(verified["prismatic_extrusion_estimate"]) - target_depth)
if error > tolerance:
raise AssertionError(
f"{semantics} depth verification failed: target={target_depth:g}, "
f"value={verified['prismatic_extrusion_estimate']!r}, error={error:g}"
)
if "resize completed" not in result.lower():
raise AssertionError(f"{semantics} depth result did not report completion: {result}")
def assert_rectangular_axial_center_move_blocked(
model_path: Path,
semantics: str,
target_center: tuple[float, float, float],
) -> None:
model = StepModel.load(model_path)
face_id, _info = find_prismatic_semantics(model, semantics)
plan = model.face_center_local_move_plan(face_id, target_center)
if plan["status"] != "blocked":
raise AssertionError(f"{semantics} axial center move should be blocked: {plan}")
if plan.get("resize_strategy") != "rectangular-prismatic-center-rebuild":
raise AssertionError(f"unexpected blocked {semantics} center strategy: {plan.get('resize_strategy')}")
def assert_rectangular_role_swap_blocked(model_path: Path, semantics: str) -> None:
model = StepModel.load(model_path)
face_id, _info = find_prismatic_semantics(model, semantics)
plan = model.face_size_local_resize_plan(face_id, 7.0, "width")
if plan["status"] != "blocked":
raise AssertionError(f"{semantics} length/width role swap should be blocked: {plan}")
def main() -> int:
model = StepModel.load(DEFAULT_MODEL)
if len(model.faces) != 6:
@@ -66,63 +331,186 @@ def main() -> int:
if len(tuple(info.get("prismatic_connected_side_face_ids", ()))) != 4:
raise AssertionError(f"Face {face_id} does not have four connected side faces")
info = model.feature_info(0)
face_id = 0
info = model.feature_info(face_id)
expected_keys = ("local_face_width", "local_face_height", "shell_thickness_estimate")
if _feature_dimension_keys(info) != expected_keys:
raise AssertionError(f"unexpected cube feature dimensions: {_feature_dimension_keys(info)}")
state = object.__new__(WindowStateMixin)
state.model = model
state.operation_in_progress = False
state.scan_in_progress = False
state.load_in_progress = False
state.selected_face_id = 0
state.selected_edge_id = None
state.selected_kind = "feature"
state.selected_part_id = int(info["part_id"])
state.selected_solid_id = int(info["solid_id"])
specs, _used = state._editable_property_specs(info)
filtered = state._feature_property_specs(specs, info)
dimensions = [spec for spec in filtered if spec.get("parameter_role") == "dimension"]
actual = tuple(
(
str(spec.get("key")),
str(spec.get("label")),
str(spec.get("current_text")),
)
for spec in dimensions
)
expected = (
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "10"),
("shell_thickness_estimate", "高度/深度", "10"),
)
if actual != expected:
raise AssertionError(f"unexpected prismatic UI parameters: {actual}")
assert_rectangular_feature_ui(model, face_id, info, expected)
assert_prismatic_feature_display_labels()
with tempfile.TemporaryDirectory(prefix="step-editor-prismatic-") as temp_dir:
temp_path = Path(temp_dir)
base = BRepPrimAPI_MakeBox(30.0, 20.0, 5.0).Shape()
pocket_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 2.0), 10.0, 8.0, 4.0).Shape()
pocket_path = temp_path / "rectangular-pocket.step"
_write_step(BRepAlgoAPI_Cut(base, pocket_tool).Shape(), pocket_path)
pocket_info = find_feature(StepModel.load(pocket_path), "矩形口袋候选")
write_rectangular_pocket_model(pocket_path)
pocket_model = StepModel.load(pocket_path)
pocket_face_id, pocket_info = find_feature(pocket_model, "矩形口袋候选")
assert_prismatic_sizes(pocket_info, 10.0, 8.0, 3.0)
if pocket_info.get("prismatic_reference_source") != "side-wall-topology":
raise AssertionError(f"unexpected pocket reference: {pocket_info.get('prismatic_reference_source')}")
assert_rectangular_feature_ui(
pocket_model,
pocket_face_id,
pocket_info,
(
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 2)"),
),
)
boss_tool = BRepPrimAPI_MakeBox(gp_Pnt(10.0, 6.0, 5.0), 10.0, 8.0, 3.0).Shape()
boss_path = temp_path / "rectangular-boss.step"
_write_step(BRepAlgoAPI_Fuse(base, boss_tool).Shape(), boss_path)
boss_info = find_feature(StepModel.load(boss_path), "矩形凸台候选")
write_rectangular_boss_model(boss_path)
boss_model = StepModel.load(boss_path)
boss_face_id, boss_info = find_feature(boss_model, "矩形凸台候选")
assert_prismatic_sizes(boss_info, 10.0, 8.0, 3.0)
if boss_info.get("prismatic_reference_source") not in {
"side-wall-topology", "overlapping-plane"
}:
raise AssertionError(f"unexpected boss reference: {boss_info.get('prismatic_reference_source')}")
assert_rectangular_feature_ui(
boss_model,
boss_face_id,
boss_info,
(
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 8)"),
),
)
print("prismatic feature recognition ok")
boss_edit_path = temp_path / "rectangular-boss-edit.step"
write_rectangular_boss_model(boss_edit_path)
assert_shell_depth_edit(boss_edit_path, "矩形凸台候选", 4.0)
pocket_edit_path = temp_path / "rectangular-pocket-edit.step"
write_rectangular_pocket_model(pocket_edit_path)
assert_shell_depth_edit(pocket_edit_path, "矩形口袋候选", 4.0)
for name, writer, semantics in (
("boss", write_rectangular_boss_model, "additive-boss"),
("pocket", write_rectangular_pocket_model, "subtractive-pocket"),
):
length_edit_path = temp_path / f"rectangular-{name}-length-edit.step"
writer(length_edit_path)
assert_rectangular_size_edit(length_edit_path, semantics, "length", 12.0, 12.0, 8.0)
width_edit_path = temp_path / f"rectangular-{name}-width-edit.step"
writer(width_edit_path)
assert_rectangular_size_edit(width_edit_path, semantics, "width", 9.0, 10.0, 9.0)
center_z = 8.0 if semantics == "additive-boss" else 2.0
center_edit_path = temp_path / f"rectangular-{name}-center-edit.step"
writer(center_edit_path)
assert_rectangular_center_edit(
center_edit_path,
semantics,
(17.0, 11.0, center_z),
(17.0, 11.0, center_z),
)
axial_center_path = temp_path / f"rectangular-{name}-axial-center.step"
writer(axial_center_path)
axial_z = center_z + (1.0 if semantics == "additive-boss" else -1.0)
assert_rectangular_axial_center_move_blocked(
axial_center_path,
semantics,
(15.0, 10.0, axial_z),
)
blocked_path = temp_path / f"rectangular-{name}-role-swap.step"
writer(blocked_path)
assert_rectangular_role_swap_blocked(blocked_path, semantics)
multistep_path = temp_path / "rectangular-multistep-boss.step"
write_rectangular_multistep_boss_model(multistep_path)
multistep_model = StepModel.load(multistep_path)
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(
multistep_model,
multistep_face_id,
multistep_info,
(
("local_face_width", "长度", "6"),
("local_face_height", "宽度", "4"),
("shell_thickness_estimate", "高度/深度", "2"),
("face_center_position", "中心", "(15, 10, 10)"),
),
)
multistep_length_path = temp_path / "rectangular-multistep-boss-length.step"
write_rectangular_multistep_boss_model(multistep_length_path)
assert_rectangular_size_edit(
multistep_length_path,
"additive-boss",
"length",
7.0,
7.0,
4.0,
2.0,
initial_length=6.0,
initial_width=4.0,
initial_depth=2.0,
)
multistep_width_path = temp_path / "rectangular-multistep-boss-width.step"
write_rectangular_multistep_boss_model(multistep_width_path)
assert_rectangular_size_edit(
multistep_width_path,
"additive-boss",
"width",
5.0,
6.0,
5.0,
2.0,
initial_length=6.0,
initial_width=4.0,
initial_depth=2.0,
)
multistep_depth_path = temp_path / "rectangular-multistep-boss-depth.step"
write_rectangular_multistep_boss_model(multistep_depth_path)
assert_rectangular_depth_edit_by_semantics(
multistep_depth_path,
"additive-boss",
2.5,
6.0,
4.0,
2.0,
)
multistep_center_path = temp_path / "rectangular-multistep-boss-center.step"
write_rectangular_multistep_boss_model(multistep_center_path)
assert_rectangular_center_edit(
multistep_center_path,
"additive-boss",
(16.0, 10.5, 10.0),
(16.0, 10.5, 10.0),
expected_length=6.0,
expected_width=4.0,
expected_depth=2.0,
initial_length=6.0,
initial_width=4.0,
initial_depth=2.0,
)
multistep_axial_path = temp_path / "rectangular-multistep-boss-axial-center.step"
write_rectangular_multistep_boss_model(multistep_axial_path)
assert_rectangular_axial_center_move_blocked(
multistep_axial_path,
"additive-boss",
(15.0, 10.0, 10.5),
)
print("prismatic feature recognition and size/depth/center/multistep edit ok")
return 0
+390 -138
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import math
import os
from pathlib import Path
import sys
@@ -25,7 +26,15 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.widgets import NoWheelComboBox
from step_editor.window_state import WindowStateMixin
from step_editor.window_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import (
PROPERTY_CURRENT_COLUMN,
PROPERTY_LABEL_COLUMN,
PROPERTY_SCOPE_COLUMN,
PROPERTY_TARGET_COLUMN,
WindowStateMixin,
)
class _StatusBar:
@@ -33,7 +42,7 @@ class _StatusBar:
pass
class _PropertyCardProbe(QWidget, WindowStateMixin):
class _PropertyTableProbe(QWidget, WindowStateMixin):
def __init__(self) -> None:
super().__init__()
self.model = object()
@@ -42,30 +51,58 @@ class _PropertyCardProbe(QWidget, WindowStateMixin):
self.load_in_progress = False
self.property_editor_updating = False
self.property_table_expanded = False
self.property_table_collapsed_rows = 4
self.property_table_collapsed_rows = 5
self.property_table_min_visible_rows = 5
self.property_editor_selected_row = None
self.property_command_active_key = ""
self.property_command_buttons = {}
self.property_editor_specs = []
self.selected_kind = "face"
self.selected_kind = "feature"
self.selected_part_id = None
self.selected_solid_id = None
self.selected_face_id = 0
self.selected_edge_id = None
self.current_info_values = {"area": 100.0}
self.property_table = QTableWidget(0, 5)
self.current_info_values = self._plane_info()
layout = QVBoxLayout(self)
self.object_edit_box = self
self.property_table = QTableWidget(0, 4)
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"])
layout.addWidget(self.property_table)
self.property_card_scroll = QScrollArea()
self.property_card_container = QWidget()
self.property_card_layout = QVBoxLayout(self.property_card_container)
self.property_card_scroll.setWidget(self.property_card_container)
layout.addWidget(self.property_card_scroll)
self.property_expand_button = QPushButton()
layout.addWidget(self.property_expand_button)
self.property_command_summary_label = QLabel()
self.property_command_bar = QFrame()
self.property_command_layout = QHBoxLayout(self.property_command_bar)
self.property_command_help_label = QLabel()
self.current_capability_headline = QLabel()
self.apply_property_button = QPushButton()
@staticmethod
def _plane_info() -> dict[str, object]:
return {
"area": 100.0,
"area_center": (5.0, 5.0, 0.0),
"bbox_center": (5.0, 5.0, 0.0),
"bbox_diagonal": 14.1421356237,
"local_face_width": 10.0,
"local_face_height": 10.0,
"plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0),
}
def _selected_action_info(self) -> dict[str, object]:
return {
"area": 100.0,
**self.current_info_values,
"surface": "plane",
"push_pull_status": "ready",
"first_level_boundary_edge_count": 4,
@@ -80,6 +117,82 @@ class _PropertyCardProbe(QWidget, WindowStateMixin):
return _StatusBar()
class _ActionMessageProbe(WindowActionMixin):
pass
class _TimerProbe:
def stop(self) -> None:
pass
class _RenderWindowProbe:
def Render(self) -> None:
pass
class _StatusBarProbe:
def __init__(self) -> None:
self.messages: list[str] = []
def showMessage(self, message: str) -> None:
self.messages.append(str(message))
class _MouseSelectionProbe(WindowCoreMixin):
def __init__(self) -> None:
self.pointer_button_down = False
self.left_button_press_position = None
self.left_button_press_camera_state = None
self.left_button_press_target = None
self.left_button_dragged = False
self.left_click_drag_threshold_px = 6
self.left_click_camera_tolerance = 1e-7
self.camera_interaction_active = False
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer = _TimerProbe()
self.camera_state = (0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 10.0, 30.0)
self.load_in_progress = False
self.operation_in_progress = False
self.scan_in_progress = False
self.model = object()
self.selected_kind = None
self.render_window = _RenderWindowProbe()
self.status_bar = _StatusBarProbe()
self.pick_targets: list[dict[str, object] | None] = [
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
]
self.selected_targets: list[dict[str, object]] = []
self.hover_clear_count = 0
self.camera_end_count = 0
def _camera_state_signature(self):
return tuple(self.camera_state)
def _current_selection_mode(self) -> str:
return "Face"
def _pick_selection_target(self, _mode: str, _x: int, _y: int) -> dict[str, object] | None:
if self.pick_targets:
return self.pick_targets.pop(0)
return None
def _select_pick_target(self, target: dict[str, object]) -> None:
self.selected_targets.append(dict(target))
def statusBar(self) -> _StatusBarProbe:
return self.status_bar
def _clear_hover(self, render: bool = True) -> None:
self.hover_clear_count += 1
def _end_camera_interaction(self) -> None:
self.camera_interaction_active = False
self.camera_end_count += 1
class _TopLevelPropertyLabelProbe(QObject):
def __init__(self) -> None:
super().__init__()
@@ -101,152 +214,291 @@ def _assert(condition: bool, message: str) -> None:
raise AssertionError(message)
def _row_by_label(probe: _PropertyTableProbe, label: str) -> int:
for row in range(probe.property_table.rowCount()):
item = probe.property_table.item(row, PROPERTY_LABEL_COLUMN)
if item is not None and item.text() == label:
return row
labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
raise AssertionError(f"{label!r} row was not found; labels={labels}")
def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
_assert(
not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table),
"property table tooltip events should not be suppressed",
)
_assert(
not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table.viewport()),
"property table viewport tooltip events should not be suppressed",
)
headers = [
probe.property_table.horizontalHeaderItem(column).text()
for column in range(probe.property_table.columnCount())
]
_assert(headers == ["尺寸参数", "当前值", "建模意图", "目标值"], f"unexpected table headers: {headers}")
_assert(probe.property_table.rowCount() == len(probe.property_editor_specs), "table row count should match specs")
header_height = int(probe.property_table.horizontalHeader().height())
frame = int(probe.property_table.frameWidth()) * 2
default_row_height = max(int(probe.property_table.verticalHeader().defaultSectionSize()), 22)
expected_five_row_height = header_height + frame + default_row_height * 5 + 8
_assert(
probe.property_table.minimumHeight() >= expected_five_row_height,
"feature parameter table should reserve enough height for five default rows",
)
_assert(not getattr(probe, "property_card_rows", {}), "property card rows should not be built in table mode")
_assert(not probe.property_card_scroll.isVisible(), "property card scroll area should stay hidden")
_assert(probe.property_card_scroll.maximumHeight() == 0, "property card scroll area should not reserve height")
_assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the parameter table")
_assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden")
table_labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()]
for label in ("面内长度", "面内宽度", "中心", "偏移"):
_assert(label in actionable_labels, f"feature parameter table did not expose {label}")
for legacy_label in ("面积", "U向尺寸", "V向尺寸", "偏移变换"):
_assert(legacy_label not in actionable_labels, f"{legacy_label} should not be exposed as an editable parameter")
_assert(legacy_label not in table_labels, f"{legacy_label} should not appear in the feature parameter table")
for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"):
_assert(diagnostic_label not in table_labels, f"{diagnostic_label} should stay out of the feature parameter table")
target_row = _row_by_label(probe, "面内长度")
target_widget = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN)
scope_widget = probe.property_table.cellWidget(target_row, PROPERTY_SCOPE_COLUMN)
_assert(isinstance(target_widget, QLineEdit), "editable table row should have a target editor")
_assert(isinstance(scope_widget, NoWheelComboBox), "editable table row should have a modeling-intent combo")
_assert(
not probe.property_table.findChildren(QPushButton),
"feature parameter table should not contain per-row apply buttons",
)
target_widget.setText("12")
probe._update_property_apply_state()
changed = probe._changed_property_rows()
_assert(any(row == target_row for row, _spec, _text in changed), "table target edit was not detected")
_assert(probe.apply_property_button.isEnabled(), "single parametric modeling button should enable for one changed row")
probe.toggle_property_table_expanded()
_assert(probe.property_table_expanded, "property table expand toggle failed")
_assert("收起" in probe.property_expand_button.text(), "expanded table button should offer to collapse")
preserved_editor = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN)
_assert(isinstance(preserved_editor, QLineEdit), "target editor disappeared after table expand")
_assert(preserved_editor.text().strip() == "12", "target value was not preserved after table expand")
def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) -> None:
long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
probe.current_info_values = {
**probe._plane_info(),
"feature_context_note": long_context,
"feature_detection_level": "相邻特征",
"associated_feature_count": 3,
}
probe._refresh_property_editor()
QApplication.processEvents()
table_labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"):
_assert(diagnostic_label not in table_labels, f"{diagnostic_label} should not be shown as a feature parameter")
def _assert_mouse_selection_guards() -> None:
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
[target.get("target_id") for target in mouse_probe.selected_targets] == [1],
"plain left click should still select",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._update_left_button_drag_state(40, 20)
mouse_probe._handle_left_button_release(40, 20)
_assert(not mouse_probe.selected_targets, "left-button drag should not select a face on release")
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe.camera_state = (0.5, 0.0, 9.8, 0.0, 0.0, 0.0, 0.02, 1.0, 0.0, 1.0, 9.8, 30.0)
mouse_probe._handle_left_button_release(22, 21)
_assert(
not mouse_probe.selected_targets,
"left-button camera rotation should not select a face even when the cursor lands on the model",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe.pick_targets = [None, {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
not mouse_probe.selected_targets,
"left-button press on the background should not select a face on release",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe.pick_targets = [
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
{"kind": "face", "target_id": 2, "pick_position": (0.0, 0.0, 0.0)},
]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
not mouse_probe.selected_targets,
"left-button press/release on different faces should not change selection",
)
def _assert_quick_blind_depth_spec() -> None:
blind_probe = _PropertyTableProbe()
blind_probe.selected_kind = "feature"
blind_probe.selected_face_id = 6
quick_blind_info = {
"surface": "cylinder",
"diameter": 4.0,
"radius": 2.0,
"axis_point": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"angular_span": math.tau,
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔候选",
"confidence": "medium",
"cylinder_end_type": "blind",
"hole_depth_estimate": 6.0,
"depth_status": "ready",
"recognition_ready_actions": "孔/槽/圆柱直径;盲孔/盲槽深度;封堵孔/槽",
}
blind_specs, _blind_used = blind_probe._editable_property_specs(quick_blind_info)
blind_feature_specs = blind_probe._feature_property_specs(blind_specs, quick_blind_info)
blind_depth_specs = [
spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_depth_estimate"
]
_assert(blind_depth_specs, "quick blind hole depth estimate should be visible in feature parameters")
blind_depth_spec = blind_depth_specs[0]
blind_effective_depth = blind_probe._effective_property_spec(blind_depth_spec)
_assert(bool(blind_effective_depth.get("enabled")), "quick blind hole depth should be editable")
_assert(
str(blind_effective_depth.get("action", "")) == "resize_hole_depth",
f"quick blind hole depth should use local depth edit first: {blind_effective_depth}",
)
_assert(
"重新确认底面" in str(blind_effective_depth.get("enabled_tip", "")),
"quick blind hole depth should explain execution-time bottom-face confirmation",
)
owning_mode = dict(blind_depth_spec.get("scope_modes", {})).get("owning", {})
_assert(
not bool(owning_mode.get("enabled")),
"quick blind hole without explicit bottom faces should not expose owning-scale depth as editable",
)
def _assert_user_facing_failure_messages() -> None:
action_probe = _ActionMessageProbe()
illegal_context = {
"operation_name": "测试修改",
"target": "Face 1",
"parameters": {"resize_status": "blocked", "resize_blockers": "目标值必须大于 0。"},
}
illegal_blocker = action_probe._edit_preflight_blocker(illegal_context)
_assert(illegal_blocker is not None, "blocked edit plan should be stopped before worker startup")
_assert(illegal_blocker[0] == "当前操作不合法", "illegal blocked edit should be classified clearly")
english_illegal_context = {
"operation_name": "调整槽宽",
"target": "Face 3",
"parameters": {"resize_status": "blocked", "resize_blockers": "Target slot value must be greater than 0."},
}
english_illegal_blocker = action_probe._edit_preflight_blocker(english_illegal_context)
_assert(
english_illegal_blocker is not None and english_illegal_blocker[0] == "当前操作不合法",
"english geometry blockers should also be classified as illegal operations",
)
risk_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "risk": "blocked", "message": "目标壳体厚度会让几何风险过高。"}
)
_assert(risk_blocker is not None and risk_blocker[0] == "风险过高", "blocked high-risk plans should be classified")
recognition_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "message": "当前平面没有识别到相对壳体平面。"}
)
_assert(
recognition_blocker is not None and recognition_blocker[0] == "识别不足",
"blocked recognition failures should be classified",
)
unsupported_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "message": "当前版本只对简单圆锥解析重建开放这类修改。"}
)
_assert(
unsupported_blocker is not None and unsupported_blocker[0] == "暂未实现",
"blocked unsupported capability plans should be classified",
)
auxiliary_context = {
"operation_name": "测试修改",
"target": "Face 4",
"parameters": {"quick_plan_status": "blocked", "resize_status": "ready", "resize_blockers": ""},
}
_assert(action_probe._edit_preflight_blocker(auxiliary_context) is None, "auxiliary statuses should not block ready edits")
deferred_context = {
"operation_name": "复杂 Face 修改",
"target": "Face 2",
"parameters": {"ui_deferred_model_plan": True, "message": "当前对象需要完整一级关系计划。"},
}
deferred_blocker = action_probe._edit_preflight_blocker(deferred_context)
_assert(deferred_blocker is not None, "deferred model plan should be stopped before slow worker startup")
_assert(deferred_blocker[0] == "暂未实现", "deferred model plan should be classified as unsupported")
title, user_message = action_probe._user_facing_edit_failure_message(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。 当前版本暂不支持复杂链式圆角。",
deferred_context,
)
_assert(title == "不能修改", "edit failure dialog title should be user-facing")
_assert("隔离子进程" not in user_message and "子进程" not in user_message, "failure message should not expose isolation details")
_assert("暂未实现" in user_message, "unsupported failure should state that the operation is not implemented yet")
_empty_title, empty_message = action_probe._user_facing_edit_failure_message(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。",
deferred_context,
)
_assert("隔离子进程" not in empty_message and "子进程" not in empty_message, "empty internal failure should stay user-facing")
def main() -> int:
app = QApplication.instance() or QApplication([])
top_level_label_probe = _TopLevelPropertyLabelProbe()
app.installEventFilter(top_level_label_probe)
probe = _PropertyCardProbe()
probe = _PropertyTableProbe()
probe.show()
QApplication.processEvents()
probe._refresh_property_editor()
QApplication.processEvents()
_assert(
not top_level_label_probe.shown_labels,
f"property card labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}",
f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}",
)
actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()]
_assert("面积" in actionable_labels, "modifiable feature list did not expose the editable area row")
_assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the modifiable-feature panel")
_assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden")
_assert("可修改项" in probe.property_command_summary_label.text(), "modifiable-feature summary is missing")
rows = getattr(probe, "property_card_rows", {})
_assert(rows, "property card rows were not built")
_assert_property_table_editor(probe)
_assert("当前支持" in probe.current_capability_headline.text(), "software progress panel did not show supported areas")
_assert("优先:" not in probe.current_capability_headline.text(), "software progress panel should not show priority copy")
_assert(
not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()),
"modifiable feature rows should be compact until the user expands one",
)
_assert(
all(str(widgets.get("status_label").text()) == "可修改" for widgets in rows.values()),
"compact modifiable rows should end with the editable status label",
)
_assert(
all(not str(widgets.get("current_value").toolTip()) for widgets in rows.values() if widgets.get("current_value") is not None),
"compact modifiable rows should not show click-triggered tooltips",
"矩形槽口袋" in probe.current_capability_headline.toolTip()
and "多台阶矩形凸台顶层" in probe.current_capability_headline.toolTip(),
"software progress tooltip should name newly supported prismatic feature edits",
)
target_row = probe._actionable_property_rows()[0][0]
probe._select_property_card_row(target_row)
rows = getattr(probe, "property_card_rows", {})
editable_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("action_button"), QPushButton)]
target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)]
scope_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("scope_combo"), NoWheelComboBox)]
_assert_diagnostics_stay_out_of_parameter_table(probe)
_assert_mouse_selection_guards()
_assert_quick_blind_depth_spec()
_assert_user_facing_failure_messages()
_assert(editable_rows, "editable property card button is missing")
_assert(target_rows, "property card target editor is missing")
_assert(scope_rows, "property card modeling-intent combo is missing")
probe._toggle_property_card_row(target_row)
QApplication.processEvents()
rows = getattr(probe, "property_card_rows", {})
_assert(probe.property_editor_selected_row is None, "clicking an expanded row should collapse it")
_assert(
not any(isinstance(widgets.get("target_editor"), QLineEdit) for widgets in rows.values()),
"collapsed modifiable rows should return to compact display",
)
_assert(
not any(editor.isVisible() for editor in probe.property_card_container.findChildren(QLineEdit)),
"collapsed modifiable rows should not leave stale target editors visible",
)
_assert(
not any(
label.isVisible() and "建模意图:" in label.text()
for label in probe.property_card_container.findChildren(QLabel)
),
"collapsed modifiable rows should not leave stale expanded hints visible",
)
probe._select_property_card_row(target_row)
rows = getattr(probe, "property_card_rows", {})
target_rows = [row for row, widgets in rows.items() if isinstance(widgets.get("target_editor"), QLineEdit)]
_assert(target_rows, "target editor is missing after re-expanding the row")
rows[target_rows[0]]["target_editor"].setText("144")
probe._update_property_apply_state()
changed = probe._changed_property_rows()
button = rows[target_rows[0]]["action_button"]
_assert(changed, "card target edit was not detected")
_assert(bool(button.property("changed")), "card button did not enter changed state")
_assert(button.isEnabled(), "card button should be enabled for a valid changed target")
probe.toggle_property_table_expanded()
_assert(probe.property_table_expanded, "property card expand toggle failed")
_assert(len(probe.property_card_rows) > len(rows), "expanded property card list did not reveal more rows")
expanded_editor = probe.property_card_rows[target_rows[0]].get("target_editor")
_assert(isinstance(expanded_editor, QLineEdit), "expanded target editor is missing")
_assert(expanded_editor.text().strip() == "144", "target value was not preserved after card rebuild")
long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
probe.property_editor_specs = [
{
"key": "feature_context_note",
"label": "关联探测",
"current_text": long_context,
"current_raw": long_context,
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"span_value_columns": True,
"pin_top": True,
},
{
"key": "associated_face_center",
"label": "相邻平面 Face 1580 · 中心",
"current_text": "(-118.585, -23.3, -269.561)",
"current_raw": "(-118.585, -23.3, -269.561)",
"target_text": "(-118.585, -23.3, -269.561)",
"editable": True,
"enabled": True,
"action": "move_face_center",
"scope_text": "局部重建",
"status_text": "可修改",
},
]
probe.property_table_expanded = True
probe.property_editor_selected_row = None
probe._rebuild_property_cards()
QApplication.processEvents()
context_value = next(
(
label
for label in probe.property_card_rows[0]["card"].findChildren(QLabel)
if label.objectName() == "propertyCardValue"
),
None,
)
_assert(isinstance(context_value, QLabel), "long associated detection note is missing")
_assert(context_value.wordWrap(), "long associated detection note should wrap in diagnostics view")
_assert("关联尺寸可在同一参数表中直接修改" in context_value.text(), "associated detection note lost its trailing text")
associated_widgets = probe.property_card_rows[1]
associated_card = associated_widgets["card"]
associated_title = next(
(
label
for label in associated_card.findChildren(QLabel)
if label.objectName() == "propertyCardTitle"
),
None,
)
_assert(isinstance(associated_title, QLabel), "long associated row title is missing")
_assert(associated_title.wordWrap(), "long associated row title should use a two-line compact layout")
_assert(associated_card.height() > 24, "long associated row should be taller than a one-line compact row")
associated_value = associated_widgets.get("current_value")
_assert(isinstance(associated_value, QLabel), "long associated row value is missing")
_assert("(-118.585" in associated_value.text(), "long associated row value was hidden by the title")
print("property card editor UI ok")
print("property table editor UI ok")
if QApplication.instance() is app:
app.quit()
return 0
+121 -49
View File
@@ -46,6 +46,15 @@ def _edge_specs(info: dict[str, object]) -> list[dict[str, object]]:
return specs
def _edge_display_specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
probe.selected_kind = "edge"
probe.selected_face_id = None
probe.selected_edge_id = 7
specs = probe._property_editor_specs(info, info)
return probe._sort_property_specs_for_display(specs)
def _display_specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
probe.selected_kind = "face"
@@ -105,7 +114,7 @@ def _assert_current_text_contains(
def _assert_spans_value_columns(specs: list[dict[str, object]], key: str) -> None:
spec = _spec(specs, key)
if not bool(spec.get("span_value_columns")):
raise SystemExit(f"{key} should span the value/intent/target/action columns: {spec}")
raise SystemExit(f"{key} should span the value/intent/target columns: {spec}")
def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None:
@@ -195,8 +204,6 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[
seen_non_actionable = False
front_keys: list[str] = []
for spec in display_specs:
if bool(spec.get("pin_top")):
continue
key = str(spec.get("key", ""))
if _is_actionable_edit_spec(spec):
if seen_non_actionable:
@@ -210,6 +217,13 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[
raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}")
def _assert_keys_absent(specs: list[dict[str, object]], forbidden_keys: tuple[str, ...], label: str) -> None:
keys = {str(spec.get("key", "")) for spec in specs}
leaked = [key for key in forbidden_keys if key in keys]
if leaked:
raise SystemExit(f"{label} should not show diagnostic/non-editable rows in the parameter table: {leaked}")
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
legacy_terms = (
"面内尺寸 1/2",
@@ -296,7 +310,7 @@ def _assert_target_change_detection() -> None:
probe = _PropertySpecProbe()
number_spec = {
"label": "",
"label": "内长度",
"current_raw": 100.0,
"current_text": "100",
"value_type": "positive",
@@ -321,19 +335,17 @@ def _assert_target_change_detection() -> None:
def _assert_property_table_column_widths() -> None:
for width in (320, 340, 360, 400, 520):
columns = _property_table_column_widths(width)
if len(columns) != 5:
raise SystemExit(f"property table should have five column widths, got {columns}")
if len(columns) != 4:
raise SystemExit(f"property table should have four column widths, got {columns}")
if sum(columns) != width:
raise SystemExit(f"property table widths should fill viewport {width}, got {columns} sum={sum(columns)}")
label_width, current_width, scope_width, target_width, action_width = columns
if current_width < label_width + 40:
raise SystemExit(f"current value column should be wider than parameter-name column at {width}: {columns}")
if action_width < 70:
raise SystemExit(f"operation column should keep row buttons visible at {width}: {columns}")
label_width, current_width, scope_width, target_width = columns
if current_width < 96:
raise SystemExit(f"current value column should stay readable at {width}: {columns}")
if target_width < 56:
raise SystemExit(f"target value column should stay usable at {width}: {columns}")
if scope_width > label_width + 8:
raise SystemExit(f"modeling-intent column should not take space from current values at {width}: {columns}")
if scope_width < 44:
raise SystemExit(f"modeling-intent column should stay usable at {width}: {columns}")
def _assert_holed_plane_local_scopes_disabled() -> None:
@@ -355,7 +367,7 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
"local_face_deform_blocker": "has inner boundary",
}
)
for key in ("area", "local_face_width", "local_face_height", "face_center_position"):
for key in ("local_face_width", "local_face_height", "face_center_position"):
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face")
@@ -429,26 +441,44 @@ def main() -> int:
_assert_current_text_contains(
plane_specs,
"cad_modeling_form",
("柔性建模", "拉伸切除", "偏移变换"),
("柔性建模", "拉伸切除", "偏移"),
"plane Face",
)
_assert_hint_fragments(
_spec(plane_specs, "cad_modeling_form").get("disabled_tip"),
("CAD 语义判断", "这东西像什么建模对象"),
"CAD modeling form tooltip",
)
_assert_label(plane_specs, "cad_recommended_operation", "推荐操作")
_assert_spans_value_columns(plane_specs, "cad_recommended_operation")
_assert_current_text_contains(
plane_specs,
"cad_recommended_operation",
("优先改偏移变换", "拉伸/切除"),
("优先改偏移", "拉伸/切除"),
"plane Face",
)
_assert_hint_fragments(
_spec(plane_specs, "cad_recommended_operation").get("disabled_tip"),
("相对安全的改法建议", "建议你怎么改"),
"recommended operation tooltip",
)
plane_display_specs = _display_specs(plane_info)
if str(plane_display_specs[0].get("key", "")) != "cad_modeling_form":
raise SystemExit(f"plane Face should show CAD modeling form first: {plane_display_specs[0]}")
if str(plane_display_specs[1].get("key", "")) != "cad_recommended_operation":
raise SystemExit(f"plane Face should show recommended operation second: {plane_display_specs[1]}")
if any(str(spec.get("key", "")) == "area" for spec in plane_display_specs):
raise SystemExit("Face property table should keep area in diagnostics, not in the parameter table")
_assert_keys_absent(
plane_display_specs,
(
"cad_modeling_form",
"cad_recommended_operation",
"face_first_level_topology",
"face_edit_semantics",
"feature_context_note",
),
"plane Face display specs",
)
_assert_contains(
plane_keys,
{
"area",
"local_face_width",
"local_face_height",
"face_center_position",
@@ -456,13 +486,14 @@ def main() -> int:
},
"plane Face",
)
_assert_label(plane_specs, "local_face_width", "U向尺寸")
_assert_label(plane_specs, "local_face_height", "V向尺寸")
_assert_label(plane_specs, "face_target_normal_position", "偏移变换")
if "area" in plane_keys:
raise SystemExit("plane Face should not expose area as a modifiable parameter")
_assert_label(plane_specs, "local_face_width", "面内长度")
_assert_label(plane_specs, "local_face_height", "面内宽度")
_assert_label(plane_specs, "face_target_normal_position", "偏移")
_assert_actionable_rows_first(
plane_info,
(
"area",
"local_face_width",
"local_face_height",
"face_center_position",
@@ -476,22 +507,18 @@ def main() -> int:
plane_feature_specs, _used = plane_feature_probe._editable_property_specs(plane_info)
plane_feature_rows = plane_feature_probe._feature_property_specs(plane_feature_specs, plane_info)
plane_feature_keys = {str(spec.get("key", "")) for spec in plane_feature_rows}
topology_spec = _spec(plane_feature_rows, "face_first_level_topology")
_assert_spans_value_columns(plane_feature_rows, "face_first_level_topology")
topology_text = str(topology_spec.get("current_text") or "")
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
if fragment not in topology_text:
raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}")
_assert_label(plane_feature_rows, "face_edit_semantics", "建模意图")
_assert_spans_value_columns(plane_feature_rows, "face_edit_semantics")
_assert_label(plane_feature_rows, "cad_modeling_form", "建模形式")
_assert_label(plane_feature_rows, "cad_recommended_operation", "推荐操作")
if str(plane_feature_rows[0].get("key", "")) != "cad_modeling_form":
raise SystemExit(f"feature mode should keep CAD modeling form first: {plane_feature_rows[0]}")
if str(plane_feature_rows[1].get("key", "")) != "cad_recommended_operation":
raise SystemExit(f"feature mode should keep recommended operation second: {plane_feature_rows[1]}")
_assert_keys_absent(
plane_feature_rows,
(
"cad_modeling_form",
"cad_recommended_operation",
"face_first_level_topology",
"face_edit_semantics",
"feature_context_note",
),
"plane feature mode display specs",
)
expected_plane_feature_keys = {
"area",
"local_face_width",
"local_face_height",
"face_center_position",
@@ -506,7 +533,6 @@ def main() -> int:
)
if "no_editable_feature_dimensions" in plane_feature_keys:
raise SystemExit("plane feature mode should not fall back to no editable dimensions")
_assert_hard_range(plane_specs, "area", 0.25, 2500.0)
_assert_hard_range(plane_specs, "local_face_width", 0.5, 50.0)
_assert_hard_range(plane_specs, "local_face_height", 0.5, 50.0)
_assert_hard_range(plane_specs, "face_target_normal_position", -70.7106781185, 70.7106781185)
@@ -514,8 +540,6 @@ def main() -> int:
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.49", True, "Face width below hard range")
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "0.5", False, "Face width lower boundary")
_assert_validation_error(probe, _spec(plane_specs, "local_face_width"), "50.1", True, "Face width above hard range")
_assert_validation_error(probe, _spec(plane_specs, "area"), "0.2", True, "Face area below hard range")
_assert_validation_error(probe, _spec(plane_specs, "area"), "2500", False, "Face area upper boundary")
_assert_validation_error(
probe,
_spec(plane_specs, "face_target_normal_position"),
@@ -543,7 +567,7 @@ def main() -> int:
)
if "push_pull_distance" in plane_keys:
raise SystemExit("plane Face should not expose a separate push_pull_distance row")
for key in ("area", "local_face_width", "local_face_height"):
for key in ("local_face_width", "local_face_height"):
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
@@ -664,7 +688,18 @@ def main() -> int:
cylinder_feature_probe = _PropertySpecProbe()
cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info)
cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info)
_spec(cylinder_feature_rows, "cylindrical_feature_first_level_topology")
_assert_keys_absent(
cylinder_feature_rows,
(
"cad_modeling_form",
"cad_recommended_operation",
"cylindrical_feature_first_level_topology",
"hole_edit_semantics",
"slot_edit_semantics",
"feature_context_note",
),
"cylindrical feature mode display specs",
)
generic_cylinder_specs = _specs(
{
@@ -820,6 +855,8 @@ def main() -> int:
"radius": 1.0,
"angular_span": 1.5707963267948966,
"existing_fillet_radius": 1.0,
"existing_fillet_arc_length_estimate": 1.5707963267948966,
"existing_fillet_angular_span": 1.5707963267948966,
"feature_existing_fillet_support_face_ids": (1, 2),
"area": 3.0,
"area_center": (0.0, 0.0, 0.0),
@@ -830,7 +867,11 @@ def main() -> int:
fillet_specs = _specs(fillet_info)
fillet_keys = {str(spec.get("key", "")) for spec in fillet_specs}
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature")
_assert_contains(
fillet_keys,
{"existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"},
"existing fillet feature",
)
_assert_current_text_contains(
fillet_specs,
"cad_modeling_form",
@@ -914,9 +955,7 @@ def main() -> int:
_assert_label(specs, "cone_reference_diameter", "参考直径")
_assert_label(specs, "cone_semi_angle_degrees", "圆锥半角")
display_specs = _display_specs(info)
surface_spec = _spec(display_specs, "surface")
if surface_spec.get("current_text") != "圆锥面 / 拔模面":
raise SystemExit(f"cone surface should be displayed in user-facing Chinese, got {surface_spec}")
_assert_keys_absent(display_specs, ("surface",), "cone feature display specs")
edge_specs = _edge_specs(
{
@@ -963,6 +1002,39 @@ def main() -> int:
{"length", "edge_first_level_topology"},
"line Edge",
)
_assert_contains(
{str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "line", "length": 10.0})},
{"length", "edge_length_anchor_mode"},
"line Edge display",
)
complex_edge_specs = _edge_specs({"curve": "b-spline curve", "length": 12.0})
complex_length_spec = _spec(complex_edge_specs, "length")
if complex_length_spec.get("enabled"):
raise SystemExit(f"complex curve Edge length should not be enabled in the property specs: {complex_length_spec}")
complex_length_tip = str(complex_length_spec.get("disabled_tip") or "")
if "复杂曲线Edge暂未实现" not in complex_length_tip:
raise SystemExit(f"complex curve Edge length tip should explain unsupported editing: {complex_length_tip}")
complex_edge_keys = {str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "b-spline curve", "length": 12.0})}
if "length" in complex_edge_keys or "edge_length_anchor_mode" in complex_edge_keys:
raise SystemExit(f"complex curve Edge display should hide length editing rows: {complex_edge_keys}")
ellipse_display_specs = _edge_display_specs(
{
"curve": "ellipse",
"length": 23.0,
"major_radius": 5.0,
"minor_radius": 2.0,
}
)
ellipse_display_keys = {str(spec.get("key", "")) for spec in ellipse_display_specs}
_assert_contains(
ellipse_display_keys,
{"ellipse_edge_major_radius", "ellipse_edge_minor_radius"},
"ellipse Edge display",
)
if "length" in ellipse_display_keys or "edge_length_anchor_mode" in ellipse_display_keys:
raise SystemExit(f"ellipse Edge display should prefer explicit axis radii over generic length: {ellipse_display_keys}")
low_recognition_specs = _specs(
{
+3
View File
@@ -163,6 +163,8 @@ def main() -> int:
"Face 594" in feature_title and "拓扑 637" in feature_title,
f"feature title should show logical and topological IDs: {feature_title}",
)
_assert("候选" not in feature_title, f"feature title should use a user-facing label, got: {feature_title}")
_assert("可拉伸/切除平面" in feature_title, f"feature title should keep the editable feature name: {feature_title}")
face_probe = _probe("face", 637, info)
_assert(
@@ -174,6 +176,7 @@ def main() -> int:
"Face 594" in face_title and "拓扑 637" in face_title,
f"Face title should show logical and topological IDs: {face_title}",
)
_assert("候选" not in face_title, f"Face title should not expose candidate wording: {face_title}")
fallback_probe = _probe("feature", 637, {"face_region_logical_id": 594})
_assert(fallback_probe._selected_id_text() == "face 594", "logical Face ID fallback should be copied")