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