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