Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
d6086dd
Add windows compatibility
joellkp Jun 25, 2025
c392246
Add windows compatibility
joellkp Jun 25, 2025
5d96d89
Add windows compatibility
joellkp Jun 25, 2025
2d03986
Add visualizer and yaml editor base
joellkp Jul 5, 2025
39ade81
add: resources submodule
Doralitze Jul 8, 2025
ce50922
chg: model path
Doralitze Jul 19, 2025
9f2044e
mrg: branch 'main' into visualizer
Doralitze Aug 2, 2025
ffbe6dc
Rebase
joellkp Jul 5, 2025
dd4eb92
add: resources submodule
Doralitze Jul 8, 2025
c10dd89
chg: model path
Doralitze Jul 19, 2025
0cf8fce
Add visualizer and yaml editor base
joellkp Oct 16, 2025
b8ceb3d
Add visualizer and yaml editor base
joellkp Oct 16, 2025
18ac80d
a
joellkp Oct 16, 2025
89e4ff7
test class
joellkp Oct 23, 2025
966fe69
Add visualizer and yaml editor base
joellkp Jul 5, 2025
a18c3b3
chg: model path
Doralitze Jul 19, 2025
fbcf593
a
joellkp Oct 16, 2025
4f24d7f
test class
joellkp Oct 23, 2025
bfd4e8a
Merge remote-tracking branch 'origin/visualizer2' into visualizer2
joellkp Oct 24, 2025
4162cf6
Merge branch 'refs/heads/main' into visualizer2
joellkp Jan 20, 2026
eb70c82
init
joellkp Apr 19, 2026
ad03e57
chg: dmx update in 45hz
joellkp May 3, 2026
a2678b6
upd: resources submodule version
Doralitze May 8, 2026
a70cdb9
Merge branch 'main' into visualizer1
Doralitze May 11, 2026
265ebf5
mrg: branch 'main' into visualizer1
Doralitze Jul 22, 2026
43c22bc
add: type annotations and doc to visualizer_widget.py
Doralitze Jul 22, 2026
ead6d5e
fix: towards type annotations in stage_gl_widget.py
Doralitze Jul 23, 2026
3a3da67
fix: type annotations in stage_gl_widget.py
Doralitze Jul 23, 2026
9351909
chg: logging to getLogger in stage_editor_widget.py
Doralitze Jul 23, 2026
5e51321
fix: types and doc in stage_editor_widget.py
Doralitze Jul 24, 2026
1aa75f9
fix: typing in stage.py
Doralitze Jul 24, 2026
80e4051
chg: automatic D fixes from ruff
Doralitze Jul 24, 2026
b8f8b9d
Merge branch 'main' into visualizer1
Doralitze Jul 24, 2026
f1a7b4f
rev: "chg: automatic D fixes from ruff"
Doralitze Jul 24, 2026
04fe2f8
chg: string concat in chaser_model.py to be explicit
Doralitze Jul 24, 2026
d602aee
fix: missing doc in StageConfig
Doralitze Jul 24, 2026
6a70380
chg: refactored stage.py to be a package instead of 2k line file
Doralitze Jul 24, 2026
b9dc2fa
fix: doc in model_entries.py
Doralitze Jul 24, 2026
83031fb
chg: stage_editor_widget.py to not include dialogs anymore.
Doralitze Jul 24, 2026
d013d2b
chg: separated Gltf model handling from stage_gl_widget.py
Doralitze Jul 24, 2026
0d16b72
chg: upload methods to be classmethod of Model3D
Doralitze Jul 24, 2026
7122c50
chg: shader to be separate of stage_gl_widget.py
Doralitze Jul 24, 2026
7a85648
fix: missing model unloading
Doralitze Jul 24, 2026
ab35579
fix: typo in doc
Doralitze Jul 24, 2026
2960ad0
fix: issue #410
Doralitze Jul 27, 2026
accee24
add: Contributors for artwork
Doralitze Jul 27, 2026
c397b99
chg: name of default stage file
Doralitze Jul 27, 2026
e31523b
chg: moving head settings gen to be separate function
Doralitze Jul 27, 2026
2639d0e
add: support for beams without pan/tilt functionality
Doralitze Jul 27, 2026
eeaf74b
fix: loading new stage file does not override old one
Doralitze Jul 29, 2026
5ac3783
add: automatic loading of stages with show file
Doralitze Jul 30, 2026
1312a9b
fix: invalid mesh upload on stage object loading prior to opening vis…
Doralitze Jul 30, 2026
0b014fd
add: mixin doc
Doralitze Aug 7, 2026
d9948e9
fix: white channel acting as dimmer
Doralitze Aug 7, 2026
23d723e
add: lense light data collection
Doralitze Aug 7, 2026
c328ca6
add: towards rendering of lense lights: initial shader code
Doralitze Aug 10, 2026
c628b6d
fix: doc in gl_init.py
Doralitze Aug 10, 2026
5ca7730
chg: use 3D Model class instead of naked VBO
Doralitze Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy-as-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

- name: make
run: |
pdm install --dev -G build
pdm install --dev -G build
pdm run make package

- name: publish
Expand All @@ -29,4 +29,4 @@ jobs:
with:
generate_release_notes: true
files: |
bin/mission-dmx-editor-v*.deb
bin/mission-dmx-editor-v*.deb
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*"
Expand All @@ -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",
Expand All @@ -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",
Expand Down
25 changes: 12 additions & 13 deletions pysidedeploy.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 =
2 changes: 1 addition & 1 deletion src/controller/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/gl_init.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Contains OpenGL context initialization."""

from logging import getLogger

Expand All @@ -7,11 +8,12 @@


def opengl_context_init() -> None:
"""Initialize OpenGL context to 4.1 core profile."""
fmt = QSurfaceFormat()
fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8)
fmt.setVersion(4, 1) # Request OpenGL 4.1 compatible context
fmt.setProfile(QSurfaceFormat.CoreProfile)
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)

QSurfaceFormat.setDefaultFormat(fmt)
logger.debug("Initialized OpenGL context to 4.1")
3 changes: 3 additions & 0 deletions src/model/broadcaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions src/model/filter_data/chaser_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ def construct_chaser_layer(identifier: str, parameter_data: list[str]) -> Chaser
("Start Color", ParameterType.COLOR, ""),
("End Color", ParameterType.COLOR, ""),
("Number of Segments", ParameterType.NUMBER_ABSOLUTE,
"Divides the pixel map into the specified number of segments and applies the effect on each "
"layer individually."),
("Divides the pixel map into the specified number of segments and applies the effect on each "
"layer individually.")),
],
parameter_data,
)
Expand Down
1 change: 1 addition & 0 deletions src/model/visualizer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contains visualizer related model classes."""
1 change: 1 addition & 0 deletions src/model/visualizer/dmx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contains required adapters for DMX -> Visualizer."""
236 changes: 236 additions & 0 deletions src/model/visualizer/dmx/dmx_visualizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""Polls live DMX data from Fish and writes it into stage fixtures.

Receives DMX frames via the Broadcaster, maps the raw 8-bit channel
values onto MovingHead properties (pan, tilt, dimmer, beam color) and
emits ``fixtures_updated`` so the 3D widget can repaint.

Channel offsets are auto-detected from the Open Fixture Library naming
convention of the connected fixture profile.
"""

from __future__ import annotations

from logging import getLogger
from typing import TYPE_CHECKING, Any

from PySide6 import QtCore

from model.broadcaster import Broadcaster
from model.visualizer.stage.so_moving_head import MovingHead

if TYPE_CHECKING:
from PySide6.QtWidgets import QWidget

import proto.DirectMode_pb2
from model import BoardConfiguration
from model.visualizer.stage import StageObject
from model.visualizer.stage.stage_config import StageConfig

logger = getLogger(__name__)

# OFL role names we try to detect on each channel.
MOVEMENT_ROLES = [
"pan_coarse", "pan_fine",
"tilt_coarse", "tilt_fine",
"dimmer", "pan_tilt_speed",
]
COLOR_ROLES = ["red", "green", "blue", "white"]
ALL_ROLES = MOVEMENT_ROLES + COLOR_ROLES

# Physical rotation range of typical moving heads.
DEFAULT_PAN_MAX_DEG = 540.0
DEFAULT_TILT_MAX_DEG = 270.0


def _primary(raw_name: str) -> str:
# OFL joins multi-function channels with "___" - keep only the first part.
return raw_name.split("___", maxsplit=1)[0].strip().lower().replace(" ", "_")


def auto_detect_mapping(channel_names: list[str],
roles: list[str]) -> dict[str, int]:
"""Return a {role: channel_offset} dict, -1 where no match was found."""
mapping = dict.fromkeys(roles, -1)

for i, raw_name in enumerate(channel_names):
p = _primary(raw_name)

# Pan
if (p == "pan_fine" or ("pan" in p and "fine" in p)) and "pan_fine" in roles:
mapping["pan_fine"] = i
elif ("pan" in p and "speed" not in p and "tilt" not in p) and "pan_coarse" in roles:
mapping["pan_coarse"] = i

# Tilt
if p == "tilt_fine" or ("tilt" in p and "fine" in p):
if "tilt_fine" in roles:
mapping["tilt_fine"] = i
elif ("tilt" in p and "speed" not in p and "pan" not in p) and "tilt_coarse" in roles:
mapping["tilt_coarse"] = i

# Dimmer / speed
if p in ("dimmer", "intensity") and "dimmer" in roles:
mapping["dimmer"] = i
if "speed" in p and ("pan" in p or "tilt" in p) and "pan_tilt_speed" in roles:
mapping["pan_tilt_speed"] = i

# Colors
if "red" in p and "red" in roles:
mapping["red"] = i
if "green" in p and "green" in roles:
mapping["green"] = i
if "blue" in p and "blue" in roles:
mapping["blue"] = i
if p == "white" and "white" in roles:
mapping["white"] = i

return mapping


class DmxVisualizer(QtCore.QObject):
"""Drives the stage fixtures from incoming DMX frames."""

fixtures_updated = QtCore.Signal()

def __init__(self, stage_config: StageConfig,
board_configuration: BoardConfiguration | None = None, parent: QWidget | None = None) -> None:
"""Initialize DMX to stage visualizer adapter."""
super().__init__(parent)
self._stage_config = stage_config
self._board_config = board_configuration
self._broadcaster = Broadcaster()
self._enabled = True

try:
self._broadcaster.dmx_from_fish.connect(self._on_dmx)
except Exception as e:
logger.warning("Could not connect broadcaster signals: %s", e)

# Request fresh DMX data at ~45 Hz.
self._poll_timer = QtCore.QTimer(self)
self._poll_timer.setInterval(22)
self._poll_timer.timeout.connect(self._request_dmx)
self._poll_timer.start()

@property
def enabled(self) -> bool:
"""Enable or disable the live updating with DMX values from fish."""
return self._enabled

@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
if value:
self._poll_timer.start()
else:
self._poll_timer.stop()

def _request_dmx(self) -> None:
if not self._enabled or self._board_config is None:
return
try:
for universe in self._board_config.universes:
self._broadcaster.send_request_dmx_data.emit(universe)
except Exception as e:
logger.exception("Could not send DMX request: %s", e)

@QtCore.Slot()
def _on_dmx(self, msg: proto.DirectMode_pb2.dmx_output) -> None:
if not self._enabled:
return

universe_id = msg.universe_id

# Normalize to exactly 512 channels; Fish sometimes sends a leading zero.
raw = list(msg.channel_data)
if len(raw) == 513:
raw = raw[1:]
raw = (raw + [0] * 512)[:512]

any_updated = False
for obj in self._stage_config.objects:
if not isinstance(obj, MovingHead):
continue
dc = obj.device_config
if not dc:
continue

mv = dc.get("movement")
if mv and mv.get("universe", -1) == universe_id:
self._apply_movement(obj, raw, mv)
any_updated = True

col = dc.get("color")
if col and col.get("universe", -1) == universe_id:
self._apply_color(obj, raw, col)
any_updated = True

if any_updated:
self.fixtures_updated.emit()

def _apply_movement(self, obj: StageObject, raw: list[int], cfg: dict[str, Any]) -> None:
"""Map pan/tilt/dimmer channels to the fixture's 2-DOF properties."""
start = cfg.get("start_channel", 0)
m = cfg.get("mapping", {})

def rd(role: str) -> int:
off = m.get(role, -1)
if off < 0 or not (0 <= start + off < 512):
return None
return int(raw[start + off])

# 16-bit pan, centered at zero.
pc, pf = rd("pan_coarse"), rd("pan_fine")
if pc is not None:
v = (pc << 8) | (pf or 0)
obj.pan = (v / 65535.0) * DEFAULT_PAN_MAX_DEG - DEFAULT_PAN_MAX_DEG / 2.0

# 16-bit tilt, centered at zero.
tc, tf = rd("tilt_coarse"), rd("tilt_fine")
if tc is not None:
v = (tc << 8) | (tf or 0)
obj.tilt = (v / 65535.0) * DEFAULT_TILT_MAX_DEG - DEFAULT_TILT_MAX_DEG / 2.0

dim = rd("dimmer")
if dim is not None:
obj.dimmer = dim / 255.0
obj.beam_on = dim > 0

def _apply_color(self, obj: StageObject, raw: list[int], cfg: dict[str, Any]) -> None:
"""Map R/G/B/W channels to beam_color."""
start = cfg.get("start_channel", 0)
m = cfg.get("mapping", {})

def rd(role: str) -> int:
off = m.get(role, -1)
if off < 0 or not (0 <= start + off < 512):
return None
return int(raw[start + off])

r, g, b = rd("red"), rd("green"), rd("blue")
if r is None or g is None or b is None:
return

# White LED adds on top of RGB (matches RGBW fixtures).
w = rd("white")
if w is not None and w > 0:
r = min(255, r + w)
g = min(255, g + w)
b = min(255, b + w)

obj.beam_color = (r, g, b)
any_color = (r > 0 or g > 0 or b > 0)
obj.beam_on = any_color

# Use the white channel as dimmer if no dedicated movement dimmer exists.
#if w is not None:
# obj.dimmer = w / 255.0 if w > 0 else (1.0 if any_color else 0.0)
if not self._has_movement_dimmer(obj) and any_color:
obj.dimmer = 1.0
# TODO update lense colors

def _has_movement_dimmer(self, obj: StageObject) -> bool:
dc = obj.device_config
if not dc:
return False
return dc.get("movement", {}).get("mapping", {}).get("dimmer", -1) >= 0
1 change: 1 addition & 0 deletions src/model/visualizer/stage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contains StageObject implementations."""
Loading
Loading