feat: 完善 Face 参数化编辑和隔离执行

This commit is contained in:
2026-07-31 16:36:05 +08:00
parent 27e4f7236c
commit bb44e3920d
31 changed files with 5428 additions and 252 deletions
+541
View File
@@ -0,0 +1,541 @@
from __future__ import annotations
from pathlib import Path
import re
import sys
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.window_state import WindowStateMixin
class _PropertySpecProbe(WindowStateMixin):
def __init__(self) -> None:
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = 1
self.selected_edge_id = None
self.selected_kind = "feature"
self.selected_part_id = 1
self.selected_solid_id = 1
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
def _spec_keys(info: dict[str, object]) -> set[str]:
return {str(spec.get("key", "")) for spec in _specs(info)}
def _specs(info: dict[str, object]) -> list[dict[str, object]]:
probe = _PropertySpecProbe()
specs, _used = probe._editable_property_specs(info)
return specs
def _scope_mode(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
for spec in specs:
if spec.get("key") != key:
continue
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or mode not in modes:
raise SystemExit(f"{key} has no scope mode {mode}")
selected = modes[mode]
if not isinstance(selected, dict):
raise SystemExit(f"{key} scope mode {mode} is invalid")
return selected
raise SystemExit(f"{key} spec was not found")
def _scoped_effective_spec(specs: list[dict[str, object]], key: str, mode: str) -> dict[str, object]:
spec = dict(_spec(specs, key))
base_label = str(spec.get("label", ""))
spec.update(_scope_mode(specs, key, mode))
spec["label"] = base_label
return spec
def _spec(specs: list[dict[str, object]], key: str) -> dict[str, object]:
for spec in specs:
if spec.get("key") == key:
return spec
raise SystemExit(f"{key} spec was not found")
def _assert_label(specs: list[dict[str, object]], key: str, label: str) -> None:
spec = _spec(specs, key)
actual = str(spec.get("label") or "")
if actual != label:
raise SystemExit(f"{key} label should be {label!r}, got {actual!r}")
def _assert_hard_range(specs: list[dict[str, object]], key: str, low: float, high: float) -> None:
spec = _spec(specs, key)
actual_low = spec.get("min_value")
actual_high = spec.get("max_value")
if actual_low is None or actual_high is None:
raise SystemExit(f"{key} should expose a hard target range, got {spec}")
if abs(float(actual_low) - low) > 1e-7 or abs(float(actual_high) - high) > 1e-7:
raise SystemExit(
f"{key} hard range should be {low:g}..{high:g}, "
f"got {float(actual_low):g}..{float(actual_high):g}"
)
def _assert_vector_distance_limit(spec: dict[str, object], high: float, label: str) -> None:
actual = spec.get("max_vector_distance")
reference = spec.get("vector_distance_reference")
if actual is None or reference is None:
raise SystemExit(f"{label} should expose a vector distance limit, got {spec}")
if abs(float(actual) - high) > 1e-7:
raise SystemExit(f"{label} vector distance limit should be {high:g}, got {float(actual):g}")
def _assert_validation_error(
probe: _PropertySpecProbe,
spec: dict[str, object],
text: str,
should_error: bool,
label: str,
) -> None:
error = probe._property_target_validation_error(spec, text)
if should_error and not error:
raise SystemExit(f"{label} should be rejected")
if not should_error and error:
raise SystemExit(f"{label} should be accepted, got {error}")
def _assert_hint_fragments(hint: object, fragments: tuple[str, ...], label: str) -> None:
hint_text = str(hint or "")
missing = [fragment for fragment in fragments if fragment not in hint_text]
if missing:
raise SystemExit(f"{label} range hint missing {missing}: {hint_text}")
def _assert_scoped_hint_fragments(
specs: list[dict[str, object]],
key: str,
mode: str,
fragments: tuple[str, ...],
) -> None:
selected = _scope_mode(specs, key, mode)
_assert_hint_fragments(selected.get("range_hint"), fragments, f"{key}/{mode}")
def _assert_contains(keys: set[str], required: set[str], label: str) -> None:
missing = sorted(required - keys)
if missing:
raise SystemExit(f"{label} edit specs missing: {missing}")
def _assert_no_generic_face_leak(keys: set[str], label: str) -> None:
forbidden = {
"area",
"face_center_position",
"local_face_width",
"local_face_height",
"face_target_normal_position",
"push_pull_distance",
}
leaked = sorted(forbidden & keys)
if leaked:
raise SystemExit(f"{label} leaked generic Face edit specs: {leaked}")
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
hits: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
# Internal keys/action names still use width/height/offset; only user-visible text is checked.
if key in {
"key",
"action",
"target_attr",
"target_attrs",
"target_transform",
"transform_context",
"used",
}:
continue
hits.extend(_collect_legacy_face_terms(child, f"{path}.{key}"))
elif isinstance(value, (list, tuple)):
for index, child in enumerate(value):
hits.extend(_collect_legacy_face_terms(child, f"{path}[{index}]"))
elif isinstance(value, str):
for term in legacy_terms:
if term in value:
hits.append(f"{path}: {term} in {value!r}")
return hits
def _assert_no_legacy_face_terms(specs: list[dict[str, object]]) -> None:
hits = _collect_legacy_face_terms(specs)
if hits:
raise SystemExit("legacy Face UI terms should not appear in property specs: " + "; ".join(hits))
def _assert_no_legacy_face_source_terms() -> None:
files = (
PROJECT_ROOT / "README.md",
PROJECT_ROOT / "step_editor" / "app.py",
PROJECT_ROOT / "step_editor" / "ui_helpers.py",
PROJECT_ROOT / "step_editor" / "window_state.py",
PROJECT_ROOT / "step_editor" / "window_actions.py",
PROJECT_ROOT / "step_editor" / "operations.py",
PROJECT_ROOT / "scripts" / "verify_isolated_face_edit.py",
)
patterns = (
re.compile(r"面内尺寸 1/2"),
re.compile(r"面内尺寸 1"),
re.compile(r"面内尺寸 2"),
re.compile(r"(?<![端平底])面位置"),
re.compile(r"偏移距离"),
)
hits: list[str] = []
for file_path in files:
text = file_path.read_text(encoding="utf-8")
for line_number, line in enumerate(text.splitlines(), start=1):
for pattern in patterns:
if pattern.search(line):
hits.append(f"{file_path.relative_to(PROJECT_ROOT)}:{line_number}: {line.strip()}")
break
if hits:
raise SystemExit("legacy Face UI terms should not appear in user-facing sources: " + "; ".join(hits))
def _assert_target_change_detection() -> None:
probe = _PropertySpecProbe()
number_spec = {
"label": "面积",
"current_raw": 100.0,
"current_text": "100",
"value_type": "positive",
}
if probe._property_target_changed(number_spec, "100"):
raise SystemExit("unchanged numeric Face target was treated as changed")
if not probe._property_target_changed(number_spec, "101"):
raise SystemExit("changed numeric Face target was not detected")
vector_spec = {
"label": "中心",
"current_raw": (5.0, 5.0, 0.0),
"current_text": "5, 5, 0",
"value_type": "vector3",
}
if probe._property_target_changed(vector_spec, "5,5,0"):
raise SystemExit("unchanged vector Face target was treated as changed")
if not probe._property_target_changed(vector_spec, "5,5,1"):
raise SystemExit("changed vector Face target was not detected")
def _assert_holed_plane_local_scopes_disabled() -> None:
specs = _specs(
{
"surface": "plane",
"area": 280.0,
"area_center": (15.0, 10.0, 8.0),
"bbox_center": (15.0, 10.0, 8.0),
"local_face_width": 30.0,
"local_face_height": 20.0,
"plane_origin": (0.0, 0.0, 8.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0),
"boundary_wires": 2,
"inner_boundary_wires": 1,
"has_inner_boundaries": True,
"local_face_deform_ready": False,
"local_face_deform_blocker": "has inner boundary",
}
)
for key in ("area", "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")
disabled_tip = str(local_mode.get("disabled_tip") or "")
if "has inner boundary" not in disabled_tip:
raise SystemExit(f"{key} local disabled tip should explain the blocker: {disabled_tip}")
owning_mode = _scope_mode(specs, key, "owning")
if not bool(owning_mode.get("enabled", False)):
raise SystemExit(f"{key} owning scope should remain available for a holed planar Face")
offset_local = _scope_mode(specs, "face_target_normal_position", "local")
if bool(offset_local.get("enabled", True)):
raise SystemExit("Face plane-offset local scope should be disabled for a holed planar Face")
offset_tip = str(offset_local.get("disabled_tip") or "")
if "has inner boundary" not in offset_tip:
raise SystemExit(f"Face plane-offset local disabled tip should explain the blocker: {offset_tip}")
offset_push_pull = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(offset_push_pull.get("enabled", False)):
raise SystemExit("Face plane-offset push/pull scope should remain available for a holed planar Face")
semantics = _spec(specs, "face_edit_semantics")
if "不能只改当前面" not in str(semantics.get("current_text") or ""):
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
if "has inner boundary" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the blocker: {semantics}")
def main() -> int:
plane_info = {
"surface": "plane",
"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),
}
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_contains(
plane_keys,
{
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
},
"plane Face",
)
_assert_label(plane_specs, "local_face_width", "面宽")
_assert_label(plane_specs, "local_face_height", "面高")
_assert_label(plane_specs, "face_target_normal_position", "面偏移")
_assert_no_legacy_face_terms(plane_specs)
_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)
probe = _PropertySpecProbe()
_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"),
"-71",
True,
"Face offset below hard range",
)
center_local = _scoped_effective_spec(plane_specs, "face_center_position", "local")
center_owning = _scoped_effective_spec(plane_specs, "face_center_position", "owning")
_assert_vector_distance_limit(center_local, 70.7106781185, "Face center local")
_assert_vector_distance_limit(center_owning, 70.7106781185, "Face center owning")
_assert_validation_error(
probe,
center_local,
"75.7106781185, 5, 0",
False,
"Face center target at distance boundary",
)
_assert_validation_error(
probe,
center_local,
"76, 5, 0",
True,
"Face center target beyond distance limit",
)
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 mode in ("local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
key,
mode,
("5%", "5 倍", "会被阻止"),
)
for mode in ("push_pull", "local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
mode,
("会被阻止",),
)
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_center_position",
mode,
("5 倍", "会被阻止"),
)
shell_specs = _specs(
{
**plane_info,
"shell_region_status": "candidate",
"shell_thickness_estimate": 2.0,
"shell_current_thickness": 2.0,
"shell_signed_thickness": 2.0,
"shell_opposite_face_id": 2,
"shell_overlap_ratio_estimate": 1.0,
}
)
_assert_hard_range(shell_specs, "shell_thickness_estimate", 0.1, 10.0)
_assert_validation_error(
probe,
_spec(shell_specs, "shell_thickness_estimate"),
"0.05",
True,
"thin wall thickness below hard range",
)
for mode in ("local", "owning"):
_assert_scoped_hint_fragments(
shell_specs,
"shell_thickness_estimate",
mode,
("5%", "5 倍", "会被阻止"),
)
cylinder_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
"feature_bottom_face_ids": (),
}
)
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
slot_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 3.141592653589793,
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
}
)
_assert_no_generic_face_leak(slot_keys, "slot/half-hole feature")
_assert_contains(
slot_keys,
{"slot_chord_width_estimate", "slot_sagitta_depth_estimate", "slot_arc_length_estimate"},
"slot/half-hole feature",
)
boss_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "boss/outer-round candidate",
"diameter": 6.0,
"radius": 3.0,
"angular_span": 6.283185307179586,
"height_estimate": 5.0,
"same_domain_height_estimate": 5.0,
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
"axis_center": (0.0, 0.0, 5.0),
"feature_start_end_face_ids": (1,),
}
)
_assert_no_generic_face_leak(boss_keys, "boss feature")
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
fillet_keys = _spec_keys(
{
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"diameter": 2.0,
"radius": 1.0,
"angular_span": 1.5707963267948966,
"existing_fillet_radius": 1.0,
"feature_existing_fillet_support_face_ids": (1, 2),
"area": 3.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
}
)
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature")
analytic_cases = (
(
"cone feature",
{
"surface": "cone",
"reference_radius": 4.0,
"reference_diameter": 8.0,
"semi_angle_degrees": 12.0,
"area": 50.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_point": (0.0, 0.0, 0.0),
},
{"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"},
),
(
"sphere feature",
{
"surface": "sphere",
"radius": 5.0,
"diameter": 10.0,
"area": 100.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"center": (0.0, 0.0, 0.0),
},
{"sphere_radius", "sphere_diameter"},
),
(
"torus feature",
{
"surface": "torus",
"major_radius": 8.0,
"minor_radius": 2.0,
"major_diameter": 16.0,
"minor_diameter": 4.0,
"area": 100.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
"center": (0.0, 0.0, 0.0),
},
{"torus_major_radius", "torus_minor_radius"},
),
)
for label, info, required in analytic_cases:
keys = _spec_keys(info)
_assert_no_generic_face_leak(keys, label)
_assert_contains(keys, required, label)
_assert_target_change_detection()
_assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms()
print("property editor specs ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())