Files
qlcplus/scripts/figure8_small_eye.py
T
jimmyandCursor 5eb623d6af Organize the lighting project and ship the combined XCHC show.
Group workspaces, generators, stage map, and tests into clear folders, and add the unified cans+movers show plus a simple shared/individual RGBW control board.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 00:59:36 +12:00

1013 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Figure-of-8 for the Small Eye 7 Mover (43ch).
Traces QLC+'s Eight path (pan = -sin(2t), tilt = cos(t)), rides focus
with throw so the spot stays a similar size, and walks hue around the loop.
The whole 8 slowly drifts in a circle so it does not sit in one place.
python3 scripts/figure8_small_eye.py
python3 scripts/figure8_small_eye.py --write-qxw shows/small_eye.qxw
python3 scripts/figure8_small_eye.py --merge-xchc shows/xchc.qxw
Stop QLC+ output before live mode so the two sources do not fight.
Quit live mode with Ctrl+C (sends blackout).
"""
from __future__ import annotations
import argparse
import colorsys
import math
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tests"))
from test_small_eye import (
CH_BLUE,
CH_DIMMER,
CH_FOCUS,
CH_GREEN,
CH_PAN,
CH_RED,
CH_ROTATE,
CH_SPEED,
CH_STROBE,
CH_TILT,
CH_WHITE,
CH_ZOOM,
FIXTURE_CHANNELS,
ArtNetSender,
SmallEye,
)
# Lens rotate: 0–127 index, 128–188 CCW spin, 189–193 stop, 194–255 CW spin.
# Mid CW — glass often only spins when zoom is fully narrow (0).
ROTATE_CW = 230
# 0-based QLC+ channel indices (same map as 43ch mode)
QLC_PAN = 0
QLC_TILT = 2
QLC_SPEED = 4
QLC_ZOOM = 5
QLC_ROTATE = 6
QLC_FOCUS = 7
QLC_DIMMER = 8
QLC_STROBE = 9
QLC_PIXEL0 = 10 # Red 1; each pixel is RGBW
PAN_DEGREES = 540.0
TILT_DEGREES = 270.0
STEPS = 64
LOOP_MS = 12000
DRIFT_LOOPS = 8 # figure-8 cycles per one slow drift orbit
BLACKOUT_FADE_MS = 4000
def clamp(value: float, lo: int = 0, hi: int = 255) -> int:
return max(lo, min(hi, int(round(value))))
def eight_xy(t: float) -> tuple[float, float]:
"""QLC+ Eight algorithm, iterator 0..2π → x,y in -1..1."""
return -math.sin(2.0 * t), math.cos(t)
def hsv_rgb(hue: float) -> tuple[int, int, int]:
r, g, b = colorsys.hsv_to_rgb(hue % 1.0, 1.0, 1.0)
return clamp(r * 255), clamp(g * 255), clamp(b * 255)
def throw_factor(pan_off: float, tilt_off: float) -> float:
"""Relative throw vs home aim, assuming a wall in front of the head."""
pan_rad = math.radians(pan_off * PAN_DEGREES / 255.0)
tilt_rad = math.radians(tilt_off * TILT_DEGREES / 255.0)
denom = math.cos(pan_rad) * math.cos(tilt_rad)
return 1.0 / max(0.35, denom)
def focus_for_throw(throw: float, throw_min: float, throw_max: float, near: int, far: int) -> int:
if throw_max <= throw_min:
return near
t = (throw - throw_min) / (throw_max - throw_min)
t = max(0.0, min(1.0, t))
# Farther throw → more focus (tighter / far), so the spot does not bloom.
return clamp(near + t * (far - near))
def zoom_for_throw(throw: float, throw_min: float, throw_max: float, close: int, far: int) -> int:
"""Narrower zoom at longer throw keeps projected size closer to constant."""
if throw_max <= throw_min:
return close
t = (throw - throw_min) / (throw_max - throw_min)
t = max(0.0, min(1.0, t))
return clamp(close + t * (far - close))
class Figure8:
def __init__(
self,
pan_center: int = 128,
tilt_center: int = 80,
pan_amp: int = 28,
tilt_amp: int = 22,
focus_near: int = 70,
focus_far: int = 200,
zoom_close: int = 0,
zoom_far: int = 0,
dimmer: int = 255,
outer_dim: int = 60,
rotate: int = ROTATE_CW,
drift_pan: int = 18,
drift_tilt: int = 12,
drift_period: float = DRIFT_LOOPS * LOOP_MS / 1000.0,
):
self.pan_center = pan_center
self.tilt_center = tilt_center
self.pan_amp = pan_amp
self.tilt_amp = tilt_amp
self.focus_near = focus_near
self.focus_far = focus_far
self.zoom_close = zoom_close
self.zoom_far = zoom_far
self.dimmer = dimmer
self.outer_dim = clamp(outer_dim)
self.rotate = clamp(rotate)
self.drift_pan = drift_pan
self.drift_tilt = drift_tilt
self.drift_period = max(0.001, drift_period)
def window_rect(self) -> tuple[int, int, int, int]:
"""Inclusive pan/tilt box centred on home, used by the XY pad."""
return (
clamp(self.pan_center - self.pan_amp),
clamp(self.pan_center + self.pan_amp),
clamp(self.tilt_center - self.tilt_amp),
clamp(self.tilt_center + self.tilt_amp),
)
def drift_offset(self, elapsed: float) -> tuple[float, float]:
if self.drift_pan == 0 and self.drift_tilt == 0:
return 0.0, 0.0
phase = 2.0 * math.pi * elapsed / self.drift_period
return self.drift_pan * math.sin(phase), self.drift_tilt * math.cos(phase)
def point(self, t: float, elapsed: float = 0.0) -> dict[str, int]:
x, y = eight_xy(t)
dx, dy = self.drift_offset(elapsed)
pan_off = dx + x * self.pan_amp
tilt_off = dy + y * self.tilt_amp
h_min, h_max, v_min, v_max = self.window_rect()
r, g, b = hsv_rgb(t / (2 * math.pi))
scale = self.outer_dim / 255.0
return {
"pan": max(h_min, min(h_max, clamp(self.pan_center + pan_off))),
"tilt": max(v_min, min(v_max, clamp(self.tilt_center + tilt_off))),
"focus": self.focus_near,
"zoom": self.zoom_close,
"rotate": self.rotate,
"red": r,
"green": g,
"blue": b,
"outer_red": clamp(r * scale),
"outer_green": clamp(g * scale),
"outer_blue": clamp(b * scale),
"dimmer": self.dimmer,
}
def apply_point(fixture: SmallEye, pt: dict[str, int]):
fixture.set(CH_PAN, pt["pan"])
fixture.set(CH_TILT, pt["tilt"])
fixture.set(CH_SPEED, 0)
fixture.set(CH_ZOOM, pt["zoom"])
fixture.set(CH_ROTATE, pt["rotate"])
fixture.set(CH_FOCUS, pt["focus"])
fixture.set(CH_DIMMER, pt["dimmer"])
fixture.set(CH_STROBE, 0)
fixture.set(CH_WHITE, 0)
# Pixel 1 = centre (full); pixels 2–7 = outer ring (dim)
fixture.set(CH_RED, pt["red"])
fixture.set(CH_GREEN, pt["green"])
fixture.set(CH_BLUE, pt["blue"])
for pixel in range(1, 7):
base = CH_RED + pixel * 4
fixture.set(base, pt["outer_red"])
fixture.set(base + 1, pt["outer_green"])
fixture.set(base + 2, pt["outer_blue"])
fixture.set(base + 3, 0)
def scene_fixture_val(channels: dict[int, int]) -> str:
parts = []
for ch in sorted(channels):
parts.append(f"{ch},{channels[ch]}")
return ",".join(parts)
def bound_scene_channels(fig: Figure8) -> dict[int, int]:
# Pan/tilt are owned by the XY-pad EFX so this look scene does not fight them.
ch = {
QLC_SPEED: 0,
QLC_ZOOM: fig.zoom_close,
QLC_ROTATE: fig.rotate,
QLC_FOCUS: fig.focus_near,
QLC_DIMMER: fig.dimmer,
QLC_STROBE: 0,
}
for pixel in range(7):
base = QLC_PIXEL0 + pixel * 4
ch[base] = 0
ch[base + 1] = 0
ch[base + 2] = 0
ch[base + 3] = 0
return ch
def sequence_step_text(
fixture_channels: list[tuple[int, dict[int, int]]],
always: tuple[int, ...] = (),
) -> str:
"""QLC+ Sequence step: fixtureID:ch,val,... — zeros omitted unless in always."""
chunks = []
for fixture_id, channels in fixture_channels:
kept = [
(ch, val)
for ch, val in sorted(channels.items())
if val or ch in always
]
if not kept:
continue
bits = [f"{fixture_id}:"]
for i, (ch, val) in enumerate(kept):
if i:
bits.append(",")
bits.append(f"{ch},{val}")
chunks.append("".join(bits))
return ":".join(chunks)
def point_channels(fig: Figure8, pt: dict[str, int]) -> dict[int, int]:
ch = bound_scene_channels(fig)
ch[QLC_ZOOM] = pt["zoom"]
ch[QLC_ROTATE] = pt["rotate"]
ch[QLC_FOCUS] = pt["focus"]
ch[QLC_DIMMER] = pt["dimmer"]
ch[QLC_PIXEL0] = pt["red"]
ch[QLC_PIXEL0 + 1] = pt["green"]
ch[QLC_PIXEL0 + 2] = pt["blue"]
ch[QLC_PIXEL0 + 3] = 0
for pixel in range(1, 7):
base = QLC_PIXEL0 + pixel * 4
ch[base] = pt["outer_red"]
ch[base + 1] = pt["outer_green"]
ch[base + 2] = pt["outer_blue"]
ch[base + 3] = 0
return ch
def exit_park_channels(
fig: Figure8,
lit: set[int],
*,
rgb: tuple[int, int, int] = (255, 210, 160),
outer: int = 90,
dimmer: int | None = None,
rotate: int = 191,
) -> dict[int, int]:
"""Home aim, rotate stopped, selected pixels on (0 = centre, 1–6 = ring)."""
ch = {
QLC_PAN: fig.pan_center,
QLC_TILT: fig.tilt_center,
QLC_SPEED: 0,
QLC_ZOOM: fig.zoom_close,
QLC_ROTATE: rotate,
QLC_FOCUS: fig.focus_near,
QLC_DIMMER: fig.dimmer if dimmer is None else dimmer,
QLC_STROBE: 0,
}
r, g, b = rgb
for pixel in range(7):
base = QLC_PIXEL0 + pixel * 4
if pixel in lit:
scale = 1.0 if pixel == 0 else outer / 255.0
ch[base] = clamp(r * scale)
ch[base + 1] = clamp(g * scale)
ch[base + 2] = clamp(b * scale)
else:
ch[base] = 0
ch[base + 1] = 0
ch[base + 2] = 0
ch[base + 3] = 0
return ch
def fixture_vals_xml(fixture_ids: tuple[int, ...], channels: dict[int, int]) -> str:
return "\n".join(
f' <FixtureVal ID="{fid}">{scene_fixture_val(channels)}</FixtureVal>'
for fid in fixture_ids
)
def exit_functions_xml(
fig: Figure8,
fixture_ids: tuple[int, ...],
fn_base: int = 0,
) -> str:
"""xchc-style exits adapted for the bee-eye ring + home park."""
def F(n: int) -> int:
return fn_base + n
blackout = F(2)
# --- Ring Collapse (Fill Unfill / Center Out, reversed as an exit) ---
collapse_sets = [
{0, 1, 2, 3, 4, 5, 6},
{0, 1, 2, 3, 4, 5},
{0, 1, 2, 3, 4},
{0, 1, 2, 3},
{0, 1},
{0},
set(),
]
parts: list[str] = []
collapse_ids = list(range(5, 5 + len(collapse_sets)))
for sid, lit in zip(collapse_ids, collapse_sets):
dim = 0 if not lit else fig.dimmer
ch = exit_park_channels(fig, lit, dimmer=dim, rotate=191)
parts.append(
f""" <Function ID="{F(sid)}" Type="Scene" Name="Exit Collapse {sid - 4}">
<Speed FadeIn="0" FadeOut="0" Duration="0"/>
{fixture_vals_xml(fixture_ids, ch)}
</Function>"""
)
collapse_steps = "\n".join(
f' <Step Number="{i}" FadeIn="400" Hold="350" FadeOut="0">{F(sid)}</Step>'
for i, sid in enumerate(collapse_ids)
)
parts.append(
f""" <Function ID="{F(12)}" Type="Chaser" Name="Ring Collapse">
<Speed FadeIn="400" FadeOut="0" Duration="750"/>
<Direction>Forward</Direction>
<RunOrder>SingleShot</RunOrder>
<SpeedModes FadeIn="Common" FadeOut="Common" Duration="Common"/>
{collapse_steps}
</Function>"""
)
# --- Ring Chase Out (Scanner / Theater, one pixel then black) ---
chase_sets = [{p} for p in range(1, 7)] + [{0}, set()]
chase_ids = list(range(13, 13 + len(chase_sets)))
for sid, lit in zip(chase_ids, chase_sets):
dim = 0 if not lit else fig.dimmer
rgb = (255, 255, 255) if lit == {0} else (255, 210, 160)
ch = exit_park_channels(fig, lit, rgb=rgb, outer=180, dimmer=dim, rotate=191)
parts.append(
f""" <Function ID="{F(sid)}" Type="Scene" Name="Exit Chase {sid - 12}">
<Speed FadeIn="0" FadeOut="0" Duration="0"/>
{fixture_vals_xml(fixture_ids, ch)}
</Function>"""
)
chase_steps = "\n".join(
f' <Step Number="{i}" FadeIn="120" Hold="200" FadeOut="0">{F(sid)}</Step>'
for i, sid in enumerate(chase_ids)
)
parts.append(
f""" <Function ID="{F(21)}" Type="Chaser" Name="Ring Chase Out">
<Speed FadeIn="120" FadeOut="0" Duration="320"/>
<Direction>Forward</Direction>
<RunOrder>SingleShot</RunOrder>
<SpeedModes FadeIn="Common" FadeOut="Common" Duration="Common"/>
{chase_steps}
</Function>"""
)
# --- Home Fade (sweep home / soft centre, then black — common live outro) ---
home = exit_park_channels(fig, {0}, rgb=(255, 200, 140), dimmer=180, rotate=191)
# --- Move in Black (kill light+spin first, then park unseen) ---
mib_dark = exit_park_channels(fig, set(), dimmer=0, rotate=191)
parts.append(
f""" <Function ID="{F(22)}" Type="Scene" Name="Home Soft">
<Speed FadeIn="2500" FadeOut="0" Duration="2500"/>
{fixture_vals_xml(fixture_ids, home)}
</Function>
<Function ID="{F(23)}" Type="Chaser" Name="Home Fade">
<Speed FadeIn="2500" FadeOut="0" Duration="3500"/>
<Direction>Forward</Direction>
<RunOrder>SingleShot</RunOrder>
<SpeedModes FadeIn="PerStep" FadeOut="Common" Duration="PerStep"/>
<Step Number="0" FadeIn="2500" Hold="1000" FadeOut="0">{F(22)}</Step>
<Step Number="1" FadeIn="3000" Hold="500" FadeOut="0">{blackout}</Step>
</Function>
<Function ID="{F(24)}" Type="Scene" Name="MIB Dark Park">
<Speed FadeIn="800" FadeOut="0" Duration="800"/>
{fixture_vals_xml(fixture_ids, mib_dark)}
</Function>
<Function ID="{F(25)}" Type="Chaser" Name="Move in Black">
<Speed FadeIn="800" FadeOut="0" Duration="2000"/>
<Direction>Forward</Direction>
<RunOrder>SingleShot</RunOrder>
<SpeedModes FadeIn="PerStep" FadeOut="Common" Duration="PerStep"/>
<Step Number="0" FadeIn="800" Hold="400" FadeOut="0">{F(24)}</Step>
<Step Number="1" FadeIn="2000" Hold="800" FadeOut="0">{F(24)}</Step>
<Step Number="2" FadeIn="500" Hold="200" FadeOut="0">{blackout}</Step>
</Function>"""
)
return "\n".join(parts)
def vc_appearance(fg: str = "Default", bg: str = "Default", frame: str = "Sunken") -> str:
return f""" <Appearance>
<FrameStyle>{frame}</FrameStyle>
<ForegroundColor>{fg}</ForegroundColor>
<BackgroundColor>{bg}</BackgroundColor>
<BackgroundImage>None</BackgroundImage>
<Font>Default</Font>
</Appearance>"""
def vc_slider(
widget_id: int,
caption: str,
x: int,
y: int,
channels: list[int],
color: str,
click_go: str = "None",
width: int = 50,
height: int = 380,
fixture_ids: tuple[int, ...] = (0, 1),
value: int = 0,
) -> str:
ch_xml = "\n".join(
f' <Channel Fixture="{fid}">{ch}</Channel>'
for fid in fixture_ids
for ch in channels
)
return f""" <Slider Caption="{caption}" ID="{widget_id}" WidgetStyle="Slider" InvertedAppearance="false">
<WindowState Visible="True" X="{x}" Y="{y}" Width="{width}" Height="{height}"/>
{vc_appearance(color)}
<SliderMode ValueDisplayStyle="Exact" ClickAndGoType="{click_go}" Monitor="true">Level</SliderMode>
<Reset/>
<Level LowLimit="0" HighLimit="255" Value="{value}">
{ch_xml}
</Level>
<Playback>
<Function>4294967295</Function>
</Playback>
</Slider>"""
def vc_widgets(
fig: Figure8,
fixture_ids: tuple[int, ...] = (0, 1),
fn_base: int = 0,
widget_base: int = 0,
origin: tuple[int, int] = (20, 20),
) -> str:
"""Buttons, speed dial, XY pad, and monitor sliders (grab to override)."""
def F(n: int) -> int:
return fn_base + n
def W(n: int) -> int:
return widget_base + n
ox, oy = origin
reds = [QLC_PIXEL0]
greens = [QLC_PIXEL0 + 1]
blues = [QLC_PIXEL0 + 2]
outer_rgb = [QLC_PIXEL0 + p * 4 + c for p in range(1, 7) for c in (0, 1, 2)]
slider_y = oy + 100
slider_x0 = ox + 410
slider_w = 44
gap = 6
specs = [
(4, "Pan", [QLC_PAN], "4294944000", "None", 0),
(5, "Tilt", [QLC_TILT], "4278255615", "None", 0),
(6, "Zoom", [QLC_ZOOM], "4294967040", "None", fig.zoom_close),
(7, "Focus", [QLC_FOCUS], "4294902015", "None", fig.focus_near),
(8, "Dim", [QLC_DIMMER], "4294967295", "None", fig.dimmer),
(9, "R", reds, "4294901760", "Red", 0),
(10, "G", greens, "4278255360", "Green", 0),
(11, "B", blues, "4278190335", "Blue", 0),
(12, "Outer", outer_rgb, "4288256409", "None", fig.outer_dim),
(13, "Rotate", [QLC_ROTATE], "4291611852", "None", fig.rotate),
]
sliders = []
for i, (wid, name, chans, color, click, value) in enumerate(specs):
sliders.append(
vc_slider(
W(wid),
name,
slider_x0 + i * (slider_w + gap),
slider_y,
chans,
color,
click,
value=value,
fixture_ids=fixture_ids,
)
)
h_min, h_max, v_min, v_max = fig.window_rect()
fixture_axes = "\n".join(
f""" <Fixture ID="{fid}" Head="0">
<Axis ID="X" LowLimit="0" HighLimit="1" Reverse="False"/>
<Axis ID="Y" LowLimit="0" HighLimit="1" Reverse="False"/>
</Fixture>"""
for fid in fixture_ids
)
return f""" <SoloFrame Caption="Movers">
<Appearance>
<FrameStyle>None</FrameStyle>
<ForegroundColor>Default</ForegroundColor>
<BackgroundColor>Default</BackgroundColor>
<BackgroundImage>None</BackgroundImage>
<Font>Default</Font>
</Appearance>
<WindowState Visible="True" X="{ox}" Y="{oy}" Width="780" Height="160"/>
<AllowChildren>True</AllowChildren>
<AllowResize>True</AllowResize>
<ShowHeader>True</ShowHeader>
<Mixing>False</Mixing>
<Button ID="{W(0)}" Icon="" Caption="Figure of 8">
<Function ID="{F(4)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="10" Y="10" Width="180" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4282664004", "None")}
</Button>
<Button ID="{W(1)}" Icon="" Caption="Ring Collapse">
<Function ID="{F(12)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="200" Y="10" Width="140" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4281545523", "None")}
</Button>
<Button ID="{W(14)}" Icon="" Caption="Ring Chase Out">
<Function ID="{F(21)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="350" Y="10" Width="140" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4284900966", "None")}
</Button>
<Button ID="{W(15)}" Icon="" Caption="Home Fade">
<Function ID="{F(23)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="500" Y="10" Width="120" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4286611584", "None")}
</Button>
<Button ID="{W(16)}" Icon="" Caption="Move in Black">
<Function ID="{F(25)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="630" Y="10" Width="130" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4279900698", "None")}
</Button>
<Button ID="{W(17)}" Icon="" Caption="Blackout">
<Function ID="{F(2)}"/>
<Action>Toggle</Action>
<Intensity Adjust="False">100</Intensity>
<WindowState X="10" Y="80" Width="140" Height="60" Visible="True"/>
{vc_appearance("4294967295", "4278190080", "None")}
</Button>
</SoloFrame>
<SpeedDial Caption="Mover Speed" ID="{W(2)}">
<WindowState Visible="True" X="{ox + 980}" Y="{slider_y}" Width="100" Height="380"/>
{vc_appearance()}
<Visibility>108</Visibility>
<AbsoluteValue Minimum="50" Maximum="20000"/>
<Time>{LOOP_MS}</Time>
<Function FadeIn="6" FadeOut="0" Duration="6">{F(1)}</Function>
<Function FadeIn="0" FadeOut="0" Duration="6">{F(3)}</Function>
</SpeedDial>
<XYPad Caption="Mover Position" ID="{W(3)}" InvertedAppearance="0">
<WindowState Visible="True" X="{ox}" Y="{slider_y}" Width="390" Height="390"/>
{vc_appearance()}
{fixture_axes}
<Window hMin="{h_min}" hMax="{h_max}" vMin="{v_min}" vMax="{v_max}"/>
<Pan Position="{fig.pan_center}"/>
<Tilt Position="{fig.tilt_center}"/>
<Preset ID="0">
<Type>EFX</Type>
<Name>Figure of 8</Name>
<FuncID>{F(3)}</FuncID>
</Preset>
</XYPad>
{chr(10).join(sliders)}"""
def mover_functions_xml(
fig: Figure8,
fixture_ids: tuple[int, ...] = (0, 1),
fn_base: int = 0,
loop_s: float = LOOP_MS / 1000.0,
) -> str:
"""Figure-of-8 look/sequence/EFX/collection + exit cues for the movers."""
def F(n: int) -> int:
return fn_base + n
bound = bound_scene_channels(fig)
n_values = len(bound) * len(fixture_ids)
step_ms = max(1, int(round(loop_s * 1000 / STEPS)))
steps_xml = []
for i in range(STEPS):
t = 2 * math.pi * i / STEPS
ch = point_channels(fig, fig.point(t, 0.0))
text = sequence_step_text(
[(fid, ch) for fid in fixture_ids],
always=(QLC_ZOOM, QLC_ROTATE),
)
steps_xml.append(
f' <Step Number="{i}" FadeIn="{step_ms}" Hold="0" FadeOut="0" Values="{n_values}">{text}</Step>'
)
blackout = {ch: 0 for ch in range(FIXTURE_CHANNELS)}
bound_vals = "\n".join(
f' <FixtureVal ID="{fid}">{scene_fixture_val(bound)}</FixtureVal>' for fid in fixture_ids
)
blackout_vals = "\n".join(
f' <FixtureVal ID="{fid}">{scene_fixture_val(blackout)}</FixtureVal>' for fid in fixture_ids
)
efx_fixtures = "\n".join(
f""" <Fixture>
<ID>{fid}</ID>
<Direction>Forward</Direction>
<Intensity>255</Intensity>
</Fixture>"""
for fid in fixture_ids
)
efx_ms = max(1, int(round(loop_s * 1000)))
return f""" <Function ID="{F(0)}" Type="Scene" Name="Figure of 8 Look">
<Speed FadeIn="0" FadeOut="0" Duration="0"/>
{bound_vals}
</Function>
<Function ID="{F(1)}" Type="Sequence" Name="Figure of 8 Colour Focus" BoundScene="{F(0)}">
<Speed FadeIn="{step_ms}" FadeOut="0" Duration="{step_ms}"/>
<Direction>Forward</Direction>
<RunOrder>Loop</RunOrder>
<SpeedModes FadeIn="Common" FadeOut="Common" Duration="Common"/>
{chr(10).join(steps_xml)}
</Function>
<Function ID="{F(2)}" Type="Scene" Name="Mover Blackout">
<Speed FadeIn="{BLACKOUT_FADE_MS}" FadeOut="0" Duration="{BLACKOUT_FADE_MS}"/>
{blackout_vals}
</Function>
<Function ID="{F(3)}" Type="EFX" Name="Figure of 8">
{efx_fixtures}
<PropagationMode>Parallel</PropagationMode>
<Speed Duration="{efx_ms}" FadeOut="0" FadeIn="0"/>
<Direction>Forward</Direction>
<RunOrder>Loop</RunOrder>
<Algorithm>Eight</Algorithm>
<Width>{fig.pan_amp}</Width>
<Height>{fig.tilt_amp}</Height>
<Rotation>0</Rotation>
<Axis Name="X">
<Offset>{fig.pan_center}</Offset>
<Frequency>1</Frequency>
<Phase>0</Phase>
</Axis>
<Axis Name="Y">
<Offset>{fig.tilt_center}</Offset>
<Frequency>1</Frequency>
<Phase>0</Phase>
</Axis>
</Function>
<Function ID="{F(4)}" Type="Collection" Name="Figure of 8">
<Step Number="0">{F(0)}</Step>
<Step Number="1">{F(1)}</Step>
<Step Number="2">{F(3)}</Step>
</Function>
{exit_functions_xml(fig, fixture_ids, fn_base=fn_base)}"""
def merge_movers_into_xchc(
xchc_path: Path,
fig: Figure8,
address: int = 0,
address2: int = 44,
fixture_ids: tuple[int, ...] = (8, 9),
fn_base: int = 73,
widget_base: int = 100,
):
"""Patch Small Eye movers + figure-8 cues into an existing xchc workspace."""
text = xchc_path.read_text()
if "Small Eye 7" in text:
raise SystemExit(f"{xchc_path} already has Small Eye fixtures — aborting")
fixtures = "\n".join(
[
fixture_xml(fixture_ids[0], "Small Eye 7 #1", address),
fixture_xml(fixture_ids[1], "Small Eye 7 #2", address2),
]
)
group = f""" <FixtureGroup ID="1">
<Name>Small Eye Movers</Name>
<Size X="2" Y="1" />
<Head X="0" Y="0" Fixture="{fixture_ids[0]}">0</Head>
<Head X="1" Y="0" Fixture="{fixture_ids[1]}">0</Head>
</FixtureGroup>"""
functions = mover_functions_xml(fig, fixture_ids=fixture_ids, fn_base=fn_base)
# Soft Blackout (72) should also kill movers for a full stage black.
black = ",".join(f"{ch},0" for ch in range(FIXTURE_CHANNELS))
soft_extra = "\n".join(
f' <FixtureVal ID="{fid}">{black}</FixtureVal>' for fid in fixture_ids
)
if 'Name="Soft Blackout"' in text:
text = text.replace(
""" <Function ID="72" Type="Scene" Name="Soft Blackout">
<Speed FadeIn="4000" FadeOut="0" Duration="4000" />
<FixtureVal ID="0">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="1">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="2">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="3">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="4">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="5">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="6">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="7">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
</Function>""",
f""" <Function ID="72" Type="Scene" Name="Soft Blackout">
<Speed FadeIn="4000" FadeOut="0" Duration="4000" />
<FixtureVal ID="0">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="1">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="2">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="3">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="4">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="5">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="6">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
<FixtureVal ID="7">0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0</FixtureVal>
{soft_extra}
</Function>""",
)
# Insert fixtures after last BCC145 fixture block.
marker = """ <FixtureGroup ID="0">
<Name>BCC145 Line</Name>"""
if marker not in text:
raise SystemExit("Could not find BCC145 fixture group in xchc")
text = text.replace(marker, fixtures + "\n" + group + "\n" + marker, 1)
# Insert mover functions before Monitor.
mon = " <Monitor DisplayMode="
idx = text.find(mon)
if idx < 0:
raise SystemExit("Could not find Monitor in xchc")
text = text[:idx] + functions + "\n" + text[idx:]
# Widen monitor grid and add mover FxItems.
text = text.replace(
""" <Monitor DisplayMode="1" ShowLabels="1">
<Font>Arial,12,-1,5,50,0,0,0,0,0</Font>
<ChannelStyle>0</ChannelStyle>
<ValueStyle>0</ValueStyle>
<Grid Width="8" Height="2" Depth="5" Units="0" />
<FxItem ID="0" XPos="0" YPos="0.4" />
<FxItem ID="1" XPos="1" YPos="0.4" />
<FxItem ID="2" XPos="2" YPos="0.4" />
<FxItem ID="3" XPos="3" YPos="0.4" />
<FxItem ID="4" XPos="4" YPos="0.4" />
<FxItem ID="5" XPos="5" YPos="0.4" />
<FxItem ID="6" XPos="6" YPos="0.4" />
<FxItem ID="7" XPos="7" YPos="0.4" />
</Monitor>""",
f""" <Monitor DisplayMode="1" ShowLabels="1">
<Font>Arial,12,-1,5,50,0,0,0,0,0</Font>
<ChannelStyle>0</ChannelStyle>
<ValueStyle>0</ValueStyle>
<Grid Width="8" Height="3" Depth="5" Units="0" />
<FxItem ID="0" XPos="0" YPos="0.4" />
<FxItem ID="1" XPos="1" YPos="0.4" />
<FxItem ID="2" XPos="2" YPos="0.4" />
<FxItem ID="3" XPos="3" YPos="0.4" />
<FxItem ID="4" XPos="4" YPos="0.4" />
<FxItem ID="5" XPos="5" YPos="0.4" />
<FxItem ID="6" XPos="6" YPos="0.4" />
<FxItem ID="7" XPos="7" YPos="0.4" />
<FxItem ID="{fixture_ids[0]}" XPos="2.5" YPos="1.6" />
<FxItem ID="{fixture_ids[1]}" XPos="4.5" YPos="1.6" />
</Monitor>""",
)
widgets = vc_widgets(
fig,
fixture_ids=fixture_ids,
fn_base=fn_base,
widget_base=widget_base,
origin=(720, 10),
)
# Insert mover VC before closing </Frame> of VirtualConsole root.
frame_end = text.rfind(" </Frame>\n <Properties>")
if frame_end < 0:
raise SystemExit("Could not find VirtualConsole frame end")
text = text[:frame_end] + widgets + "\n" + text[frame_end:]
xchc_path.write_text(text)
print(
f"Merged movers into {xchc_path} "
f"(fixtures {fixture_ids[0]},{fixture_ids[1]} @ DMX {address + 1},{address2 + 1}; "
f"functions {fn_base}–{fn_base + 25})"
)
def fixture_xml(fid: int, name: str, address: int) -> str:
return f""" <Fixture>
<Manufacturer>Small Eye 7</Manufacturer>
<Model>Mover</Model>
<Mode>43 Channel</Mode>
<ID>{fid}</ID>
<Name>{name}</Name>
<Universe>0</Universe>
<Address>{address}</Address>
<Channels>{FIXTURE_CHANNELS}</Channels>
</Fixture>"""
def write_qxw(
path: Path,
fig: Figure8,
address: int = 0,
address2: int = 44,
loop_s: float = LOOP_MS / 1000.0,
):
fixture_ids = (0, 1)
functions = mover_functions_xml(fig, fixture_ids=fixture_ids, loop_s=loop_s)
widgets = vc_widgets(fig, fixture_ids=fixture_ids)
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE Workspace>
<Workspace xmlns="http://www.qlcplus.org/Workspace" CurrentWindow="VirtualConsole">
<Creator>
<Name>Q Light Controller Plus</Name>
<Version>4.12.7</Version>
<Author>Jimmy</Author>
</Creator>
<Engine>
<InputOutputMap>
<Universe Name="Universe 1" ID="0">
<Output Plugin="ArtNet" UID="192.168.1.92" Line="7">
<PluginParameters outputIP="192.168.1.228" outputUni="0"/>
</Output>
</Universe>
<Universe Name="Universe 2" ID="1"/>
<Universe Name="Universe 3" ID="2"/>
<Universe Name="Universe 4" ID="3"/>
</InputOutputMap>
{fixture_xml(0, "Small Eye 7 #1", address)}
{fixture_xml(1, "Small Eye 7 #2", address2)}
{functions}
<Monitor DisplayMode="1" ShowLabels="1">
<Font>Arial,12,-1,5,50,0,0,0,0,0</Font>
<ChannelStyle>0</ChannelStyle>
<ValueStyle>0</ValueStyle>
<Grid Width="5" Height="3" Depth="5" Units="0"/>
<FxItem ID="0" XPos="1.5" YPos="0.5"/>
<FxItem ID="1" XPos="3.5" YPos="0.5"/>
</Monitor>
</Engine>
<VirtualConsole>
<Frame Caption="">
<Appearance>
<FrameStyle>None</FrameStyle>
<ForegroundColor>Default</ForegroundColor>
<BackgroundColor>Default</BackgroundColor>
<BackgroundImage>None</BackgroundImage>
<Font>Default</Font>
</Appearance>
{widgets}
</Frame>
<Properties>
<Size Width="1920" Height="1080"/>
<GrandMaster ChannelMode="Intensity" ValueMode="Reduce" SliderMode="Normal"/>
</Properties>
</VirtualConsole>
<SimpleDesk>
<Engine/>
</SimpleDesk>
</Workspace>
"""
path.write_text(xml)
h_min, h_max, v_min, v_max = fig.window_rect()
print(
f"Wrote {path} (EFX eight centred {fig.pan_center},{fig.tilt_center} "
f"in pad window pan {h_min}–{h_max} tilt {v_min}–{v_max})"
)
def run_live(args, fig: Figure8):
print(
f"Art-Net → {args.ip} universe {args.universe} "
f"#1 start {args.address} #2 start {args.address2} (43ch)"
)
print(
f"Figure-8 {args.loop:.0f}s, drift orbit {fig.drift_period:.0f}s "
f"(pan ±{fig.drift_pan}, tilt ±{fig.drift_tilt})."
)
print("Stop QLC+ Art-Net output first. Ctrl+C to blackout and quit.")
sender = ArtNetSender(args.ip, universe=args.universe)
fixtures = [SmallEye(sender, args.address), SmallEye(sender, args.address2)]
period = args.loop / STEPS
try:
for fixture in fixtures:
fixture.park_light(
pan=fig.pan_center,
tilt=fig.tilt_center,
dimmer=fig.dimmer,
white=0,
rgb=0,
)
t0 = time.monotonic()
while True:
elapsed = time.monotonic() - t0
t = (elapsed / args.loop) * 2 * math.pi
pt = fig.point(t, elapsed)
for fixture in fixtures:
apply_point(fixture, pt)
time.sleep(min(period, 0.025))
except KeyboardInterrupt:
print("\nstopped")
finally:
sender.close()
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--ip", default="192.168.1.228", help="Art-Net destination")
p.add_argument("--universe", type=int, default=0)
p.add_argument("--address", type=int, default=1, help="Fixture 1 DMX start, 1-based")
p.add_argument("--address2", type=int, default=45, help="Fixture 2 DMX start, 1-based")
p.add_argument("--loop", type=float, default=LOOP_MS / 1000.0, help="Seconds per figure-8")
p.add_argument("--pan-center", type=int, default=128)
p.add_argument("--tilt-center", type=int, default=80)
p.add_argument("--pan-amp", type=int, default=28, help="Half-width of the 8 (DMX)")
p.add_argument("--tilt-amp", type=int, default=22, help="Half-height of the 8 (DMX)")
p.add_argument("--focus-near", type=int, default=70, help="Focus at closest throw")
p.add_argument("--focus-far", type=int, default=200, help="Focus at farthest throw")
p.add_argument("--zoom-close", type=int, default=0, help="Zoom at closest throw (0 = tight)")
p.add_argument("--zoom-far", type=int, default=0, help="Zoom at farthest throw (0 = tight)")
p.add_argument("--outer-dim", type=int, default=60, help="Outer ring brightness 0–255")
p.add_argument(
"--rotate",
type=int,
default=ROTATE_CW,
help="Lens rotate (128–188 CCW, 194–255 CW, 0 = off)",
)
p.add_argument("--drift-pan", type=int, default=18, help="How far the 8 wanders in pan")
p.add_argument("--drift-tilt", type=int, default=12, help="How far the 8 wanders in tilt")
p.add_argument(
"--drift-period",
type=float,
default=DRIFT_LOOPS * LOOP_MS / 1000.0,
help="Seconds for one full drift orbit (default 8 figure-8s)",
)
p.add_argument("--write-qxw", metavar="PATH", help="Write a QLC+ workspace instead of live output")
p.add_argument(
"--merge-xchc",
metavar="PATH",
help="Add Small Eye movers + figure-8 cues into an existing xchc.qxw",
)
return p.parse_args()
def main():
args = parse_args()
fig = Figure8(
pan_center=args.pan_center,
tilt_center=args.tilt_center,
pan_amp=args.pan_amp,
tilt_amp=args.tilt_amp,
focus_near=args.focus_near,
focus_far=args.focus_far,
zoom_close=args.zoom_close,
zoom_far=args.zoom_far,
outer_dim=args.outer_dim,
rotate=args.rotate,
drift_pan=args.drift_pan,
drift_tilt=args.drift_tilt,
drift_period=args.drift_period,
)
if args.merge_xchc:
merge_movers_into_xchc(
Path(args.merge_xchc),
fig,
address=max(0, args.address - 1),
address2=max(0, args.address2 - 1),
)
return
if args.write_qxw:
write_qxw(
Path(args.write_qxw),
fig,
address=max(0, args.address - 1),
address2=max(0, args.address2 - 1),
loop_s=args.loop,
)
return
if not 1 <= args.address <= 512 or not 1 <= args.address2 <= 512:
sys.exit("address must be 1–512")
run_live(args, fig)
if __name__ == "__main__":
main()