2026-07-31 16:36:05 +08:00
|
|
|
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))
|
|
|
|
|
|
2026-08-06 18:15:14 +08:00
|
|
|
from step_editor.window_state import WindowStateMixin, _property_table_column_widths
|
2026-08-05 15:08:16 +08:00
|
|
|
from step_editor.ui_helpers import INFO_LABELS, _format_value
|
2026-07-31 16:36:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
def _edge_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, _used = probe._editable_property_specs(info)
|
|
|
|
|
return specs
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
def _display_specs(info: dict[str, object]) -> list[dict[str, object]]:
|
|
|
|
|
probe = _PropertySpecProbe()
|
|
|
|
|
probe.selected_kind = "face"
|
|
|
|
|
specs = probe._property_editor_specs(info, info)
|
|
|
|
|
return probe._sort_property_specs_for_display(specs)
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
def _assert_current_text_contains(
|
|
|
|
|
specs: list[dict[str, object]],
|
|
|
|
|
key: str,
|
|
|
|
|
fragments: tuple[str, ...],
|
|
|
|
|
label: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
spec = _spec(specs, key)
|
|
|
|
|
text = str(spec.get("current_text") or "")
|
|
|
|
|
missing = [fragment for fragment in fragments if fragment not in text]
|
|
|
|
|
if missing:
|
|
|
|
|
raise SystemExit(f"{label} {key} text missing {missing}: {text!r}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:15:14 +08:00
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
def _is_actionable_edit_spec(spec: dict[str, object]) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
bool(spec.get("editable"))
|
|
|
|
|
and bool(spec.get("enabled"))
|
|
|
|
|
and bool(spec.get("action"))
|
|
|
|
|
and str(spec.get("value_type", "number")) != "command"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[str, ...], label: str) -> None:
|
|
|
|
|
display_specs = _display_specs({**info, "readonly_probe_for_ordering": "readonly"})
|
|
|
|
|
seen_non_actionable = False
|
|
|
|
|
front_keys: list[str] = []
|
|
|
|
|
for spec in display_specs:
|
2026-08-05 18:06:15 +08:00
|
|
|
if bool(spec.get("pin_top")):
|
|
|
|
|
continue
|
2026-08-04 09:35:39 +08:00
|
|
|
key = str(spec.get("key", ""))
|
|
|
|
|
if _is_actionable_edit_spec(spec):
|
|
|
|
|
if seen_non_actionable:
|
|
|
|
|
raise SystemExit(f"{label}: actionable row {key!r} appeared after a read-only/non-action row")
|
|
|
|
|
front_keys.append(key)
|
|
|
|
|
else:
|
|
|
|
|
seen_non_actionable = True
|
|
|
|
|
|
|
|
|
|
missing = [key for key in expected_keys if key not in front_keys]
|
|
|
|
|
if missing:
|
|
|
|
|
raise SystemExit(f"{label}: expected editable rows at the front are missing: {missing}; front={front_keys}")
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
|
2026-08-05 15:08:16 +08:00
|
|
|
legacy_terms = (
|
|
|
|
|
"面内尺寸 1/2",
|
|
|
|
|
"面内尺寸 1",
|
|
|
|
|
"面内尺寸 2",
|
|
|
|
|
"面位置",
|
|
|
|
|
"偏移距离",
|
|
|
|
|
"面偏移",
|
|
|
|
|
"面宽",
|
|
|
|
|
"面高",
|
|
|
|
|
"推拉当前面",
|
|
|
|
|
"只改当前面",
|
|
|
|
|
"调整整个特征",
|
|
|
|
|
"移动整个特征",
|
|
|
|
|
)
|
2026-07-31 16:36:05 +08:00
|
|
|
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"偏移距离"),
|
2026-08-05 15:08:16 +08:00
|
|
|
re.compile(r"面偏移"),
|
|
|
|
|
re.compile(r"面宽"),
|
|
|
|
|
re.compile(r"(?<!侧)面高(?!度)"),
|
|
|
|
|
re.compile(r"推拉当前面"),
|
|
|
|
|
re.compile(r"只改当前面"),
|
|
|
|
|
re.compile(r"调整整个特征"),
|
|
|
|
|
re.compile(r"移动整个特征"),
|
2026-07-31 16:36:05 +08:00
|
|
|
)
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 18:15:14 +08:00
|
|
|
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 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}")
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
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")
|
2026-08-05 15:08:16 +08:00
|
|
|
if "不能局部重建" not in str(semantics.get("current_text") or ""):
|
2026-07-31 16:36:05 +08:00
|
|
|
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:
|
2026-08-05 15:08:16 +08:00
|
|
|
if INFO_LABELS.get("face_id") != "当前拓扑 Face ID":
|
|
|
|
|
raise SystemExit("raw face_id label should make clear that it is the current topological Face ID")
|
2026-08-06 18:15:14 +08:00
|
|
|
for key in (
|
|
|
|
|
"selection_title",
|
|
|
|
|
"selection_display_id",
|
|
|
|
|
"selection_topological_face_id",
|
|
|
|
|
"feature_detection_level",
|
|
|
|
|
"associated_feature_count",
|
|
|
|
|
"associated_feature_face_ids",
|
|
|
|
|
"feature_context_note",
|
|
|
|
|
):
|
2026-08-05 15:08:16 +08:00
|
|
|
if key not in INFO_LABELS:
|
|
|
|
|
raise SystemExit(f"{key} should have a user-facing label")
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
relation_depths_text = _format_value(("second-level", "third-level", "deeper"))
|
|
|
|
|
if "second-level" not in relation_depths_text or "deeper" not in relation_depths_text:
|
|
|
|
|
raise SystemExit(f"relation-depth tuple should render as text: {relation_depths_text!r}")
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
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),
|
2026-08-04 09:35:39 +08:00
|
|
|
"topology_relation_depth": 1,
|
|
|
|
|
"topology_relation_status": "ready",
|
|
|
|
|
"same_domain_face_count": 1,
|
|
|
|
|
"first_level_boundary_edge_count": 4,
|
|
|
|
|
"first_level_boundary_vertex_count": 4,
|
|
|
|
|
"first_level_adjacent_face_count": 4,
|
|
|
|
|
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
|
|
|
|
|
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
2026-07-31 16:36:05 +08:00
|
|
|
}
|
|
|
|
|
plane_specs = _specs(plane_info)
|
|
|
|
|
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_spans_value_columns(plane_specs, "cad_modeling_form")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
plane_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("柔性建模", "拉伸切除", "偏移变换"),
|
|
|
|
|
"plane Face",
|
|
|
|
|
)
|
|
|
|
|
_assert_label(plane_specs, "cad_recommended_operation", "推荐操作")
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_spans_value_columns(plane_specs, "cad_recommended_operation")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
plane_specs,
|
|
|
|
|
"cad_recommended_operation",
|
|
|
|
|
("优先改偏移变换", "拉伸/切除"),
|
|
|
|
|
"plane Face",
|
|
|
|
|
)
|
|
|
|
|
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]}")
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_contains(
|
|
|
|
|
plane_keys,
|
|
|
|
|
{
|
|
|
|
|
"area",
|
|
|
|
|
"local_face_width",
|
|
|
|
|
"local_face_height",
|
|
|
|
|
"face_center_position",
|
|
|
|
|
"face_target_normal_position",
|
|
|
|
|
},
|
|
|
|
|
"plane Face",
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
_assert_label(plane_specs, "local_face_width", "U向尺寸")
|
|
|
|
|
_assert_label(plane_specs, "local_face_height", "V向尺寸")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_label(plane_specs, "face_target_normal_position", "偏移变换")
|
2026-08-04 09:35:39 +08:00
|
|
|
_assert_actionable_rows_first(
|
|
|
|
|
plane_info,
|
|
|
|
|
(
|
|
|
|
|
"area",
|
|
|
|
|
"local_face_width",
|
|
|
|
|
"local_face_height",
|
|
|
|
|
"face_center_position",
|
|
|
|
|
"face_target_normal_position",
|
|
|
|
|
),
|
|
|
|
|
"plane Face display order",
|
|
|
|
|
)
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_no_legacy_face_terms(plane_specs)
|
2026-08-04 09:35:39 +08:00
|
|
|
plane_feature_probe = _PropertySpecProbe()
|
|
|
|
|
plane_feature_probe.selected_kind = "feature"
|
|
|
|
|
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")
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_spans_value_columns(plane_feature_rows, "face_first_level_topology")
|
2026-08-04 09:35:39 +08:00
|
|
|
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}")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_label(plane_feature_rows, "face_edit_semantics", "建模意图")
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_spans_value_columns(plane_feature_rows, "face_edit_semantics")
|
2026-08-05 18:06:15 +08:00
|
|
|
_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]}")
|
2026-08-05 15:08:16 +08:00
|
|
|
expected_plane_feature_keys = {
|
|
|
|
|
"area",
|
|
|
|
|
"local_face_width",
|
|
|
|
|
"local_face_height",
|
|
|
|
|
"face_center_position",
|
|
|
|
|
"face_target_normal_position",
|
|
|
|
|
}
|
|
|
|
|
missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys
|
|
|
|
|
if missing_plane_feature_keys:
|
2026-08-04 09:35:39 +08:00
|
|
|
raise SystemExit(
|
2026-08-05 15:08:16 +08:00
|
|
|
"plain plane feature mode should expose the same first-level editable Face parameters; "
|
|
|
|
|
f"missing {sorted(missing_plane_feature_keys)}, "
|
2026-08-04 09:35:39 +08:00
|
|
|
f"got {sorted(plane_feature_keys)}"
|
|
|
|
|
)
|
|
|
|
|
if "no_editable_feature_dimensions" in plane_feature_keys:
|
|
|
|
|
raise SystemExit("plane feature mode should not fall back to no editable dimensions")
|
2026-07-31 16:36:05 +08:00
|
|
|
_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 倍", "会被阻止"),
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
shell_local = _scope_mode(shell_specs, "shell_thickness_estimate", "local")
|
|
|
|
|
if shell_local.get("label") != "拉伸/切除":
|
|
|
|
|
raise SystemExit(f"shell thickness local scope should expose an edit semantic, got {shell_local}")
|
|
|
|
|
shell_owning = _scope_mode(shell_specs, "shell_thickness_estimate", "owning")
|
|
|
|
|
if shell_owning.get("label") != "缩放特征":
|
|
|
|
|
raise SystemExit(f"shell thickness owning scope should expose an edit semantic, got {shell_owning}")
|
2026-07-31 16:36:05 +08:00
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
cylinder_info = {
|
|
|
|
|
"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": (),
|
|
|
|
|
}
|
|
|
|
|
cylinder_specs = _specs(cylinder_info)
|
|
|
|
|
cylinder_keys = {str(spec.get("key", "")) for spec in cylinder_specs}
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
|
|
|
|
|
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
cylinder_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("工程特征", "孔", "重切"),
|
|
|
|
|
"cylindrical hole feature",
|
|
|
|
|
)
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
cylinder_specs,
|
|
|
|
|
"cad_recommended_operation",
|
|
|
|
|
("孔径", "盲孔", "轴心"),
|
|
|
|
|
"cylindrical hole feature",
|
|
|
|
|
)
|
2026-07-31 16:36:05 +08:00
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
cylinder_topology_info = {
|
|
|
|
|
"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),
|
|
|
|
|
"topology_relation_depth": 1,
|
|
|
|
|
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
|
|
|
|
|
"topology_relation_status": "ready",
|
|
|
|
|
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
|
|
|
|
"cylindrical_feature_side_face_count": 1,
|
|
|
|
|
"cylindrical_feature_boundary_edge_count": 2,
|
|
|
|
|
"cylindrical_feature_boundary_vertex_count": 4,
|
|
|
|
|
"cylindrical_feature_adjacent_face_count": 2,
|
|
|
|
|
"cylindrical_feature_end_face_count": 2,
|
|
|
|
|
"cylindrical_feature_opening_face_count": 2,
|
|
|
|
|
"first_level_topology_note": "Cylindrical side Faces=1, boundary Edges=2, boundary Vertices=4, direct adjacent Faces=2.",
|
|
|
|
|
}
|
|
|
|
|
cylinder_topology_specs = _specs(cylinder_topology_info)
|
|
|
|
|
cylinder_topology_spec = _spec(cylinder_topology_specs, "cylindrical_feature_first_level_topology")
|
|
|
|
|
cylinder_topology_text = str(cylinder_topology_spec.get("current_text") or "")
|
|
|
|
|
for fragment in ("侧壁 Face 1 个", "边界 Edge 2 条", "共享边相邻 Face 2 个"):
|
|
|
|
|
if fragment not in cylinder_topology_text:
|
|
|
|
|
raise SystemExit(f"cylindrical feature topology row should explain first-level counts: {cylinder_topology_spec}")
|
|
|
|
|
if "二级、三级" not in str(cylinder_topology_spec.get("disabled_tip") or ""):
|
|
|
|
|
raise SystemExit(f"cylindrical feature topology tip should document ignored deeper topology: {cylinder_topology_spec}")
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
generic_cylinder_specs = _specs(
|
|
|
|
|
{
|
|
|
|
|
"surface": "cylinder",
|
|
|
|
|
"feature_guess": "",
|
|
|
|
|
"diameter": 6.0,
|
|
|
|
|
"radius": 3.0,
|
|
|
|
|
"angular_span": 6.283185307179586,
|
|
|
|
|
"height_estimate": 11.0,
|
|
|
|
|
"same_domain_height_estimate": 11.0,
|
|
|
|
|
"area": 207.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"axis": (0.0, 0.0, 1.0),
|
|
|
|
|
"axis_point": (0.0, 0.0, 0.0),
|
|
|
|
|
"axis_center": (0.0, 0.0, 5.5),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
generic_height = _spec(generic_cylinder_specs, "cylinder_height")
|
|
|
|
|
if generic_height.get("scope_default") != "owning":
|
|
|
|
|
raise SystemExit(f"generic cylinder height should default to owning-axis resize: {generic_height}")
|
|
|
|
|
if generic_height.get("action") != "resize_cylindrical_height_owning_scale":
|
|
|
|
|
raise SystemExit(f"generic cylinder height should use owning-axis action by default: {generic_height}")
|
2026-08-05 15:08:16 +08:00
|
|
|
if generic_height.get("scope_text") != "缩放特征":
|
2026-08-04 18:15:29 +08:00
|
|
|
raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}")
|
|
|
|
|
|
2026-08-05 15:08:16 +08:00
|
|
|
split_full_cylinder_specs = _specs(
|
|
|
|
|
{
|
|
|
|
|
"surface": "cylinder",
|
|
|
|
|
"feature_guess": "",
|
|
|
|
|
"diameter": 6.0,
|
|
|
|
|
"radius": 3.0,
|
|
|
|
|
"angular_span": 3.141592653589793,
|
|
|
|
|
"same_domain_angular_span": 6.283185307179586,
|
|
|
|
|
"is_full_cylinder": True,
|
|
|
|
|
"height_estimate": 11.0,
|
|
|
|
|
"same_domain_height_estimate": 11.0,
|
|
|
|
|
"area": 207.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"axis": (0.0, 0.0, 1.0),
|
|
|
|
|
"axis_point": (0.0, 0.0, 0.0),
|
|
|
|
|
"axis_center": (0.0, 0.0, 5.5),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
split_full_height_local = _scope_mode(split_full_cylinder_specs, "cylinder_height", "local")
|
|
|
|
|
if not bool(split_full_height_local.get("enabled")):
|
|
|
|
|
raise SystemExit(
|
|
|
|
|
"split same-domain full cylinder should keep local height push/pull enabled: "
|
|
|
|
|
f"{split_full_height_local}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
split_full_hole_keys = _spec_keys(
|
|
|
|
|
{
|
|
|
|
|
"surface": "cylinder",
|
|
|
|
|
"feature_guess": "hole/groove candidate",
|
|
|
|
|
"diameter": 6.0,
|
|
|
|
|
"radius": 3.0,
|
|
|
|
|
"angular_span": 3.141592653589793,
|
|
|
|
|
"same_domain_angular_span": 6.283185307179586,
|
|
|
|
|
"is_full_cylinder": True,
|
|
|
|
|
"height_estimate": 11.0,
|
|
|
|
|
"same_domain_height_estimate": 11.0,
|
|
|
|
|
"area": 188.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"axis": (0.0, 0.0, 1.0),
|
|
|
|
|
"axis_point": (0.0, 0.0, 0.0),
|
|
|
|
|
"axis_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"slot_chord_width_estimate": 6.0,
|
|
|
|
|
"slot_sagitta_depth_estimate": 3.0,
|
|
|
|
|
"slot_arc_length_estimate": 9.42477796076938,
|
|
|
|
|
"feature_bottom_face_ids": (),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole")
|
|
|
|
|
if "slot_chord_width_estimate" in split_full_hole_keys:
|
|
|
|
|
raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}")
|
|
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
slot_info = {
|
|
|
|
|
"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),
|
|
|
|
|
}
|
|
|
|
|
slot_specs = _specs(slot_info)
|
|
|
|
|
slot_keys = {str(spec.get("key", "")) for spec in slot_specs}
|
2026-07-31 16:36:05 +08:00
|
|
|
_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",
|
|
|
|
|
)
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
slot_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("工程特征", "槽", "局部重建"),
|
|
|
|
|
"slot/half-hole feature",
|
|
|
|
|
)
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
slot_specs,
|
|
|
|
|
"cad_recommended_operation",
|
|
|
|
|
("槽宽", "槽深", "轴心"),
|
|
|
|
|
"slot/half-hole feature",
|
|
|
|
|
)
|
2026-07-31 16:36:05 +08:00
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
boss_info = {
|
|
|
|
|
"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,),
|
|
|
|
|
}
|
|
|
|
|
boss_specs = _specs(boss_info)
|
|
|
|
|
boss_keys = {str(spec.get("key", "")) for spec in boss_specs}
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_no_generic_face_leak(boss_keys, "boss feature")
|
|
|
|
|
_assert_contains(boss_keys, {"boss_diameter", "boss_radius", "boss_height"}, "boss feature")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
boss_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("工程特征", "凸台", "局部重建"),
|
|
|
|
|
"boss feature",
|
|
|
|
|
)
|
2026-08-04 18:15:29 +08:00
|
|
|
boss_height = _spec(boss_specs, "boss_height")
|
|
|
|
|
if boss_height.get("scope_default") != "owning":
|
|
|
|
|
raise SystemExit(f"boss height should default to owning-axis resize: {boss_height}")
|
|
|
|
|
if boss_height.get("action") != "resize_cylindrical_height_owning_scale":
|
|
|
|
|
raise SystemExit(f"boss height should use owning-axis action by default: {boss_height}")
|
2026-07-31 16:36:05 +08:00
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
fillet_info = {
|
|
|
|
|
"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),
|
|
|
|
|
}
|
|
|
|
|
fillet_specs = _specs(fillet_info)
|
|
|
|
|
fillet_keys = {str(spec.get("key", "")) for spec in fillet_specs}
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_no_generic_face_leak(fillet_keys, "existing fillet feature")
|
|
|
|
|
_assert_contains(fillet_keys, {"existing_fillet_radius_estimate"}, "existing fillet feature")
|
2026-08-05 18:06:15 +08:00
|
|
|
_assert_current_text_contains(
|
|
|
|
|
fillet_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("工程特征", "倒圆角", "重新倒圆"),
|
|
|
|
|
"existing fillet feature",
|
|
|
|
|
)
|
2026-07-31 16:36:05 +08:00
|
|
|
|
|
|
|
|
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:
|
2026-08-04 09:35:39 +08:00
|
|
|
specs = _specs(info)
|
|
|
|
|
keys = {str(spec.get("key", "")) for spec in specs}
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_no_generic_face_leak(keys, label)
|
|
|
|
|
_assert_contains(keys, required, label)
|
2026-08-05 18:06:15 +08:00
|
|
|
if label == "cone feature":
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("工程特征", "拔模", "锥孔"),
|
|
|
|
|
label,
|
|
|
|
|
)
|
|
|
|
|
elif label == "sphere feature":
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("解析曲面", "缩放特征", "球面"),
|
|
|
|
|
label,
|
|
|
|
|
)
|
|
|
|
|
elif label == "torus feature":
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("解析曲面", "缩放特征", "环面"),
|
|
|
|
|
label,
|
|
|
|
|
)
|
2026-08-04 09:35:39 +08:00
|
|
|
if label == "cone feature":
|
|
|
|
|
_assert_label(specs, "cone_reference_radius", "参考半径")
|
|
|
|
|
_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}")
|
2026-07-31 16:36:05 +08:00
|
|
|
|
2026-08-05 18:06:15 +08:00
|
|
|
edge_specs = _edge_specs(
|
|
|
|
|
{
|
|
|
|
|
"curve": "line",
|
|
|
|
|
"length": 10.0,
|
|
|
|
|
"start_point": (0.0, 0.0, 0.0),
|
|
|
|
|
"end_point": (10.0, 0.0, 0.0),
|
|
|
|
|
"length_center": (5.0, 0.0, 0.0),
|
2026-08-06 18:15:14 +08:00
|
|
|
"topology_relation_depth": 1,
|
|
|
|
|
"selected_edge_count": 1,
|
|
|
|
|
"first_level_vertex_count": 2,
|
|
|
|
|
"first_level_adjacent_edge_count": 4,
|
|
|
|
|
"first_level_adjacent_face_count": 2,
|
|
|
|
|
"first_level_edge_count": 5,
|
|
|
|
|
"first_level_topology_note": "Edge first-level topology is ready.",
|
|
|
|
|
"first_level_fact_summary": "一级事实=当前 Edge、端点、共享端点相邻 Edge、直接相邻 Face。",
|
|
|
|
|
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
2026-08-05 18:06:15 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
edge_specs,
|
|
|
|
|
"cad_modeling_form",
|
|
|
|
|
("柔性建模", "移动几何", "直线 Edge"),
|
|
|
|
|
"line Edge",
|
|
|
|
|
)
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
edge_specs,
|
|
|
|
|
"cad_recommended_operation",
|
|
|
|
|
("优先改长度", "移动端面", "只改当前Edge"),
|
|
|
|
|
"line Edge",
|
|
|
|
|
)
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_label(edge_specs, "edge_first_level_topology", "一级关系")
|
|
|
|
|
_assert_current_text_contains(
|
|
|
|
|
edge_specs,
|
|
|
|
|
"edge_first_level_topology",
|
|
|
|
|
("端点 Vertex 2 个", "相邻 Edge 4 条", "直接相邻 Face 2 个"),
|
|
|
|
|
"line Edge",
|
|
|
|
|
)
|
|
|
|
|
edge_topology_tip = str(_spec(edge_specs, "edge_first_level_topology").get("disabled_tip") or "")
|
|
|
|
|
if "二级、三级" not in edge_topology_tip:
|
|
|
|
|
raise SystemExit(f"line Edge topology tip should explain ignored deeper topology: {edge_topology_tip}")
|
|
|
|
|
_assert_contains(
|
|
|
|
|
{str(spec.get("key", "")) for spec in edge_specs},
|
|
|
|
|
{"length", "edge_first_level_topology"},
|
|
|
|
|
"line Edge",
|
|
|
|
|
)
|
2026-08-05 18:06:15 +08:00
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
low_recognition_specs = _specs(
|
|
|
|
|
{
|
|
|
|
|
"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),
|
|
|
|
|
"recognition_score": 24,
|
|
|
|
|
"recognition_confidence": "low",
|
|
|
|
|
"recognition_risk": "medium",
|
|
|
|
|
"recognition_blockers": "ambiguous analytic surface",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
for key in ("sphere_radius", "sphere_diameter"):
|
|
|
|
|
spec = _spec(low_recognition_specs, key)
|
|
|
|
|
if spec.get("enabled"):
|
|
|
|
|
raise SystemExit(f"{key} should be read-only when analytic surface recognition is low: {spec}")
|
|
|
|
|
if "ambiguous analytic surface" not in str(spec.get("disabled_tip") or ""):
|
|
|
|
|
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
|
|
|
|
|
|
2026-07-31 16:36:05 +08:00
|
|
|
_assert_target_change_detection()
|
2026-08-06 18:15:14 +08:00
|
|
|
_assert_property_table_column_widths()
|
2026-07-31 16:36:05 +08:00
|
|
|
_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())
|