322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""
|
||
Finger dashboard layout — edit this file to define the panel hierarchy and sizes.
|
||
|
||
Structure:
|
||
|
||
┌─ toolbar ─────────────────────────────────────────────────────┐
|
||
├─ mesh ─┬─ charts ─┬─ settings ────────────────────────────────┤
|
||
│ ├─ log ────┤ │
|
||
└────────┴──────────┴─────────────────────────────────────────┘
|
||
|
||
Each panel uses explicit width_px and height_px.
|
||
charts_log_stretch / log_stretch control charts vs log height inside the charts column.
|
||
|
||
MeshLighting (below) controls the 3D mesh panel sun light:
|
||
azimuth_deg — horizontal angle in degrees (0° = +X, 90° = +Y)
|
||
elevation_deg — vertical angle in degrees (positive = from above)
|
||
intensity — Filament sun strength (typical 30000–120000)
|
||
ambient/diffuse — Windows viewer shading weights (0–1)
|
||
|
||
MESH_BACKGROUND (below) sets the 3D mesh panel clear color (RGB 0–255).
|
||
|
||
MESH_CONTACT_ARROW (below) controls the yellow 3D contact arrow on the mesh panel:
|
||
reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve)
|
||
force_strength_mode — "log" | "power" | "linear"
|
||
min/max_length_fraction, min/max_thickness_scale, color
|
||
|
||
Mesh panel has two sizes (edit MESH_RENDER_* and TOP_ROW mesh slot):
|
||
display (TOP_ROW mesh width_px/height_px) — Qt widget size on screen
|
||
render (MESH_RENDER_WIDTH/HEIGHT) — Open3D offscreen resolution (upscaled for display)
|
||
|
||
Performance (3D dashboard; edit DashboardLayout defaults):
|
||
timer_interval_ms — Qt timer period (16 ≈ 60 Hz tick rate)
|
||
mesh_render_stride — render 3D mesh every N frames (2 ≈ half mesh cost)
|
||
chart_update_stride — update force charts every N frames
|
||
mesh_async_render — reserved for future use; mesh rendering currently stays on the UI thread
|
||
|
||
Edit TOP_ROW.slots to reorder or resize panels; set ``enabled=False`` to hide a panel (e.g. arrows).
|
||
Log sits under charts only (not under mesh). Settings is the rightmost column.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from typing import Literal
|
||
|
||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||
from viz.flow_arrow import FlowArrowStyle
|
||
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
||
|
||
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PanelSlot:
|
||
"""One panel in the dashboard."""
|
||
|
||
id: PanelId
|
||
title: str
|
||
width_px: int
|
||
height_px: int
|
||
stretch: int = 0
|
||
enabled: bool = True
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PanelRow:
|
||
"""Horizontal row of panels (the top visual strip)."""
|
||
|
||
slots: tuple[PanelSlot, ...]
|
||
collapsible: bool = False
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PanelSetup:
|
||
"""Full panel hierarchy: top row + log nested under charts."""
|
||
|
||
top_row: PanelRow
|
||
log_panel: PanelSlot
|
||
charts_log_stretch: int = 5
|
||
log_stretch: int = 1
|
||
|
||
|
||
# --- Top strip: mesh | arrows? | charts | settings (edit order and sizes here) ---
|
||
TOP_ROW = PanelRow(
|
||
slots=(
|
||
PanelSlot("mesh", "3D mesh", width_px=400, height_px=400),
|
||
PanelSlot("arrows", "UV Unrolled", width_px=400, height_px=400, enabled=True),
|
||
PanelSlot("charts", "Force charts", width_px=300, height_px=400),
|
||
PanelSlot("settings", "Settings", width_px=200, height_px=0),
|
||
),
|
||
)
|
||
|
||
LOG_PANEL = PanelSlot("log", "Log", width_px=0, height_px=100)
|
||
|
||
PANEL_SETUP = PanelSetup(
|
||
top_row=TOP_ROW,
|
||
log_panel=LOG_PANEL,
|
||
charts_log_stretch=5,
|
||
log_stretch=1,
|
||
)
|
||
|
||
# Open3D offscreen resolution for the mesh panel (independent of on-screen widget size).
|
||
MESH_RENDER_WIDTH_PX = 500
|
||
MESH_RENDER_HEIGHT_PX = 500
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MeshLighting:
|
||
"""3D mesh panel light — edit MESH_LIGHTING or DashboardLayout.mesh_lighting."""
|
||
|
||
azimuth_deg: float = -45.0 # finger outline
|
||
elevation_deg: float = -35.0 # tip to root
|
||
intensity: float = 75000.0
|
||
ambient: float = 0.35
|
||
diffuse: float = 0.65
|
||
|
||
@property
|
||
def direction(self) -> tuple[float, float, float]:
|
||
"""Unit light direction derived from azimuth / elevation (degrees)."""
|
||
az = math.radians(self.azimuth_deg)
|
||
el = math.radians(self.elevation_deg)
|
||
cos_el = math.cos(el)
|
||
return (cos_el * math.cos(az), cos_el * math.sin(az), math.sin(el))
|
||
|
||
|
||
MESH_LIGHTING = MeshLighting(
|
||
azimuth_deg=0,
|
||
elevation_deg=0,
|
||
intensity=75000.0,
|
||
ambient=0.5,
|
||
diffuse=0.5,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MeshBackground:
|
||
"""3D mesh panel clear color — edit MESH_BACKGROUND or DashboardLayout.mesh_background."""
|
||
|
||
color: tuple[int, int, int] = (0, 0, 0)
|
||
|
||
|
||
MESH_BACKGROUND = MeshBackground(color=(0, 0, 0))
|
||
|
||
# 3D mesh contact arrow — edit here for dashboard tuning (see ContactArrowStyle in viz/contact_arrow.py).
|
||
MESH_CONTACT_ARROW = ContactArrowStyle(
|
||
reference_normal_force=10000.0,
|
||
force_strength_mode="log",
|
||
force_strength_gamma=0.5,
|
||
min_length_fraction=0.02,
|
||
max_length_fraction=0.30,
|
||
min_thickness_scale=0.20,
|
||
max_thickness_scale=1.0,
|
||
normal_force_scale=1.0,
|
||
color=(1.0, 0.92, 0.10),
|
||
)
|
||
|
||
# 3D mesh flow line arrows — tail_color/head_color are RGB 0–255 (see FlowArrowStyle).
|
||
MESH_FLOW_ARROW = FlowArrowStyle(
|
||
threshold=2.0,
|
||
grid_spacing=20,
|
||
arrow_scale=0.5,
|
||
length_margin=1.0,
|
||
tail_color=(40, 100, 220),
|
||
head_color=(40, 100, 220),
|
||
head_fraction=0.5,
|
||
opacity=1,
|
||
line_width=15,
|
||
head_line_width=30.0,
|
||
)
|
||
|
||
# UV arrow synthetic canvas — BGR tuples (outside valid_mask, inside valid_mask).
|
||
ARROW_CANVAS_COLORS = ArrowCanvasColors(
|
||
outside_bgr=(0, 0, 0),
|
||
uv_bgr=(128, 0, 0),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LogConfig:
|
||
"""Log panel — edit LOG_CONFIG or DashboardLayout.log_config."""
|
||
|
||
status_enabled: bool = True
|
||
max_lines: int = 2000
|
||
font_family: str = "Cascadia Mono"
|
||
font_pt: int = 12
|
||
show_flow: bool = True
|
||
show_mask: bool = True
|
||
|
||
def format_status(
|
||
self,
|
||
*,
|
||
fps: float,
|
||
fnormal: float,
|
||
fshearx: float,
|
||
fsheary: float,
|
||
flow_rms: float = 0.0,
|
||
mask_coverage: float = 0.0,
|
||
wall_fps: float | None = None,
|
||
) -> str:
|
||
shear_mag = float((fshearx**2 + fsheary**2) ** 0.5)
|
||
if wall_fps is not None:
|
||
parts = [f"FPS={wall_fps:.1f}", f"sdk={fps:.1f}"]
|
||
else:
|
||
parts = [f"FPS={fps:.1f}"]
|
||
parts.extend(
|
||
[
|
||
f"Fn={fnormal:.1f}",
|
||
f"|Fs|={shear_mag:.1f}",
|
||
f"Fs=({fshearx:.1f}, {fsheary:.1f})",
|
||
]
|
||
)
|
||
return " ".join(parts)
|
||
|
||
|
||
LOG_CONFIG = LogConfig()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DashboardLayout:
|
||
"""Window chrome, chart styling, and panel hierarchy."""
|
||
|
||
panel_setup: PanelSetup = PANEL_SETUP
|
||
|
||
window_title: str = "Orisys Finger Dashboard"
|
||
window_width: int = 1300
|
||
window_height: int = 520
|
||
|
||
root_margin_px: int = 6
|
||
root_spacing_px: int = 6
|
||
toolbar_spacing_px: int = 8
|
||
|
||
plot_buffer_points: int = 120
|
||
plot_sample_rate_hz: float = 30.0
|
||
chart_title_font_pt: int = 11
|
||
chart_axis_label_font_pt: int = 9
|
||
chart_tick_font_pt: int = 9
|
||
y_axis_min_span: float = 10000.0
|
||
y_range_smooth_alpha: float = 0.5
|
||
curve_color: str = "#00E5FF"
|
||
|
||
# UV arrow panel: min flow magnitude to draw an arrow (display only; not dd02-ov.json contact.flow_threshold)
|
||
arrow_flow_threshold: float = 1.0
|
||
# "unrolled" = sensor texture; "pure_color" = solid canvas (tune colors via arrow_canvas_colors)
|
||
arrow_background: ArrowBackground = "pure_color"
|
||
arrow_canvas_colors: ArrowCanvasColors = ARROW_CANVAS_COLORS
|
||
|
||
mesh_lighting: MeshLighting = MESH_LIGHTING
|
||
mesh_background: MeshBackground = MESH_BACKGROUND
|
||
mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW
|
||
mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW
|
||
|
||
log_config: LogConfig = LOG_CONFIG
|
||
|
||
mesh_render_width_px: int = MESH_RENDER_WIDTH_PX
|
||
mesh_render_height_px: int = MESH_RENDER_HEIGHT_PX
|
||
|
||
timer_interval_ms: int = 16
|
||
mesh_async_render: bool = False
|
||
mesh_render_stride: int = 2
|
||
chart_update_stride: int = 2
|
||
|
||
def mesh_display_size(self) -> tuple[int, int]:
|
||
"""On-screen mesh panel size from TOP_ROW mesh slot."""
|
||
slot = self.top_slot("mesh")
|
||
return slot.width_px, slot.height_px
|
||
|
||
def mesh_render_size(self) -> tuple[int, int]:
|
||
"""Open3D offscreen render target size (may differ from display size)."""
|
||
return self.mesh_render_width_px, self.mesh_render_height_px
|
||
|
||
def slot(self, panel_id: PanelId) -> PanelSlot | None:
|
||
if self.panel_setup.log_panel.id == panel_id:
|
||
return self.panel_setup.log_panel
|
||
for slot in self.panel_setup.top_row.slots:
|
||
if slot.id == panel_id:
|
||
return slot
|
||
return None
|
||
|
||
def charts_column_height_px(self) -> int:
|
||
"""Total height of the charts column (charts + log when enabled)."""
|
||
charts = self.top_slot("charts")
|
||
height = charts.height_px
|
||
log = self.panel_setup.log_panel
|
||
if log.enabled and log.height_px > 0:
|
||
height += log.height_px
|
||
return height
|
||
|
||
def top_slot(self, panel_id: PanelId) -> PanelSlot:
|
||
for slot in self.panel_setup.top_row.slots:
|
||
if slot.id == panel_id:
|
||
return slot
|
||
raise KeyError(f"panel {panel_id!r} not in top row")
|
||
|
||
@property
|
||
def mesh_panel_title(self) -> str:
|
||
return self.top_slot("mesh").title
|
||
|
||
@property
|
||
def arrows_panel_title(self) -> str:
|
||
return self.top_slot("arrows").title
|
||
|
||
@property
|
||
def settings_panel_title(self) -> str:
|
||
return self.top_slot("settings").title
|
||
|
||
|
||
LAYOUT = DashboardLayout()
|
||
|
||
|
||
def top_row_splitter_sizes(layout: DashboardLayout = LAYOUT) -> tuple[int, ...]:
|
||
"""Horizontal splitter widths from panel setup."""
|
||
return tuple(slot.width_px for slot in layout.panel_setup.top_row.slots if slot.enabled)
|
||
|
||
|
||
def top_row_slot_index(panel_id: PanelId, layout: DashboardLayout = LAYOUT) -> int:
|
||
"""Index of a panel in the horizontal top splitter."""
|
||
for i, slot in enumerate(layout.panel_setup.top_row.slots):
|
||
if slot.id == panel_id:
|
||
return i
|
||
raise KeyError(f"panel {panel_id!r} not in top row")
|