feat: 完善一级关系编辑 UI 与视图体验

This commit is contained in:
2026-08-06 18:15:14 +08:00
parent 57d75541c3
commit eef9efcc1e
29 changed files with 3907 additions and 178 deletions
+294
View File
@@ -0,0 +1,294 @@
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
from PySide6.QtCore import QPoint, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QWidget
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.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window
from step_editor.widgets import NoWheelComboBox
def _pump(app: QApplication, ms: int = 150) -> None:
deadline = time.monotonic() + ms / 1000.0
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def _native_grab(widget: QWidget, target: Path) -> None:
app = QApplication.instance()
if app is None:
return
screen = app.primaryScreen()
if screen is None:
return
screen.grabWindow(int(widget.winId())).save(str(target))
def _compact_text(text: object, limit: int = 120) -> str:
value = str(text or "").replace("\n", " ").strip()
if len(value) <= limit:
return value
return f"{value[: limit - 1]}..."
def _label_overflows(label: QLabel) -> bool:
if label.width() <= 0:
return False
return bool(label.sizeHint().width() > label.width() + 3 and not label.wordWrap())
def _row_snapshot(window: StepEditorWindow) -> list[dict[str, object]]:
rows = getattr(window, "property_card_rows", {})
result: list[dict[str, object]] = []
if not isinstance(rows, dict):
return result
for row, widgets in rows.items():
if not isinstance(widgets, dict):
continue
card = widgets.get("card")
if not isinstance(card, QWidget):
continue
labels = card.findChildren(QLabel)
result.append(
{
"row": int(row),
"height": card.height(),
"selected": bool(card.property("selected")),
"visible": card.isVisible(),
"title": _compact_text(widgets.get("status_label").text() if False else ""),
"labels": [
{
"object": label.objectName(),
"text": _compact_text(label.text(), 90),
"width": label.width(),
"size_hint_width": label.sizeHint().width(),
"overflow": _label_overflows(label),
}
for label in labels
if label.isVisible()
],
"has_editor": isinstance(widgets.get("target_editor"), QLineEdit),
"has_scope": isinstance(widgets.get("scope_combo"), NoWheelComboBox),
"has_button": isinstance(widgets.get("action_button"), QPushButton),
}
)
return result
def _actionable_labels(window: StepEditorWindow) -> list[str]:
return [str(spec.get("label", "") or "") for _row, spec in window._actionable_property_rows()]
def _find_actionable_target(window: StepEditorWindow) -> dict[str, object] | None:
if window.model is None:
return None
best: dict[str, object] | None = None
best_score = -1
max_faces = len(window.model.faces)
for mode, selector in (("Face", window.select_face), ("Feature", window.select_feature)):
window._set_selection_mode(mode)
for face_id in range(max_faces):
selector(face_id)
QApplication.processEvents()
labels = _actionable_labels(window)
score = len(labels)
if score > best_score:
best_score = score
best = {"mode": mode, "face_id": face_id, "labels": labels}
if score >= 3:
return best
return best
def _numeric_target_text(text: str) -> str:
match = re.search(r"[-+]?\d+(?:\.\d+)?", text)
if not match:
return "1"
value = float(match.group(0))
if abs(value) < 1e-9:
value = 1.0
else:
value *= 1.05
return f"{value:.6g}"
def _pick_editable_numeric_row(window: StepEditorWindow) -> int | None:
for row, spec in window._actionable_property_rows():
effective = window._effective_property_spec(spec, row=row)
if str(effective.get("value_type", "number")) == "number":
return int(row)
rows = window._actionable_property_rows()
return int(rows[0][0]) if rows else None
def _click_card(window: StepEditorWindow, row: int) -> bool:
card = window._property_card_widgets(row).get("card")
if not isinstance(card, QWidget):
return False
QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(18, max(3, card.height() // 2)))
return True
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", default="assets/screenshots/property_panel_usability")
parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH))
parser.add_argument("--face-id", type=int, default=None)
parser.add_argument("--selection-mode", choices=("Face", "Feature"), default=None)
parser.add_argument("--detection-level", choices=("current-only", "associated-only", "secondary"), default=None)
parser.add_argument("--expand-diagnostics", action="store_true")
args = parser.parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
_suppress_vtk_output_window()
app = QApplication(["property-panel-usability-probe"])
window = StepEditorWindow(Path(args.model), background_load=False)
window.resize(1280, 820)
window.show()
_pump(app, 350)
window.load_step(Path(args.model), background=False)
_pump(app, 500)
_native_grab(window, output_dir / "00_loaded.png")
if args.detection_level and hasattr(window, "feature_detection_combo"):
index = window.feature_detection_combo.findData(args.detection_level)
if index >= 0:
window.feature_detection_combo.setCurrentIndex(index)
_pump(app, 250)
target = None
if args.face_id is not None:
mode = args.selection_mode or "Feature"
if mode == "Feature":
window._set_selection_mode("Feature")
window.select_feature(int(args.face_id))
else:
window._set_selection_mode("Face")
window.select_face(int(args.face_id))
_pump(app, 350)
target = {
"mode": mode,
"face_id": int(args.face_id),
"labels": _actionable_labels(window),
}
else:
target = _find_actionable_target(window)
if target is None or not target.get("labels"):
summary = {
"model": str(Path(args.model)),
"error": "no actionable face or feature found",
}
(output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"property panel usability probe wrote {output_dir}")
return 2
mode = str(target["mode"])
face_id = int(target["face_id"])
window._set_selection_mode(mode)
if mode == "Feature":
window.select_feature(face_id)
else:
window.select_face(face_id)
_pump(app, 350)
_native_grab(window, output_dir / "01_compact_actionable.png")
if args.expand_diagnostics and hasattr(window, "property_expand_button") and window.property_expand_button.isVisible():
QTest.mouseClick(
window.property_expand_button,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)),
)
_pump(app, 250)
_native_grab(window, output_dir / "01b_expanded_diagnostics_before_edit.png")
target_row = _pick_editable_numeric_row(window)
if target_row is not None:
_click_card(window, target_row)
_pump(app, 250)
_native_grab(window, output_dir / "02_expanded_first_action.png")
expanded_rows = _row_snapshot(window)
entered_target = ""
button_text_after_input = ""
selected_widgets = window._property_card_widgets(target_row) if target_row is not None else {}
editor = selected_widgets.get("target_editor")
if isinstance(editor, QLineEdit):
entered_target = _numeric_target_text(editor.text())
editor.setFocus()
editor.selectAll()
QTest.keyClicks(editor, entered_target)
_pump(app, 250)
button = selected_widgets.get("action_button")
if isinstance(button, QPushButton):
button_text_after_input = button.text()
_native_grab(window, output_dir / "03_after_target_input.png")
collapsed_after_blank_click = False
if target_row is not None:
_click_card(window, target_row)
_pump(app, 250)
collapsed_after_blank_click = getattr(window, "property_editor_selected_row", None) is None
_native_grab(window, output_dir / "04_collapsed_again.png")
if hasattr(window, "property_expand_button") and window.property_expand_button.isVisible():
QTest.mouseClick(
window.property_expand_button,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)),
)
_pump(app, 250)
_native_grab(window, output_dir / "05_more_diagnostics.png")
compact_rows = _row_snapshot(window)
overflow_labels = [
label
for row in compact_rows + expanded_rows
for label in row.get("labels", [])
if bool(label.get("overflow"))
]
summary = {
"model": str(Path(args.model)),
"target": {
"mode": mode,
"face_id": face_id,
"actionable_labels": target.get("labels", []),
},
"summary_label": getattr(window, "property_command_summary_label", None).text()
if hasattr(window, "property_command_summary_label")
else "",
"target_row": target_row,
"entered_target": entered_target,
"button_text_after_input": button_text_after_input,
"collapsed_after_blank_click": collapsed_after_blank_click,
"compact_rows": compact_rows,
"expanded_rows": expanded_rows,
"overflow_labels": overflow_labels,
"selected_row_after_diagnostics": getattr(window, "property_editor_selected_row", None),
}
(output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
window.close()
_pump(app, 100)
app.quit()
print(f"property panel usability probe wrote {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
import argparse
import ctypes
import ctypes.wintypes
import json
import os
import sys
import time
from pathlib import Path
from PySide6.QtCore import QEvent, QObject, QPoint, Qt, qInstallMessageHandler
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
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.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window
def _rect_tuple(hwnd: int) -> tuple[int, int, int, int]:
rect = ctypes.wintypes.RECT()
if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)):
return (0, 0, 0, 0)
return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
def _window_text(hwnd: int) -> str:
length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
buffer = ctypes.create_unicode_buffer(max(length + 1, 1))
ctypes.windll.user32.GetWindowTextW(hwnd, buffer, len(buffer))
return buffer.value
def _class_name(hwnd: int) -> str:
buffer = ctypes.create_unicode_buffer(256)
ctypes.windll.user32.GetClassNameW(hwnd, buffer, len(buffer))
return buffer.value
def _visible_native_windows_for_process(pid: int) -> list[dict[str, object]]:
windows: list[dict[str, object]] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
def callback(hwnd, _lparam):
window_pid = ctypes.wintypes.DWORD()
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid))
if int(window_pid.value) != pid:
return True
if not ctypes.windll.user32.IsWindowVisible(hwnd):
return True
left, top, right, bottom = _rect_tuple(int(hwnd))
width = max(0, right - left)
height = max(0, bottom - top)
if width <= 0 or height <= 0:
return True
windows.append(
{
"hwnd": int(hwnd),
"class": _class_name(int(hwnd)),
"title": _window_text(int(hwnd)),
"rect": [left, top, right, bottom],
"size": [width, height],
}
)
return True
ctypes.windll.user32.EnumWindows(callback, 0)
return windows
def _widget_text(widget: QWidget) -> str:
if isinstance(widget, QMessageBox):
return str(widget.text() or "")
getter = getattr(widget, "text", None)
if callable(getter):
try:
value = getter()
if isinstance(value, str):
return value
except Exception:
return ""
return ""
class WindowProbe(QObject):
def __init__(self, output_dir: Path) -> None:
super().__init__()
self.output_dir = output_dir
self.events: list[dict[str, object]] = []
self.native_samples: list[dict[str, object]] = []
self._native_seen: set[tuple[object, ...]] = set()
def eventFilter(self, watched, event):
if isinstance(watched, QWidget) and event.type() in {
QEvent.Type.Show,
QEvent.Type.Hide,
QEvent.Type.WindowActivate,
QEvent.Type.ToolTip,
}:
if watched.isWindow() or event.type() == QEvent.Type.ToolTip:
self.events.append(
{
"event": event.type().name,
"qt_class": type(watched).__name__,
"object_name": watched.objectName(),
"title": watched.windowTitle(),
"text": _widget_text(watched),
"is_window": watched.isWindow(),
"visible": watched.isVisible(),
"size": [watched.width(), watched.height()],
"flags": int(watched.windowFlags()),
}
)
return False
def sample_native(self, label: str) -> None:
for item in _visible_native_windows_for_process(os.getpid()):
key = (item["hwnd"], item["class"], item["title"], tuple(item["rect"]))
if key in self._native_seen:
continue
self._native_seen.add(key)
sample = dict(item)
sample["label"] = label
self.native_samples.append(sample)
def grab_qt_windows(self, label: str) -> None:
app = QApplication.instance()
if app is None:
return
for index, widget in enumerate(app.topLevelWidgets()):
if not widget.isVisible():
continue
name = widget.objectName() or type(widget).__name__
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name)
target = self.output_dir / f"{label}_qt_{index}_{safe_name}.png"
widget.grab().save(str(target))
def grab_native_windows(self, label: str) -> None:
app = QApplication.instance()
if app is None:
return
screen = app.primaryScreen()
if screen is None:
return
for index, item in enumerate(_visible_native_windows_for_process(os.getpid())):
hwnd = int(item["hwnd"])
safe_class = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(item["class"]))
target = self.output_dir / f"{label}_native_{index}_{safe_class}_{hwnd}.png"
screen.grabWindow(hwnd).save(str(target))
def write_log(self) -> None:
payload = {
"qt_events": self.events,
"native_samples": self.native_samples,
}
(self.output_dir / "transient_windows_log.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _pump(app: QApplication, probe: WindowProbe, label: str, ms: int) -> None:
deadline = time.monotonic() + ms / 1000.0
while time.monotonic() < deadline:
app.processEvents()
probe.sample_native(label)
time.sleep(0.005)
def _find_face_click_point(window: StepEditorWindow) -> QPoint:
width = max(1, window.vtk_widget.width())
height = max(1, window.vtk_widget.height())
fractions = [0.5, 0.42, 0.58, 0.35, 0.65, 0.28, 0.72]
for fy in fractions:
for fx in fractions:
x = int(width * fx)
y_qt = int(height * fy)
y_vtk = int(height - y_qt)
target = window._pick_selection_target("Face", x, y_vtk)
if target is not None:
return QPoint(x, y_qt)
return QPoint(width // 2, height // 2)
def _click_first_property_card(window: StepEditorWindow) -> bool:
rows = getattr(window, "property_card_rows", {})
if not isinstance(rows, dict):
return False
for widgets in rows.values():
card = widgets.get("card") if isinstance(widgets, dict) else None
if isinstance(card, QWidget) and card.isVisible():
QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(20, max(2, card.height() // 2)))
return True
return False
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", default="assets/screenshots/transient_probe")
parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH))
parser.add_argument("--offscreen", action="store_true")
args = parser.parse_args()
if args.offscreen:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
qt_messages: list[str] = []
def message_handler(_mode, _context, message):
text = str(message)
qt_messages.append(text)
print(text, file=sys.stderr)
qInstallMessageHandler(message_handler)
_suppress_vtk_output_window()
app = QApplication(["transient-window-probe"])
probe = WindowProbe(output_dir)
app.installEventFilter(probe)
window = StepEditorWindow(Path(args.model), background_load=False)
window.show()
window.resize(1280, 820)
_pump(app, probe, "after_show", 300)
window.load_step(Path(args.model), background=False)
_pump(app, probe, "after_load", 800)
window.render_window.Render()
_pump(app, probe, "after_render", 300)
probe.grab_qt_windows("after_render")
probe.grab_native_windows("after_render")
window._set_selection_mode("Face")
point = _find_face_click_point(window)
QTest.mouseClick(window.vtk_widget, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, point)
_pump(app, probe, "after_face_click", 900)
probe.grab_qt_windows("after_face_click")
probe.grab_native_windows("after_face_click")
clicked_card = _click_first_property_card(window)
_pump(app, probe, "after_property_click", 900)
probe.grab_qt_windows("after_property_click")
probe.grab_native_windows("after_property_click")
(output_dir / "probe_summary.json").write_text(
json.dumps(
{
"model": str(Path(args.model)),
"face_click_point": [point.x(), point.y()],
"selected_kind": getattr(window, "selected_kind", None),
"selected_face_id": getattr(window, "selected_face_id", None),
"clicked_property_card": clicked_card,
"qt_message_count": len(qt_messages),
"qt_messages": qt_messages[-50:],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
probe.write_log()
window.close()
_pump(app, probe, "after_close", 100)
app.quit()
print(f"transient window probe wrote {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+26 -1
View File
@@ -53,6 +53,15 @@ def main() -> int:
context = state._feature_context_info(SOURCE_FACE_ID)
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}")
editable = {
(str(row.get("label")), int(row.get("source_face_id", -1)), str(row.get("action")))
for row in rows
@@ -74,8 +83,24 @@ def main() -> int:
index for index in range(display.GetNumberOfCells())
if int(face_ids.GetTuple1(index)) == 394
]
if len(boss_cells) < 400:
if display.GetNumberOfCells() > 250_000:
raise AssertionError(f"display tessellation exceeded the large-model budget: {display.GetNumberOfCells()}")
if len(boss_cells) < 60:
raise AssertionError(f"cylindrical display tessellation is too coarse: {len(boss_cells)}")
local_boss = model.build_face_polydata(face_ids=[394], deflection=0.035)
if local_boss.GetNumberOfCells() < 400:
raise AssertionError(
"single-Face local display should still allow fine cylindrical tessellation: "
f"{local_boss.GetNumberOfCells()}"
)
local_first_model = StepModel.load(MODEL_PATH)
local_first_model.build_face_polydata(face_ids=[394], deflection=0.035)
local_first_display = local_first_model.build_face_polydata(deflection=0.035)
if local_first_display.GetNumberOfCells() > 250_000:
raise AssertionError(
"full display should remain budgeted after a local fine display was built first: "
f"{local_first_display.GetNumberOfCells()}"
)
plane_cells = [
index for index in range(display.GetNumberOfCells())
if int(face_ids.GetTuple1(index)) == SOURCE_FACE_ID
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import subprocess
import sys
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Boss cylindrical first-level topology and plan guards",
("verify_boss_first_level_topology.py",),
),
(
"Boss diameter, height and axis local rebuilds",
("verify_boss_resize.py",),
),
(
"Property editor keeps generic Face edits out of boss features",
("verify_property_editor_specs.py",),
),
)
def main() -> int:
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
env=env,
check=True,
)
print("\nBoss edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = Path(__file__).resolve().parent
for path in (PROJECT_ROOT, SCRIPTS_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from step_editor.model import StepModel
from verify_boss_resize import ( # noqa: E402
_axis_center,
_first_boss_face,
_first_circle_edge_adjacent_to_face,
_write_boss_model,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _assert_common_topology(topology: dict[str, object], label: str) -> None:
_assert(topology.get("topology_relation_depth") == 1, f"{label}: topology depth should be 1")
_assert(
topology.get("topology_relation_boundary") == "shared-edge",
f"{label}: topology boundary should be shared-edge",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored, f"{label}: second-level propagation should be explicitly ignored")
_assert("third-level" in ignored, f"{label}: third-level propagation should be explicitly ignored")
_assert(
int(topology.get("cylindrical_feature_side_face_count", 0) or 0) >= 1,
f"{label}: selected boss side Face is missing",
)
_assert(
int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0) >= 1,
f"{label}: boss boundary Edges are missing",
)
_assert(
int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0) >= 1,
f"{label}: direct adjacent Faces are missing",
)
_assert(
int(topology.get("cylindrical_feature_end_face_count", 0) or 0) >= 1,
f"{label}: boss end/base cap Faces are missing",
)
def _assert_plan_topology(plan: dict[str, object], label: str) -> None:
_assert(plan.get("topology_relation_depth") == 1, f"{label}: plan topology depth should be 1")
_assert(
plan.get("topology_relation_status") == "ready",
f"{label}: plan topology should be ready, got {plan.get('topology_relation_status')!r}",
)
_assert(
plan.get("first_level_topology_status") == "ready",
f"{label}: first-level guard should be ready, got {plan.get('first_level_topology_status')!r}",
)
_assert(
int(plan.get("first_level_boundary_edge_count", 0) or 0) >= 1,
f"{label}: plan boundary Edges are missing",
)
_assert(
int(plan.get("first_level_adjacent_face_count", 0) or 0) >= 1,
f"{label}: plan adjacent Faces are missing",
)
_assert(
"second-level" in tuple(plan.get("topology_ignored_relation_depths", ()) or ()),
f"{label}: plan should document ignored deeper topology",
)
_assert(
plan.get("first_level_topology_guard_note"),
f"{label}: first-level topology guard note is missing",
)
def _assert_edge_delegated_topology(plan: dict[str, object]) -> None:
_assert(plan.get("circular_edge_cylinder_mode") == "boss", "circle Edge should delegate to boss axis move")
_assert(plan.get("move_axis_topology_relation_depth") == 1, "delegated boss topology depth should be 1")
_assert(
plan.get("move_axis_topology_relation_status") == "ready",
"delegated boss topology should be ready",
)
_assert(
plan.get("move_axis_first_level_topology_status") == "ready",
"delegated boss first-level guard should be ready",
)
_assert(
int(plan.get("move_axis_first_level_adjacent_face_count", 0) or 0) >= 1,
"delegated boss adjacent Faces are missing",
)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_boss_topology_") as temp_dir:
model_path = Path(temp_dir) / "boss.step"
_write_boss_model(model_path)
model = StepModel.load(model_path)
face_id = _first_boss_face(model)
topology = model.cylindrical_feature_first_level_topology(face_id)
_assert_common_topology(topology, "cylindrical boss")
center = _axis_center(model, face_id)
diameter_plan = model.cylindrical_boss_resize_plan(face_id, 8.0)
height_plan = model.cylindrical_boss_height_plan(face_id, 7.0)
axis_plan = model.cylindrical_boss_axis_move_plan(face_id, (center[0] + 2.0, center[1], center[2]))
_assert_plan_topology(diameter_plan, "boss diameter plan")
_assert_plan_topology(height_plan, "boss height plan")
_assert_plan_topology(axis_plan, "boss axis plan")
edge_id = _first_circle_edge_adjacent_to_face(model, face_id)
edge_info = model.edge_info(edge_id)
edge_center = edge_info.get("center")
_assert(isinstance(edge_center, tuple), "boss circular Edge center is missing")
target_edge_center = (float(edge_center[0]) + 2.0, float(edge_center[1]), float(edge_center[2]))
edge_axis_plan = model.circular_edge_axis_move_plan(edge_id, target_edge_center)
_assert_edge_delegated_topology(edge_axis_plan)
result = model.resize_cylindrical_boss(face_id, 8.0)
_assert("verified_face=" in result, "boss diameter edit should report verified target Face")
_assert(
"first_level_topology_matched=True" in result,
f"boss diameter edit should verify first-level topology: {result}",
)
print("boss first-level topology verification passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+10
View File
@@ -146,6 +146,10 @@ def _run_diameter_case(target: float, tolerance: float) -> None:
raise SystemExit(
f"diameter changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}"
)
if "verified_face=" not in result:
raise SystemExit(f"diameter result did not report verified target Face: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"diameter result did not verify first-level topology: {result}")
print("mode=diameter")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
@@ -186,6 +190,8 @@ def _run_axis_center_case(offset: float, tolerance: float) -> None:
raise SystemExit(
f"axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}"
)
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"axis_center result did not verify first-level topology: {result}")
print("mode=axis_center")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
@@ -232,6 +238,8 @@ def _run_circle_edge_axis_center_case(offset: float, tolerance: float) -> None:
raise SystemExit(
f"circle_edge_axis_center changed boss height: before={current_height:g}, after={height:g}, error={height_error:g}"
)
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"circle_edge_axis_center result did not verify first-level topology: {result}")
print("mode=circle_edge_axis_center")
print(f"source_edge={edge_id}")
print(f"source_face={face_id}")
@@ -264,6 +272,8 @@ def _run_height_case(target: float, tolerance: float) -> None:
raise SystemExit(f"height changed solid count: before={before.solids}, after={after.solids}")
if error > tolerance:
raise SystemExit(f"height verification failed: target={target:g}, value={height:g}, error={error:g}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"height result did not verify first-level topology: {result}")
print("mode=height")
print(f"source_face={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
+4
View File
@@ -121,6 +121,8 @@ def _run_endpoint_case(role: str, target_delta: float, tolerance: float) -> None
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
raise SystemExit(f"{role} endpoint should use local-edge-endpoint-deform, got {plan.get('resize_strategy')}")
result = model.move_edge_endpoint(edge_id, role, target_point)
if "First-level topology check" not in result:
raise SystemExit(f"{role} endpoint result should include first-level topology check: {result}")
after = model.stats()
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
if after.solids != before.solids:
@@ -155,6 +157,8 @@ def _run_center_case(center_delta: tuple[float, float, float], tolerance: float)
if plan.get("resize_strategy") != "local-edge-center-deform":
raise SystemExit(f"center move should use local-edge-center-deform, got {plan.get('resize_strategy')}")
result = model.move_edge_center(edge_id, target_center)
if "First-level topology check" not in result:
raise SystemExit(f"center move result should include first-level topology check: {result}")
after = model.stats()
matched_edge, endpoint_error, length_error = _nearest_expected_edge(model, target_start, target_end)
if after.solids != before.solids:
@@ -108,6 +108,7 @@ def main() -> int:
"first_level_fact_adjacent_edge_count",
"first_level_fact_included_edge_ids",
"first_level_fact_included_edge_count",
"edge_first_level_topology",
"first_level_vertex_count",
"first_level_adjacent_edge_count",
):
+2
View File
@@ -136,6 +136,8 @@ def main() -> int:
anchor_mode=args.anchor,
strategy_mode=args.strategy,
)
if "First-level topology check" not in result:
raise SystemExit(f"Edge length result should include first-level topology check: {result}")
after = model.stats()
lengths = [_edge_length(model, item) for item in range(len(model.edges))]
nearest = min(lengths, key=lambda value: abs(value - args.target_length))
+25
View File
@@ -108,6 +108,22 @@ def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: floa
raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or '<none>'}")
def _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None:
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}")
if plan.get("topology_relation_status") != "ready":
raise SystemExit(f"existing fillet plan topology should be ready: {plan}")
if plan.get("first_level_topology_status") != "ready":
raise SystemExit(f"existing fillet first-level guard should be ready: {plan}")
if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) < 1:
raise SystemExit(f"existing fillet plan should expose boundary Edges: {plan}")
if int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0) < 2:
raise SystemExit(f"existing fillet plan should expose direct support Faces: {plan}")
ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ())
if "second-level" not in ignored or "third-level" not in ignored:
raise SystemExit(f"existing fillet plan should document ignored deeper topology: {plan}")
def _run_fillet_case(radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_fillet_") as temp_dir:
model_path = Path(temp_dir) / "box.step"
@@ -121,6 +137,8 @@ def _run_fillet_case(radius: float, tolerance: float) -> None:
result = model.fillet_edge(edge_id, radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, radius, tolerance)
if "Edge blend result check" not in result:
raise SystemExit(f"fillet result did not report execution-layer result check: {result}")
if after.solids != before.solids:
raise SystemExit(f"fillet changed solid count: before={before.solids}, after={after.solids}")
if not matches:
@@ -144,6 +162,8 @@ def _verify_chamfer_topology(
result: str,
extra_lines: list[str],
) -> None:
if "Edge blend result check" not in result:
raise SystemExit(f"{mode} result did not report execution-layer result check: {result}")
if after.solids != before.solids:
raise SystemExit(f"{mode} changed solid count: before={before.solids}, after={after.solids}")
if after.faces <= before.faces:
@@ -250,6 +270,7 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise SystemExit(f"existing fillet plan was blocked: {plan['message']}")
_assert_existing_fillet_plan_topology(plan)
support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ()))
if len(support_face_ids) < 2:
raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}")
@@ -263,6 +284,10 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera
raise SystemExit(f"existing fillet verification failed: no cylindrical face near radius {target_radius:g}")
if old_matches and abs(source_radius - target_radius) > tolerance:
raise SystemExit(f"existing fillet still has old radius matches: {old_matches}")
if "Existing fillet result check" not in result:
raise SystemExit(f"existing fillet result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing fillet result did not verify first-level topology: {result}")
print("mode=existing_fillet")
print(f"face_id={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
+8
View File
@@ -18,6 +18,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Face first-level shared-edge topology",
("verify_face_first_level_topology.py",),
),
(
"cylindrical Face first-level topology for holes and slots",
("verify_cylindrical_first_level_topology.py",),
),
(
"face width, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "width", "--target-size", "15"),
@@ -90,6 +94,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"freeform Face stays read-only with a clear blocker",
("verify_face_freeform_guard.py",),
),
(
"analytic curved Face radius and angle edits",
("verify_analytic_surface_resize.py",),
),
(
"cylindrical side Face height edits are checked",
("verify_cylindrical_height_resize.py",),
@@ -0,0 +1,82 @@
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
README_PATH = PROJECT_ROOT / "README.md"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from verify_first_level_edit_suites import QUICK_COMMANDS, STAGES # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _command_path(script_name: str) -> Path:
script_path = SCRIPT_DIR / script_name
if script_path.exists():
return script_path
return PROJECT_ROOT / script_name
def _commands() -> tuple[tuple[str, str, tuple[str, ...]], ...]:
collected: list[tuple[str, str, tuple[str, ...]]] = []
for stage_name, _stage_label, cases in STAGES:
for _label, command in cases:
collected.append((stage_name, command[0], command[1:]))
for _label, command in QUICK_COMMANDS:
collected.append(("quick", command[0], command[1:]))
return tuple(collected)
def _verify_script_inventory() -> None:
for stage_name, script_name, _args in _commands():
path = _command_path(script_name)
_assert(path.exists(), f"{stage_name} command target is missing: {script_name}")
def _verify_readme_mentions(readme: str) -> None:
required_fragments = (
"当前整体验证基线",
"不等于 CAD 级完成",
"Face 阶段的当前验收口径",
"verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot",
"verify_first_level_edit_suites.py --stage edge",
"verify_first_level_edit_suites.py --stage boss --stage round-chamfer --stage shell --stage analytic",
"一级编辑总验证入口",
"真实 STEP 失败项",
)
for fragment in required_fragments:
_assert(fragment in readme, f"README missing first-level acceptance fragment: {fragment}")
for stage_name, _stage_label, _cases in STAGES:
_assert(
f"--stage {stage_name}" in readme or stage_name in {"round-chamfer", "shell", "analytic"},
f"README should mention how to run stage {stage_name}",
)
for stage_name, script_name, _args in _commands():
if script_name == "main.py":
continue
_assert(script_name in readme, f"README should mention {stage_name} command script {script_name}")
def main() -> int:
readme = README_PATH.read_text(encoding="utf-8")
_verify_script_inventory()
_verify_readme_mentions(readme)
print("first-level acceptance docs ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
STAGES: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...] = (
(
"face",
"Face first-level edit baseline",
(
("Face edit suite", ("verify_face_edit_suite.py",)),
("Face isolated worker edits", ("verify_isolated_face_edit.py",)),
),
),
(
"hole-slot",
"Hole and slot first-level edit baseline",
(
("Hole/slot edit suite", ("verify_hole_slot_edit_suite.py",)),
),
),
(
"edge",
"Edge first-level edit baseline",
(
("Edge edit suite", ("verify_edge_edit_suite.py",)),
("Edge isolated worker edits", ("verify_edge_isolated_edit.py",)),
),
),
(
"boss",
"Boss first-level edit baseline",
(
("Boss edit suite", ("verify_boss_edit_suite.py",)),
),
),
(
"round-chamfer",
"Round and chamfer first-level edit baseline",
(
("Round/chamfer edit suite", ("verify_round_chamfer_edit_suite.py",)),
),
),
(
"shell",
"Shell thickness first-level edit baseline",
(
("Shell edit suite", ("verify_shell_edit_suite.py",)),
),
),
(
"analytic",
"Analytic surface edit baseline",
(
("Analytic surface edits", ("verify_analytic_surface_resize.py",)),
("Cone semi-angle isolated edits", ("verify_cone_semi_angle_isolation.py",)),
),
),
)
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",)),
("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",)),
)
def _stage_names() -> tuple[str, ...]:
return tuple(stage[0] for stage in STAGES)
def _selected_stages(names: tuple[str, ...]) -> tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...]:
if not names:
return STAGES
selected = {name.strip() for name in names if name.strip()}
return tuple(stage for stage in STAGES if stage[0] in selected)
def _run(label: str, command: tuple[str, ...], *, index: int, total: int) -> None:
print(f"\n[{index}/{total}] {label}", flush=True)
target = SCRIPT_DIR / command[0]
if not target.exists():
target = PROJECT_ROOT / command[0]
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
subprocess.run(
(sys.executable, str(target), *command[1:]),
cwd=PROJECT_ROOT,
env=env,
check=True,
)
def _display_command(command: tuple[str, ...]) -> str:
target = SCRIPT_DIR / command[0]
prefix = "scripts\\"
if not target.exists():
prefix = ""
args = " ".join(command[1:])
return f"python {prefix}{command[0]} {args}".rstrip()
def _commands_for(stages: tuple[tuple[str, str, tuple[tuple[str, tuple[str, ...]], ...]], ...]) -> list[tuple[str, tuple[str, ...]]]:
commands: list[tuple[str, tuple[str, ...]]] = []
for _stage_name, stage_label, cases in stages:
for label, command in cases:
commands.append((f"{stage_label}: {label}", command))
return commands
def main(argv: tuple[str, ...] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Run staged first-level geometry edit verification suites.",
)
parser.add_argument(
"--stage",
action="append",
choices=_stage_names(),
help="Only run one stage. Repeat this option to run multiple stages.",
)
parser.add_argument(
"--quick",
action="store_true",
help="Run lightweight first-level regression checks instead of the full geometry suites.",
)
parser.add_argument(
"--list",
action="store_true",
help="Print the staged verification plan without running it.",
)
args = parser.parse_args(argv)
if args.quick:
commands = list(QUICK_COMMANDS)
heading = "quick first-level regression checks"
else:
stages = _selected_stages(tuple(args.stage or ()))
commands = _commands_for(stages)
heading = "first-level edit suites"
if args.list:
print(f"{heading}:")
for index, (label, command) in enumerate(commands, start=1):
print(f"{index}. {label}: {_display_command(command)}")
return 0
total = len(commands)
for index, (label, command) in enumerate(commands, start=1):
_run(label, command, index=index, total=total)
print(f"\n{heading} passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import subprocess
import sys
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Hole/slot cylindrical first-level topology and plan guards",
("verify_cylindrical_first_level_topology.py",),
),
(
"Hole diameter, axis, suppress and blind-depth local rebuilds",
("verify_hole_resize.py",),
),
(
"Slot width, depth, arc, axis and obround local rebuilds",
("verify_slot_resize.py",),
),
(
"Hole/slot isolated worker execution and logical-id retention",
("verify_hole_slot_isolated_edit.py",),
),
(
"Hole/slot recognition summary and editable action grouping",
("verify_feature_recognition_summary.py",),
),
(
"Unified first-level fact graph includes holes and slots",
("verify_first_level_fact_graph.py",),
),
(
"Property editor keeps generic Face edits out of hole/slot features",
("verify_property_editor_specs.py",),
),
)
def main() -> int:
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
env=env,
check=True,
)
print("\nHole/slot edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import os
from pathlib import Path
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QEvent, QObject
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTableWidget,
QVBoxLayout,
QWidget,
)
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.widgets import NoWheelComboBox
from step_editor.window_state import WindowStateMixin
class _StatusBar:
def showMessage(self, _text: str) -> None:
pass
class _PropertyCardProbe(QWidget, WindowStateMixin):
def __init__(self) -> None:
super().__init__()
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.property_editor_updating = False
self.property_table_expanded = False
self.property_table_collapsed_rows = 4
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_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.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)
self.property_command_summary_label = QLabel()
self.property_command_bar = QFrame()
self.property_command_layout = QHBoxLayout(self.property_command_bar)
self.apply_property_button = QPushButton()
def _selected_action_info(self) -> dict[str, object]:
return {
"area": 100.0,
"surface": "plane",
"push_pull_status": "ready",
"first_level_boundary_edge_count": 4,
"first_level_boundary_vertex_count": 4,
"first_level_adjacent_face_count": 4,
}
def _set_control_state(self, widget, enabled: bool, _enabled_tip: str, _disabled_tip: str) -> None:
widget.setEnabled(enabled)
def statusBar(self) -> _StatusBar:
return _StatusBar()
class _TopLevelPropertyLabelProbe(QObject):
def __init__(self) -> None:
super().__init__()
self.shown_labels: list[str] = []
def eventFilter(self, watched, event):
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QLabel)
and watched.isWindow()
and watched.objectName().startswith("propertyCard")
):
self.shown_labels.append(f"{type(watched).__name__}:{watched.objectName()}:{watched.text()}")
return False
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
app = QApplication.instance() or QApplication([])
top_level_label_probe = _TopLevelPropertyLabelProbe()
app.installEventFilter(top_level_label_probe)
probe = _PropertyCardProbe()
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}",
)
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(
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",
)
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(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")
if QApplication.instance() is app:
app.quit()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+63 -3
View File
@@ -8,7 +8,7 @@ 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
from step_editor.window_state import WindowStateMixin, _property_table_column_widths
from step_editor.ui_helpers import INFO_LABELS, _format_value
@@ -102,6 +102,12 @@ def _assert_current_text_contains(
raise SystemExit(f"{label} {key} text missing {missing}: {text!r}")
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}")
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")
@@ -312,6 +318,24 @@ def _assert_target_change_detection() -> None:
raise SystemExit("changed vector Face target was not detected")
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}")
def _assert_holed_plane_local_scopes_disabled() -> None:
specs = _specs(
{
@@ -362,7 +386,15 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
def main() -> int:
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")
for key in ("selection_title", "selection_display_id", "selection_topological_face_id"):
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",
):
if key not in INFO_LABELS:
raise SystemExit(f"{key} should have a user-facing label")
@@ -393,6 +425,7 @@ def main() -> int:
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
_assert_spans_value_columns(plane_specs, "cad_modeling_form")
_assert_current_text_contains(
plane_specs,
"cad_modeling_form",
@@ -400,6 +433,7 @@ def main() -> int:
"plane Face",
)
_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",
@@ -443,11 +477,13 @@ def main() -> int:
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":
@@ -889,6 +925,15 @@ def main() -> int:
"start_point": (0.0, 0.0, 0.0),
"end_point": (10.0, 0.0, 0.0),
"length_center": (5.0, 0.0, 0.0),
"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": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
}
)
_assert_current_text_contains(
@@ -903,7 +948,21 @@ def main() -> int:
("优先改长度", "移动端面", "只改当前Edge"),
"line Edge",
)
_assert_contains({str(spec.get("key", "")) for spec in edge_specs}, {"length"}, "line Edge")
_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",
)
low_recognition_specs = _specs(
{
@@ -928,6 +987,7 @@ def main() -> int:
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
_assert_target_change_detection()
_assert_property_table_column_widths()
_assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms()
print("property editor specs ok")
@@ -0,0 +1,42 @@
from __future__ import annotations
import subprocess
import sys
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge fillet/chamfer and existing fillet rebuild checks",
("verify_edge_round_chamfer.py",),
),
(
"Property editor exposes round/chamfer actions without generic Face leakage",
("verify_property_editor_specs.py",),
),
)
def main() -> int:
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
env=env,
check=True,
)
print("\nRound/chamfer edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import subprocess
import sys
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Shell thickness first-level local and owning edits",
("verify_shell_thickness_resize.py",),
),
(
"Property editor exposes shell thickness semantics",
("verify_property_editor_specs.py",),
),
)
def main() -> int:
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
for index, (label, command) in enumerate(CASES, start=1):
print(f"\n[{index}/{len(CASES)}] {label}", flush=True)
subprocess.run(
(sys.executable, str(SCRIPT_DIR / command[0]), *command[1:]),
cwd=PROJECT_ROOT,
env=env,
check=True,
)
print("\nShell edit suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+24
View File
@@ -95,6 +95,24 @@ def _axis_affine_point(
)
def _assert_shell_plan_topology(plan: dict[str, object], label: str) -> None:
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"{label} should expose first-level topology depth: {plan}")
if plan.get("topology_relation_status") != "ready":
raise SystemExit(f"{label} first-level topology should be ready: {plan}")
if int(plan.get("first_level_boundary_edge_count", 0) or 0) < 1:
raise SystemExit(f"{label} should expose boundary Edges: {plan}")
if int(plan.get("first_level_boundary_vertex_count", 0) or 0) < 1:
raise SystemExit(f"{label} should expose boundary Vertices: {plan}")
if int(plan.get("first_level_adjacent_face_count", 0) or 0) < 1:
raise SystemExit(f"{label} should expose direct adjacent Faces: {plan}")
ignored = tuple(plan.get("topology_ignored_relation_depths", ()) or ())
if "second-level" not in ignored or "third-level" not in ignored:
raise SystemExit(f"{label} should document ignored deeper topology: {plan}")
if plan.get("first_level_fact_status") != "ready":
raise SystemExit(f"{label} should expose a ready first-level fact graph: {plan}")
def _assert_logical_face_retained(
model: StepModel,
logical_id: int,
@@ -181,6 +199,7 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
plan = model.shell_thickness_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise SystemExit(f"local shell plan was blocked: {plan['message']}")
_assert_shell_plan_topology(plan, "local shell thickness plan")
outward = _vector(plan.get("outward_direction"), "local shell outward_direction")
distance = float(plan.get("push_pull_distance", 0.0))
expected_logical_center = (
@@ -193,6 +212,7 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if plan["status"] == "blocked":
raise SystemExit(f"owning shell plan was blocked: {plan['message']}")
_assert_shell_plan_topology(plan, "owning shell thickness plan")
expected_logical_center = _axis_affine_point(
before_face_center,
_vector(plan.get("affine_axis_point"), "owning shell affine_axis_point"),
@@ -219,6 +239,10 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
tolerance,
f"{mode} shell thickness",
)
if "Face result check" not in result:
raise SystemExit(f"{mode} shell thickness result did not report Face result check: {result}")
if "First-level check" not in result:
raise SystemExit(f"{mode} shell thickness result did not report first-level check: {result}")
print(f"mode={mode}")
print(f"face_id={face_id}")