feat: 完善 STEP/B-Rep 一级关系参数化编辑
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user