feat: 完善一级关系编辑 UI 与视图体验
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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')}")
|
||||
|
||||
@@ -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",
|
||||
):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')}")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user