diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 74a23b7..740f885 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -230,6 +230,8 @@ "arrow_area_masked": "Valid area", "arrow_area_full": "Full frame", "depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.", + "depth_scale_tip_adaptive": "Per-frame adaptive scale: the range follows this frame's valid depth, and the 2D and 3D views share it. Grey marks samples with no depth.", + "depth_scale_tip_fixed": "Fixed scale: both the 2D and 3D views use the bounds typed in settings. Grey marks samples with no depth.", "plots": "Force Charts", "cal": "Calibration", "mesh3d": "3D mesh", @@ -518,14 +520,16 @@ "legend_fixed": "fixed", "scale": "Colour scale", "scale_range": "Range", - "scale_range_tip": "Bounds of the colour scale in fixed mode. In automatic mode the range comes from the valid samples of the frame on screen, so a quantity whose size differs by orders of magnitude is still readable.", + "scale_range_tip": "Bounds used when the colour scale is fixed. In automatic mode the depth scale stays latched to the first frame of the session, unless Per-frame adaptive is on — then both the 2D and 3D views refit to the current frame's valid depth.", + "depth_adaptive": "Per-frame adaptive", + "depth_adaptive_tip": "When on, the 2D depth view and the 3D surface both refit the colour scale to the current frame's valid depth. When off, the scale stays latched to the first depth frame of the session so frames stay comparable. This switch does nothing while the colour scale is fixed.", "scale_fixed": "Fixed", "scale_auto": "Automatic", "max_fps": "Display rate", "max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.", "reset_view": "Reset view", "settings_title": "3D display settings", - "settings_hint": "Everything here affects the 3D view's display only: measuring, recording and the charts are untouched, and each change applies at once.", + "settings_hint": "Per-frame adaptive applies to both the 2D depth view and the 3D surface. Every other item affects the 3D display only. Measuring, recording and the charts are untouched, and each change applies at once.", "legend": "{quantity} [{unit}] · {quality}", "legend_none": "No measurement on screen — the surface model is shown uncoloured", "quality_calibrated": "calibrated", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 8fe8532..201c502 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -230,6 +230,8 @@ "arrow_area_masked": "有效区域", "arrow_area_full": "全画幅", "depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据", + "depth_scale_tip_adaptive": "每帧自适应色标:范围随当前帧的有效深度变化,二维与三维使用同一范围;灰色区域表示无深度数据", + "depth_scale_tip_fixed": "固定色标:二维与三维都使用设置中填写的上下限;灰色区域表示无深度数据", "plots": "力曲线图", "cal": "标定", "mesh3d": "三维网格", @@ -518,14 +520,16 @@ "legend_fixed": "固定", "scale": "色标", "scale_range": "范围", - "scale_range_tip": "固定模式下色标的上下限。自动模式的范围来自当前帧的有效样本,因此量级差别很大的量也都能看清。", + "scale_range_tip": "固定模式下色标的上下限。自动模式默认把深度色标锁定在本会话第一帧;勾选「每帧自适应」后,二维与三维都按当前帧的有效深度重算。", + "depth_adaptive": "每帧自适应", + "depth_adaptive_tip": "勾选后,二维深度图和三维表面都按当前帧的有效深度重算色标。不勾选时,色标锁定在本会话第一个有效深度帧,便于跨帧比较。固定色标模式下此项不起作用。", "scale_fixed": "固定", "scale_auto": "自动", "max_fps": "显示帧率", "max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。", "reset_view": "复位视角", "settings_title": "三维显示设置", - "settings_hint": "这里的每一项都只影响三维视图的显示:采集、记录与力曲线完全不受影响,改完立即生效。", + "settings_hint": "「每帧自适应」同时作用于二维深度图和三维表面;其余项只影响三维显示。采集、记录与力曲线不受影响,改完立即生效。", "legend": "{quantity} [{unit}] · {quality}", "legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型", "quality_calibrated": "已标定", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index fcab083..4f5a9f3 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -2001,6 +2001,10 @@ class StudioMainWindow(QMainWindow): self.mesh3d_panel.message.text().strip() or t("panels.mesh3d_no_scene") ) + return + # The depth colour-bar switch lives in that dialog and also recolours the 2D + # canvas, so the live path has to hear the change as soon as the dialog exists. + self._connect_analysis_display(self.mesh3d_panel) def _on_sensor_type_changed(self, sensor_type: str) -> None: self.prefs["sensor_type"] = str(sensor_type) @@ -2331,7 +2335,7 @@ class StudioMainWindow(QMainWindow): self._active_calibration_id = "" self._depth_range_value = None self._last_depth_frame = None - self._sync_shared_depth_scale(None) + self._publish_shared_depth_scale(None) # The new session has not established a domain boundary yet; the previous # session's island must not survive into it (a different sensor is a different # island, and the latch compares against what it holds). @@ -2647,34 +2651,97 @@ class StudioMainWindow(QMainWindow): presenter.tick_3d(display_paused=self._display_paused) # ------------------------------------------------------------ depth scale - def _depth_range(self) -> tuple[float, float] | None: - """The colour scale, latched to the first frame that carries depth. + def _depth_display_settings(self): + """The display settings that decide the shared depth colour span.""" + panel = getattr(self, "mesh3d_panel", None) + if panel is None or not hasattr(panel, "display_settings"): + return None + try: + return panel.display_settings() + except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view + return None - Re-fitting the range on every frame (the old behaviour) made colours - drift, so two frames could look identical while holding different - depths. Latching keeps frames comparable for the whole session; the - numeric range is shown in the panel header. The same span is handed to - the 3D pane so both legends name one interval. - """ - if self._depth_range_value is not None: - return self._depth_range_value - depth = self._last_depth_frame + def _fixed_depth_span(self) -> tuple[float, float] | None: + """Typed colour bounds, when the operator chose a fixed scale.""" + settings = self._depth_display_settings() + scale = getattr(settings, "color_scale", None) if settings is not None else None + if scale is None or str(getattr(scale, "mode", "")) != "fixed": + return None + lo = float(scale.lower) + hi = float(scale.upper) + if hi <= lo: + hi = lo + 1.0 + return lo, hi + + def _depth_scale_adaptive(self) -> bool: + settings = self._depth_display_settings() + return bool(getattr(settings, "depth_scale_adaptive", False)) + + def _depth_scale_mode(self) -> str: + """``fixed``, ``adaptive`` or ``latched`` — the rule both views follow.""" + if self._fixed_depth_span() is not None: + return "fixed" + if self._depth_scale_adaptive(): + return "adaptive" + return "latched" + + def _frame_depth_span(self, depth) -> tuple[float, float] | None: + """Finite minimum and maximum of one depth map.""" if depth is None: return None arr = np.asarray(depth, dtype=np.float64) finite = np.isfinite(arr) if arr.size == 0 or not np.any(finite): return None - lo = float(np.nanmin(arr[finite])) - hi = float(np.nanmax(arr[finite])) + lo = float(np.min(arr[finite])) + hi = float(np.max(arr[finite])) if hi <= lo: hi = lo + 1.0 - self._depth_range_value = (lo, hi) - self._sync_shared_depth_scale(self._depth_range_value) + return lo, hi + + def _depth_range(self) -> tuple[float, float] | None: + """The session latch: the first frame that carries finite depth. + + Re-fitting the range on every frame made colours drift, so two frames + could look identical while holding different depths. Latching keeps + frames comparable for the whole session. The per-frame colour bar is + the adaptive setting; this method is only the latch. + """ + if self._depth_range_value is not None: + return self._depth_range_value + span = self._frame_depth_span(self._last_depth_frame) + if span is None: + return None + self._depth_range_value = span return self._depth_range_value - def _sync_shared_depth_scale(self, span: tuple[float, float] | None) -> None: - """Push the latched 2D depth colour span to the 3D surface (or clear it).""" + def _active_depth_span(self) -> tuple[float, float] | None: + """The span both views colour with for the depth now on screen. + + Leaving the adaptive or fixed rule re-latches on this frame, so the + next comparison does not keep a range from the other mode. + """ + mode = self._depth_scale_mode() + previous = getattr(self, "_depth_scale_mode_applied", "latched") + if mode == "latched" and previous != "latched": + self._depth_range_value = None + self._depth_scale_mode_applied = mode + if mode == "fixed": + return self._fixed_depth_span() + if mode == "adaptive": + return self._frame_depth_span(self._last_depth_frame) + return self._depth_range() + + def _depth_scale_tip_key(self) -> str: + mode = getattr(self, "_depth_scale_mode_applied", "latched") + if mode == "fixed": + return "panels.depth_scale_tip_fixed" + if mode == "adaptive": + return "panels.depth_scale_tip_adaptive" + return "panels.depth_scale_tip" + + def _publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None: + """Hand the depth colour span to the 3D pane, when one exists.""" panel = getattr(self, "mesh3d_panel", None) if panel is not None and hasattr(panel, "set_shared_depth_scale"): panel.set_shared_depth_scale(span) @@ -2688,7 +2755,7 @@ class StudioMainWindow(QMainWindow): instead of leaving the operator to wonder. It is not the same statement as ``panels.depth_unavailable``, which is for a frame that carries no depth at all. """ - span = self._depth_range() + span = self._active_depth_span() text = "" if span is None else t( "panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}" ) @@ -2804,13 +2871,17 @@ class StudioMainWindow(QMainWindow): boundary = self._latched_domain_mask(frame) self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style)) self.arrow_panel.set_valid_area(frame.valid_mask is not None) - # Latch (and sync to 3D) from any depth-carrying frame, not only when the - # 2D mode is Depth — the surface may show depth while the canvas shows arrows. if frame.depth is not None: + # Remember every depth-bearing frame, whichever 2D mode is showing, + # so a later depth view and the 3D surface name the same interval. self._last_depth_frame = frame.depth - self._depth_range() + span = self._active_depth_span() + self._publish_shared_depth_scale(span) if mode == DEPTH: - self.arrow_panel.set_depth_scale(self._depth_scale_text(frame)) + self.arrow_panel.set_depth_scale( + self._depth_scale_text(frame), + tip_key=self._depth_scale_tip_key(), + ) else: self.arrow_panel.set_depth_scale(None) rendered = render_image( @@ -2819,7 +2890,7 @@ class StudioMainWindow(QMainWindow): mode=mode, theme=self._theme, draw_style=draw_style, - depth_range=self._depth_range(), + depth_range=span, canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")), domain_mask=boundary, # A frame read from a stored analysis result says which policy its *recovered* @@ -3060,9 +3131,16 @@ class StudioMainWindow(QMainWindow): if panel is None or not hasattr(panel, "settingsChanged"): return False panel.settingsChanged.connect(self.replay_ctl.on_display_settings) + panel.settingsChanged.connect(self._on_depth_scale_display) self._analysis_display_connected = True return True + def _on_depth_scale_display(self, _settings) -> None: + """A colour-scale change has to repaint the frame already on screen.""" + presenter = getattr(self, "acquire_presenter", None) + if presenter is not None and hasattr(presenter, "drop_render_marker"): + presenter.drop_render_marker() + def _await_analysis_display(self) -> None: """Wait, briefly and off the critical path, for the 3D view to exist. diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 3e2c697..7efdb11 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -506,12 +506,6 @@ class ThreeDCoordinator: # held field frame so the operator sees the change without waiting. self._settings_dirty = True - def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None: - """Adopt the 2D canvas's latched depth colour span for automatic colouring.""" - setter = getattr(self._widget, "set_shared_depth_scale", None) - if callable(setter): - setter(span) - def set_ramp(self, ramp: str) -> None: """Hand the theme's colour ramp to the composer. diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index a539f62..cbc7890 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -367,9 +367,10 @@ def _field_scalar_range( ) -> tuple[float, float] | None: """The automatic colour span for the uploaded field. - Depth shares the 2D canvas's latched ``(lo, hi)`` when the window has one, so the - two legends name the same interval. Flow (and depth before that latch exists) keep - the measured P5/P95 blend. + Depth shares the span the window published for this frame — the session latch, or + the current frame's finite minimum and maximum when the adaptive colour bar is on — + so the two legends name the same interval. Flow (and depth before that span exists) + keep the measured P5/P95 blend. """ descriptor = getattr(field, "descriptor", None) quantity = str(getattr(descriptor, "quantity", "") or "") @@ -1018,8 +1019,8 @@ class GLRenderer: view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4) projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4) scale = settings.color_scale - # Prefer the 2D latched span at draw time, not only at upload: the latch can - # arrive after this field was uploaded, and a repaint must still equalise. + # Prefer the shared depth span at draw time, not only at upload: the window can + # publish a new latch or a new per-frame span after this field was uploaded. automatic = self._scalar_range descriptor = getattr(scale, "descriptor", None) if scale is not None else None if ( diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 35692ab..fc181f6 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -257,6 +257,13 @@ class ThreeDPanel(QFrame): self.scale_combo.currentIndexChanged.connect(self._on_scale_mode_changed) form.addRow(self.scale_label, self.scale_combo) + # Depth only, and shared with the 2D canvas: off keeps the session latch, + # on restores the per-frame colour bar. Fixed bounds ignore this switch. + self.depth_adaptive_check = QCheckBox() + self.depth_adaptive_check.setChecked(False) + self.depth_adaptive_check.toggled.connect(self._emit_settings) + form.addRow(self.depth_adaptive_check) + # The two bounds get their own row rather than sharing the mode's: three # controls side by side made this row the widest thing in the pane. self.range_label = QLabel() @@ -389,6 +396,7 @@ class ThreeDPanel(QFrame): self.scale_combo.setEnabled(False) self.lower_spin.setEnabled(False) self.upper_spin.setEnabled(False) + self._sync_depth_adaptive_check() self.scale_bar.setVisible(False) self.scale_lower_label.setVisible(False) self.scale_upper_label.setVisible(False) @@ -397,6 +405,7 @@ class ThreeDPanel(QFrame): fixed = self.scale_combo.currentData() == "fixed" self.lower_spin.setEnabled(fixed) self.upper_spin.setEnabled(fixed) + self._sync_depth_adaptive_check() self.scale_bar.set_lut( "diverging" if descriptor.signed and not fixed else "sequential" ) @@ -685,6 +694,7 @@ class ThreeDPanel(QFrame): self.field_combo.setCurrentIndex(index) self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled)) self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled)) + self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive)) scale = settings.color_scale if scale is not None: index = self.scale_combo.findData(str(scale.mode)) @@ -730,6 +740,7 @@ class ThreeDPanel(QFrame): field=self.field(), flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()), contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()), + depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()), ) # ------------------------------------------------------------------ signals @@ -751,6 +762,21 @@ class ThreeDPanel(QFrame): self.gain_spin.setEnabled(bool(enabled) and available and self.enable_check.isChecked()) self._emit_settings() + def _sync_depth_adaptive_check(self) -> None: + """The per-frame switch applies only while the colour scale is automatic. + + Fixed bounds are typed by the operator and already shared; leaving the box + enabled there would look as if both rules were in force. + """ + automatic = str(self.scale_combo.currentData() or "fixed") != "fixed" + measuring = ( + self._descriptor is not None + and self._descriptor.quantity != "unavailable" + ) + self.depth_adaptive_check.setEnabled( + automatic and measuring and self.enable_check.isChecked() + ) + def _on_scale_mode_changed(self, index: int) -> None: """Only a fixed scale has bounds to type in; automatic ones come from data.""" self.set_descriptor(self._descriptor) @@ -789,6 +815,8 @@ class ThreeDPanel(QFrame): self.range_label.setToolTip(t("scene3d.scale_range_tip")) self.scale_combo.setItemText(0, t("scene3d.scale_fixed")) self.scale_combo.setItemText(1, t("scene3d.scale_auto")) + self.depth_adaptive_check.setText(t("scene3d.depth_adaptive")) + self.depth_adaptive_check.setToolTip(t("scene3d.depth_adaptive_tip")) self.fps_label.setText(t("scene3d.max_fps")) self.fps_label.setToolTip(t("scene3d.max_fps_tip")) self.reset_button.setText(t("scene3d.reset_view")) diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index 55be27c..4630cfa 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -88,6 +88,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]: "flow_off_px": float(settings.flow_off_px), "flow_grid_spacing": int(settings.flow_grid_spacing), "max_arrows": int(settings.max_arrows), + "depth_scale_adaptive": bool(settings.depth_scale_adaptive), } if scale is not None: block["color_scale"] = { @@ -169,4 +170,5 @@ def settings_from_prefs( flow_off_px=flow_off, flow_grid_spacing=spacing, max_arrows=max_arrows, + depth_scale_adaptive=_flag(raw.get("depth_scale_adaptive"), False), ) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 472f312..617c6ee 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -107,8 +107,6 @@ class Scene3DView(QWidget): #: they cannot drift apart. ``None`` means "no session": the adapter then uses #: each frame's own mask, which is what the tests and the PoC want. self._domain_latch: Any = None - #: The 2D canvas's latched depth colour span, held until the coordinator exists. - self._shared_depth_scale: tuple[float, float] | None = None self._expected: ExpectedGeometry | None = None self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None #: The adapter behind ``_snapshot``: it owns the reason a packet was refused, @@ -150,8 +148,6 @@ class Scene3DView(QWidget): self._coordinator = ThreeDCoordinator( self._widget, self._composer, self._executor, max_fps=30 ) - if self._shared_depth_scale is not None: - self._coordinator.set_shared_depth_scale(self._shared_depth_scale) self._panel = ThreeDPanel(self._coordinator, self._widget) # The picture must not be squeezed out of existence by the controls above it: # a GL widget with zero height draws nothing and looks like a broken view. @@ -318,17 +314,9 @@ class Scene3DView(QWidget): self._adapter.domain_latch = latch def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None: - """Share the 2D canvas's latched depth colour span with this pane. - - Automatic depth colouring on the surface uses this interval so the 3D legend - names the same ``(lo, hi)`` as the 2D depth scale. Fixed mode still follows the - operator's spin boxes. - """ - self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1])) - if self._coordinator is not None and hasattr( - self._coordinator, "set_shared_depth_scale" - ): - self._coordinator.set_shared_depth_scale(self._shared_depth_scale) + """Colour depth with the 2D canvas's latched ``(lo, hi)``.""" + if self._widget is not None: + self._widget.set_shared_depth_scale(span) def set_theme(self, theme: str) -> None: """Adopt the application theme's colour ramp. diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index 22b5fe1..04e195f 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -383,11 +383,17 @@ class ArrowPanel(QFrame): self._theme = str(theme) self.colorbar.set_theme(self._theme) - def set_depth_scale(self, text: str | None) -> None: - """Show the fixed depth colour-scale range (``None`` hides it).""" + def set_depth_scale( + self, text: str | None, *, tip_key: str = "panels.depth_scale_tip" + ) -> None: + """Show the depth colour-scale range (``None`` hides it). + + ``tip_key`` names which rule produced the numbers: the session latch, the + per-frame adaptive bar, or typed fixed bounds. + """ self._depth_scale_text = str(text or "") self.depth_scale_label.setText(self._depth_scale_text) - self.depth_scale_label.setToolTip(t("panels.depth_scale_tip")) + self.depth_scale_label.setToolTip(t(tip_key)) self._refresh_footer_visibility() def _refresh_scale_label(self) -> None: diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index e2679a1..226960b 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -114,7 +114,7 @@ class Mesh3dPanel(QFrame): #: The session's island latch, shared with the 2D canvas (see #: ``set_domain_latch``). Held until the view exists. self._domain_latch = None - #: The 2D canvas's latched depth colour span (see ``set_shared_depth_scale``). + #: The 2D canvas's latched depth colour span, held until the view exists. self._shared_depth_scale: tuple[float, float] | None = None # Reserve this top-level window's native handle *now*, before the window is @@ -230,10 +230,25 @@ class Mesh3dPanel(QFrame): self._view.set_theme(self._theme) def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None: - """Share the 2D depth colour span with the 3D surface (and hold it until built).""" - self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1])) + """Share the 2D canvas's latched depth colour span with the 3D surface. + + Held until the view exists, the same way the island latch is: the view is + built on first use, and a span decided before then still has to colour the + first depth frame. + """ + normalized: tuple[float, float] | None + if span is None: + normalized = None + else: + normalized = (float(span[0]), float(span[1])) + if normalized == self._shared_depth_scale: + return + self._shared_depth_scale = normalized if self._view is not None and hasattr(self._view, "set_shared_depth_scale"): - self._view.set_shared_depth_scale(self._shared_depth_scale) + self._view.set_shared_depth_scale(normalized) + + def shared_depth_scale(self) -> tuple[float, float] | None: + return self._shared_depth_scale def set_domain_latch(self, latch) -> None: """Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``). @@ -329,7 +344,7 @@ class Mesh3dPanel(QFrame): view.set_theme(self._theme) if self._domain_latch is not None and hasattr(view, "set_domain_latch"): view.set_domain_latch(self._domain_latch) - if hasattr(view, "set_shared_depth_scale"): + if self._shared_depth_scale is not None and hasattr(view, "set_shared_depth_scale"): view.set_shared_depth_scale(self._shared_depth_scale) if self._pending_settings is not None: view.restore_settings(self._pending_settings) diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index 7746513..f57d230 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -57,6 +57,7 @@ def test_the_pane_holds_a_legend_and_no_settings(panel): panel.deform_check, panel.gain_spin, panel.scale_combo, + panel.depth_adaptive_check, panel.fps_combo, panel.reset_button, ): @@ -89,10 +90,22 @@ def test_the_settings_dialog_hosts_the_panes_own_controls(panel): assert not dialog.isVisible() assert panel.enable_check.text(), "closing the dialog destroyed its controls" assert panel.settings().max_fps == 30 + assert panel.settings().depth_scale_adaptive is False finally: dialog.deleteLater() +def test_adaptive_depth_scale_round_trips_through_the_dialog(panel): + """The per-frame colour bar is a saved setting, not a one-shot toggle.""" + from orisys_app_shared.scene3d.contracts import DisplaySettings + + panel.apply_settings(DisplaySettings(depth_scale_adaptive=True)) + assert panel.depth_adaptive_check.isChecked() + assert panel.settings().depth_scale_adaptive is True + panel.apply_settings(DisplaySettings()) + assert panel.settings().depth_scale_adaptive is False + + def test_the_pane_header_offers_the_two_actions(qt_app): """A gear and a view reset: the two things that belong to *looking*.""" from orisys_studio.ui.mesh3d_panel import Mesh3dPanel diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 71a052f..7133984 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -473,6 +473,7 @@ def test_the_display_settings_round_trip(): flow_off_px=0.35, flow_grid_spacing=12, max_arrows=250, + depth_scale_adaptive=True, color_scale=ColorScale( descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), lower=0.25, @@ -495,6 +496,8 @@ def test_the_display_settings_round_trip(): assert restored.color_scale is not None assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5) assert restored.color_scale.mode == "fixed" + assert restored.depth_scale_adaptive is True + assert scene3d_prefs.settings_from_prefs({}).depth_scale_adaptive is False def test_a_saved_descriptor_is_never_restored_as_a_measurement(): diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 15dbef9..6137c5f 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -920,11 +920,6 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): window._on_run_tick() latched = window.arrow_panel.depth_scale_label.text() assert latched - shared = window.mesh3d_panel._shared_depth_scale - assert shared == window._depth_range_value - assert shared is not None - assert shared[0] == pytest.approx(0.0) - assert shared[1] == pytest.approx(1.0) window._last_rendered_view_mode = None stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8))) @@ -932,12 +927,61 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): assert window.arrow_panel.depth_scale_label.text() == latched, ( "the depth colour scale re-fitted on every frame" ) - assert window.mesh3d_panel._shared_depth_scale == shared, ( - "the 3D depth colour span must stay locked to the 2D latch" + assert window.mesh3d_panel.shared_depth_scale() == window._depth_range(), ( + "the 3D depth colour scale is not the 2D canvas's latched span" ) _finish(window, stuck) +def test_adaptive_depth_scale_refits_both_views(make_window): + """The setting restores the per-frame colour bar, and both views share it.""" + import numpy as np + from orisys_app_shared.scene3d.contracts import DisplaySettings + + window, stuck = _running_window(make_window) + window.arrow_panel.set_view_mode("depth") + window.mesh3d_panel.set_display_settings( + DisplaySettings(depth_scale_adaptive=True) + ) + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(1, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8))) + window._on_run_tick() + from orisys_app_shared.i18n import t + + first = window.arrow_panel.depth_scale_label.text() + assert window.mesh3d_panel.shared_depth_scale() == (0.0, 1.0) + assert "1.000" in first + assert window.arrow_panel.depth_scale_label.toolTip() == t( + "panels.depth_scale_tip_adaptive" + ) + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8))) + window._on_run_tick() + assert window.arrow_panel.depth_scale_label.text() != first, ( + "the adaptive colour bar stayed on the first frame" + ) + assert window.mesh3d_panel.shared_depth_scale() == (0.0, 9.0) + assert window._depth_range_value is None + + window.mesh3d_panel.set_display_settings(DisplaySettings()) + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(3, depth=np.linspace(0.0, 4.0, 64).reshape(8, 8))) + window._on_run_tick() + latched = window.mesh3d_panel.shared_depth_scale() + assert latched == (0.0, 4.0) + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(4, depth=np.linspace(0.0, 8.0, 64).reshape(8, 8))) + window._on_run_tick() + assert window.mesh3d_panel.shared_depth_scale() == latched, ( + "leaving adaptive did not return to the session latch" + ) + assert window.arrow_panel.depth_scale_label.toolTip() == t("panels.depth_scale_tip") + _finish(window, stuck) + + def test_the_domain_boundary_is_latched_for_the_session(make_window): """The 2D boundary is the device's island: rebuilt per frame, decided once. diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 4034912..77d26ae 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -645,6 +645,11 @@ class DisplaySettings: flow_grid_spacing: int = 10 #: Cap on arrows uploaded per overlay kind. max_arrows: int = 400 + #: Depth colour span for both the 2D canvas and the 3D surface. Off (the default) + #: latches the first finite depth of the session so frames stay comparable. On + #: refits both views to the current frame's finite minimum and maximum — the + #: earlier per-frame colour bar. A fixed colour scale still wins over either. + depth_scale_adaptive: bool = False @dataclass(frozen=True)