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 d1df6b28..901e7610 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/gl_init.py b/src/gl_init.py
index 930b6072..d3f7e023 100644
--- a/src/gl_init.py
+++ b/src/gl_init.py
@@ -1,3 +1,4 @@
+"""Contains OpenGL context initialization."""
from logging import getLogger
@@ -7,11 +8,12 @@
def opengl_context_init() -> None:
+ """Initialize OpenGL context to 4.1 core profile."""
fmt = QSurfaceFormat()
fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8)
fmt.setVersion(4, 1) # Request OpenGL 4.1 compatible context
- fmt.setProfile(QSurfaceFormat.CoreProfile)
+ fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
QSurfaceFormat.setDefaultFormat(fmt)
logger.debug("Initialized OpenGL context to 4.1")
diff --git a/src/main.py b/src/main.py
index 11bf6fbe..425c12d3 100644
--- a/src/main.py
+++ b/src/main.py
@@ -52,6 +52,7 @@
from controller.cli.remote_control_port import RemoteCLIServer
from controller.joystick.joystick_handling import JoystickHandler
from gl_init import opengl_context_init
+ opengl_context_init()
from model.final_globals import FinalGlobals
from view.main_window import MainWindow
@@ -110,7 +111,6 @@ def main(application: QApplication) -> None:
"""Startup entry."""
setup_logging()
logging.basicConfig(level="INFO")
- opengl_context_init()
setup_asyncio()
width, height = application.primaryScreen().size().toTuple()
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/filter_data/chaser_model.py b/src/model/filter_data/chaser_model.py
index 37fa16ff..12011725 100644
--- a/src/model/filter_data/chaser_model.py
+++ b/src/model/filter_data/chaser_model.py
@@ -98,8 +98,8 @@ def construct_chaser_layer(identifier: str, parameter_data: list[str]) -> Chaser
("Start Color", ParameterType.COLOR, ""),
("End Color", ParameterType.COLOR, ""),
("Number of Segments", ParameterType.NUMBER_ABSOLUTE,
- "Divides the pixel map into the specified number of segments and applies the effect on each "
- "layer individually."),
+ ("Divides the pixel map into the specified number of segments and applies the effect on each "
+ "layer individually.")),
],
parameter_data,
)
diff --git a/src/model/visualizer/__init__.py b/src/model/visualizer/__init__.py
new file mode 100644
index 00000000..52838805
--- /dev/null
+++ b/src/model/visualizer/__init__.py
@@ -0,0 +1 @@
+"""Contains visualizer related model classes."""
diff --git a/src/model/visualizer/dmx/__init__.py b/src/model/visualizer/dmx/__init__.py
new file mode 100644
index 00000000..6814cf14
--- /dev/null
+++ b/src/model/visualizer/dmx/__init__.py
@@ -0,0 +1 @@
+"""Contains required adapters for DMX -> Visualizer."""
diff --git a/src/model/visualizer/dmx/dmx_visualizer.py b/src/model/visualizer/dmx/dmx_visualizer.py
new file mode 100644
index 00000000..bce867de
--- /dev/null
+++ b/src/model/visualizer/dmx/dmx_visualizer.py
@@ -0,0 +1,236 @@
+"""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.
+"""
+
+from __future__ import annotations
+
+from logging import getLogger
+from typing import TYPE_CHECKING, Any
+
+from PySide6 import QtCore
+
+from model.broadcaster import Broadcaster
+from model.visualizer.stage.so_moving_head import MovingHead
+
+if TYPE_CHECKING:
+ from PySide6.QtWidgets import QWidget
+
+ import proto.DirectMode_pb2
+ from model import BoardConfiguration
+ from model.visualizer.stage import StageObject
+ from model.visualizer.stage.stage_config import StageConfig
+
+logger = getLogger(__name__)
+
+# 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)) and "pan_fine" in roles:
+ mapping["pan_fine"] = i
+ elif ("pan" in p and "speed" not in p and "tilt" not in p) and "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) and "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) and "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: BoardConfiguration | None = None, parent: QWidget | None = None) -> None:
+ """Initialize DMX to stage visualizer adapter."""
+ 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:
+ """Enable or disable the live updating with DMX values from fish."""
+ return self._enabled
+
+ @enabled.setter
+ def enabled(self, value: bool) -> None:
+ self._enabled = value
+ if value:
+ self._poll_timer.start()
+ else:
+ self._poll_timer.stop()
+
+ def _request_dmx(self) -> None:
+ 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 as e:
+ logger.exception("Could not send DMX request: %s", e)
+
+ @QtCore.Slot()
+ def _on_dmx(self, msg: proto.DirectMode_pb2.dmx_output) -> 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: StageObject, raw: list[int], cfg: dict[str, Any]) -> None:
+ """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: str) -> int:
+ 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: StageObject, raw: list[int], cfg: dict[str, Any]) -> None:
+ """Map R/G/B/W channels to beam_color."""
+ start = cfg.get("start_channel", 0)
+ m = cfg.get("mapping", {})
+
+ def rd(role: str) -> int:
+ 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)
+ if not self._has_movement_dimmer(obj) and any_color:
+ obj.dimmer = 1.0
+ # TODO update lense colors
+
+ def _has_movement_dimmer(self, obj: StageObject) -> 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/visualizer/stage/__init__.py b/src/model/visualizer/stage/__init__.py
new file mode 100644
index 00000000..376efe0e
--- /dev/null
+++ b/src/model/visualizer/stage/__init__.py
@@ -0,0 +1 @@
+"""Contains StageObject implementations."""
diff --git a/src/model/visualizer/stage/fixture_group.py b/src/model/visualizer/stage/fixture_group.py
new file mode 100644
index 00000000..92f74530
--- /dev/null
+++ b/src/model/visualizer/stage/fixture_group.py
@@ -0,0 +1,51 @@
+"""Contains StageObject FixtureGroup implementation."""
+from __future__ import annotations
+
+from typing import Any
+
+
+class FixtureGroup:
+ """Named bundle of fixtures that can be moved or rotated together.
+
+ Note:
+ This only groups fixtures within stages but has nothing to do with FixtureGroup's from show files.
+
+ 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) -> None:
+ """Initialize the group."""
+ 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]:
+ """Serialize state to nested dictionary."""
+ 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]) -> FixtureGroup:
+ """Instantiate from deserialized data."""
+ 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", []),
+ )
diff --git a/src/model/visualizer/stage/model_entries.py b/src/model/visualizer/stage/model_entries.py
new file mode 100644
index 00000000..a4bf6590
--- /dev/null
+++ b/src/model/visualizer/stage/model_entries.py
@@ -0,0 +1,38 @@
+"""Contains ModelEntry class and fixture key definitions."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@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], ...] = ()
+
+
+# Keys exposed by the "Add Fixture" dialog.
+FIXTURE_KEYS = [
+ "truss_default",
+ "truss_2point_medium",
+ "truss_cross",
+ "truss_long",
+ "truss_medium",
+ "moving_head",
+]
+
+TRUSS_VARIANTS: dict[str, str] = {
+ "Default": "truss_default",
+ "2-Point Medium": "truss_2point_medium",
+ "Cross": "truss_cross",
+ "Long": "truss_long",
+ "Medium": "truss_medium",
+}
diff --git a/src/model/visualizer/stage/paths.py b/src/model/visualizer/stage/paths.py
new file mode 100644
index 00000000..bf4e7f79
--- /dev/null
+++ b/src/model/visualizer/stage/paths.py
@@ -0,0 +1,27 @@
+"""Contains file system paths for data."""
+
+from __future__ import annotations
+
+import os
+
+from utility import resource_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")),
+}
+
+# User-local stage directory (XDG).
+STAGE_DIR = os.path.join(
+ os.path.expanduser("~"), ".local", "share", "missionDMX", "stage"
+)
+if not os.path.exists(STAGE_DIR):
+ os.makedirs(STAGE_DIR)
+DEFAULT_STAGE_PATH = os.path.join(STAGE_DIR, "current_stage.yaml")
diff --git a/src/model/visualizer/stage/so_moving_head.py b/src/model/visualizer/stage/so_moving_head.py
new file mode 100644
index 00000000..66e3820c
--- /dev/null
+++ b/src/model/visualizer/stage/so_moving_head.py
@@ -0,0 +1,144 @@
+"""Contains MovingHead."""
+from __future__ import annotations
+
+from typing import Any, override
+
+from model.visualizer.stage.model_entries import ModelEntry
+from model.visualizer.stage.paths import DEFAULT_MODEL_PATHS
+from model.visualizer.stage.stage_object import StageObject
+
+
+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: tuple[float, float, float] | None = None,
+ rotation: tuple[float, float, float] | None = 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,
+ ) -> None:
+ """Initialize MovingHead stage object."""
+ # 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)))
+
+ @override
+ def get_type(self) -> str:
+ return "moving_head"
+
+ @override
+ def get_display_name(self) -> str:
+ return "Moving Head"
+
+ @override
+ 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)),
+ }
+
+ @override
+ 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]) -> MovingHead:
+ """Construct an instance from parsed data."""
+ 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
diff --git a/src/model/visualizer/stage/so_platform.py b/src/model/visualizer/stage/so_platform.py
new file mode 100644
index 00000000..333b91aa
--- /dev/null
+++ b/src/model/visualizer/stage/so_platform.py
@@ -0,0 +1,32 @@
+"""Contains Platform StageObject."""
+from __future__ import annotations
+
+from typing import override
+
+from model.visualizer.stage.paths import DEFAULT_MODEL_PATHS
+from model.visualizer.stage.stage_object import StageObject
+
+
+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: tuple[float, float, float] | None = None,
+ rotation: tuple[float, float, float] | None = None, scale: float = 1.0) -> None:
+ """Initialize a new Platform StageObject."""
+ 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"],
+ )
+
+ @override
+ def get_type(self) -> str:
+ return "platform"
+
+ @override
+ def get_display_name(self) -> str:
+ return "Platform"
diff --git a/src/model/visualizer/stage/so_truss.py b/src/model/visualizer/stage/so_truss.py
new file mode 100644
index 00000000..a5ea3993
--- /dev/null
+++ b/src/model/visualizer/stage/so_truss.py
@@ -0,0 +1,75 @@
+"""Contains Truss."""
+from __future__ import annotations
+
+from typing import Any, override
+
+from model.visualizer.stage.paths import DEFAULT_MODEL_PATHS
+from model.visualizer.stage.stage_object import StageObject
+
+
+class Truss(StageObject):
+ """Truss fixture (default, cross, long, medium, 2-point)."""
+
+ def __init__(self, object_id: str, variant: str = "default",
+ position: tuple[float, float, float] | None = None,
+ rotation: tuple[float, float, float] | None = None, scale: float = 1.0) -> None:
+ """Initialize Truss StageObject."""
+ 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],
+ )
+
+ @override
+ def get_type(self) -> str:
+ return f"truss_{self.variant}" if self.variant != "" else "truss"
+
+ @override
+ 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}")
+
+ @override
+ 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]) -> Truss:
+ """Construct instance from parsed data."""
+ 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
diff --git a/src/model/visualizer/stage/stage_config.py b/src/model/visualizer/stage/stage_config.py
new file mode 100644
index 00000000..5ec44396
--- /dev/null
+++ b/src/model/visualizer/stage/stage_config.py
@@ -0,0 +1,234 @@
+"""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/``.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+from datetime import datetime
+from logging import getLogger
+
+from ruamel import yaml
+
+from model.visualizer.stage.fixture_group import FixtureGroup
+from model.visualizer.stage.paths import DEFAULT_STAGE_PATH, STAGE_DIR
+from model.visualizer.stage.so_moving_head import MovingHead
+from model.visualizer.stage.so_platform import Platform
+from model.visualizer.stage.so_truss import Truss
+from model.visualizer.stage.stage_object import StageObject
+from utility import resource_path
+
+logger = getLogger(__name__)
+
+
+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", "default_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") # NOQA: DTZ005 We'd like to get local time zone.
+ 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
+
+
+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 StageConfig:
+ """Aggregate root: list of objects + list of groups, persisted as YAML."""
+
+ def __init__(self, yaml_file_path: str, show_file_path: str | None = None) -> None:
+ """Initialize stage configuration."""
+ if yaml_file_path == "":
+ yaml_file_path = get_default_stage_path()
+ self.objects: list[StageObject] = []
+ self.groups: list[FixtureGroup] = []
+
+ resolved_file_path = yaml_file_path
+ resolved = False
+ local_file_candidate: str | None = None
+ if show_file_path is not None and len(show_file_path) > 0:
+ show_file_next_to_stage = os.path.join(os.path.dirname(show_file_path), resolved_file_path)
+ local_file_candidate = show_file_next_to_stage
+ if os.path.isfile(show_file_next_to_stage):
+ resolved_file_path = show_file_next_to_stage
+ resolved = True
+ if not resolved:
+ show_file_next_to_stage = os.path.join(os.path.dirname(
+ get_default_stage_path()), resolved_file_path)
+ if os.path.isfile(show_file_next_to_stage):
+ resolved_file_path = show_file_next_to_stage
+
+ if os.path.exists(resolved_file_path):
+ try:
+ yaml_loader = yaml.YAML(typ="safe")
+ with open(resolved_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", resolved_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:
+ if local_file_candidate is None:
+ logger.info("Stage YAML file %s not found, starting empty.", resolved_file_path)
+ else:
+ resolved_file_path = local_file_candidate
+
+ self.file_path = resolved_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) -> None:
+ """Save the configuration to the last known file path."""
+ self.save_to(self.file_path)
+
+ def save_to(self, path: str) -> None:
+ """Save the configuration to the given path.
+
+ Args:
+ path: Path to save to.
+
+ """
+ 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]:
+ """Get a list of all object and group names present in stage configuration."""
+ 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) -> None:
+ """Add a new StageObject to the stage configuration."""
+ 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) -> None:
+ """Remove a StageObject from the stage configuration specified by its ID."""
+ 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) -> None:
+ """Get a StageObject by its ID."""
+ for obj in self.objects:
+ if obj.id == object_id:
+ return obj
+ return None
+
+ def add_group(self, group: FixtureGroup) -> None:
+ """Add a group to the stage configuration."""
+ 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:
+ """Remove a FixtureGroup from the stage configuration, specified by its ID."""
+ 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:
+ """Get a FixtureGroup by its ID."""
+ 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:
+ """Get the FixtureGroup a StageObject is associated with based on the ID of the StageObject.
+
+ Returns:
+ FixtureGroup | None based on a group being found.
+
+ """
+ for grp in self.groups:
+ if object_id in grp.member_ids:
+ return grp
+ return None
diff --git a/src/model/visualizer/stage/stage_object.py b/src/model/visualizer/stage/stage_object.py
new file mode 100644
index 00000000..c0ff4cb4
--- /dev/null
+++ b/src/model/visualizer/stage/stage_object.py
@@ -0,0 +1,92 @@
+"""Contains StageObject base class."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from model.visualizer.stage.model_entries import ModelEntry
+from model.visualizer.stage.paths import DEFAULT_MODEL_PATHS
+
+
+class StageObject:
+ """Base class for anything placed on the stage.
+
+ An implementing class may provide additional attributes which are checked while rendering.
+ For performance reasons, they are not provided as mixin classes. Here's a full list:
+ * `beam_on` (bool) if provided the StageObject (SO) has a light beam to be rendered. True/False indicates
+ visibility. Having disabled beams still consumes resources.
+ * `pan` and `tilt` (float) pan and tilt coordinates for movable part and beam
+ * `beam_color` (tuple[int, int, int]) RGB color of beam (if present). Range 0 to 255
+ * `dimmer` (float) brightness multiplier
+ * `lense_colors` (list[tuple[vec3[float], vec3[float], float, vec3[int], str, str, str]]) a list containing lense
+ illumination descriptions (position, rotation, size, color(rgb 0-255)). For each entry a lense illumination
+ will be drawn. Positions are relative to model base position as defined by the provided node (name,
+ base_node_name and tilt_node_name). If the fixture does not have pan/tilt capabilities, the strings can be empty.
+
+ """
+
+ 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,
+ ) -> None:
+ """Initialize base stage object data structures."""
+ 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:
+ """Get the type of this stage object."""
+ return "default"
+
+ def get_display_name(self) -> str:
+ """Get the human readable name of this stage object."""
+ 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]:
+ """Get a dictionary representation of this object, suitable for serialization."""
+ 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]) -> StageObject:
+ """Instantiate from deserialized data."""
+ 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
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/contributors.html b/src/resources/contributors.html
index ee5df292..b7eb9048 100644
--- a/src/resources/contributors.html
+++ b/src/resources/contributors.html
@@ -11,6 +11,8 @@ Contributors
Hannes Iven
Tatsu Tiedemann
Dominik Philipp
+ Joell Keanu
+ Anja Rabich
Dependencies
The following section documents the required dependencies as well as their associated licenses. The licenses can be viewed
diff --git a/src/resources/data/default_stage.yaml b/src/resources/data/default_stage.yaml
new file mode 100644
index 00000000..49b84b56
--- /dev/null
+++ b/src/resources/data/default_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/resources/shaders/stage_beam.frag b/src/resources/shaders/stage_beam.frag
new file mode 100644
index 00000000..f4bf337f
--- /dev/null
+++ b/src/resources/shaders/stage_beam.frag
@@ -0,0 +1,110 @@
+#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);
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_beam.vert b/src/resources/shaders/stage_beam.vert
new file mode 100644
index 00000000..04cbd385
--- /dev/null
+++ b/src/resources/shaders/stage_beam.vert
@@ -0,0 +1,20 @@
+#version 410 core
+
+// Beam shader (Pass 2: volumetric cone with ray-marched shadows)
+
+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;
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_depth.frag b/src/resources/shaders/stage_depth.frag
new file mode 100644
index 00000000..46e822a5
--- /dev/null
+++ b/src/resources/shaders/stage_depth.frag
@@ -0,0 +1,4 @@
+#version 410 core
+void main() {
+ // depth is written automatically
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_depth.vert b/src/resources/shaders/stage_depth.vert
new file mode 100644
index 00000000..03058279
--- /dev/null
+++ b/src/resources/shaders/stage_depth.vert
@@ -0,0 +1,11 @@
+#version 410 core
+
+// Depth-only shader (Pass 0: shadow map generation)
+
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal; // unused but matches VAO layout
+uniform mat4 lightSpaceMatrix;
+uniform mat4 model;
+void main() {
+ gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_lense.frag b/src/resources/shaders/stage_lense.frag
new file mode 100644
index 00000000..36102224
--- /dev/null
+++ b/src/resources/shaders/stage_lense.frag
@@ -0,0 +1,47 @@
+#version 410 core
+
+in vec2 vUV; // (0,0) .. (1,1) from the quad
+in vec3 vColor;
+in float vRadius;
+in vec3 vWorldPos;
+
+out vec4 fragColor;
+
+// Optional: if you have a depth buffer you may want to write depth manually
+// (the default depth write from gl_FragDepth works fine).
+
+// Parameters you can tweak at runtime
+uniform float uGlowFalloff = 2.0; // higher = sharper edge
+uniform vec3 uGlowColor = vec3(1.0); // colour of the glow (usually same as vColor)
+uniform float uGlowIntensity = 1.0; // multiplier for the additive part
+
+void main()
+{
+ // ---- Compute distance from centre in the disc plane ----
+ // vUV goes from (0,0) at lower‑left to (1,1) at upper‑right.
+ // Transform to [-0.5, +0.5] and then to radius units.
+ vec2 discCoord = (vUV - vec2(0.5)) * 2.0; // now in [-1, +1]
+ float dist = length(discCoord) * vRadius; // world distance from centre
+
+ // ---- Discard fragments outside the radius (hard edge) ----
+ // If you want a perfectly sharp disc you can just `if (dist > vRadius) discard;`
+ // but we keep them and let the smoothstep create a soft edge.
+ // The discard is optional; keeping them allows a smoother fall‑off.
+
+ // ---- Compute the radial fall‑off (glow) ----
+ // 0 at centre, 1 at radius, >1 outside.
+ float t = dist / vRadius;
+
+ // A smoothstep that goes from opaque to transparent a little before the radius.
+ // The exponent controls how quickly the glow fades.
+ float alpha = 1.0 - smoothstep(0.0, 1.0, pow(t, uGlowFalloff));
+
+ // ---- Final colour ----
+ // Base colour (inside the disc) + additive glow that continues past the edge.
+ vec3 base = vColor * alpha; // inside disc
+ vec3 glow = uGlowColor * pow(1.0 - t, 2.0) * uGlowIntensity; // simple radial glow
+
+ // Combine – we keep the alpha of the base disc, but we also output a bright
+ // additive component that will be blended later.
+ fragColor = vec4(base + glow, alpha);
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_lense.vert b/src/resources/shaders/stage_lense.vert
new file mode 100644
index 00000000..8d9731ba
--- /dev/null
+++ b/src/resources/shaders/stage_lense.vert
@@ -0,0 +1,49 @@
+#version 410 core
+
+// ---------- per‑instance attributes ----------
+layout (location = 0) in vec3 aPos; // centre
+layout (location = 1) in vec3 aNormal; // facing direction (must be normalized)
+layout (location = 2) in float aRadius; // world radius
+layout (location = 3) in vec3 aColor; // linear RGB
+
+// ---------- per‑vertex attributes (the quad) ----------
+layout (location = 4) in vec2 aQuadOffset; // (-1,-1) .. (+1,+1)
+layout (location = 5) in vec2 aQuadUV; // (0,0) .. (1,1)
+
+// ---------- outputs to fragment shader ----------
+out vec2 vUV; // local disc coordinates (0‑1)
+out vec3 vColor; // colour passed through
+out float vRadius; // radius (world units)
+out vec3 vWorldPos; // world position of the fragment (for depth, lighting, etc.)
+
+// ---------- camera uniforms ----------
+uniform mat4 uView;
+uniform mat4 uProj;
+
+void main()
+{
+ // ---- Build a tangent‑bitangent basis from the normal ----
+ // Choose an arbitrary vector that is not parallel to the normal.
+ vec3 up = abs(aNormal.z) < 0.999 ? vec3(0,0,1) : vec3(0,1,0);
+ vec3 tangent = normalize(cross(up, aNormal));
+ vec3 bitangent = cross(aNormal, tangent); // already normalized
+
+ // ---- Scale the quad to the disc radius ----
+ // aQuadOffset is in [-1,1] range, so we multiply by 0.5 to get [-0.5,0.5]
+ // then by the radius to get world units.
+ vec2 localPos = (aQuadOffset * 0.5) * aRadius;
+
+ // ---- Transform the local quad into world space ----
+ vec3 worldPos = aPos
+ + tangent * localPos.x
+ + bitangent * localPos.y;
+
+ // ---- Pass data to the fragment shader ----
+ vUV = aQuadUV; // (0,0) .. (1,1)
+ vColor = aColor;
+ vRadius = aRadius;
+ vWorldPos = worldPos;
+
+ // ---- Final clip‑space position ----
+ gl_Position = uProj * uView * vec4(worldPos, 1.0);
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_scene.frag b/src/resources/shaders/stage_scene.frag
new file mode 100644
index 00000000..614b1d70
--- /dev/null
+++ b/src/resources/shaders/stage_scene.frag
@@ -0,0 +1,114 @@
+#version 410 core
+
+// TODO make MAX_SPOT_LIGHTS a parameter
+#define MAX_LIGHTS 16
+// TODO make MAX_SHADOW_MAPS a parameter
+#define MAX_SHADOWS 4
+
+struct SpotLight {
+ vec3 position;
+ vec3 direction;
+ vec3 color;
+ float innerCos;
+ float outerCos;
+};
+
+uniform int numLights;
+uniform SpotLight lights[MAX_LIGHTS];
+
+uniform vec3 viewPos;
+uniform vec3 baseColor;
+uniform float ambientLevel;
+
+// Selection highlight: 0.0 = normal, >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);
+}
\ No newline at end of file
diff --git a/src/resources/shaders/stage_scene.vert b/src/resources/shaders/stage_scene.vert
new file mode 100644
index 00000000..c85a4bd3
--- /dev/null
+++ b/src/resources/shaders/stage_scene.vert
@@ -0,0 +1,20 @@
+#version 410 core
+
+// Scene shader (Pass 1: Phong + spotlights + PCF shadows)
+
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal;
+
+uniform mat4 model;
+uniform mat4 view;
+uniform mat4 projection;
+
+out vec3 FragPos;
+out vec3 Normal;
+
+void main() {
+ vec4 wp = model * vec4(aPos, 1.0);
+ FragPos = wp.xyz;
+ Normal = mat3(transpose(inverse(model))) * aNormal;
+ gl_Position = projection * view * wp;
+}
\ No newline at end of file
diff --git a/src/view/gl/__init__.py b/src/view/gl/__init__.py
new file mode 100644
index 00000000..10b6b63f
--- /dev/null
+++ b/src/view/gl/__init__.py
@@ -0,0 +1,28 @@
+"""Contains shared functionality for using OpenGL."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from PySide6 import QtGui
+
+
+def _apply_local_ops(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)
diff --git a/src/view/gl/gltf_model.py b/src/view/gl/gltf_model.py
new file mode 100644
index 00000000..1c3c16be
--- /dev/null
+++ b/src/view/gl/gltf_model.py
@@ -0,0 +1,190 @@
+"""Contains gltf model handling."""
+
+from __future__ import annotations
+
+import json
+import struct
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from view.gl.model_3d import Model3D
+
+if TYPE_CHECKING:
+ from PySide6.QtGui import QOpenGLContext
+
+# 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,
+}
+
+
+class GltfNode:
+ """A single node from a glTF scene graph."""
+
+ def __init__(self,
+ name: str,
+ mesh_index: int,
+ children: list[int] | None,
+ translation: list[float] | None,
+ rotation: list[float] | None,
+ scale: list[float] | None) -> None:
+ """Initialize the struct."""
+ self.name: str = name or ""
+ self.mesh_index: int = mesh_index
+ self.children: list[int] = children or []
+ self.translation: list[float] = translation or [0.0, 0.0, 0.0]
+ self.rotation: list[float] = rotation or [0.0, 0.0, 0.0, 1.0] # quaternion (x,y,z,w)
+ self.scale: list[float] = scale or [1.0, 1.0, 1.0]
+
+
+class GltfModel:
+ """Minimal glTF/GLB container with node hierarchy and GPU meshes."""
+
+ def __init__(self, nodes: list[GltfNode], scene_roots: list[int],
+ mesh_primitives: dict[int, list[Model3D]]) -> None:
+ """Initialize the struct."""
+ self.nodes: list[GltfNode] = nodes # list of GltfNode
+ self.scene_roots: list[int] = scene_roots # list of root node indices
+ self.mesh_primitives: dict[int, list[Model3D]] = mesh_primitives # dict: mesh_index -> [Model3D]
+
+ def unload(self) -> None:
+ """Unload all nodes and meshes."""
+ for pl in self.mesh_primitives.values():
+ for p in pl:
+ p.unload()
+
+
+ @classmethod
+ def load_gltf_model(cls, path: str, context: QOpenGLContext) -> GltfModel:
+ """Load a GLB file, build the node hierarchy, and upload all meshes.
+
+ Returns a GltfModel containing the scene graph and GPU mesh handles.
+ """
+ gltf, bin_chunk = _read_glb(path)
+
+ # Build node list
+ nodes = [GltfNode(n.get("name", ""), n.get("mesh"), n.get("children") or [],
+ n.get("translation"), n.get("rotation"), n.get("scale"))
+ for n in gltf.get("nodes", [])]
+
+ # Determine scene root nodes
+ si = int(gltf.get("scene", 0))
+ scenes = gltf.get("scenes", [])
+ scene_roots = (scenes[si].get("nodes", [])
+ if scenes and 0 <= si < len(scenes)
+ else list(range(len(nodes))))
+
+ # Upload mesh primitives to GPU
+ mesh_prims = {}
+ for mi, mesh in enumerate(gltf.get("meshes", [])):
+ plist = []
+ for prim in mesh.get("primitives", []) or []:
+ attrs = prim.get("attributes", {})
+ if "POSITION" not in attrs:
+ continue
+ pos = _read_accessor(gltf, bin_chunk, attrs["POSITION"]).astype(np.float32)
+ nrm = (_read_accessor(gltf, bin_chunk, attrs["NORMAL"]).astype(np.float32)
+ if "NORMAL" in attrs else None)
+ idx = (_read_accessor(gltf, bin_chunk, prim["indices"]).reshape(-1).astype(np.uint32)
+ if "indices" in prim
+ else np.arange(pos.shape[0], dtype=np.uint32))
+ if nrm is None or nrm.shape[0] != pos.shape[0]:
+ nrm = _compute_vertex_normals(pos, idx)
+ plist.append(Model3D.upload_mesh(np.concatenate([pos[:, :3], nrm[:, :3]], axis=1), idx,
+ context=context))
+ if plist:
+ mesh_prims[mi] = plist
+
+ return cls(nodes, scene_roots, mesh_prims)
+
+
+# glTF binary loading
+def _read_glb(path: str) -> tuple[dict[str, Any], bytes]:
+ """Read a GLB file and return (json_dict, bin_chunk).
+
+ GLB layout: 12-byte header + JSON chunk + BIN chunk.
+ """
+ with open(path, "rb") as f:
+ data = f.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(" np.ndarray:
+ """Read a glTF accessor as a numpy array.
+
+ Handles byte offsets, strides, component types, and normalization
+ as specified by the glTF 2.0 standard.
+ """
+ acc = gltf["accessors"][acc_idx]
+ bv = gltf["bufferViews"][acc["bufferView"]]
+ dt = _GLTF_COMPONENT_DTYPE[acc["componentType"]]
+ comps = _GLTF_TYPE_NUMCOMP[acc["type"]]
+ count = int(acc["count"])
+ base = int(bv.get("byteOffset", 0)) + int(acc.get("byteOffset", 0))
+ stride = bv.get("byteStride")
+ item_size = np.dtype(dt).itemsize * comps
+
+ if stride is None or int(stride) == item_size:
+ # Read directly
+ flat = np.frombuffer(bin_chunk, dtype=dt, count=count * comps, offset=base)
+ out = flat.reshape((count, comps))
+ else:
+ # Read element by element
+ stride = int(stride)
+ out = np.empty((count, comps), dtype=dt)
+ for i in range(count):
+ out[i, :] = np.frombuffer(bin_chunk, dtype=dt, count=comps,
+ offset=base + i * stride)
+
+ # Apply normalization for integer types (glTF spec)
+ if acc.get("normalized") and np.issubdtype(out.dtype, np.integer):
+ out = out.astype(np.float32) / float(np.iinfo(out.dtype).max)
+
+ return out
+
+
+def _compute_vertex_normals(positions: np.ndarray, indices: np.ndarray) -> np.ndarray:
+ """Compute smooth vertex normals by averaging face normals.
+
+ Used as fallback when the glTF model does not provide NORMAL attributes.
+ """
+ normals = np.zeros_like(positions, dtype=np.float32)
+ tris = indices.reshape((-1, 3))
+ p0, p1, p2 = positions[tris[:, 0]], positions[tris[:, 1]], positions[tris[:, 2]]
+ # Face normals via cross product
+ n = np.cross(p1 - p0, p2 - p0)
+ # Accumulate face normals at each vertex
+ for k in range(3):
+ np.add.at(normals, tris[:, k], n)
+ # Normalize
+ lens = np.linalg.norm(normals, axis=1)
+ lens[lens == 0.0] = 1.0
+ normals /= lens[:, None]
+ return normals
diff --git a/src/view/gl/model_3d.py b/src/view/gl/model_3d.py
new file mode 100644
index 00000000..da3669d9
--- /dev/null
+++ b/src/view/gl/model_3d.py
@@ -0,0 +1,107 @@
+"""Contains Model3D binding and mesh upload functions."""
+
+from __future__ import annotations
+
+import ctypes
+from logging import getLogger
+from typing import TYPE_CHECKING
+
+import numpy as np
+from OpenGL import GL as gl # NOQA: N811 it is common practice to import is as lower case gl. Also it's not a const.
+
+if TYPE_CHECKING:
+ from PySide6.QtGui import QOpenGLContext
+
+logger = getLogger(__name__)
+
+class Model3D:
+ """GPU mesh: VAO + VBO + EBO + index count.
+
+ Class only contains bindings. Data must be loaded separately.
+
+ """
+
+ def __init__(self, vao: int, vbo: int, ebo: int, index_count: int) -> None:
+ """Initialize struct."""
+ self.vao: int = vao
+ self.vbo: int = vbo
+ self.ebo: int = ebo
+ self.index_count: int = index_count
+ self._still_bound: bool = True
+
+ def unload(self) -> None:
+ """Release the VAO, VBO and EBO that belong to this model.
+
+ After this call the instance is considered “unbound”; any further
+ attempts to use it will raise because the GPU resources are gone.
+ """
+ if not self._still_bound:
+ return
+ gl.glDeleteBuffers(1, np.array([self.vbo], dtype=np.uint32))
+ gl.glDeleteBuffers(1, np.array([self.ebo], dtype=np.uint32))
+ gl.glDeleteVertexArrays(1, np.array([self.vao], dtype=np.uint32))
+
+ self._still_bound = False
+ self.vao = self.vbo = self.ebo = 0
+
+ def __del__(self) -> None:
+ """Checks if the object was successfully deleted or throws an error.
+
+ This cannot happen automatically as it must occur within the thread and OpenGL context that created the model.
+
+ """
+ if self._still_bound:
+ try:
+ self.unload()
+ except gl.GLError as e:
+ raise RuntimeError("Model3D object is still bound. This would cause a memory leak.") from e
+
+ @classmethod
+ def upload_mesh(cls, vertex_data: np.ndarray, indices: np.ndarray,
+ context: QOpenGLContext | None = None) -> Model3D:
+ """Upload interleaved position+normal vertex data to the GPU.
+
+ Vertex layout: [pos_x, pos_y, pos_z, norm_x, norm_y, norm_z] (6 floats).
+ Returns a Model3D with the GPU handles.
+ """
+ if context is None:
+ logger.warning("Context was None. Make sure the mesh data is uploaded from the correct context.")
+ vertex_data = np.ascontiguousarray(vertex_data, dtype=np.float32)
+ indices = np.ascontiguousarray(indices, dtype=np.uint32)
+ ebo, vao, vbo = cls._allocate_vao(indices, vertex_data)
+ stride = 6 * 4 # 6 floats * 4 bytes
+ gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, ctypes.c_void_p(0))
+ gl.glEnableVertexAttribArray(0)
+ gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, ctypes.c_void_p(12))
+ gl.glEnableVertexAttribArray(1)
+ gl.glBindVertexArray(0)
+ return cls(vao, vbo, ebo, int(indices.size))
+
+ @classmethod
+ def upload_vao(cls, verts: np.ndarray, indices: np.ndarray, context: QOpenGLContext | None = None, stride: int = 24,
+ vertex_size: int = 3, vertex_location_index: int = 0, uv_location_index: int = 1) -> Model3D:
+ """Upload interleaved position+normal vertex data to a new VAO."""
+ if context is None:
+ logger.warning("Context was None. Make sure the VAO is uploaded from the correct context.")
+ ebo, vao, vbo = cls._allocate_vao(indices, verts)
+ gl.glVertexAttribPointer(vertex_location_index, vertex_size, gl.GL_FLOAT, gl.GL_FALSE, stride,
+ ctypes.c_void_p(0))
+ gl.glEnableVertexAttribArray(vertex_location_index)
+ sizeof_float = 4
+ gl.glVertexAttribPointer(uv_location_index, vertex_size, gl.GL_FLOAT, gl.GL_FALSE, stride,
+ ctypes.c_void_p(vertex_size * sizeof_float))
+ gl.glEnableVertexAttribArray(uv_location_index)
+ gl.glBindVertexArray(0)
+ return cls(vao, vbo, ebo, int(indices.size))
+
+ @classmethod
+ def _allocate_vao(cls, indices: np.ndarray, verts: np.ndarray) -> tuple[int, int, int]:
+ 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)
+ return ebo, vao, vbo
diff --git a/src/view/gl/shaders.py b/src/view/gl/shaders.py
new file mode 100644
index 00000000..d502a500
--- /dev/null
+++ b/src/view/gl/shaders.py
@@ -0,0 +1,51 @@
+"""Contains shader loading methods."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from OpenGL import GL as gl # NOQA: N811 it is common practice to import is as lower case gl. Also it's not a const.
+
+if TYPE_CHECKING:
+ from OpenGL.constant import IntConstant
+
+
+def _compile_shader(src: bytes, stype: IntConstant) -> int:
+ """Compile a single GLSL shader and raise on error."""
+ s = gl.glCreateShader(stype)
+ gl.glShaderSource(s, src)
+ gl.glCompileShader(s)
+ if gl.glGetShaderiv(s, gl.GL_COMPILE_STATUS) != gl.GL_TRUE:
+ log = gl.glGetShaderInfoLog(s)
+ kind = "vertex" if stype == gl.GL_VERTEX_SHADER else "fragment"
+ raise RuntimeError(f"{kind} shader failed: {log}")
+ return s
+
+
+def load_and_link_shader(vs_src: bytes, fs_src: bytes) -> int:
+ """Compile vertex + fragment shaders and link into a program."""
+ vs = _compile_shader(vs_src, gl.GL_VERTEX_SHADER)
+ fs = _compile_shader(fs_src, gl.GL_FRAGMENT_SHADER)
+ prog = gl.glCreateProgram()
+ gl.glAttachShader(prog, vs)
+ gl.glAttachShader(prog, fs)
+ gl.glLinkProgram(prog)
+ if gl.glGetProgramiv(prog, gl.GL_LINK_STATUS) != gl.GL_TRUE:
+ raise RuntimeError(f"link failed: {gl.glGetProgramInfoLog(prog)}")
+ gl.glDeleteShader(vs)
+ gl.glDeleteShader(fs)
+ return prog
+
+def load_and_link_shader_from_files(vertex_shader_path: str, fragment_shader_path: str) -> int:
+ """Compile vertex + fragment shaders from file and link into a program."""
+ with open(vertex_shader_path, "rb") as f:
+ vertex_bytes: bytes = f.read()
+ with open(fragment_shader_path, "rb") as f:
+ fragment_bytes: bytes = f.read()
+ return load_and_link_shader(vertex_bytes, fragment_bytes)
+
+def delete_shader(program_ptr: int) -> None:
+ """Delete a shader."""
+ if program_ptr == 0:
+ return
+ gl.glDeleteProgram(program_ptr)
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/misc/settings/settings_dialog.py b/src/view/misc/settings/settings_dialog.py
index 6a574f5d..da6a907b 100644
--- a/src/view/misc/settings/settings_dialog.py
+++ b/src/view/misc/settings/settings_dialog.py
@@ -41,6 +41,9 @@ def __init__(self, parent: QWidget | None, show: "BoardConfiguration") -> None:
general_layout = QFormLayout()
self.show_file_tb = QLineEdit(self._general_settings_tab)
general_layout.addRow("Show File Name: ", self.show_file_tb)
+ self._stage_filename_tb = QLineEdit(self._general_settings_tab)
+ self._stage_filename_tb.setToolTip("The specified stage file will be loaded when the show file is loaded.")
+ general_layout.addRow("Stage Filename: ", self._stage_filename_tb)
self.show_notes_tb = QTextEdit(self._general_settings_tab)
general_layout.addRow("Notes: ", self.show_notes_tb)
self._general_settings_tab.setLayout(general_layout)
@@ -95,6 +98,7 @@ def show_file(self, new_show: "BoardConfiguration") -> None:
self._show = new_show
self.show_file_tb.setText(new_show.show_name)
self.show_notes_tb.setText(new_show.notes)
+ self._stage_filename_tb.setText(new_show.ui_hints.get("associated_stage_file", ""))
self._brightness_mixin_enbled_cb.setChecked(
str(new_show.ui_hints.get("color-mixin-auto-add-disabled")).lower() != "true")
try:
@@ -114,6 +118,10 @@ def apply(self) -> None:
self._show.ui_hints[
"color-mixin-auto-add-disabled"] = "false" if self._brightness_mixin_enbled_cb.isChecked() else "true"
self._show.ui_hints["show_ui_window_count"] = str(self._show_ui_window_count_tb.value())
+ stage_filename = str(self._stage_filename_tb.text())
+ if not stage_filename.endswith(".yaml") and not stage_filename.endswith(".yml"):
+ stage_filename += ".yaml"
+ self._show.ui_hints["associated_stage_file"] = stage_filename
update_window_count(self._show_ui_window_count_tb.value(), self._show)
def _ok_button_pressed(self) -> None:
diff --git a/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_layer_descriptions.py b/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_layer_descriptions.py
index 002e2338..63773433 100644
--- a/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_layer_descriptions.py
+++ b/src/view/show_mode/editor/node_editor_widgets/chaser_editor/_layer_descriptions.py
@@ -96,12 +96,12 @@ def _load_label_resource(path: str) -> QImage | QMovie | None:
"color_chanmod": (
"Channel Set",
"Sets a single channel (r, g, b, h, s, i) to the supplied numeric value.",
- _load_label_resource(resource_path(os.path.join("resources", "chaser_layer_help", "color_chanmod.png"))),
+ _load_label_resource(resource_path(os.path.join("resources", "chaser_layer_help", "chanmod.png"))),
),
"color_chancalc": (
"Channel Calculation",
"Modifies a channel (r, g, b, h, s, i) with an operation (add, sub, mult, div).",
- _load_label_resource(resource_path(os.path.join("resources", "chaser_layer_help", "color_chancalc.png"))),
+ _load_label_resource(resource_path(os.path.join("resources", "chaser_layer_help", "chancalc.png"))),
),
"random_color": (
"Random Color",
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/add_fixture_dialog.py b/src/view/visualizer/add_fixture_dialog.py
new file mode 100644
index 00000000..6e4eb8ef
--- /dev/null
+++ b/src/view/visualizer/add_fixture_dialog.py
@@ -0,0 +1,132 @@
+"""Contains stage editor's AddFixtureDialog."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from PySide6 import QtCore, QtWidgets
+
+from model.visualizer.stage.model_entries import TRUSS_VARIANTS
+from model.visualizer.stage.stage_config import make_unique_name
+
+if TYPE_CHECKING:
+ from model.ofl.fixture import UsedFixture
+
+
+def _fixture_label(fix: UsedFixture) -> 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)"
+
+
+class AddFixtureDialog(QtWidgets.QDialog):
+ """Dialog for adding a new fixture to the stage."""
+
+ def __init__(self,
+ existing_names: list[str],
+ used_fixtures: list[UsedFixture] | None = None,
+ parent: QtWidgets.QWidget | None = None) -> None:
+ """Initialize the dialog.
+
+ It guarantees that the entered name is unique.
+
+ Args:
+ existing_names: Existing names, which should be avoided.
+ used_fixtures: Fixtures to choose from.
+ parent: Parent widget.
+
+ """
+ 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) -> None:
+ """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) -> None:
+ """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) -> str:
+ if self._category_combo.currentText() == "Truss":
+ return f"Truss {self._variant_combo.currentText()}"
+ return "Moving Head"
+
+ def selected_fixture_key(self) -> str:
+ """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) -> str:
+ """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) -> UsedFixture | None:
+ """Return the selected UsedFixture for DMX linking, or None."""
+ return self._device_combo.currentData()
diff --git a/src/view/visualizer/geometry_helpers.py b/src/view/visualizer/geometry_helpers.py
new file mode 100644
index 00000000..4070502a
--- /dev/null
+++ b/src/view/visualizer/geometry_helpers.py
@@ -0,0 +1,171 @@
+"""Contains various rendering and geometry helper methods."""
+
+from __future__ import annotations
+
+import math
+from logging import getLogger
+from typing import TYPE_CHECKING
+
+import numpy as np
+from PySide6 import QtGui
+
+from view.gl.model_3d import Model3D
+
+if TYPE_CHECKING:
+ from PySide6.QtGui import QOpenGLContext
+
+ from model.visualizer.stage.stage_object import StageObject
+ from view.gl.gltf_model import GltfNode
+ from view.visualizer.spotlight_data import SpotLightData
+
+logger = getLogger(__name__)
+
+# also needs to be updated in stage_scene.frag
+MAX_SPOT_LIGHTS = 16 # maximum simultaneous spotlights in the scene shader,
+
+# also needs to be updated in stage_scene.frag
+MAX_SHADOW_MAPS = 4 # shadow-casting lights (texture array layers),
+SHADOW_MAP_SIZE = 1024 # per-layer shadow map resolution
+
+
+def build_cone_matrix(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
+
+
+def node_local_matrix(node: GltfNode,
+ overrides: dict[str, tuple[float, float, float, float]]) -> QtGui.QMatrix4x4:
+ """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 get_overrides(stage_obj: StageObject) -> dict[str, tuple[float, float, float, float]]:
+ """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 as e:
+ logger.exception("Unable to extract GLTF overrides from model (%s) : %s", str(stage_obj), str(e))
+ return {}
+
+
+def build_base_model_matrix(obj: StageObject) -> 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 compute_light_space_matrix(spotlight: SpotLightData) -> QtGui.QMatrix4x4:
+ """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 create_unit_cone(segments: int=48, context: QOpenGLContext | None = None) -> 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 Model3D.upload_vao(v, ii, context)
+
+
+def create_ground_plane(size: float=2000.0, context: QOpenGLContext | None = None) -> 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 Model3D.upload_vao(v, ii, context)
diff --git a/src/view/visualizer/spotlight_data.py b/src/view/visualizer/spotlight_data.py
new file mode 100644
index 00000000..0f4a8126
--- /dev/null
+++ b/src/view/visualizer/spotlight_data.py
@@ -0,0 +1,28 @@
+"""Contains SpotLightData."""
+
+from __future__ import annotations
+
+import math
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from PySide6 import QtGui
+
+
+class SpotLightData:
+ """Spotlight data collected per frame from active MovingHeads."""
+
+ __slots__ = ("color", "direction", "inner_cos", "outer_cos", "position")
+
+ def __init__(self,
+ position: QtGui.QVector3D,
+ direction: QtGui.QVector3D,
+ color: tuple[float, float, float],
+ inner_deg: float=10.0,
+ outer_deg: float=18.0) -> None:
+ """Initialize struct."""
+ self.position: QtGui.QVector3D = position # QVector3D
+ self.direction: QtGui.QVector3D = direction # QVector3D (normalized)
+ self.color: tuple[float, float, float] = color # (r, g, b) floats in [0, 1]
+ self.inner_cos: float = math.cos(math.radians(inner_deg))
+ self.outer_cos: float = math.cos(math.radians(outer_deg))
diff --git a/src/view/visualizer/stage_editor_widget.py b/src/view/visualizer/stage_editor_widget.py
new file mode 100644
index 00000000..616668de
--- /dev/null
+++ b/src/view/visualizer/stage_editor_widget.py
@@ -0,0 +1,1071 @@
+"""Right-hand editor panel: fixture list, property form and DMX device mapping."""
+
+from __future__ import annotations
+
+import math
+import time
+from logging import getLogger
+from typing import TYPE_CHECKING
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+from model.visualizer.dmx.dmx_visualizer import COLOR_ROLES, MOVEMENT_ROLES, auto_detect_mapping
+from model.visualizer.stage.so_moving_head import MovingHead
+from view.visualizer.add_fixture_dialog import AddFixtureDialog, _fixture_label
+from view.visualizer.stage_group_name_dialog import GroupNameDialog
+
+if TYPE_CHECKING:
+ from model.ofl.fixture import UsedFixture
+ from model.visualizer.stage import FixtureGroup, StageConfig, StageObject
+
+logger = getLogger(__name__)
+
+
+def _fixture_combo_data(fix: UsedFixture) -> dict[str, int | list[str]]:
+ """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,
+ }
+
+ROLE_ID = QtCore.Qt.ItemDataRole.UserRole
+ROLE_IS_GROUP = QtCore.Qt.ItemDataRole.UserRole + 1 # bool: True for group headers
+
+
+class StageEditorWidget(QtWidgets.QWidget):
+ """Right-hand panel: fixture list + property editor + DMX controls."""
+
+ add_object_requested = QtCore.Signal(str, str, object) # (fixture_key, name, device_or_None)
+ remove_object_requested = QtCore.Signal(str) # object_id
+ object_changed = QtCore.Signal(str) # object_id
+ selection_changed = QtCore.Signal(list, bool) # (highlight_ids, is_multi)
+ group_requested = QtCore.Signal(list, str) # (fixture_ids, group_name)
+ remove_group_requested = QtCore.Signal(str) # group_id
+ dmx_toggled = QtCore.Signal(bool) # True = start, False = stop
+
+ def __init__(self,
+ stage_config: StageConfig,
+ used_fixtures: list[UsedFixture] | None = None,
+ parent: QtWidgets.QWidget | None = None) -> None:
+ """Initialize Stage Editor Widget.
+
+ Args:
+ stage_config: The stage configuration to provide an editor for.
+ used_fixtures: The fixtures a user may select from when adding to the stage
+ parent: The parent widget.
+
+ """
+ super().__init__(parent)
+ self._stage_config = stage_config
+ self._used_fixtures = used_fixtures or []
+ self._current_obj = None # currently selected fixture
+ self._current_group: FixtureGroup | None = 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: StageObject) -> str:
+ """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: FixtureGroup) -> str:
+ """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) -> None:
+ """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) -> None:
+ """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.selection_changed.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.selection_changed.emit(highlight_ids, is_multi)
+
+ # Property panel helpers
+
+ def _clear_properties(self) -> None:
+ """Remove all rows from the property form."""
+ while self._prop_layout.rowCount() > 0:
+ self._prop_layout.removeRow(0)
+
+ def _add_section_header(self, text: str) -> None:
+ """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) -> None:
+ """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: FixtureGroup) -> None:
+ """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: StageObject) -> None:
+ """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, 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, MovingHead):
+ self._setup_movinghead_settings(obj)
+
+ self._updating_ui = False
+
+ def _setup_movinghead_settings(self, obj: MovingHead) -> None:
+ 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 axis, val in [("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)
+
+ # DMX lock / unlock logic
+
+ def _has_dmx_role(self, obj: StageObject, section: str, role: COLOR_ROLES) -> bool:
+ """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: bool) -> None:
+ self.dmx_toggled.emit(checked)
+ self._refresh_locks()
+
+ def _apply_dmx_locks(self, obj: StageObject) -> None:
+ """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, 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) -> None:
+ """Re-apply lock state after a device or mapping change."""
+ if self._current_obj and isinstance(self._current_obj, MovingHead) and hasattr(self, "_pan_spin"):
+ self._apply_dmx_locks(self._current_obj)
+
+ def update_live_values(self) -> None:
+ """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, 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 as e:
+ logger.exception("Failed to update attribute: %s", e)
+ self._updating_ui = False
+
+ def _update_color_btn_style(self, r: float, g: float, b: float) -> None:
+ """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: StageObject) -> None:
+ """Build the Movement Device and Color Device property sections."""
+ if not isinstance(obj, 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: StageObject) -> None:
+ """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: StageObject) -> None:
+ """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, _: int) -> None: # Index argument is not required
+ 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, _: int) -> None: # Provided index argument is not required
+ 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: COLOR_ROLES) -> None:
+ 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: COLOR_ROLES) -> None:
+ 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) -> None:
+ """Notify the mediator that the current fixture's properties changed."""
+ if self._updating_ui or not self._current_obj:
+ return
+ self.object_changed.emit(self._current_obj.id)
+
+ def _on_name_changed(self, text: str) -> None:
+ 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) -> None:
+ 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) -> None:
+ 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: float | str) -> None:
+ if self._updating_ui or not self._current_obj:
+ return
+ self._current_obj.scale = float(val)
+ self._emit_changed()
+
+ def _on_attr(self, attr: str, val: float | str) -> None:
+ """Handle simple float attribute (pan, tilt, dimmer) changes."""
+ 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: bool) -> None:
+ 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: float) -> None:
+ """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) -> None:
+ 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) -> None:
+ """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: str) -> None:
+ """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) -> None:
+ """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) -> None:
+ """Rotate all members around the group center using total rotation."""
+ if self._updating_ui or not self._current_group:
+ return
+
+ 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) -> None:
+ """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.object_changed.emit(mid)
+
+ # Actions (Add / Remove / Group)
+
+ def _on_add_clicked(self) -> None:
+ """Open 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.add_object_requested.emit(
+ dlg.selected_fixture_key(), dlg.selected_name(), dlg.selected_device())
+
+ def _on_remove_clicked(self) -> None:
+ """Remove 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.remove_group_requested.emit(oid)
+ else:
+ self.remove_object_requested.emit(oid)
+
+ def _on_group_clicked(self) -> None:
+ """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.group_requested.emit(fixture_ids, dlg.selected_name())
+
+ # API
+
+ def add_object_to_list(self, obj: StageObject) -> None:
+ """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: str) -> None:
+ """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) -> None:
+ """Full rebuild of the fixture list."""
+ self._rebuild_list()
+
+ def select_fixture_by_id(self, object_id: str) -> None:
+ """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) -> None:
+ """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..d8fdc8ff
--- /dev/null
+++ b/src/view/visualizer/stage_gl_widget.py
@@ -0,0 +1,1249 @@
+"""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 math
+import os
+import time
+from curses import has_key
+from logging import getLogger
+from typing import TYPE_CHECKING, override
+
+import numpy as np
+from OpenGL import GL as gl # NOQA: N811 it is common practice to import is as lower case gl. Also it's not a const.
+from PySide6 import QtCore, QtGui
+from PySide6.QtOpenGLWidgets import QOpenGLWidget
+
+from model.visualizer.stage.so_moving_head import MovingHead
+from utility import resource_path
+from view.gl import _apply_local_ops
+from view.gl.gltf_model import GltfModel, GltfNode
+from view.gl.model_3d import Model3D
+from view.gl.shaders import delete_shader, load_and_link_shader_from_files
+from view.visualizer.geometry_helpers import (
+ MAX_SHADOW_MAPS,
+ MAX_SPOT_LIGHTS,
+ SHADOW_MAP_SIZE,
+ build_base_model_matrix,
+ build_cone_matrix,
+ compute_light_space_matrix,
+ create_ground_plane,
+ create_unit_cone,
+ get_overrides,
+ node_local_matrix,
+)
+from view.visualizer.spotlight_data import SpotLightData
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from PySide6.QtCore import QPoint
+ from PySide6.QtWidgets import QApplication, QWidget
+
+ from model.visualizer.stage.stage_config import StageConfig, StageObject
+
+logger = getLogger(__name__)
+
+
+class Stage3DWidget(QOpenGLWidget):
+ """OpenGL 3D viewport for the stage visualizer."""
+
+ # Emitted when user left-clicks a fixture in 3D
+ fixture_clicked = QtCore.Signal(str)
+ # Emitted when user right-clicks (deselect all)
+ deselect_all_requested = QtCore.Signal()
+
+ def __init__(self, stage_config: StageConfig, parent: QWidget | None=None) -> None:
+ """Initialize using given stage configuration and parent."""
+ super().__init__(parent)
+ self._gl_initialized = False
+ self._stage_config = stage_config
+
+ # Shader programs (initialized in initializeGL)
+ self._scene_program: int = 0
+ self._beam_program: int = 0
+ self._depth_program: int = 0
+ self._lense_light_program: int = 0
+
+ # Uniform location caches
+ self._scene_uniforms = {} # scene shader uniforms
+ self._sc_light_locs = [] # per-light uniform locations
+ self._beam_uniforms = {} # beam shader uniforms
+ self._depth_uniforms = {} # 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
+
+ # base quad
+ self._lense_light_quad_model: Model3D | None = None
+ self._lense_light_data: np.ndarray = np.zeros(16, dtype=np.float32)
+ self._lense_shader_view_uniform_location: gl.GL_INT = 0
+ self._lense_shader_proj_uniform_location: gl.GL_INT = 0
+
+ # OpenGL initialization
+
+ @override
+ def initializeGL(self) -> None:
+ fmt = self.context().format()
+ logger.error(
+ "Initializing Visualizer OpenGL context with version %d.%d, profile=%s, options=%s\nGL_VENDOR: %s\n"
+ "GL_RENDERER: %s\nGL_VERSION: %s",
+ fmt.majorVersion(),
+ fmt.minorVersion(),
+ fmt.profile(),
+ fmt.options(),
+ gl.glGetString(gl.GL_VENDOR).decode(),
+ gl.glGetString(gl.GL_RENDERER).decode(),
+ gl.glGetString(gl.GL_VERSION).decode()
+ )
+
+ 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 = load_and_link_shader_from_files(
+ resource_path(os.path.join("resources", "shaders", "stage_scene.vert")),
+ resource_path(os.path.join("resources", "shaders", "stage_scene.frag"))
+ )
+ except RuntimeError as e:
+ logger.error("Scene shader: %s", e)
+ return
+ try:
+ self._beam_program = load_and_link_shader_from_files(
+ resource_path(os.path.join("resources", "shaders", "stage_beam.vert")),
+ resource_path(os.path.join("resources", "shaders", "stage_beam.frag"))
+ )
+ except RuntimeError as e:
+ logger.error("Beam shader: %s", e)
+ try:
+ self._depth_program = load_and_link_shader_from_files(
+ resource_path(os.path.join("resources", "shaders", "stage_depth.vert")),
+ resource_path(os.path.join("resources", "shaders", "stage_depth.frag")))
+ except RuntimeError as e:
+ logger.error("Depth shader: %s", e)
+
+ try:
+ self._lense_light_program = load_and_link_shader_from_files(
+ resource_path(os.path.join("resources", "shaders", "stage_lense.vert")),
+ resource_path(os.path.join("resources", "shaders", "stage_lense.frag"))
+ )
+ except RuntimeError as e:
+ logger.error("Lense 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._scene_uniforms[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._beam_uniforms[name] = gl.glGetUniformLocation(bp, name)
+
+ # Depth shader
+ dp = self._depth_program
+ if dp:
+ self._depth_uniforms["lightSpaceMatrix"] = gl.glGetUniformLocation(dp, "lightSpaceMatrix")
+ self._depth_uniforms["model"] = gl.glGetUniformLocation(dp, "model")
+
+ # Create shadow map resources
+ self._init_shadow_map_resources()
+
+ # Create geometry
+ self._beam_cone = create_unit_cone(64, context=self.context())
+ self._ground_plane = create_ground_plane(2000.0, context=self.context())
+
+ # Load 3D models for all existing stage objects
+ for obj in self._stage_config.objects:
+ self._ensure_models_loaded(obj)
+
+ # x y U V
+ self._lense_light_quad_model = Model3D.upload_vao(np.array([
+ -1.0, -1.0, 0.0, 0.0,
+ 1.0, -1.0, 1.0, 0.0,
+ 1.0, 1.0, 1.0, 1.0,
+ -1.0, 1.0, 0.0, 1.0
+ ], dtype=np.float32), np.array([0, 1, 2, 2, 3, 0], dtype=np.int32), self.context(),
+ stride=4, vertex_size=2, vertex_location_index=4, uv_location_index=5
+ )
+ gl.glBindVertexArray(self._lense_light_quad_model.vao)
+
+ # Position
+ gl.glEnableVertexAttribArray(0)
+ gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 16*4, ctypes.c_void_p(0))
+ gl.glVertexAttribDivisor(0, 1)
+
+ # Direction
+ gl.glEnableVertexAttribArray(1)
+ gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, 16*4, ctypes.c_void_p(4*4))
+ gl.glVertexAttribDivisor(1, 1)
+
+ # Size
+ gl.glEnableVertexAttribArray(2)
+ gl.glVertexAttribPointer(2, 1, gl.GL_FLOAT, gl.GL_FALSE, 16*4, ctypes.c_void_p(8*4))
+ gl.glVertexAttribDivisor(2, 1)
+
+ # Color
+ gl.glEnableVertexAttribArray(3)
+ gl.glVertexAttribPointer(3, 4, gl.GL_FLOAT, gl.GL_FALSE, 16*4, ctypes.c_void_p(12*4))
+ gl.glVertexAttribDivisor(3, 1)
+ self._lense_shader_view_uniform_location = gl.glGetUniformLocation(self._lense_light_program, "uView")
+ self._lense_shader_proj_uniform_location = gl.glGetUniformLocation(self._lense_light_program, "uProj")
+
+ gl.glBindVertexArray(0)
+
+ self.context().aboutToBeDestroyed.connect(self._clean_up_opengl_context)
+ logger.info("OpenGL init done. %d objects.", len(self._stage_config.objects))
+ self._gl_initialized = True
+
+ @property
+ def gl_initialized(self) -> bool:
+ """Check if the OpenGL context was already initialized."""
+ return self._gl_initialized
+
+ def _init_shadow_map_resources(self) -> None:
+ """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)
+
+ @override
+ def resizeGL(self, w: int, h: int) -> None:
+ 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)
+
+ @override
+ def paintGL(self) -> None:
+ 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 = np.array(self._projection.copyDataTo(), dtype=np.float32)
+ view_data = np.array(view.copyDataTo(), dtype=np.float32)
+
+ spotlights, beam_list = self._collect_lights_and_beams()
+ lense_light_count = self._collect_lense_lights()
+
+ # 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.1: lense_lights
+ self._render_lense_lights(lense_light_count, proj_data, view_data)
+
+ # PASS 1: Scene objects (Phong + spotlights + shadows)
+ gl.glUseProgram(self._scene_program)
+ gl.glUniformMatrix4fv(self._scene_uniforms["projection"], 1, gl.GL_TRUE, proj_data)
+ gl.glUniformMatrix4fv(self._scene_uniforms["view"], 1, gl.GL_TRUE, view_data)
+ cam = self._camera_pos
+ gl.glUniform3f(self._scene_uniforms["viewPos"], cam.x(), cam.y(), cam.z())
+ gl.glUniform1f(self._scene_uniforms["ambientLevel"], 0.09)
+
+ # Upload spotlight data to shader
+ num_lights = min(len(spotlights), MAX_SPOT_LIGHTS)
+ gl.glUniform1i(self._scene_uniforms["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._scene_uniforms["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._scene_uniforms["shadowMap"], 0)
+
+ # Draw ground plane
+ if self._ground_plane:
+ gl.glUniformMatrix4fv(self._scene_uniforms["model"], 1, gl.GL_TRUE, QtGui.QMatrix4x4().copyDataTo())
+ gl.glUniform3f(self._scene_uniforms["baseColor"], 0.15, 0.15, 0.15)
+ gl.glUniform1f(self._scene_uniforms["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
+ hl_color = (1.0, 0.55, 0.1) if self._highlight_is_multi else (1.0, 0.95, 0.15)
+ # warm orange for multi/group else neon yellow for single
+ gl.glUniform3f(self._scene_uniforms["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._scene_uniforms["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: list[SpotLightData]) -> list[QtGui.QMatrix4x4]:
+ """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 = 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._depth_uniforms["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 _draw_scene_depth_only(self) -> None:
+ """Draw all scene objects with the depth shader (for shadow maps)."""
+ for obj in self._stage_config.objects:
+ base = build_base_model_matrix(obj)
+ for entry in getattr(obj, "get_model_entries", list)():
+ model = QtGui.QMatrix4x4(base)
+ _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._depth_uniforms["model"])
+ elif entry.model_path in self._models:
+ gl.glUniformMatrix4fv(self._depth_uniforms["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)
+
+ def _traverse_gltf(self,
+ model_path: str,
+ base_model: QtGui.QMatrix4x4,
+ stage_obj: StageObject,
+ model_loc: int,
+ color: tuple[float, float, float] | None = None,
+ color_loc: tuple[float, float, float] | None = None) -> 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 = 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 *= 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)
+ stack.extend((int(child), world) for child in reversed(node.children or []))
+
+ # Pass 1: Scene object drawing
+ def _draw_stage_object(self, obj: StageObject, color: tuple[float, float, float]) -> None:
+ """Draw a single stage object with the scene shader."""
+ base = build_base_model_matrix(obj)
+ gl.glUniform3f(self._scene_uniforms["baseColor"], color[0], color[1], color[2])
+
+ for entry in getattr(obj, "get_model_entries", list)():
+ model = QtGui.QMatrix4x4(base)
+ _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._scene_uniforms["model"],
+ color=color, color_loc=self._scene_uniforms["baseColor"])
+ elif entry.model_path in self._models:
+ gl.glUniformMatrix4fv(self._scene_uniforms["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._beam_uniforms["projection"], 1, gl.GL_TRUE, proj_data)
+ gl.glUniformMatrix4fv(self._beam_uniforms["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._beam_uniforms["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 = build_cone_matrix(origin, direction, actual_length, actual_radius)
+ gl.glUniformMatrix4fv(self._beam_uniforms["model"], 1, gl.GL_TRUE, mat.copyDataTo())
+ gl.glUniform3f(self._beam_uniforms["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._beam_uniforms["hasShadow"], 1)
+ gl.glUniform1i(self._beam_uniforms["beamShadowLayer"], shadow_layer)
+ gl.glUniformMatrix4fv(
+ self._beam_uniforms["beamLightSpaceMatrix"], 1, gl.GL_TRUE,
+ light_space_matrices[shadow_layer].copyDataTo())
+ else:
+ gl.glUniform1i(self._beam_uniforms["hasShadow"], 0)
+
+ # Light origin for the volumetric shadow ray-march.
+ gl.glUniform3f(self._beam_uniforms["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)
+ gl.glUseProgram(0)
+
+ def _render_lense_lights(self, light_data_count: int, proj_data: Sequence[float],
+ view_data: Sequence[float]) -> None:
+ gl.glUseProgram(self._lense_light_program)
+ gl.glEnable(gl.GL_BLEND)
+ gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._lense_light_quad_model.vbo)
+ gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, self._lense_light_data.nbytes, self._lense_light_data)
+ gl.glUniformMatrix4fv(self._lense_shader_proj_uniform_location, 1, gl.GL_FALSE, proj_data)
+ gl.glUniformMatrix4fv(self._lense_shader_view_uniform_location, 1, gl.GL_FALSE, view_data)
+ gl.glBindVertexArray(self._lense_light_quad_model.vao)
+ gl.glDrawElementsInstanced(gl.GL_TRIANGLES, 6, gl.GL_UNSIGNED_INT, ctypes.c_void_p(0), light_data_count)
+ gl.glBindVertexArray(0)
+ gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0)
+ gl.glDisable(gl.GL_BLEND)
+ gl.glUseProgram(0)
+
+ # Light and beam collection
+
+ def _collect_lense_lights(self) -> int:
+ """Compute the positions of lense lights.
+
+ Updates:
+ List of lense lights. Each tuple contains the effective position (3), effective rotation (3), size (1) and
+ RGB color in range 0 to 1 (3).
+
+ Returns:
+ Number of lense lights.
+
+ """
+ lense_lights = 0
+ stage_objects: list[StageObject] = getattr(self._stage_config, "objects", [])
+ for obj in stage_objects:
+ ll_definition: tuple[QtGui.QVector3D, QtGui.QVector3D, float,
+ tuple[int, int, int], str, str, str] | None = getattr(obj, "lense_colors", None)
+ if ll_definition is None:
+ continue
+ arr = self._lense_light_data
+ if arr.shape[0] < (lense_lights + 1) * 16:
+ self._lense_light_data = np.resize(arr, (lense_lights + 1) * 16)
+ arr = self._lense_light_data
+ position_offset_from_base_node: QtGui.QVector3D = ll_definition[0]
+ rotation_offset_from_base_node: QtGui.QVector3D = ll_definition[1]
+ size: float = ll_definition[2]
+ color: tuple[int, int, int] = ll_definition[3]
+ position, direction = self._calculate_extension_translation_matrices(
+ obj,
+ ll_definition[4], # model path
+ ll_definition[5], # origin node name
+ ll_definition[6] # name of movable node
+ )
+ position += position_offset_from_base_node
+ direction += rotation_offset_from_base_node
+ arr[16*lense_lights + 0] = position.x()
+ arr[16*lense_lights + 1] = position.y()
+ arr[16*lense_lights + 2] = position.z()
+ arr[16*lense_lights + 4] = direction.x()
+ arr[16*lense_lights + 5] = direction.y()
+ arr[16*lense_lights + 6] = direction.z()
+ arr[16*lense_lights + 8] = size
+ arr[16*lense_lights + 12] = color[0] / 255.0 # r
+ arr[16*lense_lights + 13] = color[1] / 255.0 # g
+ arr[16*lense_lights + 14] = color[2] / 255.0 # b
+ arr[16*lense_lights + 15] = 1.0 # a
+ lense_lights += 1
+ return lense_lights
+
+ 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:
+ beam_origin_node_name = MovingHead.BEAM_ORIGIN_NODE_NAME
+ tilt_node_name = MovingHead.TILT_NODE_NAME
+ except Exception:
+ logger.error("Bug: Object did not provide beam origin node and tilt node.")
+ beam_origin_node_name = "BeamOrigin"
+ tilt_node_name = "Cylinder.018"
+
+ stage_objects: list[StageObject] = getattr(self._stage_config, "objects", [])
+ for obj in stage_objects:
+ has_beam = hasattr(obj, "beam_on")
+ if not has_beam or not bool(getattr(obj, "beam_on", False)):
+ continue
+
+ entries = getattr(obj, "get_model_entries", list)()
+ if not entries:
+ continue
+ model_path = entries[0].model_path
+
+ origin_pos, dir_vec = self._calculate_extension_translation_matrices(
+ obj, model_path, beam_origin_node_name, tilt_node_name
+ )
+
+ # 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
+
+ def _calculate_extension_translation_matrices(self, obj: StageObject, model_path: str | None,
+ origin_node_name: str | None,
+ tilt_node_name: str | None) -> (
+ tuple)[QtGui.QVector3D, QtGui.QVector3D]:
+ """Calculate end-effector position and direction from stage object and optional transition nodes."""
+ base = build_base_model_matrix(obj)
+ has_pan_and_tilt = hasattr(obj, "pan") and hasattr(obj, "tilt")
+ has_trans_node_data = tilt_node_name is not None and model_path is not None and origin_node_name is not None
+ if has_pan_and_tilt and has_trans_node_data:
+ # Find world-space position of the BeamOrigin node
+ origin_mat = self._find_gltf_node_world(model_path, base, obj, origin_node_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)
+ else:
+ origin_pos = QtGui.QVector3D(*obj.position)
+ degrees = np.degrees(np.array(obj.rotation, dtype=np.float64))
+ tilt_mat = QtGui.QMatrix4x4().rotate(QtGui.QQuaternion.fromEulerAngles(*degrees))
+
+ # 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)
+ return origin_pos, dir_vec
+
+ 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.deselect_all_requested.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()
+
+ 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).
+ """
+ self.makeCurrent()
+ 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] = GltfModel.load_gltf_model(path, self.context())
+ logger.debug("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] = Model3D.upload_mesh(
+ np.array(vd, dtype=np.float32).reshape(-1, 6),
+ np.array(il, dtype=np.uint32),
+ context=self.context()
+ )
+ 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)
+ m.unload()
+ if path in self._gltf_models:
+ gm = self._gltf_models.pop(path)
+ gm.unload()
+
+ # 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 = get_overrides(stage_obj)
+ stack = [(int(r), QtGui.QMatrix4x4(base_model)) for r in gm.scene_roots]
+ while stack:
+ node_index, parent = stack.pop()
+ if node_index < 0 or node_index >= len(gm.nodes):
+ continue
+ node = gm.nodes[node_index]
+ world = QtGui.QMatrix4x4(parent)
+ world *= 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:
+ """Find a named node in the glTF hierarchy and return its world matrix.
+
+ 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 *= 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 | None:
+ """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.fixture_clicked.emit(best_id)
+
+ def _clean_up_opengl_context(self) -> None:
+ """Unload the models."""
+ for model in self._models.values():
+ model.unload()
+ for model in self._gltf_models.values():
+ model.unload()
+ if self._lense_light_quad_model is not None:
+ self._lense_light_quad_model.unload()
+ delete_shader(self._lense_light_program)
+ self._lense_light_program = 0
+ delete_shader(self._beam_program)
+ self._beam_program = 0
+ delete_shader(self._depth_program)
+ self._depth_program = 0
+ delete_shader(self._scene_program)
+ self._scene_program = 0
+ logger.debug("Successfully cleaned up models.")
diff --git a/src/view/visualizer/stage_group_name_dialog.py b/src/view/visualizer/stage_group_name_dialog.py
new file mode 100644
index 00000000..f1050054
--- /dev/null
+++ b/src/view/visualizer/stage_group_name_dialog.py
@@ -0,0 +1,40 @@
+"""Contains stage editor's GroupNameDialog."""
+
+from __future__ import annotations
+
+from PySide6 import QtWidgets
+
+from model.visualizer.stage.stage_config import make_unique_name
+
+
+class GroupNameDialog(QtWidgets.QDialog):
+ """Simple dialog that asks the user for a group name."""
+
+ def __init__(self, existing_names: list[str], parent: QtWidgets.QWidget | None = None) -> None:
+ """Initialize the dialog."""
+ 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) -> str:
+ """Get the selected name of the group."""
+ text = self._name_edit.text().strip()
+ return text or self._name_edit.placeholderText()
diff --git a/src/view/visualizer/visualizer_widget.py b/src/view/visualizer/visualizer_widget.py
new file mode 100644
index 00000000..976120f9
--- /dev/null
+++ b/src/view/visualizer/visualizer_widget.py
@@ -0,0 +1,257 @@
+"""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.visualizer.dmx.dmx_visualizer import MOVEMENT_ROLES, DmxVisualizer, auto_detect_mapping
+from model.visualizer.stage.fixture_group import FixtureGroup
+from model.visualizer.stage.stage_config import (
+ STAGE_DIR,
+ StageConfig,
+ backup_stage_file,
+ create_object_from_key,
+ 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 = board_configuration.ui_hints.get("associated_stage_file", 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.add_object_requested.connect(self._on_add_object)
+ self._editor_widget.remove_object_requested.connect(self._on_remove_object)
+ self._editor_widget.object_changed.connect(self._on_object_changed)
+ self._editor_widget.selection_changed.connect(self._on_selection_changed)
+ self._editor_widget.group_requested.connect(self._on_group_requested)
+ self._editor_widget.remove_group_requested.connect(self._on_remove_group)
+ self._editor_widget.dmx_toggled.connect(self._on_dmx_toggled)
+
+ # 3D viewport -> mediator
+ self._gl_widget.fixture_clicked.connect(self._on_fixture_clicked)
+ self._gl_widget.deselect_all_requested.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_loaded.connect(
+ lambda: self._reload_stage(self._board_configuration.ui_hints.get("associated_stage_file", ""))
+ )
+ 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.
+ 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."""
+ 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("Switching to new stage: %s", new_path)
+
+ self._stage_config.save()
+ new_config = StageConfig(new_path, show_file_path=self._board_configuration.file_path)
+
+ self._stage_config = new_config
+ self._dmx_vis._stage_config = new_config
+
+ self._gl_widget._stage_config = new_config
+ if not self._gl_widget.gl_initialized:
+ return
+ self._gl_widget.makeCurrent()
+ 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:
+ 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()
diff --git a/submodules/resources b/submodules/resources
index b3cc0a5c..180810ae 160000
--- a/submodules/resources
+++ b/submodules/resources
@@ -1 +1 @@
-Subproject commit b3cc0a5c89abec62939e7d4c44ebb650fb833018
+Subproject commit 180810aee263ff29d176d86e293db545454d0a8b