改善 Studio 回放布局与传感器切换体验

缩短界面提示,保存上次视频目录,并防止旧结果绘入 Finger 3D。针对性测试及可见界面复测通过。
This commit is contained in:
ykcheungab
2026-10-06 20:18:50 +08:00
parent 425fa0d44d
commit 321e52f47a
11 changed files with 265 additions and 41 deletions
+28 -15
View File
@@ -84,14 +84,14 @@ RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
DEFAULT_SPLITTER_SIZES = (560, 400, 480)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
# its minimum was measured under the stylesheet in both languages (241 px in English)
# and could not go below 260. The controls now live in the settings dialog and what
# is left wraps, so the pane can be dragged narrow again — the picture is the point.
"mesh3d": 200,
"arrow": 300,
"arrow": 380,
"plots": 340,
"cal": 300,
}
@@ -101,13 +101,11 @@ VIEW_PRESETS = ("custom", "live", "replay", "calibration")
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
#: Replay is the live layout on purpose: the two views differ in their source
#: (a camera, or a recorded video), not in their furniture.
#: The 3D pane is the widest of the three: it is the view's subject when it is on, and
#: the 2D panel is a reading of the same measurement rather than a second subject.
#: Live gives the 3D picture the most width; replay gives the history chart the
#: most width. Both keep the 2D picture large enough to inspect marker detail.
VIEW_SPLITTER_SIZES = {
"live": (620, 300, 520, 0),
"replay": (620, 300, 520, 0),
"live": (560, 400, 480, 0),
"replay": (480, 400, 560, 0),
"calibration": (0, 0, 560, 880),
}
#: The 3D pane belongs to the live view and to history browsing, and only for a
@@ -1488,7 +1486,12 @@ class StudioMainWindow(QMainWindow):
if self._view_preset == "replay":
path = self.toolbar.video_path()
if path:
detail = t("status.rail_video_name", name=Path(path).name)
key = (
"status.rail_video_name"
if self.replay.has_video()
else "status.rail_recent_video"
)
detail = t(key, name=Path(path).name)
# Name the *result's* profile and whether it was reused or just finished, so
# changing the sensor picker cannot look like the open figures came from the
# newly selected product.
@@ -1497,11 +1500,8 @@ class StudioMainWindow(QMainWindow):
provenance = getattr(getattr(session, "reader", None), "provenance", None)
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
if result_type:
detail = (
f"{detail} · {t('status.rail_result_profile', type=result_type)}"
if detail
else t("status.rail_result_profile", type=result_type)
)
result_label = t("status.rail_result_profile", type=result_type)
detail = f"{result_label} · {detail}" if detail else result_label
progress = getattr(self.replay_ctl, "_progress", None)
progress_state = (
str(progress.state()) if progress is not None and hasattr(progress, "state") else ""
@@ -1534,7 +1534,7 @@ class StudioMainWindow(QMainWindow):
# The full path and the replay's notes do not fit on a 30 px rail, so
# they live in the summary's tooltip (the bar that used to show them is
# gone: the two views share one layout).
tip_parts = [self.toolbar.video_path()]
tip_parts = [detail, self.toolbar.video_path()]
if self._view_preset == "replay" and self.replay.has_video():
# Includes the "this is a video, not a recording" note for a bare video.
tip_parts.append(self.replay.warnings_text())
@@ -1999,6 +1999,11 @@ class StudioMainWindow(QMainWindow):
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
if sensor_type == "finger":
# Entering Finger opens its principal view even if an earlier session
# left the optional pane turned off. The user can still turn it off.
self._show_finger_3d = True
self.prefs["show_finger_3d"] = True
self.cal_panel.set_meta_context(
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
@@ -2006,6 +2011,7 @@ class StudioMainWindow(QMainWindow):
# A different product is a different UV island; the previous latch must not
# paint the new canvas (a box result has no mask and would inherit a finger's).
self._domain_mask.reset()
self.mesh3d_panel.show_reference_only()
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
# missing package must not be reported while looking at a box.
self.layout_ctl.sync_3d_source()
@@ -2941,6 +2947,13 @@ class StudioMainWindow(QMainWindow):
policy is the open session's ``paint_policy``, so 3D arrows share the 2D canvas's
grid and threshold (including the box painter's 2 px floor).
"""
session = self.replay_ctl.available_result()
provenance = getattr(getattr(session, "reader", None), "provenance", None)
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
if result_type and result_type != self.sensor_picker.sensor_type():
# The old replay stays inspectable after a sensor switch, but its
# depth grid must never be submitted to the new sensor's 3D scene.
return
view = self.mesh3d_panel.view
if view is None:
return
@@ -238,7 +238,7 @@ class ReplayController(QObject):
*video* (what the operator recognises) and the charts pair it with the
``forces.csv`` written beside it.
"""
start = str(recordings_dir())
start = self._video_dialog_directory(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName(
self._dialog_parent(),
t("playback.open"),
@@ -247,6 +247,7 @@ class ReplayController(QObject):
)
if not chosen:
return
self._remember_video_directory(Path(chosen))
self.load_video(Path(chosen))
def on_browse_video(self) -> None:
@@ -258,7 +259,7 @@ class ReplayController(QObject):
"""
if self._host.running or self._host.preview_open:
return
start = self._toolbar.video_path() or str(Path.home())
start = self._video_dialog_directory(Path.home())
path, _ = QFileDialog.getOpenFileName(
self._dialog_parent(),
t("menu.open_video"),
@@ -267,8 +268,25 @@ class ReplayController(QObject):
)
if not path:
return
self._remember_video_directory(Path(path))
self.load_video(Path(path))
def _video_dialog_directory(self, fallback: Path) -> str:
"""Start both video pickers in the last directory the operator chose."""
saved = str(self._host.prefs.get("last_video_directory") or "")
if saved and Path(saved).is_dir():
return saved
current = self._toolbar.video_path()
if current and Path(current).parent.is_dir():
return str(Path(current).parent)
return str(fallback)
def _remember_video_directory(self, video: Path) -> None:
directory = video.parent
if directory.is_dir():
self._host.prefs["last_video_directory"] = str(directory)
self._host.persist_prefs()
def on_recompute_button(self) -> None:
"""The replay view's own entry for the processed picture."""
if self._actions is None:
@@ -1415,12 +1433,14 @@ class ReplayController(QObject):
if self._charts.is_recording_source():
self._charts.clear_recording_source()
self._charts.follow_latest()
self._image_panel.set_source_kind("live")
self._image_panel.set_source_kind("replay" if active else "live")
# The panel held a recorded frame: the next live packet must repaint it, so the
# "already rendered" marker is dropped.
self._host.drop_render_marker()
if not self._host.running and not self._host.preview_open:
self._image_panel.clear_preview(t("panels.arrow_empty"))
self._image_panel.clear_preview(
t("panels.replay_empty" if active else "panels.arrow_empty")
)
def _serve_curves(self, session: AnalysisSession | None = None) -> str:
"""Point the charts at the force trace this timeline really has, and label it.
+4 -6
View File
@@ -193,12 +193,10 @@ class ViewLayoutController(QObject):
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
# Panel weights: the charts are the widest panel in a view without 3D, the 3D
# pane is the widest one when it is on (it is the subject, not an inset), and
# the 2D panel is deliberately the smaller one.
arrow_weight = 3
plots_weight = 4
mesh_weight = 5
# Keep each preset's panel priority when the window is resized.
arrow_weight = 4
plots_weight = 5 if preset == "replay" else 4
mesh_weight = 4 if preset == "replay" else 5
for i in range(self._splitter.count()):
widget = self._splitter.widget(i)
if widget is self._mesh_panel: