diff --git a/.github/workflows/deploy-as-package.yml b/.github/workflows/deploy-as-package.yml
index 25ac4f01..6706efbf 100644
--- a/.github/workflows/deploy-as-package.yml
+++ b/.github/workflows/deploy-as-package.yml
@@ -15,7 +15,7 @@ jobs:
- name: make
run: |
- pdm install --dev -G build
+ pdm install --dev -G build
pdm run make package
- name: publish
@@ -29,4 +29,4 @@ jobs:
with:
generate_release_notes: true
files: |
- bin/mission-dmx-editor-v*.deb
+ bin/mission-dmx-editor-v*.deb
diff --git a/pyproject.toml b/pyproject.toml
index 29f95369..ced16087 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,8 @@ authors = [
{ name = "CorsCodini", email = "cors.codini@web.de" },
{ name = "Niklas Naumann", email = "niklas.naumann@student.uni-luebeck.de" },
{ name = "Doralitze", email = "doralitze@chaotikum.org" },
+ { name = "Ludwig Rahlff"},
+ { name = "Joell Keanu"},
]
requires-python = "==3.13.*"
@@ -18,7 +20,7 @@ dependencies = [
"xmlschema>=3.4.5",
"requests>=2.32.3",
"numpy>=2.2.4",
- "ruamel-yaml>=0.18.10",
+ "ruamel-yaml>=0.18.14",
"html2text>=2024.2.26",
"markdown>=3.7",
"typing-extensions>=4.13.1",
@@ -31,6 +33,7 @@ dependencies = [
"pyjoystick>=1.2.4",
"pyopengl>=3.1.9",
"onnxruntime-openvino>=1.21.0",
+ "PySDL2>=0.9.17",
"pydantic>=2.11.7",
"defusedxml>=0.7.1",
"tzlocal>=5.3.1",
diff --git a/pysidedeploy.spec b/pysidedeploy.spec
index 6948ff7b..fd30f933 100755
--- a/pysidedeploy.spec
+++ b/pysidedeploy.spec
@@ -33,10 +33,10 @@ android_packages = buildozer==1.5.0,cython==0.29.33
# paths to required qml files. comma separated
# normally all the qml files required by the project are added automatically
-qml_files =
+qml_files =
# excluded qml plugin binaries
-excluded_qml_plugins =
+excluded_qml_plugins =
# qt modules used. comma separated
modules = Asyncio,Core,DBus,Gui,Widgets
@@ -48,20 +48,20 @@ plugins = egldeviceintegrations,networkaccess,platformthemes,accessiblebridge,im
[android]
# path to pyside wheel
-wheel_pyside =
+wheel_pyside =
# path to shiboken wheel
-wheel_shiboken =
+wheel_shiboken =
# plugins to be copied to libs folder of the packaged application. comma separated
-plugins =
+plugins =
[nuitka]
# usage description for permissions requested by the app as found in the info.plist file
# of the app bundle. comma separated
# eg = extra_args = --show-modules --follow-stdlib
-macos.permissions =
+macos.permissions =
# mode of using nuitka. accepts standalone or onefile. default = onefile
mode = standalone
@@ -77,20 +77,19 @@ extra_args = --quiet --noinclude-qt-translations --include-package=controller.ut
mode = debug
# path to pyside6 and shiboken6 recipe dir
-recipe_dir =
+recipe_dir =
# path to extra qt android .jar files to be loaded by the application
-jars_dir =
+jars_dir =
# if empty, uses default ndk path downloaded by buildozer
-ndk_path =
+ndk_path =
# if empty, uses default sdk path downloaded by buildozer
-sdk_path =
+sdk_path =
# other libraries to be loaded at app startup. comma separated.
-local_libs =
+local_libs =
# architecture of deployed platform
-arch =
-
+arch =
diff --git a/src/controller/network.py b/src/controller/network.py
index 48233133..ad565109 100644
--- a/src/controller/network.py
+++ b/src/controller/network.py
@@ -188,7 +188,7 @@ def _react_request_dmx_data(self, universe: Universe) -> None:
"""
if self._socket.state() == QtNetwork.QLocalSocket.LocalSocketState.ConnectedState:
- msg = proto.DirectMode_pb2.request_dmx_data(universe_id=universe.universe_proto.id)
+ msg = proto.DirectMode_pb2.request_dmx_data(universe_id=universe.id)
self._send_with_format(msg.SerializeToString(), proto.MessageTypes_pb2.MSGT_REQUEST_DMX_DATA)
def _generate_universe(self, universe: Universe) -> None:
diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py
index 7d8d54e7..084bcf7a 100644
--- a/src/model/broadcaster.py
+++ b/src/model/broadcaster.py
@@ -88,6 +88,9 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta):
view_to_temperature: QtCore.Signal = QtCore.Signal()
view_leave_temperature: QtCore.Signal = QtCore.Signal()
+ view_to_visualizer: QtCore.Signal = QtCore.Signal()
+ view_leave_visualizer: QtCore.Signal = QtCore.Signal()
+
view_to_console_mode: QtCore.Signal = QtCore.Signal()
view_leave_console_mode: QtCore.Signal = QtCore.Signal()
diff --git a/src/model/dmx/__init__.py b/src/model/dmx/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/model/dmx/dmx_visualizer.py b/src/model/dmx/dmx_visualizer.py
new file mode 100644
index 00000000..daa3283c
--- /dev/null
+++ b/src/model/dmx/dmx_visualizer.py
@@ -0,0 +1,227 @@
+"""Polls live DMX data from Fish and writes it into stage fixtures.
+
+Receives DMX frames via the Broadcaster, maps the raw 8-bit channel
+values onto MovingHead properties (pan, tilt, dimmer, beam color) and
+emits ``fixtures_updated`` so the 3D widget can repaint.
+
+Channel offsets are auto-detected from the Open Fixture Library naming
+convention of the connected fixture profile.
+"""
+
+import logging
+from typing import Dict, List
+
+from PySide6 import QtCore
+
+from model.broadcaster import Broadcaster
+from model.stage import MovingHead, StageConfig
+
+logger = logging.getLogger(__file__)
+
+# OFL role names we try to detect on each channel.
+MOVEMENT_ROLES = [
+ "pan_coarse", "pan_fine",
+ "tilt_coarse", "tilt_fine",
+ "dimmer", "pan_tilt_speed",
+]
+COLOR_ROLES = ["red", "green", "blue", "white"]
+ALL_ROLES = MOVEMENT_ROLES + COLOR_ROLES
+
+# Physical rotation range of typical moving heads.
+DEFAULT_PAN_MAX_DEG = 540.0
+DEFAULT_TILT_MAX_DEG = 270.0
+
+
+def _primary(raw_name: str) -> str:
+ # OFL joins multi-function channels with "___" - keep only the first part.
+ return raw_name.split("___", maxsplit=1)[0].strip().lower().replace(" ", "_")
+
+
+def auto_detect_mapping(channel_names: list[str],
+ roles: list[str]) -> dict[str, int]:
+ """Return a {role: channel_offset} dict, -1 where no match was found."""
+ mapping = dict.fromkeys(roles, -1)
+
+ for i, raw_name in enumerate(channel_names):
+ p = _primary(raw_name)
+
+ # Pan
+ if p == "pan_fine" or ("pan" in p and "fine" in p):
+ if "pan_fine" in roles:
+ mapping["pan_fine"] = i
+ elif "pan" in p and "speed" not in p and "tilt" not in p:
+ if "pan_coarse" in roles:
+ mapping["pan_coarse"] = i
+
+ # Tilt
+ if p == "tilt_fine" or ("tilt" in p and "fine" in p):
+ if "tilt_fine" in roles:
+ mapping["tilt_fine"] = i
+ elif "tilt" in p and "speed" not in p and "pan" not in p:
+ if "tilt_coarse" in roles:
+ mapping["tilt_coarse"] = i
+
+ # Dimmer / speed
+ if p in ("dimmer", "intensity") and "dimmer" in roles:
+ mapping["dimmer"] = i
+ if "speed" in p and ("pan" in p or "tilt" in p):
+ if "pan_tilt_speed" in roles:
+ mapping["pan_tilt_speed"] = i
+
+ # Colors
+ if "red" in p and "red" in roles:
+ mapping["red"] = i
+ if "green" in p and "green" in roles:
+ mapping["green"] = i
+ if "blue" in p and "blue" in roles:
+ mapping["blue"] = i
+ if p == "white" and "white" in roles:
+ mapping["white"] = i
+
+ return mapping
+
+
+class DmxVisualizer(QtCore.QObject):
+ """Drives the stage fixtures from incoming DMX frames."""
+
+ fixtures_updated = QtCore.Signal()
+
+ def __init__(self, stage_config: StageConfig,
+ board_configuration=None, parent=None):
+ super().__init__(parent)
+ self._stage_config = stage_config
+ self._board_config = board_configuration
+ self._broadcaster = Broadcaster()
+ self._enabled = True
+
+ try:
+ self._broadcaster.dmx_from_fish.connect(self._on_dmx)
+ except Exception as e:
+ logger.warning("Could not connect broadcaster signals: %s", e)
+
+ # Request fresh DMX data at ~45 Hz.
+ self._poll_timer = QtCore.QTimer(self)
+ self._poll_timer.setInterval(22)
+ self._poll_timer.timeout.connect(self._request_dmx)
+ self._poll_timer.start()
+
+ @property
+ def enabled(self) -> bool:
+ return self._enabled
+
+ @enabled.setter
+ def enabled(self, value: bool):
+ self._enabled = value
+ if value:
+ self._poll_timer.start()
+ else:
+ self._poll_timer.stop()
+
+ def _request_dmx(self):
+ if not self._enabled or self._board_config is None:
+ return
+ try:
+ for universe in self._board_config.universes:
+ self._broadcaster.send_request_dmx_data.emit(universe)
+ except Exception:
+ pass
+
+ @QtCore.Slot()
+ def _on_dmx(self, msg) -> None:
+ if not self._enabled:
+ return
+
+ universe_id = msg.universe_id
+
+ # Normalize to exactly 512 channels; Fish sometimes sends a leading zero.
+ raw = list(msg.channel_data)
+ if len(raw) == 513:
+ raw = raw[1:]
+ raw = (raw + [0] * 512)[:512]
+
+ any_updated = False
+ for obj in self._stage_config.objects:
+ if not isinstance(obj, MovingHead):
+ continue
+ dc = obj.device_config
+ if not dc:
+ continue
+
+ mv = dc.get("movement")
+ if mv and mv.get("universe", -1) == universe_id:
+ self._apply_movement(obj, raw, mv)
+ any_updated = True
+
+ col = dc.get("color")
+ if col and col.get("universe", -1) == universe_id:
+ self._apply_color(obj, raw, col)
+ any_updated = True
+
+ if any_updated:
+ self.fixtures_updated.emit()
+
+ def _apply_movement(self, obj, raw, cfg):
+ """Map pan/tilt/dimmer channels to the fixture's 2-DOF properties."""
+ start = cfg.get("start_channel", 0)
+ m = cfg.get("mapping", {})
+
+ def rd(role):
+ off = m.get(role, -1)
+ if off < 0 or not (0 <= start + off < 512):
+ return None
+ return int(raw[start + off])
+
+ # 16-bit pan, centered at zero.
+ pc, pf = rd("pan_coarse"), rd("pan_fine")
+ if pc is not None:
+ v = (pc << 8) | (pf or 0)
+ obj.pan = (v / 65535.0) * DEFAULT_PAN_MAX_DEG - DEFAULT_PAN_MAX_DEG / 2.0
+
+ # 16-bit tilt, centered at zero.
+ tc, tf = rd("tilt_coarse"), rd("tilt_fine")
+ if tc is not None:
+ v = (tc << 8) | (tf or 0)
+ obj.tilt = (v / 65535.0) * DEFAULT_TILT_MAX_DEG - DEFAULT_TILT_MAX_DEG / 2.0
+
+ dim = rd("dimmer")
+ if dim is not None:
+ obj.dimmer = dim / 255.0
+ obj.beam_on = dim > 0
+
+ def _apply_color(self, obj, raw, cfg):
+ """Map R/G/B/W channels to beam_color."""
+ start = cfg.get("start_channel", 0)
+ m = cfg.get("mapping", {})
+
+ def rd(role):
+ off = m.get(role, -1)
+ if off < 0 or not (0 <= start + off < 512):
+ return None
+ return int(raw[start + off])
+
+ r, g, b = rd("red"), rd("green"), rd("blue")
+ if r is None or g is None or b is None:
+ return
+
+ # White LED adds on top of RGB (matches RGBW fixtures).
+ w = rd("white")
+ if w is not None and w > 0:
+ r = min(255, r + w)
+ g = min(255, g + w)
+ b = min(255, b + w)
+
+ obj.beam_color = (r, g, b)
+ any_color = (r > 0 or g > 0 or b > 0)
+ obj.beam_on = any_color
+
+ # Use the white channel as dimmer if no dedicated movement dimmer exists.
+ if w is not None:
+ obj.dimmer = w / 255.0 if w > 0 else (1.0 if any_color else 0.0)
+ elif not self._has_movement_dimmer(obj) and any_color:
+ obj.dimmer = 1.0
+
+ def _has_movement_dimmer(self, obj) -> bool:
+ dc = obj.device_config
+ if not dc:
+ return False
+ return dc.get("movement", {}).get("mapping", {}).get("dimmer", -1) >= 0
diff --git a/src/model/stage.py b/src/model/stage.py
new file mode 100644
index 00000000..4b0eeadf
--- /dev/null
+++ b/src/model/stage.py
@@ -0,0 +1,548 @@
+"""Stage configuration: data model, YAML persistence and fixture classes.
+
+Objects on the stage are subclasses of ``StageObject`` (Truss, MovingHead,
+Platform). ``StageConfig`` is the aggregate root that loads and saves
+the full stage to a YAML file under ``~/.local/share/missionDMX/stage/``.
+"""
+
+import logging
+import os
+import shutil
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Tuple
+
+from ruamel import yaml
+
+from utility import resource_path
+
+logger = logging.getLogger(__file__)
+
+# User-local stage directory (XDG).
+STAGE_DIR = os.path.join(
+ os.path.expanduser("~"), ".local", "share", "missionDMX", "stage"
+)
+DEFAULT_STAGE_PATH = os.path.join(STAGE_DIR, "current_stage.yaml")
+
+
+def get_default_stage_path() -> str:
+ """Return the persistent stage file path, creating it on first run."""
+ os.makedirs(STAGE_DIR, exist_ok=True)
+ if not os.path.exists(DEFAULT_STAGE_PATH):
+ bundled = resource_path(os.path.join("resources", "data", "stage.yaml"))
+ if os.path.exists(bundled):
+ shutil.copy2(bundled, DEFAULT_STAGE_PATH)
+ logger.info("Copied bundled stage.yaml to %s", DEFAULT_STAGE_PATH)
+ return DEFAULT_STAGE_PATH
+
+
+def backup_stage_file(stage_path: str) -> str:
+ """Write a timestamped copy next to the stage file; return its path."""
+ if not os.path.exists(stage_path):
+ return ""
+ directory = os.path.dirname(stage_path)
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ backup_path = os.path.join(directory, f"stage_backup_{timestamp}.yaml")
+ shutil.copy2(stage_path, backup_path)
+ logger.info("Stage backup created: %s", backup_path)
+ return backup_path
+
+
+# Bundled GLB models. Keys must match StageObject.get_type().
+DEFAULT_MODEL_PATHS: dict[str, str] = {
+ "truss": resource_path(os.path.join("resources", "3dmodels", "truss.glb")),
+ "truss_default": resource_path(os.path.join("resources", "3dmodels", "truss.glb")),
+ "truss_2point_medium": resource_path(os.path.join("resources", "3dmodels", "truss 2point medium.glb")),
+ "truss_cross": resource_path(os.path.join("resources", "3dmodels", "truss cross.glb")),
+ "truss_long": resource_path(os.path.join("resources", "3dmodels", "truss long.glb")),
+ "truss_medium": resource_path(os.path.join("resources", "3dmodels", "truss medium.glb")),
+ "platform": resource_path(os.path.join("resources", "3dmodels", "platform.glb")),
+ "moving_head": resource_path(os.path.join("resources", "3dmodels", "movinghead.glb")),
+}
+
+
+@dataclass(frozen=True)
+class ModelEntry:
+ """One mesh to render for a stage object, with optional local transforms.
+
+ ``local_ops`` entries are applied before the object's world transform
+ and take the form ``("translate", (x, y, z))`` or
+ ``("rotate", (degrees, ax, ay, az), pivot=(px, py, pz))``.
+ """
+
+ model_path: str
+ local_ops: tuple[tuple[str, Any], ...] = ()
+
+
+class StageObject:
+ """Base class for anything placed on the stage."""
+
+ def __init__(
+ self,
+ object_id: str,
+ position: tuple[float, float, float] | None = None,
+ rotation: tuple[float, float, float] | None = None,
+ scale: float | None = None,
+ model_path: str | None = None,
+ ):
+ self.id = object_id
+ self.name = ""
+ self.position = position if position is not None else (0.0, 0.0, 0.0)
+ self.rotation = rotation if rotation is not None else (0.0, 0.0, 0.0)
+ self.scale = float(scale) if scale is not None else 1.0
+ self.model_path = model_path
+
+ # Optional link to a real DMX device (universe, start_channel, mapping).
+ self.device_config: dict[str, Any] | None = None
+
+ if self.model_path is None:
+ key = self.get_type().lower()
+ if key in DEFAULT_MODEL_PATHS:
+ self.model_path = DEFAULT_MODEL_PATHS[key]
+
+ def get_type(self) -> str:
+ return "default"
+
+ def get_display_name(self) -> str:
+ return self.get_type()
+
+ def get_model_entries(self) -> list[ModelEntry]:
+ """One or more render entries. Composite fixtures override this."""
+ if not self.model_path:
+ return []
+ return [ModelEntry(self.model_path)]
+
+ def to_dict(self) -> dict[str, Any]:
+ d = {
+ "id": self.id,
+ "name": self.name,
+ "type": self.get_type(),
+ "position": {"x": self.position[0], "y": self.position[1], "z": self.position[2]},
+ "rotation": {"x": self.rotation[0], "y": self.rotation[1], "z": self.rotation[2]},
+ "scale": self.scale,
+ }
+ if self.device_config:
+ d["device"] = self.device_config
+ return d
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]):
+ object_id = data.get("id")
+ pos = data.get("position", {})
+ rot = data.get("rotation", {})
+ position = (pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0))
+ rotation = (rot.get("x", 0.0), rot.get("y", 0.0), rot.get("z", 0.0))
+ scale = float(data.get("scale", 1.0))
+ obj = cls(object_id, position, rotation, scale)
+ obj.name = data.get("name", "")
+ obj.device_config = data.get("device")
+ return obj
+
+
+class Platform(StageObject):
+ """Static stage floor. Every stage has exactly one."""
+
+ DEFAULT_POSITION = (-23.0, 0.0, 0.0)
+
+ def __init__(self, object_id: str = "platform", position=None,
+ rotation=None, scale: float = 1.0):
+ super().__init__(
+ object_id,
+ position if position is not None else Platform.DEFAULT_POSITION,
+ rotation if rotation is not None else (0.0, 0.0, 0.0),
+ float(scale),
+ model_path=DEFAULT_MODEL_PATHS["platform"],
+ )
+
+ def get_type(self) -> str:
+ return "platform"
+
+ def get_display_name(self) -> str:
+ return "Platform"
+
+
+class Truss(StageObject):
+ """Truss fixture (default, cross, long, medium, 2-point)."""
+
+ def __init__(self, object_id: str, variant: str = "default",
+ position=None, rotation=None, scale: float = 1.0):
+ self.variant = variant
+
+ key = f"truss_{variant}" if variant != "" else "truss"
+ if key not in DEFAULT_MODEL_PATHS:
+ key = "truss_default"
+ self.variant = "default"
+
+ super().__init__(
+ object_id, position, rotation, float(scale),
+ model_path=DEFAULT_MODEL_PATHS[key],
+ )
+
+ def get_type(self) -> str:
+ return f"truss_{self.variant}" if self.variant != "" else "truss"
+
+ def get_display_name(self) -> str:
+ mapping = {
+ "default": "Truss Default",
+ "2point_medium": "Truss 2-Point",
+ "cross": "Truss Cross",
+ "long": "Truss Long",
+ "medium": "Truss Medium",
+ }
+ return mapping.get(self.variant, f"Truss {self.variant}")
+
+ def to_dict(self) -> dict[str, Any]:
+ data = super().to_dict()
+ data["variant"] = self.variant
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]):
+ object_id = data.get("id")
+ pos = data.get("position", {})
+ rot = data.get("rotation", {})
+ position = (pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0))
+ rotation = (rot.get("x", 0.0), rot.get("y", 0.0), rot.get("z", 0.0))
+
+ # Variant may be explicit or embedded in legacy type strings like "truss_cross".
+ t = (data.get("type") or "truss").lower()
+ variant = data.get("variant")
+ if not variant:
+ if t == "truss":
+ variant = "default"
+ elif t.startswith("truss_"):
+ variant = t[len("truss_"):]
+ else:
+ variant = "default"
+
+ scale = float(data.get("scale", 1.0))
+ obj = cls(object_id, variant=variant, position=position,
+ rotation=rotation, scale=scale)
+ obj.name = data.get("name", "")
+ obj.device_config = data.get("device")
+ return obj
+
+
+class MovingHead(StageObject):
+ """Moving head with pan/tilt control and colored beam.
+
+ Loaded from a single .glb file whose node hierarchy the renderer
+ overrides per frame using the axis-angle pairs from
+ ``get_gltf_node_overrides()``.
+ """
+
+ # Names of the joint nodes inside the .glb model.
+ PAN_NODE_NAME = "Cube.035"
+ TILT_NODE_NAME = "Cylinder.018"
+ BEAM_ORIGIN_NODE_NAME = "BeamOrigin"
+
+ PAN_AXIS = (0.0, 1.0, 0.0)
+ TILT_AXIS = (1.0, 0.0, 0.0)
+
+ def __init__(
+ self,
+ object_id: str,
+ channels: int = 16,
+ position=None,
+ rotation=None,
+ scale: float = 20.0, # .glb is small; scale up for visibility
+ pan: float = 0.0,
+ tilt: float = 0.0,
+ beam_on: bool = True,
+ beam_color: tuple[int, int, int] | None = None,
+ dimmer: float = 1.0,
+ ):
+ # Must be set before super().__init__ since get_type() reads it.
+ self.channels = 8 if int(channels) == 8 else 16
+ super().__init__(object_id, position, rotation, float(scale), model_path=None)
+
+ self.pan = float(pan)
+ self.tilt = float(tilt)
+ self.beam_on = bool(beam_on)
+
+ if beam_color is None:
+ beam_color = (0, 255, 0)
+ r, g, b = beam_color
+ self.beam_color = (int(r), int(g), int(b))
+ self.dimmer = max(0.0, min(1.0, float(dimmer)))
+
+ def get_type(self) -> str:
+ return "moving_head"
+
+ def get_display_name(self) -> str:
+ return "Moving Head"
+
+ def get_model_entries(self) -> list[ModelEntry]:
+ return [ModelEntry(DEFAULT_MODEL_PATHS["moving_head"])]
+
+ def get_gltf_node_overrides(self) -> dict[str, tuple[float, float, float, float]]:
+ """Axis-angle overrides for pan and tilt: ``{node: (ax, ay, az, deg)}``."""
+ return {
+ MovingHead.PAN_NODE_NAME: (*MovingHead.PAN_AXIS, float(self.pan)),
+ MovingHead.TILT_NODE_NAME: (*MovingHead.TILT_AXIS, float(self.tilt)),
+ }
+
+ def to_dict(self) -> dict[str, Any]:
+ data = super().to_dict()
+ data.update({
+ "pan": self.pan,
+ "tilt": self.tilt,
+ "channels": self.channels,
+ "beam_on": bool(self.beam_on),
+ "beam_color": {
+ "r": int(self.beam_color[0]),
+ "g": int(self.beam_color[1]),
+ "b": int(self.beam_color[2]),
+ },
+ "dimmer": float(self.dimmer),
+ })
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]):
+ object_id = data.get("id")
+ pos = data.get("position", {})
+ rot = data.get("rotation", {})
+ position = (pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0))
+ rotation = (rot.get("x", 0.0), rot.get("y", 0.0), rot.get("z", 0.0))
+ pan = float(data.get("pan", 0.0))
+ tilt = float(data.get("tilt", 0.0))
+ beam_on = bool(data.get("beam_on", True))
+
+ bc = data.get("beam_color") or {}
+ if isinstance(bc, dict):
+ beam_color = (int(bc.get("r", 0)), int(bc.get("g", 255)), int(bc.get("b", 0)))
+ elif isinstance(bc, (list, tuple)) and len(bc) >= 3:
+ beam_color = (int(bc[0]), int(bc[1]), int(bc[2]))
+ else:
+ beam_color = (0, 255, 0)
+
+ t = (data.get("type") or "moving_head_16ch").lower()
+ channels = int(data.get("channels", 8 if t.endswith("8ch") else 16))
+ dimmer = float(data.get("dimmer", 1.0))
+ scale = float(data.get("scale", 20.0))
+
+ obj = cls(
+ object_id,
+ channels=channels,
+ position=position,
+ rotation=rotation,
+ scale=scale,
+ pan=pan,
+ tilt=tilt,
+ beam_on=beam_on,
+ beam_color=beam_color,
+ dimmer=dimmer,
+ )
+ obj.name = data.get("name", "")
+ obj.device_config = data.get("device")
+
+ # Reset DMX-controlled values so they come from live data, not the file.
+ if obj.device_config:
+ dc = obj.device_config
+ mv_map = dc.get("movement", {}).get("mapping", {})
+ col_map = dc.get("color", {}).get("mapping", {})
+ if mv_map.get("pan_coarse", -1) >= 0:
+ obj.pan = 0.0
+ if mv_map.get("tilt_coarse", -1) >= 0:
+ obj.tilt = 0.0
+ if mv_map.get("dimmer", -1) >= 0 or col_map.get("white", -1) >= 0:
+ obj.dimmer = 1.0
+ obj.beam_on = True
+
+ return obj
+
+
+# Keys exposed by the "Add Fixture" dialog.
+FIXTURE_KEYS = [
+ "truss_default",
+ "truss_2point_medium",
+ "truss_cross",
+ "truss_long",
+ "truss_medium",
+ "moving_head",
+]
+
+
+def create_object_from_key(fixture_key: str, object_id: str,
+ name: str = "") -> StageObject:
+ """Factory: build a StageObject from one of the ``FIXTURE_KEYS``."""
+ key = fixture_key.lower()
+ if key.startswith("truss"):
+ variant = "default" if key in ("truss", "truss_default") else key[len("truss_"):]
+ obj = Truss(object_id, variant=variant)
+ elif key.startswith("moving_head"):
+ obj = MovingHead(object_id)
+ else:
+ raise ValueError(f"Unknown fixture key: {fixture_key}")
+ obj.name = name
+ return obj
+
+
+def make_unique_name(desired_name: str, existing_names: list[str]) -> str:
+ """Append ``(1)``, ``(2)``... until the name is unique."""
+ if desired_name not in existing_names:
+ return desired_name
+ num = 1
+ candidate = f"{desired_name} ({num})"
+ while candidate in existing_names:
+ num += 1
+ candidate = f"{desired_name} ({num})"
+ return candidate
+
+
+class FixtureGroup:
+ """Named bundle of fixtures that can be moved or rotated together.
+
+ The group's own position is the centroid of its members at creation
+ time; the editor widget applies deltas to all members when the group
+ is transformed.
+ """
+
+ def __init__(self, group_id: str, name: str = "",
+ position: tuple[float, float, float] | None = None,
+ rotation: tuple[float, float, float] | None = None,
+ member_ids: list[str] | None = None):
+ self.id = group_id
+ self.name = name
+ self.position = position if position is not None else (0.0, 0.0, 0.0)
+ self.rotation = rotation if rotation is not None else (0.0, 0.0, 0.0)
+ self.member_ids: list[str] = list(member_ids) if member_ids else []
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "position": {"x": self.position[0], "y": self.position[1], "z": self.position[2]},
+ "rotation": {"x": self.rotation[0], "y": self.rotation[1], "z": self.rotation[2]},
+ "member_ids": list(self.member_ids),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]):
+ pos = data.get("position", {})
+ rot = data.get("rotation", {})
+ return cls(
+ data.get("id", "group"),
+ data.get("name", ""),
+ (pos.get("x", 0.0), pos.get("y", 0.0), pos.get("z", 0.0)),
+ (rot.get("x", 0.0), rot.get("y", 0.0), rot.get("z", 0.0)),
+ data.get("member_ids", []),
+ )
+
+
+class StageConfig:
+ """Aggregate root: list of objects + list of groups, persisted as YAML."""
+
+ def __init__(self, yaml_file_path: str):
+ self.file_path = yaml_file_path
+ self.objects: list[StageObject] = []
+ self.groups: list[FixtureGroup] = []
+
+ if os.path.exists(self.file_path):
+ try:
+ yaml_loader = yaml.YAML(typ="safe")
+ with open(self.file_path, "r", encoding="UTF-8") as f:
+ data = yaml_loader.load(f) or {}
+ except yaml.YAMLError as e:
+ logger.error("Failed to parse YAML file %s: %s", self.file_path, e)
+ data = {}
+
+ for obj_data in data.get("objects", []):
+ type_name = (obj_data.get("type") or "truss").lower()
+ if type_name.startswith("truss"):
+ obj = Truss.from_dict(obj_data)
+ elif type_name.startswith("moving_head"):
+ obj = MovingHead.from_dict(obj_data)
+ elif type_name == "platform":
+ obj = Platform.from_dict(obj_data)
+ else:
+ obj = StageObject.from_dict(obj_data)
+ self.objects.append(obj)
+
+ for grp_data in data.get("groups", []):
+ self.groups.append(FixtureGroup.from_dict(grp_data))
+ else:
+ logger.info("Stage YAML file %s not found, starting empty.", self.file_path)
+
+ # Stage invariant: always have exactly one platform.
+ if not any(o.get_type() == "platform" for o in self.objects):
+ self.objects.insert(0, Platform())
+
+ def save(self):
+ self.save_to(self.file_path)
+
+ def save_to(self, path: str):
+ data = {"objects": [obj.to_dict() for obj in self.objects]}
+ if self.groups:
+ data["groups"] = [grp.to_dict() for grp in self.groups]
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ yaml_dumper = yaml.YAML()
+ yaml_dumper.default_flow_style = False
+ with open(path, "w", encoding="UTF-8") as f:
+ yaml_dumper.dump(data, f)
+ except Exception as e:
+ logger.error("Failed to save stage config to %s: %s", path, e)
+
+ def get_all_names(self) -> list[str]:
+ names = [obj.name for obj in self.objects if obj.name]
+ names += [grp.name for grp in self.groups if grp.name]
+ return names
+
+ def get_new_id(self, base_type: str = "obj") -> str:
+ """Return a unique id of the form ````."""
+ base = (base_type or "obj").lower().replace("-", "_")
+ existing = {o.id for o in self.objects} | {g.id for g in self.groups}
+ i = 1
+ while f"{base}{i}" in existing:
+ i += 1
+ return f"{base}{i}"
+
+ def add_object(self, obj: StageObject):
+ if any(o.id == obj.id for o in self.objects):
+ obj.id = self.get_new_id(obj.get_type())
+ if obj.name:
+ obj.name = make_unique_name(obj.name, self.get_all_names())
+ self.objects.append(obj)
+
+ def remove_object(self, object_id: str):
+ for i, obj in enumerate(self.objects):
+ if obj.id == object_id:
+ removed = self.objects.pop(i)
+ for grp in self.groups:
+ if object_id in grp.member_ids:
+ grp.member_ids.remove(object_id)
+ return removed
+ return None
+
+ def get_object(self, object_id: str):
+ for obj in self.objects:
+ if obj.id == object_id:
+ return obj
+ return None
+
+ def add_group(self, group: FixtureGroup):
+ if any(g.id == group.id for g in self.groups):
+ group.id = self.get_new_id("group")
+ if group.name:
+ group.name = make_unique_name(group.name, self.get_all_names())
+ self.groups.append(group)
+
+ def remove_group(self, group_id: str) -> FixtureGroup | None:
+ for i, grp in enumerate(self.groups):
+ if grp.id == group_id:
+ return self.groups.pop(i)
+ return None
+
+ def get_group(self, group_id: str) -> FixtureGroup | None:
+ for grp in self.groups:
+ if grp.id == group_id:
+ return grp
+ return None
+
+ def get_group_for_fixture(self, object_id: str) -> FixtureGroup | None:
+ for grp in self.groups:
+ if object_id in grp.member_ids:
+ return grp
+ return None
diff --git a/src/resources/3dmodels b/src/resources/3dmodels
new file mode 120000
index 00000000..919d2b45
--- /dev/null
+++ b/src/resources/3dmodels
@@ -0,0 +1 @@
+../../submodules/resources/3dmodels
\ No newline at end of file
diff --git a/src/resources/data/stage.yaml b/src/resources/data/stage.yaml
new file mode 100644
index 00000000..49b84b56
--- /dev/null
+++ b/src/resources/data/stage.yaml
@@ -0,0 +1,20 @@
+objects:
+- id: truss1
+ type: truss
+ position:
+ x: 30.0
+ y: 30.0
+ z: 0.0
+ rotation:
+ x: 0.0
+ y: 0.0
+ z: 0.0
+- id: truss2
+ type: truss
+ position:
+ x: 0.0
+ z: 0.0
+ rotation:
+ x: 0.0
+ y: 0.0
+ z: 0.0
diff --git a/src/view/logging_view/dmx_data_log.py b/src/view/logging_view/dmx_data_log.py
index ec6de916..d8e6ad2c 100644
--- a/src/view/logging_view/dmx_data_log.py
+++ b/src/view/logging_view/dmx_data_log.py
@@ -8,7 +8,6 @@
from model import Broadcaster, Universe
from model.final_globals import FinalGlobals
-
# TODO komplett
class DmxDataLogWidget(QtWidgets.QWidget):
"""Widget to Log DMX Data"""
diff --git a/src/view/main_window.py b/src/view/main_window.py
index 063ad8e9..eb15cd75 100644
--- a/src/view/main_window.py
+++ b/src/view/main_window.py
@@ -42,6 +42,7 @@
from view.utility_widgets.file_list_label import FileListLabelDelegate
from view.utility_widgets.wizzards.patch_plan_export import PatchPlanExportWizard
from view.utility_widgets.wizzards.theater_scene_wizard import TheaterSceneWizard
+from view.visualizer.visualizer_widget import StageVisualizerWidget
if TYPE_CHECKING:
from collections.abc import Callable
@@ -99,8 +100,13 @@ def __init__(self, parent: QWidget | None = None) -> None:
MainWidget(CombinedActionSetupWidget(self, self._broadcaster, self._board_configuration), self),
self._broadcaster.view_to_action_config.emit,
),
+ ("Visualizer", MainWidget(StageVisualizerWidget(self._board_configuration, self), self),
+ self._broadcaster.view_to_visualizer.emit),
]
+ # Keep reference to visualizer for stage file menu actions
+ self._stage_visualizer = views[6][1].findChild(StageVisualizerWidget)
+
# select Views
self._widgets = QtWidgets.QStackedWidget(self)
self._toolbar = self.addToolBar("Mode")
@@ -129,6 +135,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._broadcaster.view_to_temperature.connect(self._is_column_dialog)
self._broadcaster.save_button_pressed.connect(self._save_show)
self._broadcaster.view_to_action_config.connect(lambda: self._to_widget(5))
+ self._broadcaster.view_to_visualizer.connect(lambda: self._to_widget(6))
self._fish_connector.start()
if self._fish_connector:
@@ -141,6 +148,8 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._broadcaster.view_leave_color.emit()
self._broadcaster.view_leave_temperature.emit()
self._broadcaster.view_leave_console_mode.emit()
+ self._broadcaster.view_leave_visualizer.emit()
+
self._about_window = None
self._settings_dialog = None
self._utility_wizard: QWizard | None = None
@@ -209,6 +218,9 @@ def _setup_menubar(self) -> None:
("---", None, None),
("Export to Standalone", lambda: open_show_export_dialog(self, self._board_configuration), None),
("---", None, None),
+ ("Load Stagefile", self._load_stage_file, None),
+ ("Save Stagefile As", self._save_stage_file, None),
+ ("---", None, None),
("Settings", self.open_show_settings, ","),
],
"Edit": [
@@ -369,6 +381,14 @@ def _save_show(self) -> None:
else:
show_save_showfile_dialog(self, self._board_configuration)
+ def _load_stage_file(self) -> None:
+ if self._stage_visualizer:
+ self._stage_visualizer.load_stage_file()
+
+ def _save_stage_file(self) -> None:
+ if self._stage_visualizer:
+ self._stage_visualizer.save_stage_file()
+
def _open_about_window(self) -> None:
if not self._about_window:
from view.misc.about_window import AboutWindow
diff --git a/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_variant_editor.py b/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_variant_editor.py
index 9d61e7a8..9fa143db 100644
--- a/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_variant_editor.py
+++ b/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_variant_editor.py
@@ -33,4 +33,3 @@ def __init__(self, layer: ChaserLayer, parent: QWidget | None = None) -> None:
layout.addWidget(cb)
layout.addStretch()
self.setLayout(layout)
-
diff --git a/src/view/show_mode/show_ui_widgets/chaser_apply_preset_uiwidget.py b/src/view/show_mode/show_ui_widgets/chaser_apply_preset_uiwidget.py
index 06aa1975..298ad03b 100644
--- a/src/view/show_mode/show_ui_widgets/chaser_apply_preset_uiwidget.py
+++ b/src/view/show_mode/show_ui_widgets/chaser_apply_preset_uiwidget.py
@@ -100,4 +100,3 @@ def _config_width_value_changed(self, new_value: int) -> None:
def _config_height_value_changed(self, new_value: int) -> None:
# TODO implement live update (like macro buttons
self.configuration["height"] = str(new_value)
-
diff --git a/src/view/visualizer/__init__.py b/src/view/visualizer/__init__.py
new file mode 100644
index 00000000..2a4b12d4
--- /dev/null
+++ b/src/view/visualizer/__init__.py
@@ -0,0 +1 @@
+"""Module contains visualizer."""
diff --git a/src/view/visualizer/stage_editor_widget.py b/src/view/visualizer/stage_editor_widget.py
new file mode 100644
index 00000000..269c65c1
--- /dev/null
+++ b/src/view/visualizer/stage_editor_widget.py
@@ -0,0 +1,1195 @@
+"""Right-hand editor panel: fixture list, property form and DMX device mapping."""
+
+import logging
+import time
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+from model import stage as stage_model
+from model.dmx.dmx_visualizer import COLOR_ROLES, MOVEMENT_ROLES, auto_detect_mapping
+from model.stage import make_unique_name
+
+logger = logging.getLogger(__file__)
+
+
+def _fixture_label(fix) -> str:
+ """Build a display label: ``[TAG] Name @ U{u}/CH{start} ({n}ch)``."""
+ try:
+ cats = fix._fixture.categories
+ if "Moving Head" in cats:
+ tag = "[MH]"
+ elif any(c in cats for c in ("Color Changer", "Blinder", "Pixel Bar")):
+ tag = "[RGB]"
+ else:
+ tag = "[" + cats[0] + "]" if cats else "[?]"
+ except Exception:
+ tag = ""
+ name = fix.name_on_stage or fix.name or fix.short_name or "?"
+ return f"{tag} {name} @ U{fix.universe_id}/CH{fix.start_index} ({fix.channel_length}ch)"
+
+
+def _fixture_combo_data(fix) -> dict:
+ """Extract the data dict needed for device combo boxes from a UsedFixture."""
+ ch_names = [ch.name for ch in fix.fixture_channels]
+ return {
+ "universe": fix.universe_id,
+ "start_channel": fix.start_index,
+ "channel_count": fix.channel_length,
+ "channel_names": ch_names,
+ }
+
+TRUSS_VARIANTS = {
+ "Default": "truss_default",
+ "2-Point Medium": "truss_2point_medium",
+ "Cross": "truss_cross",
+ "Long": "truss_long",
+ "Medium": "truss_medium",
+}
+
+ROLE_ID = QtCore.Qt.ItemDataRole.UserRole
+ROLE_IS_GROUP = QtCore.Qt.ItemDataRole.UserRole + 1 # bool: True for group headers
+
+class AddFixtureDialog(QtWidgets.QDialog):
+ """Dialog for adding a new fixture to the stage."""
+
+ def __init__(self, existing_names, used_fixtures=None, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Add Fixture")
+ self.setModal(True)
+ self.setMinimumWidth(380)
+ self._existing_names = existing_names or []
+ self._used_fixtures = used_fixtures or []
+
+ layout = QtWidgets.QVBoxLayout(self)
+ form = QtWidgets.QFormLayout()
+ form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
+ layout.addLayout(form)
+
+ # Category selector
+ self._category_combo = QtWidgets.QComboBox()
+ self._category_combo.addItems(["Truss", "Moving Head"])
+ self._category_combo.currentIndexChanged.connect(self._on_category_changed)
+ form.addRow("Fixture:", self._category_combo)
+
+ # Truss variant selector
+ self._variant_label = QtWidgets.QLabel("Type:")
+ self._variant_combo = QtWidgets.QComboBox()
+ self._variant_combo.addItems(list(TRUSS_VARIANTS.keys()))
+ self._variant_combo.currentIndexChanged.connect(self._update_suggested_name)
+ form.addRow(self._variant_label, self._variant_combo)
+
+ # DMX device selector
+ self._device_label = QtWidgets.QLabel("Device:")
+ self._device_combo = QtWidgets.QComboBox()
+ self._device_combo.addItem("(None)", None)
+ for fix in self._used_fixtures:
+ self._device_combo.addItem(_fixture_label(fix), fix)
+ form.addRow(self._device_label, self._device_combo)
+
+ # Name input
+ self._name_edit = QtWidgets.QLineEdit()
+ form.addRow("Name:", self._name_edit)
+
+ # OK / Cancel buttons
+ btns = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
+ | QtWidgets.QDialogButtonBox.StandardButton.Cancel)
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ layout.addWidget(btns)
+
+ # Initialize visibility
+ self._on_category_changed()
+
+ def _on_category_changed(self):
+ """Show/hide category-specific controls."""
+ is_truss = self._category_combo.currentText() == "Truss"
+ self._variant_combo.setVisible(is_truss)
+ self._variant_label.setVisible(is_truss)
+ is_mh = self._category_combo.currentText() == "Moving Head"
+ self._device_combo.setVisible(is_mh)
+ self._device_label.setVisible(is_mh)
+ self._update_suggested_name()
+
+ def _update_suggested_name(self):
+ """Auto-generate a unique name suggestion as placeholder text."""
+ base = self._get_base_name()
+ candidate = make_unique_name(base, self._existing_names)
+ self._name_edit.setPlaceholderText(candidate)
+
+ def _get_base_name(self):
+ if self._category_combo.currentText() == "Truss":
+ return f"Truss {self._variant_combo.currentText()}"
+ return "Moving Head"
+
+ def selected_fixture_key(self):
+ """Return the internal fixture key for the selected type."""
+ if self._category_combo.currentText() == "Truss":
+ v = self._variant_combo.currentText()
+ return TRUSS_VARIANTS.get(v, "truss_default")
+ return "moving_head"
+
+ def selected_name(self):
+ """Return the user-entered name (or the auto-generated placeholder)."""
+ text = self._name_edit.text().strip()
+ return text or self._name_edit.placeholderText()
+
+ def selected_device(self):
+ """Return the selected UsedFixture for DMX linking, or None."""
+ return self._device_combo.currentData()
+
+
+class GroupNameDialog(QtWidgets.QDialog):
+ """Simple dialog that asks the user for a group name."""
+
+ def __init__(self, existing_names, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Create Group")
+ self.setModal(True)
+ self.setMinimumWidth(250)
+ self.setMaximumWidth(400)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ form = QtWidgets.QFormLayout()
+ layout.addLayout(form)
+
+ self._name_edit = QtWidgets.QLineEdit()
+ suggested = make_unique_name("Group", existing_names)
+ self._name_edit.setPlaceholderText(suggested)
+ form.addRow("Group name:", self._name_edit)
+
+ btns = QtWidgets.QDialogButtonBox(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
+ | QtWidgets.QDialogButtonBox.StandardButton.Cancel)
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ layout.addWidget(btns)
+
+ def selected_name(self):
+ text = self._name_edit.text().strip()
+ return text or self._name_edit.placeholderText()
+
+
+class StageEditorWidget(QtWidgets.QWidget):
+ """Right-hand panel: fixture list + property editor + DMX controls."""
+
+ addObjectRequested = QtCore.Signal(str, str, object) # (fixture_key, name, device_or_None)
+ removeObjectRequested = QtCore.Signal(str) # object_id
+ objectChanged = QtCore.Signal(str) # object_id
+ selectionChanged = QtCore.Signal(list, bool) # (highlight_ids, is_multi)
+ groupRequested = QtCore.Signal(list, str) # (fixture_ids, group_name)
+ removeGroupRequested = QtCore.Signal(str) # group_id
+ dmxToggled = QtCore.Signal(bool) # True = start, False = stop
+
+ def __init__(self, stage_config, used_fixtures=None, parent=None):
+ super().__init__(parent)
+ self._stage_config = stage_config
+ self._used_fixtures = used_fixtures or []
+ self._current_obj = None # currently selected fixture
+ self._current_group = None # currently selected group
+ self._updating_ui = False # guard against recursive signal loops
+ self._group_base_offsets = {} # snapshot for group rotation
+ self._group_base_rotation = (0, 0, 0)
+ self._last_live_update = 0.0 # throttle for DMX live refresh
+
+ root = QtWidgets.QVBoxLayout(self)
+ root.setContentsMargins(4, 4, 4, 4)
+ root.setSpacing(6)
+
+ # Fixture list header with action buttons
+ list_header = QtWidgets.QHBoxLayout()
+ lbl = QtWidgets.QLabel("Fixtures")
+ fnt = lbl.font()
+ fnt.setBold(True)
+ fnt.setPointSize(fnt.pointSize() + 1)
+ lbl.setFont(fnt)
+ list_header.addWidget(lbl)
+ list_header.addStretch(1)
+
+ self._add_btn = QtWidgets.QPushButton("Add")
+ self._remove_btn = QtWidgets.QPushButton("Remove")
+ self._group_btn = QtWidgets.QPushButton("Group")
+ self._add_btn.setFixedWidth(60)
+ self._remove_btn.setFixedWidth(60)
+ self._group_btn.setFixedWidth(60)
+ self._group_btn.setToolTip("Group selected fixtures (Shift/Ctrl to multi-select)")
+ list_header.addWidget(self._add_btn)
+ list_header.addWidget(self._remove_btn)
+ list_header.addWidget(self._group_btn)
+
+ # DMX live toggle checkbox
+ self._dmx_cb = QtWidgets.QCheckBox("DMX Live")
+ self._dmx_cb.setChecked(True)
+ self._dmx_cb.setToolTip("Enable/disable live DMX reception from Fish")
+ self._dmx_cb.toggled.connect(self._on_dmx_live_toggled)
+ list_header.addWidget(self._dmx_cb)
+
+ root.addLayout(list_header)
+
+ # Fixture list widget
+ self._fixture_list = QtWidgets.QListWidget()
+ self._fixture_list.setMaximumHeight(200)
+ self._fixture_list.setAlternatingRowColors(True)
+ self._fixture_list.setSelectionMode(
+ QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
+ root.addWidget(self._fixture_list)
+
+ # Scrollable property panel
+ scroll = QtWidgets.QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
+ self._prop_container = QtWidgets.QWidget()
+ self._prop_layout = QtWidgets.QFormLayout(self._prop_container)
+ self._prop_layout.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
+ self._prop_layout.setContentsMargins(0, 0, 0, 0)
+ self._prop_layout.setVerticalSpacing(5)
+ scroll.setWidget(self._prop_container)
+ root.addWidget(scroll, 1)
+
+ # Connect button signals
+ self._add_btn.clicked.connect(self._on_add_clicked)
+ self._remove_btn.clicked.connect(self._on_remove_clicked)
+ self._group_btn.clicked.connect(self._on_group_clicked)
+ self._fixture_list.itemSelectionChanged.connect(self._on_selection_changed)
+
+ # Build initial list
+ self._rebuild_list()
+
+ # Fixture list building
+
+ def _display_text(self, obj):
+ """Format display text for a fixture list item."""
+ display = obj.get_display_name()
+ if obj.name:
+ return f"{obj.name} ({display})"
+ return display
+
+ def _group_display_text(self, grp):
+ """Format display text for a group header list item."""
+ count = len(grp.member_ids)
+ name = grp.name or grp.id
+ return f"[G] {name} ({count} fixtures)"
+
+ def _rebuild_list(self):
+ """Full rebuild of the fixture list (after group creation/removal etc.)."""
+ self._fixture_list.blockSignals(True)
+ self._fixture_list.clear()
+
+ # Collect fixture IDs that belong to any group
+ grouped_ids = set()
+ for grp in self._stage_config.groups:
+ grouped_ids.update(grp.member_ids)
+
+ # Add groups with their member fixtures indented below
+ for grp in self._stage_config.groups:
+ # Group header item (bold, colored background)
+ grp_item = QtWidgets.QListWidgetItem(self._group_display_text(grp))
+ grp_item.setData(ROLE_ID, grp.id)
+ grp_item.setData(ROLE_IS_GROUP, True)
+ grp_item.setBackground(QtGui.QColor(50, 55, 75))
+ grp_item.setForeground(QtGui.QColor(190, 195, 255))
+ fnt = grp_item.font()
+ fnt.setBold(True)
+ grp_item.setFont(fnt)
+ self._fixture_list.addItem(grp_item)
+
+ # Member fixtures indented with tree connector
+ for mid in grp.member_ids:
+ obj = self._stage_config.get_object(mid)
+ if obj is None:
+ continue
+ text = f" \u2514 {self._display_text(obj)}"
+ item = QtWidgets.QListWidgetItem(text)
+ item.setData(ROLE_ID, obj.id)
+ item.setData(ROLE_IS_GROUP, False)
+ self._fixture_list.addItem(item)
+
+ # Add ungrouped fixtures
+ for obj in self._stage_config.objects:
+ if obj.get_type() == "platform":
+ continue
+ if obj.id in grouped_ids:
+ continue # already shown under its group
+ item = QtWidgets.QListWidgetItem(self._display_text(obj))
+ item.setData(ROLE_ID, obj.id)
+ item.setData(ROLE_IS_GROUP, False)
+ self._fixture_list.addItem(item)
+
+ self._fixture_list.blockSignals(False)
+
+ # Auto-select first item
+ if self._fixture_list.count() > 0:
+ self._fixture_list.setCurrentRow(0)
+ else:
+ self._clear_properties()
+
+ # Selection handling
+
+ def _on_selection_changed(self):
+ """React to list selection changes and update the property panel."""
+ selected_items = self._fixture_list.selectedItems()
+ if not selected_items:
+ self._current_obj = None
+ self._current_group = None
+ self._clear_properties()
+ self.selectionChanged.emit([], False)
+ return
+
+ # Collect all fixture IDs that should be highlighted in 3D
+ highlight_ids = []
+ for item in selected_items:
+ oid = item.data(ROLE_ID)
+ is_group = item.data(ROLE_IS_GROUP)
+ if is_group:
+ grp = self._stage_config.get_group(oid)
+ if grp:
+ highlight_ids.extend(grp.member_ids)
+ else:
+ highlight_ids.append(oid)
+
+ is_multi = len(highlight_ids) > 1
+
+ # Build properties for the last clicked item
+ last_item = selected_items[-1]
+ last_id = last_item.data(ROLE_ID)
+ is_group = last_item.data(ROLE_IS_GROUP)
+
+ if is_group:
+ grp = self._stage_config.get_group(last_id)
+ if grp:
+ self._current_obj = None
+ self._current_group = grp
+ self._build_group_properties(grp)
+ else:
+ obj = self._stage_config.get_object(last_id)
+ if obj:
+ self._current_obj = obj
+ self._current_group = None
+ self._build_properties(obj)
+
+ # Enable group button only if 2+ non-group fixtures are selected
+ fixture_count = sum(1 for it in selected_items if not it.data(ROLE_IS_GROUP))
+ self._group_btn.setEnabled(fixture_count >= 2)
+
+ self.selectionChanged.emit(highlight_ids, is_multi)
+
+ # Property panel helpers
+
+ def _clear_properties(self):
+ """Remove all rows from the property form."""
+ while self._prop_layout.rowCount() > 0:
+ self._prop_layout.removeRow(0)
+
+ def _add_section_header(self, text):
+ """Add a bold section header label to the property form."""
+ lbl = QtWidgets.QLabel(text)
+ fnt = lbl.font()
+ fnt.setBold(True)
+ lbl.setFont(fnt)
+ self._prop_layout.addRow(lbl)
+
+ def _add_separator(self):
+ """Add a horizontal line separator to the property form."""
+ line = QtWidgets.QFrame()
+ line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
+ line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
+ self._prop_layout.addRow(line)
+
+ # Group property panel
+
+ def _build_group_properties(self, grp):
+ """Build the property panel for a selected fixture group."""
+ self._updating_ui = True
+ self._clear_properties()
+
+ # Snapshot: store each member's offset from group center + rotation
+ cx, cy, cz = grp.position
+ self._group_base_offsets = {}
+ for mid in grp.member_ids:
+ obj = self._stage_config.get_object(mid)
+ if obj:
+ offset = (obj.position[0] - cx, obj.position[1] - cy, obj.position[2] - cz)
+ self._group_base_offsets[mid] = (offset, obj.rotation)
+ self._group_base_rotation = grp.rotation
+
+ self._add_section_header("[G] Group")
+
+ self._name_edit = QtWidgets.QLineEdit(grp.name)
+ self._name_edit.setPlaceholderText("Group")
+ self._name_edit.textChanged.connect(self._on_group_name_changed)
+ self._prop_layout.addRow("Name:", self._name_edit)
+
+ self._add_separator()
+ self._add_section_header("Group Position (moves all members)")
+
+ self._pos_spins = []
+ for i, axis in enumerate(("X:", "Y:", "Z:")):
+ sp = QtWidgets.QDoubleSpinBox()
+ sp.setRange(-5000, 5000)
+ sp.setDecimals(1)
+ sp.setSingleStep(1.0)
+ sp.setSuffix(" u")
+ sp.setValue(grp.position[i])
+ sp.valueChanged.connect(self._on_group_position_changed)
+ self._prop_layout.addRow(axis, sp)
+ self._pos_spins.append(sp)
+
+ self._add_separator()
+ self._add_section_header("Group Rotation (rotates all members)")
+
+ self._rot_spins = []
+ for i, axis in enumerate(("X:", "Y:", "Z:")):
+ sp = QtWidgets.QDoubleSpinBox()
+ sp.setRange(-360, 360)
+ sp.setDecimals(1)
+ sp.setSingleStep(5.0)
+ sp.setSuffix(" deg")
+ sp.setValue(grp.rotation[i])
+ sp.valueChanged.connect(self._on_group_rotation_changed)
+ self._prop_layout.addRow(axis, sp)
+ self._rot_spins.append(sp)
+
+ self._add_separator()
+ self._add_section_header(f"Members ({len(grp.member_ids)})")
+ for mid in grp.member_ids:
+ obj = self._stage_config.get_object(mid)
+ name_str = self._display_text(obj) if obj else mid
+ lbl = QtWidgets.QLabel(name_str)
+ self._prop_layout.addRow("", lbl)
+
+ self._updating_ui = False
+
+ # Fixture property panel
+
+ def _build_properties(self, obj):
+ """Build the property panel for a single selected fixture."""
+ self._updating_ui = True
+ self._clear_properties()
+
+ # Name
+ self._name_edit = QtWidgets.QLineEdit(obj.name)
+ self._name_edit.setPlaceholderText(obj.get_display_name())
+ self._name_edit.textChanged.connect(self._on_name_changed)
+ self._prop_layout.addRow("Name:", self._name_edit)
+
+ # Device Link (DMX)
+ if isinstance(obj, stage_model.MovingHead):
+ self._add_separator()
+ self._build_device_section(obj)
+
+ # Position
+ self._add_separator()
+ self._add_section_header("Position")
+
+ self._pos_spins = []
+ for i, axis in enumerate(("X:", "Y:", "Z:")):
+ sp = QtWidgets.QDoubleSpinBox()
+ sp.setRange(-5000, 5000)
+ sp.setDecimals(1)
+ sp.setSingleStep(1.0)
+ sp.setSuffix(" u")
+ sp.setValue(obj.position[i])
+ sp.valueChanged.connect(self._on_position_changed)
+ self._prop_layout.addRow(axis, sp)
+ self._pos_spins.append(sp)
+
+ # Rotation
+ self._add_separator()
+ self._add_section_header("Rotation")
+
+ self._rot_spins = []
+ for i, axis in enumerate(("X:", "Y:", "Z:")):
+ sp = QtWidgets.QDoubleSpinBox()
+ sp.setRange(-360, 360)
+ sp.setDecimals(1)
+ sp.setSingleStep(5.0)
+ sp.setSuffix(" deg")
+ sp.setValue(obj.rotation[i])
+ sp.valueChanged.connect(self._on_rotation_changed)
+ self._prop_layout.addRow(axis, sp)
+ self._rot_spins.append(sp)
+
+ # Scale
+ self._add_separator()
+
+ self._scale_spin = QtWidgets.QDoubleSpinBox()
+ self._scale_spin.setRange(0.01, 1000)
+ self._scale_spin.setDecimals(2)
+ self._scale_spin.setSingleStep(0.5)
+ self._scale_spin.setValue(obj.scale)
+ self._scale_spin.valueChanged.connect(self._on_scale_changed)
+ self._prop_layout.addRow("Scale:", self._scale_spin)
+
+ # MovingHead beam properties
+ if isinstance(obj, stage_model.MovingHead):
+ self._add_separator()
+ self._add_section_header("Beam Control")
+
+ self._pan_spin = QtWidgets.QDoubleSpinBox()
+ self._pan_spin.setRange(-270, 270)
+ self._pan_spin.setDecimals(1)
+ self._pan_spin.setSingleStep(1.0)
+ self._pan_spin.setSuffix(" deg")
+ self._pan_spin.setValue(obj.pan)
+ self._pan_spin.valueChanged.connect(
+ lambda v: self._on_attr("pan", v))
+ self._prop_layout.addRow("Pan:", self._pan_spin)
+
+ self._tilt_spin = QtWidgets.QDoubleSpinBox()
+ self._tilt_spin.setRange(-135, 135)
+ self._tilt_spin.setDecimals(1)
+ self._tilt_spin.setSingleStep(1.0)
+ self._tilt_spin.setSuffix(" deg")
+ self._tilt_spin.setValue(obj.tilt)
+ self._tilt_spin.valueChanged.connect(
+ lambda v: self._on_attr("tilt", v))
+ self._prop_layout.addRow("Tilt:", self._tilt_spin)
+
+ self._add_separator()
+
+ self._beam_cb = QtWidgets.QCheckBox("Enabled")
+ self._beam_cb.setChecked(obj.beam_on)
+ self._beam_cb.stateChanged.connect(self._on_beam_toggled)
+ self._prop_layout.addRow("Beam:", self._beam_cb)
+
+ self._dimmer_spin = QtWidgets.QDoubleSpinBox()
+ self._dimmer_spin.setRange(0, 1)
+ self._dimmer_spin.setDecimals(2)
+ self._dimmer_spin.setSingleStep(0.05)
+ self._dimmer_spin.setValue(obj.dimmer)
+ self._dimmer_spin.valueChanged.connect(
+ lambda v: self._on_attr("dimmer", v))
+ self._prop_layout.addRow("Dimmer:", self._dimmer_spin)
+
+ self._dimmer_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
+ self._dimmer_slider.setRange(0, 100)
+ self._dimmer_slider.setValue(int(obj.dimmer * 100))
+ self._dimmer_slider.valueChanged.connect(self._on_dimmer_slider)
+ self._prop_layout.addRow("", self._dimmer_slider)
+
+ # Beam color
+ self._add_separator()
+ self._add_section_header("Beam Color")
+
+ r, g, b = obj.beam_color
+ self._color_btn = QtWidgets.QPushButton()
+ self._color_btn.setFixedHeight(28)
+ self._update_color_btn_style(r, g, b)
+ self._color_btn.clicked.connect(self._on_color_picker)
+ self._prop_layout.addRow("Pick:", self._color_btn)
+
+ self._rgb_spins = []
+ for i, (axis, val) in enumerate(
+ (("R:", r), ("G:", g), ("B:", b))):
+ sp = QtWidgets.QSpinBox()
+ sp.setRange(0, 255)
+ sp.setSingleStep(5)
+ sp.setValue(val)
+ sp.valueChanged.connect(self._on_rgb_changed)
+ self._prop_layout.addRow(axis, sp)
+ self._rgb_spins.append(sp)
+
+ # Lock controls that are driven by DMX
+ self._apply_dmx_locks(obj)
+
+ self._updating_ui = False
+
+ # DMX lock / unlock logic
+
+ def _has_dmx_role(self, obj, section, role):
+ """Check if a MovingHead has a DMX channel assigned for a given role."""
+ dc = obj.device_config
+ if not dc:
+ return False
+ sub = dc.get(section, {})
+ mapping = sub.get("mapping", {})
+ return mapping.get(role, -1) >= 0
+
+ def _on_dmx_live_toggled(self, checked):
+ self.dmxToggled.emit(checked)
+ self._refresh_locks()
+
+ def _apply_dmx_locks(self, obj):
+ """Disable UI controls for channels that are driven by live DMX.
+
+ When DMX Live is off, all controls remain unlocked for manual editing.
+ """
+ if not isinstance(obj, stage_model.MovingHead):
+ return
+
+ lock_style = "background-color: #3a3a2a; color: #aa9;"
+ unlock_style = ""
+
+ # Reset all controls to unlocked state first
+ for widget in [self._pan_spin, self._tilt_spin, self._dimmer_spin]:
+ widget.setEnabled(True)
+ widget.setToolTip("")
+ widget.setStyleSheet(unlock_style)
+ self._dimmer_slider.setEnabled(True)
+ self._beam_cb.setEnabled(True)
+ self._color_btn.setEnabled(True)
+ self._color_btn.setToolTip("")
+ for sp in self._rgb_spins:
+ sp.setEnabled(True)
+ sp.setStyleSheet(unlock_style)
+
+ # If DMX Live is off, keep everything unlocked
+ if not self._dmx_cb.isChecked():
+ return
+
+ # Lock DMX-controlled movement channels
+ has_pan = self._has_dmx_role(obj, "movement", "pan_coarse")
+ has_tilt = self._has_dmx_role(obj, "movement", "tilt_coarse")
+ has_dim = (self._has_dmx_role(obj, "movement", "dimmer") or
+ self._has_dmx_role(obj, "color", "white"))
+
+ if has_pan:
+ self._pan_spin.setEnabled(False)
+ self._pan_spin.setToolTip("Controlled by DMX")
+ self._pan_spin.setStyleSheet(lock_style)
+ if has_tilt:
+ self._tilt_spin.setEnabled(False)
+ self._tilt_spin.setToolTip("Controlled by DMX")
+ self._tilt_spin.setStyleSheet(lock_style)
+ if has_dim:
+ self._dimmer_spin.setEnabled(False)
+ self._dimmer_slider.setEnabled(False)
+ self._beam_cb.setEnabled(False)
+ self._dimmer_spin.setToolTip("Controlled by DMX")
+ self._dimmer_spin.setStyleSheet(lock_style)
+
+ # Lock DMX-controlled color channels
+ has_r = self._has_dmx_role(obj, "color", "red")
+ has_g = self._has_dmx_role(obj, "color", "green")
+ has_b = self._has_dmx_role(obj, "color", "blue")
+ if has_r and has_g and has_b:
+ self._color_btn.setEnabled(False)
+ self._color_btn.setToolTip("Controlled by DMX")
+ for sp in self._rgb_spins:
+ sp.setEnabled(False)
+ sp.setStyleSheet(lock_style)
+
+ def _refresh_locks(self):
+ """Re-apply lock state after a device or mapping change."""
+ if self._current_obj and isinstance(self._current_obj, stage_model.MovingHead):
+ if hasattr(self, "_pan_spin"):
+ self._apply_dmx_locks(self._current_obj)
+
+ def update_live_values(self):
+ """Refresh the property panel with current fixture values from DMX.
+
+ Throttled to 10 Hz to avoid excessive UI updates during fast polling.
+ """
+ if not self._dmx_cb.isChecked():
+ return
+ now = time.time()
+ if now - self._last_live_update < 0.1:
+ return
+ self._last_live_update = now
+
+ obj = self._current_obj
+ if not obj or not isinstance(obj, stage_model.MovingHead):
+ return
+
+ self._updating_ui = True
+ try:
+ if hasattr(self, "_pan_spin"):
+ self._pan_spin.setValue(obj.pan)
+ if hasattr(self, "_tilt_spin"):
+ self._tilt_spin.setValue(obj.tilt)
+ if hasattr(self, "_dimmer_spin"):
+ self._dimmer_spin.setValue(obj.dimmer)
+ if hasattr(self, "_dimmer_slider"):
+ self._dimmer_slider.setValue(int(obj.dimmer * 100))
+ if hasattr(self, "_beam_cb"):
+ self._beam_cb.setChecked(obj.beam_on)
+ if hasattr(self, "_rgb_spins") and len(self._rgb_spins) == 3:
+ r, g, b = obj.beam_color
+ self._rgb_spins[0].setValue(r)
+ self._rgb_spins[1].setValue(g)
+ self._rgb_spins[2].setValue(b)
+ if hasattr(self, "_color_btn"):
+ r, g, b = obj.beam_color
+ self._update_color_btn_style(r, g, b)
+ except Exception:
+ pass
+ self._updating_ui = False
+
+ def _update_color_btn_style(self, r, g, b):
+ """Set the color button background and auto-contrast text color."""
+ lum = 0.299 * r + 0.587 * g + 0.114 * b
+ tc = "#000" if lum > 128 else "#fff"
+ self._color_btn.setStyleSheet(
+ f"background-color: rgb({r},{g},{b}); color: {tc}; border: 1px solid #555;")
+ self._color_btn.setText(f"({r}, {g}, {b})")
+
+ # Device (DMX) section — Movement and Color
+
+ def _build_device_section(self, obj):
+ """Build the Movement Device and Color Device property sections."""
+ if not isinstance(obj, stage_model.MovingHead):
+ return
+
+ dc = obj.device_config or {}
+
+ # Movement Device (Pan/Tilt/Dimmer)
+ self._add_section_header("Movement Device (Pan/Tilt/Dimmer)")
+ self._mv_device_combo = QtWidgets.QComboBox(self._prop_container)
+ self._mv_device_combo.addItem("(None)", None)
+ for fix in self._used_fixtures:
+ self._mv_device_combo.addItem(_fixture_label(fix), _fixture_combo_data(fix))
+
+ # Pre-select the matching device if already configured
+ self._mv_device_combo.setCurrentIndex(0)
+ mv_cfg = dc.get("movement")
+ if mv_cfg:
+ for i in range(1, self._mv_device_combo.count()):
+ d = self._mv_device_combo.itemData(i)
+ if d and d["universe"] == mv_cfg.get("universe") and d["start_channel"] == mv_cfg.get("start_channel"):
+ self._mv_device_combo.setCurrentIndex(i)
+ break
+ self._mv_device_combo.currentIndexChanged.connect(self._on_mv_device_changed)
+ self._prop_layout.addRow("Device:", self._mv_device_combo)
+
+ # Channel mapping combos for movement roles
+ self._mv_ch_container = QtWidgets.QWidget()
+ self._mv_ch_layout = QtWidgets.QFormLayout(self._mv_ch_container)
+ self._mv_ch_layout.setContentsMargins(0, 0, 0, 0)
+ self._mv_ch_layout.setVerticalSpacing(3)
+ self._prop_layout.addRow(self._mv_ch_container)
+ self._mv_combos = {}
+ self._rebuild_mv_combos(obj)
+
+ self._add_separator()
+
+ # Color Device (RGB/W)
+ self._add_section_header("Color Device (RGB)")
+ self._col_device_combo = QtWidgets.QComboBox(self._prop_container)
+ self._col_device_combo.addItem("(None)", None)
+ for fix in self._used_fixtures:
+ self._col_device_combo.addItem(_fixture_label(fix), _fixture_combo_data(fix))
+
+ # Pre-select the matching device if already configured
+ self._col_device_combo.setCurrentIndex(0)
+ col_cfg = dc.get("color")
+ if col_cfg:
+ for i in range(1, self._col_device_combo.count()):
+ d = self._col_device_combo.itemData(i)
+ if d and d["universe"] == col_cfg.get("universe") and d["start_channel"] == col_cfg.get("start_channel"):
+ self._col_device_combo.setCurrentIndex(i)
+ break
+ self._col_device_combo.currentIndexChanged.connect(self._on_col_device_changed)
+ self._prop_layout.addRow("Device:", self._col_device_combo)
+
+ # Channel mapping combos for color roles
+ self._col_ch_container = QtWidgets.QWidget()
+ self._col_ch_layout = QtWidgets.QFormLayout(self._col_ch_container)
+ self._col_ch_layout.setContentsMargins(0, 0, 0, 0)
+ self._col_ch_layout.setVerticalSpacing(3)
+ self._prop_layout.addRow(self._col_ch_container)
+ self._col_combos = {}
+ self._rebuild_col_combos(obj)
+
+ def _rebuild_mv_combos(self, obj):
+ """Rebuild the movement channel mapping combo boxes."""
+ while self._mv_ch_layout.rowCount() > 0:
+ self._mv_ch_layout.removeRow(0)
+ self._mv_combos = {}
+ device_data = self._mv_device_combo.currentData()
+ if not device_data:
+ return
+ ch_names = device_data.get("channel_names", [])
+ dc = (obj.device_config or {}).get("movement", {})
+ mapping = dc.get("mapping") or auto_detect_mapping(ch_names, MOVEMENT_ROLES)
+
+ labels = {
+ "pan_coarse": "Pan:", "pan_fine": "Pan fine:",
+ "tilt_coarse": "Tilt:", "tilt_fine": "Tilt fine:",
+ "dimmer": "Dimmer:", "pan_tilt_speed": "P/T Speed:",
+ }
+ for role in MOVEMENT_ROLES:
+ combo = QtWidgets.QComboBox(self._mv_ch_container)
+ combo.addItem("(None)", -1)
+ for idx, cn in enumerate(ch_names):
+ combo.addItem(f"CH{idx}: {cn}", idx)
+ # Pre-select the mapped channel
+ cur = mapping.get(role, -1)
+ if cur >= 0:
+ for ci in range(1, combo.count()):
+ if combo.itemData(ci) == cur:
+ combo.setCurrentIndex(ci)
+ break
+ combo.currentIndexChanged.connect(
+ lambda _idx, r=role: self._on_mv_mapping_changed(r))
+ self._mv_ch_layout.addRow(labels.get(role, role), combo)
+ self._mv_combos[role] = combo
+
+ def _rebuild_col_combos(self, obj):
+ """Rebuild the color channel mapping combo boxes."""
+ while self._col_ch_layout.rowCount() > 0:
+ self._col_ch_layout.removeRow(0)
+ self._col_combos = {}
+ device_data = self._col_device_combo.currentData()
+ if not device_data:
+ return
+ ch_names = device_data.get("channel_names", [])
+ dc = (obj.device_config or {}).get("color", {})
+ mapping = dc.get("mapping") or auto_detect_mapping(ch_names, COLOR_ROLES)
+
+ labels = {"red": "Red:", "green": "Green:", "blue": "Blue:", "white": "White:"}
+ for role in COLOR_ROLES:
+ combo = QtWidgets.QComboBox(self._col_ch_container)
+ combo.addItem("(None)", -1)
+ for idx, cn in enumerate(ch_names):
+ combo.addItem(f"CH{idx}: {cn}", idx)
+ cur = mapping.get(role, -1)
+ if cur >= 0:
+ for ci in range(1, combo.count()):
+ if combo.itemData(ci) == cur:
+ combo.setCurrentIndex(ci)
+ break
+ combo.currentIndexChanged.connect(
+ lambda _idx, r=role: self._on_col_mapping_changed(r))
+ self._col_ch_layout.addRow(labels.get(role, role), combo)
+ self._col_combos[role] = combo
+
+ def _on_mv_device_changed(self, index):
+ if self._updating_ui or not self._current_obj:
+ return
+ dd = self._mv_device_combo.currentData()
+ if self._current_obj.device_config is None:
+ self._current_obj.device_config = {}
+ if dd is None:
+ self._current_obj.device_config.pop("movement", None)
+ else:
+ mapping = auto_detect_mapping(dd["channel_names"], MOVEMENT_ROLES)
+ self._current_obj.device_config["movement"] = {
+ "universe": dd["universe"], "start_channel": dd["start_channel"],
+ "channel_count": dd["channel_count"], "mapping": mapping}
+ self._updating_ui = True
+ self._rebuild_mv_combos(self._current_obj)
+ self._updating_ui = False
+ self._refresh_locks()
+ self._emit_changed()
+
+ def _on_col_device_changed(self, index):
+ if self._updating_ui or not self._current_obj:
+ return
+ dd = self._col_device_combo.currentData()
+ if self._current_obj.device_config is None:
+ self._current_obj.device_config = {}
+ if dd is None:
+ self._current_obj.device_config.pop("color", None)
+ else:
+ mapping = auto_detect_mapping(dd["channel_names"], COLOR_ROLES)
+ self._current_obj.device_config["color"] = {
+ "universe": dd["universe"], "start_channel": dd["start_channel"],
+ "channel_count": dd["channel_count"], "mapping": mapping}
+ self._updating_ui = True
+ self._rebuild_col_combos(self._current_obj)
+ self._updating_ui = False
+ self._refresh_locks()
+ self._emit_changed()
+
+ def _on_mv_mapping_changed(self, role):
+ if self._updating_ui or not self._current_obj:
+ return
+ dc = self._current_obj.device_config
+ if not dc or "movement" not in dc:
+ return
+ combo = self._mv_combos.get(role)
+ if combo:
+ dc["movement"]["mapping"][role] = combo.currentData()
+ self._refresh_locks()
+ self._emit_changed()
+
+ def _on_col_mapping_changed(self, role):
+ if self._updating_ui or not self._current_obj:
+ return
+ dc = self._current_obj.device_config
+ if not dc or "color" not in dc:
+ return
+ combo = self._col_combos.get(role)
+ if combo:
+ dc["color"]["mapping"][role] = combo.currentData()
+ self._refresh_locks()
+ self._emit_changed()
+
+ # Fixture change handlers
+
+ def _emit_changed(self):
+ """Notify the mediator that the current fixture's properties changed."""
+ if self._updating_ui or not self._current_obj:
+ return
+ self.objectChanged.emit(self._current_obj.id)
+
+ def _on_name_changed(self, text):
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.name = text.strip()
+ # Update the list item text for the selected fixture
+ for item in self._fixture_list.selectedItems():
+ oid = item.data(ROLE_ID)
+ if oid == self._current_obj.id and not item.data(ROLE_IS_GROUP):
+ grp = self._stage_config.get_group_for_fixture(oid)
+ if grp:
+ item.setText(f" \u2514 {self._display_text(self._current_obj)}")
+ else:
+ item.setText(self._display_text(self._current_obj))
+ self._emit_changed()
+
+ def _on_position_changed(self):
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.position = tuple(s.value() for s in self._pos_spins)
+ self._emit_changed()
+
+ def _on_rotation_changed(self):
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.rotation = tuple(s.value() for s in self._rot_spins)
+ self._emit_changed()
+
+ def _on_scale_changed(self, val):
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.scale = float(val)
+ self._emit_changed()
+
+ def _on_attr(self, attr, val):
+ """Generic handler for simple float attributes (pan, tilt, dimmer)."""
+ if self._updating_ui or not self._current_obj:
+ return
+ setattr(self._current_obj, attr, float(val))
+ self._emit_changed()
+
+ def _on_beam_toggled(self, state):
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.beam_on = bool(state)
+ self._emit_changed()
+
+ def _on_dimmer_slider(self, val):
+ """Synchronize the dimmer slider with the spin box."""
+ if self._updating_ui or not self._current_obj:
+ return
+ v = val / 100.0
+ self._current_obj.dimmer = v
+ self._updating_ui = True
+ self._dimmer_spin.setValue(v)
+ self._updating_ui = False
+ self._emit_changed()
+
+ def _on_rgb_changed(self):
+ if self._updating_ui or not self._current_obj:
+ return
+ r, g, b = (s.value() for s in self._rgb_spins)
+ self._current_obj.beam_color = (r, g, b)
+ self._update_color_btn_style(r, g, b)
+ self._emit_changed()
+
+ def _on_color_picker(self):
+ """Open a QColorDialog and apply the chosen color."""
+ if not self._current_obj:
+ return
+ r, g, b = self._current_obj.beam_color
+ color = QtWidgets.QColorDialog.getColor(
+ QtGui.QColor(r, g, b), self, "Beam Color")
+ if color.isValid():
+ nr, ng, nb = color.red(), color.green(), color.blue()
+ self._updating_ui = True
+ self._rgb_spins[0].setValue(nr)
+ self._rgb_spins[1].setValue(ng)
+ self._rgb_spins[2].setValue(nb)
+ self._updating_ui = False
+ self._current_obj.beam_color = (nr, ng, nb)
+ self._update_color_btn_style(nr, ng, nb)
+ self._emit_changed()
+
+ # Group change handlers
+
+ def _on_group_name_changed(self, text):
+ """Rename the selected group."""
+ if self._updating_ui or not self._current_group:
+ return
+ self._current_group.name = text.strip()
+ for item in self._fixture_list.selectedItems():
+ if item.data(ROLE_IS_GROUP) and item.data(ROLE_ID) == self._current_group.id:
+ item.setText(self._group_display_text(self._current_group))
+ self._emit_group_changed()
+
+ def _on_group_position_changed(self):
+ """Translate all group members by the same delta as the group center."""
+ if self._updating_ui or not self._current_group:
+ return
+ old_pos = self._current_group.position
+ new_pos = tuple(s.value() for s in self._pos_spins)
+ dx = new_pos[0] - old_pos[0]
+ dy = new_pos[1] - old_pos[1]
+ dz = new_pos[2] - old_pos[2]
+ self._current_group.position = new_pos
+
+ # Apply the same translation delta to all member fixtures
+ for mid in self._current_group.member_ids:
+ obj = self._stage_config.get_object(mid)
+ if obj:
+ obj.position = (
+ obj.position[0] + dx,
+ obj.position[1] + dy,
+ obj.position[2] + dz,
+ )
+
+ self._emit_group_changed()
+
+ def _on_group_rotation_changed(self):
+ """Rotate all members around the group center using total rotation.
+ """
+ if self._updating_ui or not self._current_group:
+ return
+ import math
+
+ new_rot = tuple(s.value() for s in self._rot_spins)
+
+ # Total rotation relative to the snapshot baseline
+ base = self._group_base_rotation
+ total_rx = new_rot[0] - base[0]
+ total_ry = new_rot[1] - base[1]
+ total_rz = new_rot[2] - base[2]
+ self._current_group.rotation = new_rot
+
+ cx, cy, cz = self._current_group.position
+
+ for mid in self._current_group.member_ids:
+ if mid not in self._group_base_offsets:
+ continue
+ (ox, oy, oz), base_rot = self._group_base_offsets[mid]
+
+ # Apply total rotation to the original offset (X then Y then Z)
+ rx, ry, rz = ox, oy, oz
+
+ # Rotation around X axis
+ if abs(total_rx) > 1e-9:
+ rad = math.radians(total_rx)
+ c, s = math.cos(rad), math.sin(rad)
+ ry2 = ry * c - rz * s
+ rz2 = ry * s + rz * c
+ ry, rz = ry2, rz2
+
+ # Rotation around Y axis
+ if abs(total_ry) > 1e-9:
+ rad = math.radians(total_ry)
+ c, s = math.cos(rad), math.sin(rad)
+ rx2 = rx * c + rz * s
+ rz2 = -rx * s + rz * c
+ rx, rz = rx2, rz2
+
+ # Rotation around Z axis
+ if abs(total_rz) > 1e-9:
+ rad = math.radians(total_rz)
+ c, s = math.cos(rad), math.sin(rad)
+ rx2 = rx * c - ry * s
+ ry2 = rx * s + ry * c
+ rx, ry = rx2, ry2
+
+ obj = self._stage_config.get_object(mid)
+ if obj:
+ obj.position = (cx + rx, cy + ry, cz + rz)
+ obj.rotation = (
+ base_rot[0] + total_rx,
+ base_rot[1] + total_ry,
+ base_rot[2] + total_rz,
+ )
+
+ self._emit_group_changed()
+
+ def _emit_group_changed(self):
+ """Notify that group member objects changed (triggers 3D update + save)."""
+ if self._updating_ui or not self._current_group:
+ return
+ for mid in self._current_group.member_ids:
+ self.objectChanged.emit(mid)
+
+ # Actions (Add / Remove / Group)
+
+ def _on_add_clicked(self):
+ """Opens the fixture selection dialog and adds the selected fixtures."""
+ existing = self._stage_config.get_all_names()
+ dlg = AddFixtureDialog(existing, self._used_fixtures, self)
+ if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
+ return
+ self.addObjectRequested.emit(
+ dlg.selected_fixture_key(), dlg.selected_name(), dlg.selected_device())
+
+ def _on_remove_clicked(self):
+ """Removes the selected fixture from the stage."""
+ selected_items = self._fixture_list.selectedItems()
+ if not selected_items:
+ return
+ for item in list(selected_items):
+ oid = item.data(ROLE_ID)
+ is_group = item.data(ROLE_IS_GROUP)
+ if not oid:
+ continue
+ if is_group:
+ self.removeGroupRequested.emit(oid)
+ else:
+ self.removeObjectRequested.emit(oid)
+
+ def _on_group_clicked(self):
+ """Group all selected non-group fixtures together."""
+ selected_items = self._fixture_list.selectedItems()
+ fixture_ids = []
+ for item in selected_items:
+ if not item.data(ROLE_IS_GROUP):
+ oid = item.data(ROLE_ID)
+ if oid:
+ fixture_ids.append(oid)
+ if len(fixture_ids) < 2:
+ return
+
+ existing = self._stage_config.get_all_names()
+ dlg = GroupNameDialog(existing, self)
+ if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
+ return
+
+ self.groupRequested.emit(fixture_ids, dlg.selected_name())
+
+ # API
+
+ def add_object_to_list(self, obj):
+ """Add a newly created fixture to the list widget."""
+ if obj.get_type() == "platform":
+ return
+ item = QtWidgets.QListWidgetItem(self._display_text(obj))
+ item.setData(ROLE_ID, obj.id)
+ item.setData(ROLE_IS_GROUP, False)
+ self._fixture_list.addItem(item)
+ self._fixture_list.clearSelection()
+ item.setSelected(True)
+ self._fixture_list.scrollToItem(item)
+
+ def remove_object_from_list(self, object_id):
+ """Remove a fixture from the list widget by ID."""
+ for i in range(self._fixture_list.count()):
+ item = self._fixture_list.item(i)
+ if item and item.data(ROLE_ID) == object_id:
+ self._fixture_list.takeItem(i)
+ break
+
+ def refresh_list(self):
+ """Full rebuild of the fixture list."""
+ self._rebuild_list()
+
+ def select_fixture_by_id(self, object_id: str):
+ """Select a fixture in the list by its object ID (from left-click)."""
+ for i in range(self._fixture_list.count()):
+ item = self._fixture_list.item(i)
+ if item and item.data(ROLE_ID) == object_id and not item.data(ROLE_IS_GROUP):
+ self._fixture_list.clearSelection()
+ item.setSelected(True)
+ self._fixture_list.scrollToItem(item)
+ return
+
+ def deselect_all(self):
+ """Clear selection entirely (from right-click)."""
+ self._fixture_list.clearSelection()
diff --git a/src/view/visualizer/stage_gl_widget.py b/src/view/visualizer/stage_gl_widget.py
new file mode 100644
index 00000000..818861a4
--- /dev/null
+++ b/src/view/visualizer/stage_gl_widget.py
@@ -0,0 +1,1753 @@
+"""3D OpenGL viewport for the stage visualizer.
+
+Renders the scene in three passes (shadow maps, scene objects, volumetric
+beam cones) and handles camera, picking and the name-label overlay.
+
+"""
+
+from __future__ import annotations
+
+import ctypes
+import json
+import math
+import os
+import struct
+import time
+from collections.abc import Sequence
+from logging import getLogger
+from typing import TYPE_CHECKING, override
+
+import numpy as np
+from OpenGL import GL as gl
+from PySide6 import QtCore, QtGui
+from PySide6.QtOpenGLWidgets import QOpenGLWidget
+
+if TYPE_CHECKING:
+ from PySide6.QtCore import QPoint
+
+ from model.stage import StageObject
+
+logger = getLogger(__name__)
+
+MAX_SPOT_LIGHTS = 16 # maximum simultaneous spotlights in the scene shader
+MAX_SHADOW_MAPS = 4 # shadow-casting lights (texture array layers)
+SHADOW_MAP_SIZE = 1024 # per-layer shadow map resolution
+
+
+class Model3D:
+ """GPU mesh: VAO + VBO + EBO + index count."""
+
+ def __init__(self, vao, vbo, ebo, index_count):
+ self.vao = vao
+ self.vbo = vbo
+ self.ebo = ebo
+ self.index_count = index_count
+
+
+class GltfNode:
+ """A single node from a glTF scene graph."""
+
+ def __init__(self, name, mesh_index, children, translation, rotation, scale):
+ self.name = name or ""
+ self.mesh_index = mesh_index
+ self.children = children or []
+ self.translation = translation or [0.0, 0.0, 0.0]
+ self.rotation = rotation or [0.0, 0.0, 0.0, 1.0] # quaternion (x,y,z,w)
+ self.scale = scale or [1.0, 1.0, 1.0]
+
+
+class GltfModel:
+ """Minimal glTF/GLB container with node hierarchy and GPU meshes."""
+
+ def __init__(self, nodes, scene_roots, mesh_primitives):
+ self.nodes = nodes # list of GltfNode
+ self.scene_roots = scene_roots # list of root node indices
+ self.mesh_primitives = mesh_primitives # dict: mesh_index -> [Model3D]
+
+
+class SpotLightData:
+ """Spotlight data collected per frame from active MovingHeads."""
+
+ __slots__ = ("color", "direction", "inner_cos", "outer_cos", "position")
+
+ def __init__(self, position, direction, color, inner_deg=10.0, outer_deg=18.0):
+ self.position = position # QVector3D
+ self.direction = direction # QVector3D (normalized)
+ self.color = color # (r, g, b) floats in [0, 1]
+ self.inner_cos = math.cos(math.radians(inner_deg))
+ self.outer_cos = math.cos(math.radians(outer_deg))
+
+
+# glTF binary loading
+
+# Mapping from glTF componentType to numpy dtype
+_GLTF_COMPONENT_DTYPE = {
+ 5120: np.int8, 5121: np.uint8, 5122: np.int16,
+ 5123: np.uint16, 5125: np.uint32, 5126: np.float32,
+}
+# Mapping from glTF accessor type to number of components
+_GLTF_TYPE_NUMCOMP = {
+ "SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4,
+ "MAT2": 4, "MAT3": 9, "MAT4": 16,
+}
+
+
+def _read_glb(path):
+ """Read a GLB file and return (json_dict, bin_chunk).
+
+ GLB layout: 12-byte header + JSON chunk + BIN chunk.
+ """
+ data = open(path, "rb").read()
+ if len(data) < 20:
+ raise ValueError("GLB too small")
+ magic, version, length = struct.unpack_from("<4sII", data, 0)
+ if magic != b"glTF" or version != 2:
+ raise ValueError("Invalid GLB")
+
+ off = 12
+ json_chunk = bin_chunk = None
+ while off < length:
+ chunk_len, chunk_type = struct.unpack_from("0.0 = glow overlay
+uniform float highlightMix;
+uniform vec3 highlightColor;
+
+// Shadow mapping
+uniform int numShadowLights;
+uniform mat4 lightSpaceMatrices[MAX_SHADOWS];
+uniform sampler2DArray shadowMap;
+
+in vec3 FragPos;
+in vec3 Normal;
+out vec4 FragColor;
+
+float calcShadow(int idx) {
+ vec4 lsPos = lightSpaceMatrices[idx] * vec4(FragPos, 1.0);
+ vec3 proj = lsPos.xyz / lsPos.w;
+ proj = proj * 0.5 + 0.5;
+
+ // Outside shadow map = fully lit
+ if (proj.x < 0.0 || proj.x > 1.0 || proj.y < 0.0 || proj.y > 1.0 || proj.z > 1.0)
+ return 1.0;
+
+ // Slope-based bias to reduce shadow acne on angled surfaces
+ vec3 norm = normalize(Normal);
+ vec3 lightDir = normalize(lights[idx].position - FragPos);
+ float slopeFactor = 1.0 - max(dot(norm, lightDir), 0.0);
+ float bias = 0.0008 + 0.002 * slopeFactor;
+
+ float curDepth = proj.z;
+
+ // 3x3 PCF kernel for soft shadow edges
+ float lit = 0.0;
+ vec2 texelSize = 1.0 / vec2(textureSize(shadowMap, 0).xy);
+ for (int x = -1; x <= 1; x++) {
+ for (int y = -1; y <= 1; y++) {
+ float closest = texture(shadowMap, vec3(proj.xy + vec2(x, y) * texelSize, float(idx))).r;
+ lit += (curDepth - bias > closest) ? 0.0 : 1.0;
+ }
+ }
+ return lit / 9.0;
+}
+
+void main() {
+ vec3 norm = normalize(Normal);
+ vec3 result = baseColor * ambientLevel;
+
+ // Subtle fill light from above so geometry is never fully black
+ vec3 fillDir = normalize(vec3(0.2, 1.0, 0.1));
+ float fillDiff = max(dot(norm, fillDir), 0.0);
+ result += baseColor * fillDiff * 0.08;
+
+ for (int i = 0; i < numLights && i < MAX_LIGHTS; i++) {
+ vec3 toLight = lights[i].position - FragPos;
+ float dist = length(toLight);
+ vec3 lightDir = toLight / max(dist, 0.001);
+
+ // Spotlight cone attenuation
+ float theta = dot(lightDir, -lights[i].direction);
+ float eps = lights[i].innerCos - lights[i].outerCos;
+ float spot = clamp((theta - lights[i].outerCos) / max(eps, 0.001), 0.0, 1.0);
+
+ if (spot > 0.0) {
+ float diff = max(dot(norm, lightDir), 0.0);
+ vec3 viewDir = normalize(viewPos - FragPos);
+ vec3 halfDir = normalize(lightDir + viewDir);
+ float spec = pow(max(dot(norm, halfDir), 0.0), 64.0);
+
+ // Distance attenuation (quadratic falloff)
+ float atten = 1.0 / (1.0 + 0.002 * dist + 0.00003 * dist * dist);
+
+ // Shadow factor
+ float shadow = 1.0;
+ if (i < numShadowLights) {
+ shadow = calcShadow(i);
+ }
+
+ vec3 contrib = (diff * baseColor + spec * vec3(0.35)) * lights[i].color;
+ result += contrib * spot * atten * shadow * 2.2;
+ }
+ }
+
+ // Reinhard tone mapping
+ result = result / (result + vec3(1.0));
+
+ // Selection highlight overlay (neon-yellow for single, orange for multi)
+ if (highlightMix > 0.0) {
+ result = mix(result, highlightColor, highlightMix * 0.45);
+ result += highlightColor * highlightMix * 0.18;
+ }
+
+ FragColor = vec4(result, 1.0);
+}
+""").encode("utf-8")
+
+
+# Beam shader (Pass 2: volumetric cone with ray-marched shadows)
+
+BEAM_VS = b"""
+#version 410 core
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal;
+
+uniform mat4 model;
+uniform mat4 view;
+uniform mat4 projection;
+
+out vec3 LocalPos;
+out vec3 WorldPos;
+
+void main() {
+ LocalPos = aPos;
+ vec4 wp = model * vec4(aPos, 1.0);
+ WorldPos = wp.xyz;
+ gl_Position = projection * view * wp;
+}
+"""
+
+BEAM_FS = b"""
+#version 410 core
+
+in vec3 LocalPos;
+in vec3 WorldPos;
+
+uniform vec3 beamColor;
+
+// Shadow mapping for volumetric light shafts
+uniform mat4 beamLightSpaceMatrix;
+uniform sampler2DArray shadowMap;
+uniform int beamShadowLayer;
+uniform int hasShadow;
+
+// Light source position for ray-marching from light to fragment
+uniform vec3 beamLightPos;
+
+out vec4 FragColor;
+
+// Hash function for procedural noise
+float hash(vec3 p) {
+ p = fract(p * vec3(443.897, 441.423, 437.195));
+ p += dot(p, p.yzx + 19.19);
+ return fract((p.x + p.y) * p.z);
+}
+
+// Smooth 3D value noise
+float noise3D(vec3 p) {
+ vec3 i = floor(p);
+ vec3 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f); // smoothstep interpolation
+
+ float n = mix(
+ mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
+ mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
+ mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
+ mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
+ f.z);
+ return n;
+}
+
+// Multi-octave noise for beam streaks (simulates individual light rays)
+float beamNoise(vec3 worldP) {
+ float n1 = noise3D(worldP * 0.015); // large-scale streaks
+ float n2 = noise3D(worldP * 0.04) * 0.5; // medium detail
+ float n3 = noise3D(worldP * 0.12) * 0.25; // fine grain (dust particles)
+ float combined = n1 + n2 + n3;
+ return 0.4 + 0.6 * combined; // remap to [0.5, 1.0]
+}
+
+// Check shadow map visibility at a world position (single sample)
+float sampleShadowAt(vec3 worldP) {
+ if (hasShadow == 0) return 1.0;
+
+ vec4 lsPos = beamLightSpaceMatrix * vec4(worldP, 1.0);
+ vec3 proj = lsPos.xyz / lsPos.w;
+ proj = proj * 0.5 + 0.5;
+
+ if (proj.x < 0.0 || proj.x > 1.0 || proj.y < 0.0 || proj.y > 1.0 || proj.z > 1.0)
+ return 1.0;
+
+ float bias = 0.003;
+ float curDepth = proj.z;
+ float closest = texture(shadowMap, vec3(proj.xy, float(beamShadowLayer))).r;
+ return (curDepth - bias > closest) ? 0.0 : 1.0;
+}
+
+void main() {
+ // Discard fragments below ground plane
+ if (WorldPos.y < 0.0) discard;
+
+ float axial = clamp(-LocalPos.z, 0.0, 1.0);
+ float coneR = max(axial, 0.001);
+ float radial = length(LocalPos.xy) / coneR;
+
+ // Soft gaussian radial falloff
+ float edge = exp(-radial * radial * 1.5);
+ // Density increases along beam (atmospheric scattering accumulation)
+ float density = pow(axial, 0.25);
+ // Bright core along center axis (Mie-like forward scattering)
+ float core = exp(-radial * radial * 3.5);
+
+ // Ray-march 8 samples from light to fragment for volumetric shadows
+ float visibility = 1.0;
+ if (hasShadow == 1) {
+ vec3 rayDir = WorldPos - beamLightPos;
+ float rayLen = length(rayDir);
+ if (rayLen > 0.01) {
+ float shadow_acc = 0.0;
+ const int STEPS = 8;
+ for (int s = 0; s < STEPS; s++) {
+ float t = (float(s) + 0.5) / float(STEPS);
+ vec3 sampleP = beamLightPos + rayDir * t;
+ shadow_acc += sampleShadowAt(sampleP);
+ }
+ visibility = shadow_acc / float(STEPS);
+ }
+ }
+
+ // Apply streaky noise for atmospheric look
+ float streaks = beamNoise(WorldPos);
+
+ // Combine edge, density, core, noise, and shadow visibility
+ float alpha = (edge * density * 1.4) + (core * density * 1.8);
+ alpha *= streaks;
+ alpha *= visibility;
+ alpha = clamp(alpha, 0.0, 1.0);
+
+ // Additive blending output
+ FragColor = vec4(beamColor * alpha * 6.0, alpha);
+}
+"""
+
+
+class Stage3DWidget(QOpenGLWidget):
+ """OpenGL 3D viewport for the stage visualizer."""
+
+ # Emitted when user left-clicks a fixture in 3D
+ fixtureClicked = QtCore.Signal(str)
+ # Emitted when user right-clicks (deselect all)
+ deselectAllRequested = QtCore.Signal()
+
+ def __init__(self, stage_config, parent=None):
+ super().__init__(parent)
+ self._stage_config = stage_config
+
+ # Shader programs (initialized in initializeGL)
+ self._scene_program = None
+ self._beam_program = None
+ self._depth_program = None
+
+ # Uniform location caches
+ self._sc = {} # scene shader uniforms
+ self._sc_light_locs = [] # per-light uniform locations
+ self._bm = {} # beam shader uniforms
+ self._dp = {} # depth shader uniforms
+
+ # Shadow map GPU resources
+ self._shadow_fbo = None
+ self._shadow_tex = None
+
+ # Projection matrix
+ self._projection = QtGui.QMatrix4x4()
+
+ # Camera state (orbit mode)
+ self._camera_target = QtGui.QVector3D(0.0, 10.0, 0.0)
+ self._camera_up = QtGui.QVector3D(0.0, 1.0, 0.0)
+ self._camera_pos = QtGui.QVector3D(0.0, 200.0, 400.0)
+ self._cam_yaw = -90.0
+ self._cam_pitch = -20.0
+ self._cam_distance = (self._camera_pos - self._camera_target).length()
+
+ # Input state
+ self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
+ self.setMouseTracking(True)
+ self._mouse_last_pos = None
+ self._mouse_press_pos = None
+ self._mouse_buttons = set()
+ self._keys_down = set()
+ self._move_speed = 400.0
+ self._boost_speed = 1200.0
+
+ # Camera movement timer (~60 Hz)
+ self._camera_timer = QtCore.QTimer(self)
+ self._camera_timer.timeout.connect(self._tick_camera)
+ self._camera_timer.start(16)
+
+ # Model caches
+ self._models: dict[str, Model3D] = {} # path -> Model3D (OBJ meshes)
+ self._gltf_models: dict[str, GltfModel] = {} # path -> GltfModel
+ self._beam_cone = None
+ self._ground_plane = None
+
+ # Selection highlight state
+ self._selected_object_ids = set()
+ self._highlight_is_multi = False # True = orange, False = neon-yellow
+
+ # F-key overlay toggle
+ self._show_labels = False
+
+ # FPS counter
+ self._fps_frame_count = 0
+ self._fps_last_time = time.time()
+ self._fps_display = 0.0
+
+ # OpenGL initialization
+
+ def initializeGL(self):
+ gl.glClearColor(0.02, 0.02, 0.03, 1.0)
+ gl.glEnable(gl.GL_DEPTH_TEST)
+ gl.glEnable(gl.GL_CULL_FACE)
+
+ # Compile and link shader programs
+ try:
+ self._scene_program = _link_program(SCENE_VS, SCENE_FS)
+ except RuntimeError as e:
+ logger.error("Scene shader: %s", e)
+ return
+ try:
+ self._beam_program = _link_program(BEAM_VS, BEAM_FS)
+ except RuntimeError as e:
+ logger.error("Beam shader: %s", e)
+ try:
+ self._depth_program = _link_program(DEPTH_VS, DEPTH_FS)
+ except RuntimeError as e:
+ logger.error("Depth shader: %s", e)
+
+ # Cache uniform locations for each program
+
+ # Scene shader
+ sp = self._scene_program
+ for name in ("projection", "view", "model", "viewPos", "baseColor",
+ "ambientLevel", "numLights", "numShadowLights", "shadowMap",
+ "highlightMix", "highlightColor"):
+ self._sc[name] = gl.glGetUniformLocation(sp, name)
+
+ # Per-light uniforms (spotlight array)
+ self._sc_light_locs = []
+ for i in range(MAX_SPOT_LIGHTS):
+ p = f"lights[{i}]."
+ self._sc_light_locs.append({
+ k: gl.glGetUniformLocation(sp, p + k)
+ for k in ("position", "direction", "color", "innerCos", "outerCos")
+ })
+
+ # Light-space matrix array for shadow mapping
+ self._sc_lsm_locs = [
+ gl.glGetUniformLocation(sp, f"lightSpaceMatrices[{i}]")
+ for i in range(MAX_SHADOW_MAPS)
+ ]
+
+ # Beam shader
+ bp = self._beam_program
+ if bp:
+ for name in ("projection", "view", "model", "beamColor",
+ "beamLightSpaceMatrix", "shadowMap", "beamShadowLayer",
+ "hasShadow", "beamLightPos"):
+ self._bm[name] = gl.glGetUniformLocation(bp, name)
+
+ # Depth shader
+ dp = self._depth_program
+ if dp:
+ self._dp["lightSpaceMatrix"] = gl.glGetUniformLocation(dp, "lightSpaceMatrix")
+ self._dp["model"] = gl.glGetUniformLocation(dp, "model")
+
+ # Create shadow map resources
+ self._init_shadow_map_resources()
+
+ # Create geometry
+ self._beam_cone = self._create_unit_cone(64)
+ self._ground_plane = self._create_ground_plane(2000.0)
+
+ # Load 3D models for all existing stage objects
+ for obj in self._stage_config.objects:
+ self._ensure_models_loaded(obj)
+
+ logger.info("OpenGL init done. %d objects.", len(self._stage_config.objects))
+
+ def _init_shadow_map_resources(self):
+ """Create the FBO and 2D texture array for shadow maps.
+
+ Each shadow-casting light gets one layer in the texture array.
+ The FBO is reused for all layers by rebinding the depth attachment.
+ """
+ if self._depth_program is None:
+ return
+
+ # Create depth texture array
+ self._shadow_tex = gl.glGenTextures(1)
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, self._shadow_tex)
+ gl.glTexImage3D(
+ gl.GL_TEXTURE_2D_ARRAY, 0, gl.GL_DEPTH_COMPONENT24,
+ SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, MAX_SHADOW_MAPS,
+ 0, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT, None
+ )
+ gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST)
+ gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST)
+ gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_BORDER)
+ gl.glTexParameteri(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_BORDER)
+ # Border color = max depth so areas outside shadow map are fully lit
+ gl.glTexParameterfv(gl.GL_TEXTURE_2D_ARRAY, gl.GL_TEXTURE_BORDER_COLOR,
+ (gl.GLfloat * 4)(1.0, 1.0, 1.0, 1.0))
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, 0)
+
+ # Create FBO and attach layer 0 initially
+ self._shadow_fbo = gl.glGenFramebuffers(1)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._shadow_fbo)
+ gl.glFramebufferTextureLayer(
+ gl.GL_FRAMEBUFFER, gl.GL_DEPTH_ATTACHMENT,
+ self._shadow_tex, 0, 0
+ )
+ gl.glDrawBuffer(gl.GL_NONE)
+ gl.glReadBuffer(gl.GL_NONE)
+
+ status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER)
+ if status != gl.GL_FRAMEBUFFER_COMPLETE:
+ logger.error("Shadow FBO incomplete: %s", status)
+ self._shadow_fbo = None
+
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+
+ def resizeGL(self, w, h):
+ gl.glViewport(0, 0, w, h)
+ self._projection = QtGui.QMatrix4x4()
+ self._projection.perspective(45.0, w / max(h, 1), 1.0, 15000.0)
+
+ # Main render loop (paintGL)
+
+ def paintGL(self):
+ if self._scene_program is None:
+ return
+
+ # Reset GL state
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
+ gl.glDisable(gl.GL_BLEND)
+ gl.glEnable(gl.GL_DEPTH_TEST)
+ gl.glDepthMask(gl.GL_TRUE)
+ gl.glEnable(gl.GL_CULL_FACE)
+ gl.glCullFace(gl.GL_BACK)
+
+ self._update_camera_pos()
+ view = QtGui.QMatrix4x4()
+ view.lookAt(self._camera_pos, self._camera_target, self._camera_up)
+
+ proj_data = self._projection.copyDataTo()
+ view_data = view.copyDataTo()
+
+ spotlights, beam_list = self._collect_lights_and_beams()
+
+ # PASS 0: Shadow maps
+ light_space_matrices = self._render_shadow_maps(spotlights)
+
+ # Restore widget's default FBO and viewport
+ default_fbo = self.defaultFramebufferObject()
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, default_fbo)
+ gl.glViewport(0, 0, self.width(), self.height())
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
+
+ # PASS 1: Scene objects (Phong + spotlights + shadows)
+ gl.glUseProgram(self._scene_program)
+ gl.glUniformMatrix4fv(self._sc["projection"], 1, gl.GL_TRUE, proj_data)
+ gl.glUniformMatrix4fv(self._sc["view"], 1, gl.GL_TRUE, view_data)
+ cam = self._camera_pos
+ gl.glUniform3f(self._sc["viewPos"], cam.x(), cam.y(), cam.z())
+ gl.glUniform1f(self._sc["ambientLevel"], 0.09)
+
+ # Upload spotlight data to shader
+ num_lights = min(len(spotlights), MAX_SPOT_LIGHTS)
+ gl.glUniform1i(self._sc["numLights"], num_lights)
+ for i in range(num_lights):
+ sl = spotlights[i]
+ locs = self._sc_light_locs[i]
+ gl.glUniform3f(locs["position"], sl.position.x(), sl.position.y(), sl.position.z())
+ gl.glUniform3f(locs["direction"], sl.direction.x(), sl.direction.y(), sl.direction.z())
+ gl.glUniform3f(locs["color"], sl.color[0], sl.color[1], sl.color[2])
+ gl.glUniform1f(locs["innerCos"], sl.inner_cos)
+ gl.glUniform1f(locs["outerCos"], sl.outer_cos)
+
+ # Upload shadow data
+ num_shadow = min(len(light_space_matrices), MAX_SHADOW_MAPS)
+ gl.glUniform1i(self._sc["numShadowLights"], num_shadow)
+ for i, lsm in enumerate(light_space_matrices):
+ gl.glUniformMatrix4fv(self._sc_lsm_locs[i], 1, gl.GL_TRUE, lsm.copyDataTo())
+
+ # Bind shadow map texture array to texture unit 0
+ gl.glActiveTexture(gl.GL_TEXTURE0)
+ if self._shadow_tex is not None:
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, self._shadow_tex)
+ gl.glUniform1i(self._sc["shadowMap"], 0)
+
+ # Draw ground plane
+ if self._ground_plane:
+ gl.glUniformMatrix4fv(self._sc["model"], 1, gl.GL_TRUE, QtGui.QMatrix4x4().copyDataTo())
+ gl.glUniform3f(self._sc["baseColor"], 0.15, 0.15, 0.15)
+ gl.glUniform1f(self._sc["highlightMix"], 0.0)
+ gl.glBindVertexArray(self._ground_plane.vao)
+ gl.glDrawElements(gl.GL_TRIANGLES, self._ground_plane.index_count, gl.GL_UNSIGNED_INT, None)
+
+ # Draw stage objects with selection highlighting
+ if self._highlight_is_multi:
+ hl_color = (1.0, 0.55, 0.1) # warm orange for multi/group
+ else:
+ hl_color = (1.0, 0.95, 0.15) # neon yellow for single
+ gl.glUniform3f(self._sc["highlightColor"], *hl_color)
+
+ for idx, obj in enumerate(self._stage_config.objects):
+ is_selected = (obj.id in self._selected_object_ids)
+ # Alternate object colors for visual distinction
+ color = (0.50, 0.50, 0.55) if idx % 2 == 0 else (0.45, 0.45, 0.50)
+ gl.glUniform1f(self._sc["highlightMix"], 1.0 if is_selected else 0.0)
+ self._draw_stage_object(obj, color)
+
+ gl.glBindVertexArray(0)
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, 0)
+
+ # PASS 2: Volumetric beam cones
+ if beam_list and self._beam_program and self._beam_cone:
+ self._draw_all_beams(beam_list, proj_data, view_data,
+ spotlights, light_space_matrices)
+
+ gl.glUseProgram(0)
+
+ # Overlays (QPainter on top of GL)
+ if self._show_labels:
+ self._draw_fixture_labels(view)
+
+ self._update_fps()
+ self._draw_fps_counter()
+
+ # Pass 0: Shadow map rendering
+
+ def _render_shadow_maps(self, spotlights):
+ """Render depth from each spotlight's POV into the shadow texture array.
+
+ Returns:
+ List of light-space matrices (one per shadow-casting light).
+ """
+ if not spotlights or self._shadow_fbo is None or self._depth_program is None:
+ return []
+
+ light_space_matrices = []
+ num = min(len(spotlights), MAX_SHADOW_MAPS)
+
+ gl.glUseProgram(self._depth_program)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._shadow_fbo)
+ gl.glViewport(0, 0, SHADOW_MAP_SIZE, SHADOW_MAP_SIZE)
+
+ # Polygon offset reduces self-shadowing artifacts (shadow acne)
+ gl.glEnable(gl.GL_POLYGON_OFFSET_FILL)
+ gl.glPolygonOffset(1.0, 1.0)
+
+ for i in range(num):
+ sl = spotlights[i]
+ lsm = self._compute_light_space_matrix(sl)
+ light_space_matrices.append(lsm)
+
+ # Attach this layer of the texture array to the FBO
+ gl.glFramebufferTextureLayer(
+ gl.GL_FRAMEBUFFER, gl.GL_DEPTH_ATTACHMENT,
+ self._shadow_tex, 0, i
+ )
+ gl.glClear(gl.GL_DEPTH_BUFFER_BIT)
+
+ gl.glUniformMatrix4fv(self._dp["lightSpaceMatrix"], 1, gl.GL_TRUE, lsm.copyDataTo())
+ self._draw_scene_depth_only()
+
+ gl.glDisable(gl.GL_POLYGON_OFFSET_FILL)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+ gl.glUseProgram(0)
+
+ return light_space_matrices
+
+ def _compute_light_space_matrix(self, spotlight):
+ """Build a perspective projection matrix from a spotlight's POV.
+
+ The FOV is derived from the spotlight's outer cone angle to ensure
+ the shadow map fully covers the illuminated area.
+ """
+ pos = spotlight.position
+ d = spotlight.direction
+ target = pos + d * 500.0
+
+ # Pick an up vector that isn't parallel to the light direction
+ up = QtGui.QVector3D(0.0, 1.0, 0.0)
+ if abs(QtGui.QVector3D.dotProduct(up, d)) > 0.95:
+ up = QtGui.QVector3D(1.0, 0.0, 0.0)
+
+ view = QtGui.QMatrix4x4()
+ view.lookAt(pos, target, up)
+
+ # FOV from outer cone angle, clamped to reasonable range
+ half_angle = math.degrees(math.acos(max(spotlight.outer_cos, 0.01)))
+ fov = min(130.0, max(40.0, half_angle * 3.0))
+
+ proj = QtGui.QMatrix4x4()
+ proj.perspective(fov, 1.0, 0.5, 800.0)
+
+ result = QtGui.QMatrix4x4(proj)
+ result *= view
+ return result
+
+ def _draw_scene_depth_only(self):
+ """Draw all scene objects with the depth shader (for shadow maps)."""
+ for obj in self._stage_config.objects:
+ base = self._build_base_model_matrix(obj)
+ for entry in getattr(obj, "get_model_entries", list)():
+ model = QtGui.QMatrix4x4(base)
+ self._apply_local_ops(model, getattr(entry, "local_ops", ()))
+
+ if entry.model_path in self._gltf_models:
+ self._traverse_gltf(entry.model_path, model, obj,
+ model_loc=self._dp["model"])
+ elif entry.model_path in self._models:
+ gl.glUniformMatrix4fv(self._dp["model"], 1, gl.GL_TRUE, model.copyDataTo())
+ m = self._models[entry.model_path]
+ gl.glBindVertexArray(m.vao)
+ gl.glDrawElements(gl.GL_TRIANGLES, m.index_count, gl.GL_UNSIGNED_INT, None)
+
+ gl.glBindVertexArray(0)
+
+ # Shared rendering helpers
+
+ def _build_base_model_matrix(self, obj) -> QtGui.QMatrix4x4:
+ """Build the T * Rz * Ry * Rx * S model matrix for a stage object."""
+ m = QtGui.QMatrix4x4()
+ m.translate(obj.position[0], obj.position[1], obj.position[2])
+ m.rotate(obj.rotation[2], 0.0, 0.0, 1.0)
+ m.rotate(obj.rotation[1], 0.0, 1.0, 0.0)
+ m.rotate(obj.rotation[0], 1.0, 0.0, 0.0)
+ m.scale(float(getattr(obj, "scale", 1.0)))
+ return m
+
+ def _get_overrides(self, stage_obj):
+ """Get glTF node rotation overrides (pan/tilt) from a stage object."""
+ if stage_obj and hasattr(stage_obj, "get_gltf_node_overrides"):
+ try:
+ return stage_obj.get_gltf_node_overrides() or {}
+ except Exception:
+ pass
+ return {}
+
+ def _node_local_matrix(self, node, overrides):
+ """Compute the local transform matrix for a glTF node.
+
+ Applies translation, quaternion rotation, optional pan/tilt override,
+ and scale — matching the glTF 2.0 transform specification.
+ """
+ m = QtGui.QMatrix4x4()
+ t, r, s = node.translation, node.rotation, node.scale
+ m.translate(float(t[0]), float(t[1]), float(t[2]))
+ # glTF quaternion: (x, y, z, w)
+ q = QtGui.QQuaternion(float(r[3]), float(r[0]), float(r[1]), float(r[2]))
+ m.rotate(q)
+ # Apply axis-angle override if this node has one (for pan/tilt)
+ if node.name in overrides:
+ ax, ay, az, deg = overrides[node.name]
+ m.rotate(float(deg), float(ax), float(ay), float(az))
+ m.scale(float(s[0]), float(s[1]), float(s[2]))
+ return m
+
+ def _traverse_gltf(self, model_path, base_model, stage_obj, model_loc,
+ color=None, color_loc=None):
+ """Traverse the glTF node hierarchy and draw each mesh.
+
+ Uses an iterative stack-based depth-first traversal instead of
+ recursion. Works for both the scene shader (with color) and the
+ depth shader (without color).
+ """
+ gm = self._gltf_models.get(model_path)
+ if gm is None:
+ return
+ overrides = self._get_overrides(stage_obj)
+
+ # Stack of (node_index, parent_world_matrix)
+ stack = [(int(r), QtGui.QMatrix4x4(base_model)) for r in gm.scene_roots]
+ while stack:
+ ni, parent = stack.pop()
+ if ni < 0 or ni >= len(gm.nodes):
+ continue
+ node = gm.nodes[ni]
+ world = QtGui.QMatrix4x4(parent)
+ world *= self._node_local_matrix(node, overrides)
+
+ # Draw mesh primitives at this node
+ if node.mesh_index is not None and int(node.mesh_index) in gm.mesh_primitives:
+ gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, world.copyDataTo())
+ if color and color_loc is not None:
+ gl.glUniform3f(color_loc, color[0], color[1], color[2])
+ for prim in gm.mesh_primitives[int(node.mesh_index)]:
+ gl.glBindVertexArray(prim.vao)
+ gl.glDrawElements(gl.GL_TRIANGLES, prim.index_count, gl.GL_UNSIGNED_INT, None)
+
+ # Push children (reversed so left children are processed first)
+ for child in reversed(node.children or []):
+ stack.append((int(child), world))
+
+ # Pass 1: Scene object drawing
+
+ def _draw_stage_object(self, obj, color):
+ """Draw a single stage object with the scene shader."""
+ base = self._build_base_model_matrix(obj)
+ gl.glUniform3f(self._sc["baseColor"], color[0], color[1], color[2])
+
+ for entry in getattr(obj, "get_model_entries", list)():
+ model = QtGui.QMatrix4x4(base)
+ self._apply_local_ops(model, getattr(entry, "local_ops", ()))
+
+ if entry.model_path in self._gltf_models:
+ self._traverse_gltf(entry.model_path, model, obj,
+ model_loc=self._sc["model"],
+ color=color, color_loc=self._sc["baseColor"])
+ elif entry.model_path in self._models:
+ gl.glUniformMatrix4fv(self._sc["model"], 1, gl.GL_TRUE, model.copyDataTo())
+ m = self._models[entry.model_path]
+ gl.glBindVertexArray(m.vao)
+ gl.glDrawElements(gl.GL_TRIANGLES, m.index_count, gl.GL_UNSIGNED_INT, None)
+
+ # Pass 2: Beam rendering
+
+ def _draw_all_beams(self,
+ beam_list: list[tuple[QtGui.QVector3D, QtGui.QVector3D, tuple[float, float, float], float]],
+ proj_data: Sequence[float],
+ view_data: Sequence[float],
+ spotlights: list[SpotLightData],
+ light_space_matrices: list[QtGui.QMatrix4x4]) -> None:
+ """Draw all volumetric beam cones with additive blending.
+
+ The depth buffer from Pass 1 (ground plane) naturally prevents
+ beam fragments below the floor from being visible, giving a
+ proper elliptical intersection where the cone meets the ground.
+ """
+ gl.glUseProgram(self._beam_program)
+ gl.glUniformMatrix4fv(self._bm["projection"], 1, gl.GL_TRUE, proj_data)
+ gl.glUniformMatrix4fv(self._bm["view"], 1, gl.GL_TRUE, view_data)
+
+ # Bind shadow map to texture unit 1 (unit 0 is used by the scene pass)
+ has_shadow = (self._shadow_tex is not None and len(light_space_matrices) > 0)
+ if has_shadow:
+ gl.glActiveTexture(gl.GL_TEXTURE1)
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, self._shadow_tex)
+ gl.glUniform1i(self._bm["shadowMap"], 1)
+
+ # Enable additive blending and disable backface culling for cones
+ gl.glEnable(gl.GL_BLEND)
+ gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE)
+ gl.glDisable(gl.GL_CULL_FACE)
+ gl.glDepthMask(gl.GL_FALSE)
+ gl.glEnable(gl.GL_DEPTH_TEST)
+
+ gl.glBindVertexArray(self._beam_cone.vao)
+
+ max_beam_length = 500.0
+
+ for beam_idx, (origin, direction, color, _dimmer) in enumerate(beam_list):
+ actual_length = max_beam_length
+ # Cone radius matches the spotlight's outer cone angle so that the
+ # volumetric beam lines up with the lit area on the scene.
+ if beam_idx < len(spotlights):
+ outer_cos = spotlights[beam_idx].outer_cos
+ half_angle_rad = math.acos(max(outer_cos, 0.01))
+ else:
+ half_angle_rad = math.radians(18.0)
+ actual_radius = float(math.tan(half_angle_rad) * actual_length)
+
+ mat = self._build_cone_matrix(origin, direction, actual_length, actual_radius)
+ gl.glUniformMatrix4fv(self._bm["model"], 1, gl.GL_TRUE, mat.copyDataTo())
+ gl.glUniform3f(self._bm["beamColor"], color[0], color[1], color[2])
+
+ # Upload per-beam shadow data
+ shadow_layer = beam_idx
+ if has_shadow and shadow_layer < len(light_space_matrices):
+ gl.glUniform1i(self._bm["hasShadow"], 1)
+ gl.glUniform1i(self._bm["beamShadowLayer"], shadow_layer)
+ gl.glUniformMatrix4fv(
+ self._bm["beamLightSpaceMatrix"], 1, gl.GL_TRUE,
+ light_space_matrices[shadow_layer].copyDataTo())
+ else:
+ gl.glUniform1i(self._bm["hasShadow"], 0)
+
+ # Light origin for the volumetric shadow ray-march.
+ gl.glUniform3f(self._bm["beamLightPos"],
+ origin.x(), origin.y(), origin.z())
+
+ gl.glDrawElements(gl.GL_TRIANGLES, self._beam_cone.index_count, gl.GL_UNSIGNED_INT, None)
+
+ # Restore GL state
+ gl.glBindVertexArray(0)
+ gl.glDepthMask(gl.GL_TRUE)
+ gl.glEnable(gl.GL_CULL_FACE)
+ gl.glDisable(gl.GL_BLEND)
+
+ if has_shadow:
+ gl.glActiveTexture(gl.GL_TEXTURE1)
+ gl.glBindTexture(gl.GL_TEXTURE_2D_ARRAY, 0)
+ gl.glActiveTexture(gl.GL_TEXTURE0)
+
+ def _build_cone_matrix(self, origin: QtGui.QVector3D, direction: QtGui.QVector3D,
+ length: float, radius: float) -> QtGui.QMatrix4x4:
+ """Build a model matrix that places the unit cone (tip=origin, base along direction).
+
+ Constructs a rotation matrix from a local coordinate frame
+ (right, up, forward) and applies translation + non-uniform scaling.
+ """
+ fwd = QtGui.QVector3D(direction)
+ if fwd.length() < 1e-6:
+ fwd = QtGui.QVector3D(0.0, -1.0, 0.0)
+ else:
+ fwd.normalize()
+
+ # Build orthonormal basis
+ up = QtGui.QVector3D(0.0, 1.0, 0.0)
+ if abs(QtGui.QVector3D.dotProduct(up, fwd)) > 0.95:
+ up = QtGui.QVector3D(1.0, 0.0, 0.0)
+
+ right = QtGui.QVector3D.crossProduct(up, fwd)
+ right.normalize()
+ up2 = QtGui.QVector3D.crossProduct(fwd, right)
+ up2.normalize()
+
+ # Build rotation matrix from basis vectors
+ rot = QtGui.QMatrix4x4()
+ rot.setColumn(0, QtGui.QVector4D(right, 0.0))
+ rot.setColumn(1, QtGui.QVector4D(up2, 0.0))
+ rot.setColumn(2, QtGui.QVector4D(-fwd, 0.0))
+ rot.setColumn(3, QtGui.QVector4D(0.0, 0.0, 0.0, 1.0))
+
+ m = QtGui.QMatrix4x4()
+ m.translate(origin)
+ m *= rot
+ m.scale(float(radius), float(radius), float(length))
+ return m
+
+ # Light and beam collection
+
+ def _collect_lights_and_beams(self) \
+ -> tuple[list[SpotLightData],
+ list[tuple[QtGui.QVector3D, QtGui.QVector3D, tuple[float, float, float], float]]]:
+ """Collect spotlight data and beam parameters from all active MovingHeads.
+
+ For each moving head with ``beam_on == True``, computes the world-space
+ beam origin (from the BeamOrigin node) and direction (from BeamOrigin
+ toward the tilt pivot), then creates both a SpotLightData (for scene
+ lighting) and a beam tuple (for volumetric rendering).
+ """
+ spotlights = []
+ beam_list = []
+
+ try:
+ from model.stage import MovingHead
+ beam_origin_name = MovingHead.BEAM_ORIGIN_NODE_NAME
+ tilt_node_name = MovingHead.TILT_NODE_NAME
+ except Exception:
+ beam_origin_name = "BeamOrigin"
+ tilt_node_name = "Cylinder.018"
+
+ for obj in getattr(self._stage_config, "objects", []):
+ is_mh = hasattr(obj, "pan") and hasattr(obj, "tilt") and hasattr(obj, "beam_on")
+ if not is_mh or not bool(getattr(obj, "beam_on", False)):
+ continue
+
+ base = self._build_base_model_matrix(obj)
+ entries = getattr(obj, "get_model_entries", list)()
+ if not entries:
+ continue
+ model_path = entries[0].model_path
+
+ # Find world-space position of the BeamOrigin node
+ origin_mat = self._find_gltf_node_world(model_path, base, obj, beam_origin_name)
+ if origin_mat is None:
+ origin_mat = QtGui.QMatrix4x4(base)
+ origin_pos = origin_mat.map(QtGui.QVector3D(0.0, 0.0, 0.0))
+
+ # Find world-space position of the tilt pivot node
+ tilt_mat = self._find_gltf_node_world(model_path, base, obj, tilt_node_name)
+
+ # Beam direction: from tilt pivot toward BeamOrigin (lens).
+ # Pan/tilt naturally rotates this since BeamOrigin moves with the head.
+ if tilt_mat is not None:
+ tilt_pos = tilt_mat.map(QtGui.QVector3D(0.0, 0.0, 0.0))
+ dir_vec = origin_pos - tilt_pos
+ if dir_vec.length() < 1e-6:
+ dir_vec = QtGui.QVector3D(0.0, -1.0, 0.0)
+ else:
+ dir_vec.normalize()
+ else:
+ dir_vec = QtGui.QVector3D(0.0, -1.0, 0.0)
+
+ # Convert beam color from 0-255 int to 0-1 float, apply dimmer
+ rgb = getattr(obj, "beam_color", (255, 255, 255))
+ dimmer = max(0.0, min(1.0, float(getattr(obj, "dimmer", 1.0))))
+ color_f = (
+ float(rgb[0]) / 255.0 * dimmer,
+ float(rgb[1]) / 255.0 * dimmer,
+ float(rgb[2]) / 255.0 * dimmer,
+ )
+
+ spotlights.append(SpotLightData(
+ position=origin_pos, direction=dir_vec, color=color_f,
+ inner_deg=8.0, outer_deg=16.0,
+ ))
+ beam_list.append((origin_pos, dir_vec, color_f, dimmer))
+
+ return spotlights, beam_list
+
+ # Geometry creation
+
+ def _create_unit_cone(self, segments: int=48) -> Model3D:
+ """Create a unit cone mesh (tip at origin, base ring at z=-1).
+
+ Used for beam rendering. Normals point outward from the cone surface.
+ """
+ seg = max(3, segments)
+ verts, idx = [], []
+ # Tip vertex at origin (index 0)
+ verts.extend([0.0, 0.0, 0.0, 0.0, 0.0, 1.0])
+ # Base ring vertices
+ for i in range(seg):
+ a = (i / seg) * 2.0 * math.pi
+ x, y = math.cos(a), math.sin(a)
+ length = math.sqrt(x * x + y * y + 0.09)
+ verts.extend([x, y, -1.0, x / length, y / length, 0.3 / length])
+ # Triangle fan from tip to base ring
+ for i in range(seg):
+ idx.extend([0, 1 + i, 1 + (i + 1) % seg])
+ v = np.array(verts, dtype=np.float32)
+ ii = np.array(idx, dtype=np.uint32)
+ return self._upload_vao(v, ii)
+
+ def _create_ground_plane(self, size: float=2000.0) -> Model3D:
+ """Create a flat ground plane quad at y=0 with upward normals."""
+ h = size / 2.0
+ v = np.array([
+ -h, 0, -h, 0, 1, 0, h, 0, -h, 0, 1, 0,
+ h, 0, h, 0, 1, 0, -h, 0, h, 0, 1, 0,
+ ], dtype=np.float32)
+ ii = np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32)
+ return self._upload_vao(v, ii)
+
+ def _upload_vao(self, verts: np.ndarray, indices: np.ndarray) -> Model3D:
+ """Upload interleaved position+normal vertex data to a new VAO."""
+ vao = gl.glGenVertexArrays(1)
+ vbo = gl.glGenBuffers(1)
+ ebo = gl.glGenBuffers(1)
+ gl.glBindVertexArray(vao)
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo)
+ gl.glBufferData(gl.GL_ARRAY_BUFFER, verts.nbytes, verts, gl.GL_STATIC_DRAW)
+ gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ebo)
+ gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, gl.GL_STATIC_DRAW)
+ gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 24, ctypes.c_void_p(0))
+ gl.glEnableVertexAttribArray(0)
+ gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, 24, ctypes.c_void_p(12))
+ gl.glEnableVertexAttribArray(1)
+ gl.glBindVertexArray(0)
+ return Model3D(vao, vbo, ebo, int(indices.size))
+
+ # Camera controls
+
+ def _update_camera_pos(self) -> None:
+ """Compute camera position from orbit parameters (yaw, pitch, distance)."""
+ yaw = math.radians(self._cam_yaw)
+ pitch = math.radians(self._cam_pitch)
+ cy = math.cos(pitch)
+ fwd = QtGui.QVector3D(
+ float(math.cos(yaw) * cy), float(math.sin(pitch)), float(math.sin(yaw) * cy))
+ self._camera_pos = self._camera_target - fwd * float(self._cam_distance)
+
+ def _tick_camera(self) -> None:
+ """Process WASD/arrow key camera movement at ~60 Hz."""
+ if not self.isVisible():
+ return
+ if self._keys_down:
+ dt = 0.016
+ speed = (self._boost_speed if QtCore.Qt.Key.Key_Shift in self._keys_down
+ else self._move_speed)
+ yaw = math.radians(self._cam_yaw)
+ fwd = QtGui.QVector3D(float(math.cos(yaw)), 0.0, float(math.sin(yaw)))
+ if fwd.length() == 0:
+ fwd = QtGui.QVector3D(0, 0, -1)
+ fwd.normalize()
+ right = QtGui.QVector3D.crossProduct(fwd, self._camera_up)
+ right.normalize()
+ move = QtGui.QVector3D(0, 0, 0)
+ if QtCore.Qt.Key.Key_W in self._keys_down or QtCore.Qt.Key.Key_Up in self._keys_down:
+ move += fwd
+ if QtCore.Qt.Key.Key_S in self._keys_down or QtCore.Qt.Key.Key_Down in self._keys_down:
+ move -= fwd
+ if QtCore.Qt.Key.Key_D in self._keys_down or QtCore.Qt.Key.Key_Right in self._keys_down:
+ move += right
+ if QtCore.Qt.Key.Key_A in self._keys_down or QtCore.Qt.Key.Key_Left in self._keys_down:
+ move -= right
+ if QtCore.Qt.Key.Key_E in self._keys_down:
+ move += self._camera_up
+ if QtCore.Qt.Key.Key_Q in self._keys_down:
+ move -= self._camera_up
+ if move.length() > 0:
+ move.normalize()
+ self._camera_target += move * float(speed * dt)
+ # main 60 Hz render loop
+ self.update()
+
+ # Mouse and keyboard input
+
+ @override
+ def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
+ self._mouse_last_pos = e.position().toPoint()
+ self._mouse_press_pos = e.position().toPoint()
+ self._mouse_buttons.add(e.button())
+ self.setFocus()
+
+ @override
+ def mouseReleaseEvent(self, e: QtGui.QMouseEvent) -> None:
+ self._mouse_buttons.discard(e.button())
+ release_pos = e.position().toPoint()
+
+ # Detect click (no drag): if mouse barely moved, treat as a pick
+ if self._mouse_press_pos is not None:
+ dx = abs(release_pos.x() - self._mouse_press_pos.x())
+ dy = abs(release_pos.y() - self._mouse_press_pos.y())
+ if dx < 5 and dy < 5:
+ if e.button() == QtCore.Qt.MouseButton.LeftButton:
+ self._pick_fixture(release_pos)
+ elif e.button() == QtCore.Qt.MouseButton.RightButton:
+ self.deselectAllRequested.emit()
+
+ self._mouse_last_pos = release_pos
+ self._mouse_press_pos = None
+
+ @override
+ def mouseMoveEvent(self, e: QtGui.QMouseEvent) -> None:
+ if self._mouse_last_pos is None:
+ self._mouse_last_pos = e.position().toPoint()
+ return
+ pos = e.position().toPoint()
+ dx = pos.x() - self._mouse_last_pos.x()
+ dy = pos.y() - self._mouse_last_pos.y()
+ self._mouse_last_pos = pos
+ if not self._mouse_buttons:
+ return
+
+ # Left-drag: orbit camera (yaw/pitch)
+ if QtCore.Qt.MouseButton.LeftButton in self._mouse_buttons:
+ self._cam_yaw += dx * 0.3
+ self._cam_pitch = max(-89.0, min(89.0, self._cam_pitch - dy * 0.3))
+ self.update()
+
+ # Middle/right-drag: pan camera target
+ if (QtCore.Qt.MouseButton.MiddleButton in self._mouse_buttons or
+ QtCore.Qt.MouseButton.RightButton in self._mouse_buttons):
+ ps = float(self._cam_distance) / 800.0
+ yaw = math.radians(self._cam_yaw)
+ pitch = math.radians(self._cam_pitch)
+ cy = math.cos(pitch)
+ f = QtGui.QVector3D(float(math.cos(yaw) * cy), float(math.sin(pitch)),
+ float(math.sin(yaw) * cy))
+ f.normalize()
+ r = QtGui.QVector3D.crossProduct(f, self._camera_up)
+ r.normalize()
+ u = QtGui.QVector3D.crossProduct(r, f)
+ u.normalize()
+ self._camera_target += (-r * float(dx) + u * float(dy)) * ps
+ self.update()
+
+ @override
+ def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
+ """Zoom camera in/out via scroll wheel."""
+ d = e.angleDelta().y()
+ self._cam_distance = max(10.0, min(20000.0, self._cam_distance * (1.0 - d / 1200.0)))
+ self.update()
+
+ @override
+ def keyPressEvent(self, e: QtGui.QKeyEvent) -> None:
+ self._keys_down.add(e.key())
+ if e.key() == QtCore.Qt.Key.Key_F:
+ self._show_labels = True
+ self.update()
+ if e.key() == QtCore.Qt.Key.Key_Z:
+ self._reset_camera()
+
+ @override
+ def keyReleaseEvent(self, e: QtGui.QKeyEvent) -> None:
+ self._keys_down.discard(e.key())
+ if e.key() == QtCore.Qt.Key.Key_F:
+ self._show_labels = False
+ self.update()
+
+ # Transform helpers
+
+ def _apply_local_ops(self, matrix: QtGui.QMatrix4x4, ops:
+ list[tuple[str, tuple[float, float, float] | tuple[float, float, float, float, float, float]]]) -> None:
+ """Apply a sequence of local transform operations to a matrix.
+
+ Supported operations:
+ ("translate", (x, y, z))
+ ("rotate", (degrees, ax, ay, az, pivot_x, pivot_y, pivot_z))
+ """
+ for op in ops or ():
+ if not op:
+ continue
+ name, payload = op[0], op[1]
+ if name == "translate":
+ matrix.translate(*payload)
+ elif name == "rotate":
+ deg, ax, ay, az, px, py, pz = payload
+ matrix.translate(px, py, pz)
+ matrix.rotate(deg, ax, ay, az)
+ matrix.translate(-px, -py, -pz)
+
+ # Model loading / unloading
+
+ def _ensure_models_loaded(self, obj: StageObject) -> None:
+ """Ensure all 3D models for a stage object are uploaded to the GPU."""
+ for entry in getattr(obj, "get_model_entries", list)():
+ self._ensure_model_loaded_by_path(entry.model_path)
+
+ def _ensure_model_loaded_by_path(self, path: str) -> None:
+ """Load and upload a 3D model file if not already cached.
+
+ Supports GLB/glTF (preferred) and OBJ (legacy fallback).
+ """
+ if not path or path in self._models or path in self._gltf_models:
+ return
+ ext = os.path.splitext(path)[1].lower()
+ if ext in (".glb", ".gltf"):
+ try:
+ self._gltf_models[path] = _load_gltf_model(path)
+ logger.info("Loaded glTF: %s", path)
+ except Exception as e:
+ logger.error("glTF load error %s: %s", path, e)
+ return
+
+ # OBJ fallback loader
+ try:
+ verts, norms, faces = [], [], []
+ with open(path, "r", encoding="UTF-8") as f:
+ for line in f:
+ if line.startswith("v "):
+ p = line.split()
+ verts.append((float(p[1]), float(p[2]), float(p[3])))
+ elif line.startswith("vn "):
+ p = line.split()
+ norms.append((float(p[1]), float(p[2]), float(p[3])))
+ elif line.startswith("f "):
+ ps = line.split()[1:]
+ face = []
+ for pt in ps:
+ ii = pt.split("/")
+ face.append((int(ii[0]), int(ii[-1]) if ii[-1] else None))
+ faces.append(face)
+ # Build interleaved vertex buffer with index deduplication
+ vd, il, im = [], [], {}
+ for face in faces:
+ if len(face) < 3:
+ continue
+ # Fan triangulation for polygons with more than 3 vertices
+ for k in range(1, len(face) - 1):
+ for vi, ni in [face[0], face[k], face[k+1]]:
+ key = (vi, ni)
+ if key not in im:
+ p = verts[vi - 1]
+ n = norms[ni - 1] if ni and ni <= len(norms) else (0, 1, 0)
+ im[key] = len(im)
+ vd.extend([p[0], p[1], p[2], n[0], n[1], n[2]])
+ il.append(im[key])
+ self._models[path] = _upload_mesh(
+ np.array(vd, dtype=np.float32).reshape(-1, 6),
+ np.array(il, dtype=np.uint32))
+ except Exception as e:
+ logger.error("OBJ load error %s: %s", path, e)
+
+ def load_object(self, obj: StageObject) -> None:
+ """Public API: ensure models for a newly added object are loaded."""
+ self._ensure_models_loaded(obj)
+
+ def _load_all_objects(self) -> None:
+ """Reload all objects from stage_config (used after loading a new stage file)."""
+ for obj in self._stage_config.objects:
+ self._ensure_models_loaded(obj)
+
+ def set_selected_objects(self, object_ids: list[str], is_multi: bool = False) -> None:
+ """Set which objects are highlighted in the 3D view.
+
+ Args:
+ object_ids: list of object IDs to highlight.
+ is_multi: True = orange (multi/group), False = neon-yellow (single).
+ """
+ self._selected_object_ids = set(object_ids) if object_ids else set()
+ self._highlight_is_multi = is_multi
+
+ def remove_object(self, obj: StageObject) -> None:
+ """Release GPU resources for models no longer used by any stage object."""
+ for entry in getattr(obj, "get_model_entries", list)():
+ path = entry.model_path
+ if not path:
+ continue
+ # Check if any remaining object still uses this model
+ still_used = any(
+ e.model_path == path
+ for o in self._stage_config.objects
+ for e in getattr(o, "get_model_entries", list)()
+ )
+ if still_used:
+ continue
+ # Free GPU resources
+ if path in self._models:
+ m = self._models.pop(path)
+ gl.glDeleteBuffers(1, [m.vbo])
+ gl.glDeleteBuffers(1, [m.ebo])
+ gl.glDeleteVertexArrays(1, [m.vao])
+ if path in self._gltf_models:
+ gm = self._gltf_models.pop(path)
+ for pl in gm.mesh_primitives.values():
+ for p in pl:
+ gl.glDeleteBuffers(1, [p.vbo])
+ gl.glDeleteBuffers(1, [p.ebo])
+ gl.glDeleteVertexArrays(1, [p.vao])
+
+ # glTF node search
+
+ def _find_gltf_node_world(self, model_path: str, base_model: QtGui.QMatrix4x4, stage_obj: StageObject,
+ target_name: str) -> QtGui.QMatrix4x4 | None:
+ """Find a named node in the glTF hierarchy and return its world matrix.
+
+ Uses iterative depth-first search with pan/tilt overrides applied.
+ Returns None if the node is not found.
+ """
+ gm = self._gltf_models.get(model_path)
+ if not gm:
+ return None
+ overrides = self._get_overrides(stage_obj)
+ stack = [(int(r), QtGui.QMatrix4x4(base_model)) for r in gm.scene_roots]
+ while stack:
+ ni, parent = stack.pop()
+ if ni < 0 or ni >= len(gm.nodes):
+ continue
+ node = gm.nodes[ni]
+ world = QtGui.QMatrix4x4(parent)
+ world *= self._node_local_matrix(node, overrides)
+ if node.name == target_name:
+ return world
+ stack.extend((int(child), world) for child in reversed(node.children or []))
+ return None
+
+ def _find_gltf_node_world_rest(self, model_path: str, base_model: QtGui.QMatrix4x4, target_name: str) \
+ -> QtGui.QMatrix4x4 | None:
+ """Same as ``_find_gltf_node_world`` but without pan/tilt overrides (rest pose)."""
+ gm = self._gltf_models.get(model_path)
+ if not gm:
+ return None
+ no_overrides = {}
+ stack = [(int(r), QtGui.QMatrix4x4(base_model)) for r in gm.scene_roots]
+ while stack:
+ ni, parent = stack.pop()
+ if ni < 0 or ni >= len(gm.nodes):
+ continue
+ node = gm.nodes[ni]
+ world = QtGui.QMatrix4x4(parent)
+ world *= self._node_local_matrix(node, no_overrides)
+ if node.name == target_name:
+ return world
+ stack.extend((int(child), world) for child in reversed(node.children or []))
+ return None
+
+ # FPS counter overlay
+
+ def _update_fps(self) -> None:
+ """Track frames and compute FPS once per second."""
+ self._fps_frame_count += 1
+ now = time.time()
+ elapsed = now - self._fps_last_time
+ if elapsed >= 1.0:
+ self._fps_display = self._fps_frame_count / elapsed
+ self._fps_frame_count = 0
+ self._fps_last_time = now
+
+ def _draw_fps_counter(self) -> None:
+ """Draw FPS counter text in the bottom-left corner using QPainter."""
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
+
+ font = painter.font()
+ font.setPointSize(10)
+ font.setBold(True)
+ painter.setFont(font)
+
+ text = f"{self._fps_display:.0f} FPS"
+ x, y = 10, self.height() - 12
+
+ # Drop shadow for readability
+ painter.setPen(QtGui.QColor(0, 0, 0, 180))
+ painter.drawText(x + 1, y + 1, text)
+ painter.setPen(QtGui.QColor(80, 220, 80))
+ painter.drawText(x, y, text)
+
+ painter.end()
+
+ # Fixture name label overlay (F key)
+
+ def _world_to_screen(self, world_pos: QtGui.QVector3D, view_matrix: QtGui.QMatrix4x4) -> QtCore.QPointF:
+ """Project a 3D world position to 2D screen coordinates.
+
+ Returns a QPointF, or None if the point is behind the camera.
+ """
+ mvp = QtGui.QMatrix4x4(self._projection)
+ mvp *= view_matrix
+ clip = mvp.map(QtGui.QVector4D(
+ world_pos.x(), world_pos.y(), world_pos.z(), 1.0))
+ if abs(clip.w()) < 1e-6:
+ return None
+ ndc_x = clip.x() / clip.w()
+ ndc_y = clip.y() / clip.w()
+ ndc_z = clip.z() / clip.w()
+ if ndc_z < -1.0 or ndc_z > 1.0:
+ return None
+ sx = (ndc_x * 0.5 + 0.5) * self.width()
+ sy = (1.0 - (ndc_y * 0.5 + 0.5)) * self.height()
+ return QtCore.QPointF(sx, sy)
+
+ def _draw_fixture_labels(self, view_matrix: QtGui.QMatrix4x4) -> None:
+ """Draw name labels above each fixture using a QPainter overlay.
+
+ Platform is excluded. Selected fixtures get a highlighted tag color.
+ """
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
+
+ font = painter.font()
+ font.setPointSize(9)
+ font.setBold(True)
+ painter.setFont(font)
+ fm = QtGui.QFontMetrics(font)
+
+ for obj in self._stage_config.objects:
+ if obj.get_type() == "platform":
+ continue
+
+ label = obj.name or obj.get_display_name()
+ if not label:
+ continue
+
+ # Position label slightly above the fixture
+ wx, wy, wz = obj.position
+ label_world = QtGui.QVector3D(wx, wy + 25.0, wz)
+ screen_pt = self._world_to_screen(label_world, view_matrix)
+ if screen_pt is None:
+ continue
+
+ # Measure text and compute background rectangle
+ text_rect = fm.boundingRect(label)
+ pad = 5
+ bg_w = text_rect.width() + pad * 2
+ bg_h = text_rect.height() + pad * 2
+ bg_x = screen_pt.x() - bg_w / 2.0
+ bg_y = screen_pt.y() - bg_h
+ bg_rect = QtCore.QRectF(bg_x, bg_y, bg_w, bg_h)
+
+ # Style based on selection state
+ is_sel = (obj.id in self._selected_object_ids)
+ if is_sel:
+ if self._highlight_is_multi:
+ painter.setBrush(QtGui.QColor(255, 140, 30, 210))
+ painter.setPen(QtGui.QPen(QtGui.QColor(200, 100, 0), 1.5))
+ else:
+ painter.setBrush(QtGui.QColor(230, 220, 20, 210))
+ painter.setPen(QtGui.QPen(QtGui.QColor(180, 170, 0), 1.5))
+ else:
+ painter.setBrush(QtGui.QColor(30, 30, 40, 190))
+ painter.setPen(QtGui.QPen(QtGui.QColor(150, 150, 150), 1))
+
+ painter.drawRoundedRect(bg_rect, 3, 3)
+
+ # Text color: black on bright backgrounds, white on dark
+ if is_sel:
+ painter.setPen(QtGui.QColor(0, 0, 0))
+ else:
+ painter.setPen(QtGui.QColor(255, 255, 255))
+ painter.drawText(bg_rect, QtCore.Qt.AlignmentFlag.AlignCenter, label)
+
+ painter.end()
+
+ # Camera reset (Z key)
+
+ def _reset_camera(self) -> None:
+ """Reset camera to the default stage overview position."""
+ self._camera_target = QtGui.QVector3D(0.0, 10.0, 0.0)
+ self._cam_yaw = -90.0
+ self._cam_pitch = -20.0
+ self._cam_distance = (
+ QtGui.QVector3D(0.0, 200.0, 400.0) - self._camera_target
+ ).length()
+ self.update()
+
+ # Click-to-select picking
+
+ def _pick_fixture(self, screen_pos: QPoint) -> None:
+ """Find the closest fixture to the click position and emit fixtureClicked.
+
+ Uses simple screen-space distance to each fixture's projected position.
+ Closest fixture within a 60px radius is selected.
+ """
+ self._update_camera_pos()
+ view = QtGui.QMatrix4x4()
+ view.lookAt(self._camera_pos, self._camera_target, self._camera_up)
+
+ best_id = None
+ best_dist = 60.0 # pixel radius threshold
+
+ for obj in self._stage_config.objects:
+ if obj.get_type() == "platform":
+ continue
+ wp = QtGui.QVector3D(obj.position[0], obj.position[1], obj.position[2])
+ sp = self._world_to_screen(wp, view)
+ if sp is None:
+ continue
+ dx = sp.x() - screen_pos.x()
+ dy = sp.y() - screen_pos.y()
+ dist = (dx * dx + dy * dy) ** 0.5
+ if dist < best_dist:
+ best_dist = dist
+ best_id = obj.id
+
+ if best_id:
+ self.fixtureClicked.emit(best_id)
diff --git a/src/view/visualizer/visualizer_widget.py b/src/view/visualizer/visualizer_widget.py
new file mode 100644
index 00000000..a2f7274a
--- /dev/null
+++ b/src/view/visualizer/visualizer_widget.py
@@ -0,0 +1,249 @@
+"""Top-level widget of the stage visualizer.
+
+Combines the 3D viewport, the editor panel and the DMX poller behind a
+single QSplitter and relays signals between them.
+
+"""
+
+from __future__ import annotations
+
+from logging import getLogger
+from typing import TYPE_CHECKING
+
+from PySide6 import QtCore, QtWidgets
+
+from model.broadcaster import Broadcaster
+from model.dmx.dmx_visualizer import MOVEMENT_ROLES, DmxVisualizer, auto_detect_mapping
+from model.stage import FixtureGroup, StageConfig, backup_stage_file, get_default_stage_path
+from view.visualizer.stage_editor_widget import StageEditorWidget
+from view.visualizer.stage_gl_widget import Stage3DWidget
+
+if TYPE_CHECKING:
+ from PySide6.QtWidgets import QWidget
+
+ from model import BoardConfiguration
+ from model.ofl.fixture import UsedFixture
+
+logger = getLogger(__name__)
+
+
+class StageVisualizerWidget(QtWidgets.QSplitter):
+ """Horizontal split: 3D viewport on the left, editor panel on the right."""
+
+ def __init__(self, board_configuration: BoardConfiguration, parent: QWidget | None = None) -> None:
+ """Initialize using provided show file and parent object."""
+ super().__init__(parent)
+ self._broadcaster = Broadcaster()
+ self._board_configuration = board_configuration
+
+ self.setOrientation(QtCore.Qt.Orientation.Horizontal)
+
+ stage_path = get_default_stage_path()
+ logger.info("Loading stage from %s", stage_path)
+ self._stage_config = StageConfig(stage_path)
+
+ self._gl_widget = Stage3DWidget(self._stage_config, parent=self)
+ self._editor_widget = StageEditorWidget(
+ self._stage_config,
+ used_fixtures=self._get_fixtures(),
+ parent=self,
+ )
+ self.addWidget(self._gl_widget)
+ self.addWidget(self._editor_widget)
+
+ # 3D viewport takes most of the width.
+ self.setStretchFactor(0, 1)
+ self.setStretchFactor(1, 0)
+ self.setSizes([2200, 360])
+
+ # Editor -> mediator
+ self._editor_widget.addObjectRequested.connect(self._on_add_object)
+ self._editor_widget.removeObjectRequested.connect(self._on_remove_object)
+ self._editor_widget.objectChanged.connect(self._on_object_changed)
+ self._editor_widget.selectionChanged.connect(self._on_selection_changed)
+ self._editor_widget.groupRequested.connect(self._on_group_requested)
+ self._editor_widget.removeGroupRequested.connect(self._on_remove_group)
+ self._editor_widget.dmxToggled.connect(self._on_dmx_toggled)
+
+ # 3D viewport -> mediator
+ self._gl_widget.fixtureClicked.connect(self._on_fixture_clicked)
+ self._gl_widget.deselectAllRequested.connect(self._on_deselect_all)
+
+ self._dmx_vis = DmxVisualizer(
+ self._stage_config,
+ board_configuration=board_configuration,
+ parent=self,
+ )
+ self._dmx_vis.fixtures_updated.connect(self._on_dmx_updated)
+
+ # Refresh fixture list when the show file changes.
+ self._broadcaster.show_file_loaded.connect(self._refresh_fixtures)
+ self._broadcaster.show_file_path_changed.connect(lambda _: self._refresh_fixtures())
+ self._broadcaster.connection_state_updated.connect(
+ lambda connected: QtCore.QTimer.singleShot(500, self._refresh_fixtures)
+ if connected else None)
+ self._broadcaster.add_fixture.connect(lambda _fix: self._refresh_fixtures())
+
+ self._broadcaster.application_closing.connect(self._on_app_closing)
+
+ def _on_app_closing(self) -> None:
+ self._stage_config.save()
+ logger.info("Stage saved to %s", self._stage_config.file_path)
+
+ def load_stage_file(self) -> None:
+ """Opens a file dialog to query a stage file and loads it."""
+ # FIXME this is a blocking UI call.
+ from model.stage import STAGE_DIR
+ path, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self, "Load Stagefile", STAGE_DIR,
+ "Stage Files (*.yaml *.yml);;All Files (*)")
+ if not path:
+ return
+
+ backup = backup_stage_file(self._stage_config.file_path)
+ if backup:
+ logger.info("Current stage backed up to %s", backup)
+
+ self._reload_stage(path)
+
+ def save_stage_file(self) -> None:
+ """Displays a save file dialog and saves the current stage setup into a stage file."""
+ from model.stage import STAGE_DIR
+ path, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self, "Save Stagefile", STAGE_DIR,
+ "Stage Files (*.yaml *.yml);;All Files (*)")
+ if not path:
+ return
+ self._stage_config.save_to(path)
+ logger.info("Stage saved to %s", path)
+
+ def _reload_stage(self, new_path: str) -> None:
+ logger.info("Loading new stage: %s", new_path)
+
+ new_config = StageConfig(new_path)
+ new_config.save_to(get_default_stage_path())
+ new_config.file_path = get_default_stage_path()
+
+ self._stage_config = new_config
+ self._dmx_vis._stage_config = new_config
+
+ self._gl_widget.makeCurrent()
+ self._gl_widget._stage_config = new_config
+ self._gl_widget._load_all_objects()
+ self._gl_widget.doneCurrent()
+ self._gl_widget.update()
+
+ self._editor_widget._stage_config = new_config
+ self._editor_widget.refresh_list()
+
+ logger.info("Stage loaded: %d objects", len(new_config.objects))
+
+ def _get_fixtures(self) -> list[UsedFixture]:
+ try:
+ return list(self._board_configuration.fixtures)
+ except Exception:
+ return []
+
+ def _refresh_fixtures(self) -> None:
+ self._editor_widget._used_fixtures = self._get_fixtures()
+
+ def _on_add_object(self, fixture_key: str, name: str, device: UsedFixture) -> None:
+ new_id = self._stage_config.get_new_id(fixture_key)
+ try:
+ from model.stage import create_object_from_key
+ new_obj = create_object_from_key(fixture_key, new_id, name)
+ except Exception as e:
+ logger.error("Failed to create object: %s", e)
+ return
+
+ # Auto-link the selected DMX device, if any.
+ if device is not None:
+ try:
+ ch_names = [ch.name for ch in device.fixture_channels]
+ mapping = auto_detect_mapping(ch_names, MOVEMENT_ROLES)
+ new_obj.device_config = {
+ "movement": {
+ "universe": device.universe_id,
+ "start_channel": device.start_index,
+ "channel_count": device.channel_length,
+ "mapping": mapping,
+ }
+ }
+ except Exception as e:
+ logger.warning("Could not auto-link device: %s", e)
+
+ self._stage_config.add_object(new_obj)
+ self._editor_widget.add_object_to_list(new_obj)
+
+ self._gl_widget.makeCurrent()
+ self._gl_widget.load_object(new_obj)
+ self._gl_widget.doneCurrent()
+ self._gl_widget.update()
+ self._stage_config.save()
+
+ def _on_remove_object(self, object_id: str) -> None:
+ obj = self._stage_config.remove_object(object_id)
+ if not obj:
+ return
+ self._editor_widget.remove_object_from_list(object_id)
+ self._gl_widget.makeCurrent()
+ self._gl_widget.remove_object(obj)
+ self._gl_widget.doneCurrent()
+ self._gl_widget.update()
+ self._stage_config.save()
+ self._editor_widget.refresh_list()
+
+ def _on_object_changed(self, object_id: str) -> None:
+ self._gl_widget.update()
+ self._stage_config.save()
+
+ def _on_selection_changed(self, object_ids: list, is_multi: bool) -> None:
+ self._gl_widget.set_selected_objects(object_ids, is_multi)
+ self._gl_widget.update()
+
+ def _on_group_requested(self, fixture_ids: list, group_name: str) -> None:
+ if len(fixture_ids) < 2:
+ return
+
+ # Pull fixtures out of any existing group first.
+ for fid in fixture_ids:
+ old_grp = self._stage_config.get_group_for_fixture(fid)
+ if old_grp:
+ old_grp.member_ids.remove(fid)
+ if len(old_grp.member_ids) < 2:
+ self._stage_config.remove_group(old_grp.id)
+
+ # Use the centroid of the members as the group origin.
+ positions = [self._stage_config.get_object(fid).position
+ for fid in fixture_ids
+ if self._stage_config.get_object(fid)]
+ n = max(len(positions), 1)
+ cx = sum(p[0] for p in positions) / n
+ cy = sum(p[1] for p in positions) / n
+ cz = sum(p[2] for p in positions) / n
+
+ group_id = self._stage_config.get_new_id("group")
+ new_group = FixtureGroup(
+ group_id=group_id, name=group_name,
+ position=(cx, cy, cz), rotation=(0.0, 0.0, 0.0),
+ member_ids=fixture_ids)
+ self._stage_config.add_group(new_group)
+ self._stage_config.save()
+ self._editor_widget.refresh_list()
+
+ def _on_remove_group(self, group_id: str) -> None:
+ if self._stage_config.remove_group(group_id):
+ self._stage_config.save()
+ self._editor_widget.refresh_list()
+
+ def _on_fixture_clicked(self, object_id: str) -> None:
+ self._editor_widget.select_fixture_by_id(object_id)
+
+ def _on_deselect_all(self) -> None:
+ self._editor_widget.deselect_all()
+
+ def _on_dmx_toggled(self, enabled: bool) -> None:
+ self._dmx_vis.enabled = enabled
+
+ def _on_dmx_updated(self) -> None:
+ self._editor_widget.update_live_values()