fix the finger 2D canvas orientation and the 3D arrow size

The unrolled canvas put the finger's +x where the 3D default view puts screen-left,
and the 3D flow arrows rasterised as 0.23 px hairlines because their stroke was 2 %
of their own sub-millimetre length. The 2D presentation is now the derived half turn
(persisted, set from the plot-settings dialog) and the 3D stroke is one canvas pixel
— the width the 2D canvas draws — verified numerically and with real-GL snapshots.
This commit is contained in:
ykcheungab
2026-10-04 10:03:46 +08:00
parent 9b1a3fb922
commit dda6c74fb7
14 changed files with 1383 additions and 15 deletions
@@ -10,6 +10,11 @@ from typing import Any
import numpy as np
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.acquisition.display_orientation import (
DEFAULT_FINGER_ORIENTATION,
effective_orientation,
normalise_orientation,
)
from orisys_app_shared.acquisition.image_backend import (
DEPTH,
SOURCE_LIVE,
@@ -841,6 +846,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
# The finger's 2D presentation: a *display* convention for the unrolled canvas
# (see ``acquisition.display_orientation``), stored as a preference and applied
# where the picture is drawn, never to the frame's own arrays.
self._finger_orientation = normalise_orientation(
self.prefs.get("finger_2d_orientation", DEFAULT_FINGER_ORIENTATION)
)
self.toolbar.set_settings_expanded(bool(self.prefs.get("settings_expanded", False)))
self._sync_settings_action()
self._apply_plot_prefs_to_panel()
@@ -1521,6 +1532,30 @@ class StudioMainWindow(QMainWindow):
def _draw_style(self) -> str:
return self.display_ctl.draw_style()
def finger_orientation(self) -> str:
"""How the finger's 2D canvas is presented (a display convention only)."""
return normalise_orientation(
getattr(self, "_finger_orientation", DEFAULT_FINGER_ORIENTATION)
)
def set_finger_orientation(self, orientation: str) -> str:
"""Adopt a 2D presentation for the finger and redraw the frame on screen.
Takes effect at once — the next render of the frame the panel is already
showing uses it, and a running session repaints immediately — and is stored in
the preferences, so it also survives a restart. The 3D pane is untouched by
design: its frame is the design export's, and the 2D orientation exists to make
the *canvas* agree with it.
"""
wanted = normalise_orientation(orientation)
if wanted == self.finger_orientation():
return wanted
self._finger_orientation = wanted
self.prefs["finger_2d_orientation"] = wanted
self._persist_prefs()
self._repaint_live_frame()
return wanted
def _arrow_policy(self):
"""The one flow-arrow policy the 2D canvas and the 3D surface both sample with.
@@ -1580,12 +1615,17 @@ class StudioMainWindow(QMainWindow):
# dialog is the place that has to say "next session, not this one".
filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self.display_ctl.device_sample_rate(),
finger_orientation=self._finger_orientation,
)
if dialog.exec() != PlotSettingsDialog.Accepted:
return
# One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings())
# The 2D presentation is applied here rather than in the controller: it changes
# the picture the *window* draws (``_show_image_frame``), and it must take
# effect without a restart.
self.set_finger_orientation(dialog.orientation())
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
@@ -2585,6 +2625,12 @@ class StudioMainWindow(QMainWindow):
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary,
arrow_policy=self._arrow_policy(),
# The 2D presentation of the finger's unrolled canvas. Display only: the
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
# not involved at all (its frame is the design export's).
orientation=effective_orientation(
self._finger_orientation, draw_style=draw_style
),
)
if rendered is None:
# Never substitute another picture here: a missing depth map that showed
@@ -2619,6 +2665,7 @@ class StudioMainWindow(QMainWindow):
self.prefs["units"] = self.toolbar.units()
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg()
self.prefs["finger_2d_orientation"] = self.finger_orientation()
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
@@ -11,6 +11,11 @@ reports as done is what the record will say (see ``force/filters.py``).
from __future__ import annotations
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.acquisition.display_orientation import (
DEFAULT_FINGER_ORIENTATION,
ORIENTATIONS,
normalise_orientation,
)
from orisys_app_shared.force.filters import (
FilterSettings,
available_presets,
@@ -69,9 +74,12 @@ class PlotSettingsDialog(QDialog):
filters: FilterSettings | None = None,
filters_locked: bool = False,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
finger_orientation: str = DEFAULT_FINGER_ORIENTATION,
) -> None:
super().__init__(parent)
self.setModal(True)
# The layout's own minimum (618 px with the 2D presentation group) keeps the
# buttons on screen; this is the height it opens at.
self.resize(460, 640)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
@@ -153,6 +161,22 @@ class PlotSettingsDialog(QDialog):
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
# How the finger's 2D canvas is turned for display. A *presentation* choice, so
# it lives with the other display ones (this dialog), not with the acquisition
# filters below it, and it is applied at display time only — see
# ``acquisition.display_orientation`` for why the shipped default is a half turn.
self.orientation_label = QLabel()
self.orientation_combo = QComboBox()
for name in ORIENTATIONS:
self.orientation_combo.addItem(t(f"dialog.orientation_{name}"), name)
index = self.orientation_combo.findData(
normalise_orientation(finger_orientation)
)
self.orientation_combo.setCurrentIndex(max(index, 0))
self.orientation_note = QLabel()
self.orientation_note.setObjectName("hintText")
self.orientation_note.setWordWrap(True)
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
@@ -164,6 +188,12 @@ class PlotSettingsDialog(QDialog):
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form)
self.orientation_group = QGroupBox()
orientation_form = QFormLayout(self.orientation_group)
orientation_form.addRow(self.orientation_label, self.orientation_combo)
orientation_form.addRow(self.orientation_note)
root.addWidget(self.orientation_group)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_min_spin,
@@ -418,6 +448,10 @@ class PlotSettingsDialog(QDialog):
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
def orientation(self) -> str:
"""The 2D presentation the dialog is showing (see ``display_orientation``)."""
return normalise_orientation(self.orientation_combo.currentData())
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
@@ -426,6 +460,10 @@ class PlotSettingsDialog(QDialog):
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Reset" means the shipped picture as well: the finger's derived default.
self.orientation_combo.setCurrentIndex(
max(self.orientation_combo.findData(DEFAULT_FINGER_ORIENTATION), 0)
)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
@@ -461,6 +499,13 @@ class PlotSettingsDialog(QDialog):
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.orientation_group.setTitle(t("dialog.plot_orientation_group"))
self.orientation_label.setText(t("dialog.plot_orientation"))
self.orientation_combo.setToolTip(t("dialog.plot_orientation_tip"))
self.orientation_note.setText(t("dialog.plot_orientation_note"))
for index in range(self.orientation_combo.count()):
name = str(self.orientation_combo.itemData(index) or "")
self.orientation_combo.setItemText(index, t(f"dialog.orientation_{name}"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
self.preset_combo.setToolTip(t("dialog.filter_preset_tip"))