#!/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' {scene_fixture_val(channels)}'
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"""
{fixture_vals_xml(fixture_ids, ch)}
"""
)
collapse_steps = "\n".join(
f' {F(sid)}'
for i, sid in enumerate(collapse_ids)
)
parts.append(
f"""
Forward
SingleShot
{collapse_steps}
"""
)
# --- 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"""
{fixture_vals_xml(fixture_ids, ch)}
"""
)
chase_steps = "\n".join(
f' {F(sid)}'
for i, sid in enumerate(chase_ids)
)
parts.append(
f"""
Forward
SingleShot
{chase_steps}
"""
)
# --- 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"""
{fixture_vals_xml(fixture_ids, home)}
Forward
SingleShot
{F(22)}
{blackout}
{fixture_vals_xml(fixture_ids, mib_dark)}
Forward
SingleShot
{F(24)}
{F(24)}
{blackout}
"""
)
return "\n".join(parts)
def vc_appearance(fg: str = "Default", bg: str = "Default", frame: str = "Sunken") -> str:
return f"""
{frame}
{fg}
{bg}
None
Default
"""
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' {ch}'
for fid in fixture_ids
for ch in channels
)
return f"""
{vc_appearance(color)}
Level
{ch_xml}
4294967295
"""
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"""
"""
for fid in fixture_ids
)
return f"""
None
Default
Default
None
Default
True
True
True
False
{vc_appearance()}
108
{F(1)}
{F(3)}
{vc_appearance()}
{fixture_axes}
EFX
Figure of 8
{F(3)}
{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' {text}'
)
blackout = {ch: 0 for ch in range(FIXTURE_CHANNELS)}
bound_vals = "\n".join(
f' {scene_fixture_val(bound)}' for fid in fixture_ids
)
blackout_vals = "\n".join(
f' {scene_fixture_val(blackout)}' for fid in fixture_ids
)
efx_fixtures = "\n".join(
f"""
{fid}
Forward
255
"""
for fid in fixture_ids
)
efx_ms = max(1, int(round(loop_s * 1000)))
return f"""
{bound_vals}
Forward
Loop
{chr(10).join(steps_xml)}
{blackout_vals}
{efx_fixtures}
Parallel
Forward
Loop
Eight
{fig.pan_amp}
{fig.tilt_amp}
0
{fig.pan_center}
1
0
{fig.tilt_center}
1
0
{F(0)}
{F(1)}
{F(3)}
{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"""
Small Eye Movers
0
0
"""
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' {black}' for fid in fixture_ids
)
if 'Name="Soft Blackout"' in text:
text = text.replace(
"""
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
""",
f"""
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
0,0,1,0,2,0,3,0,4,0,5,0,6,0,7,0
{soft_extra}
""",
)
# Insert fixtures after last BCC145 fixture block.
marker = """
BCC145 Line"""
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 = "
Arial,12,-1,5,50,0,0,0,0,0
0
0
""",
f"""
Arial,12,-1,5,50,0,0,0,0,0
0
0
""",
)
widgets = vc_widgets(
fig,
fixture_ids=fixture_ids,
fn_base=fn_base,
widget_base=widget_base,
origin=(720, 10),
)
# Insert mover VC before closing of VirtualConsole root.
frame_end = text.rfind(" \n ")
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"""
Small Eye 7
Mover
43 Channel
{fid}
{name}
0
{address}
{FIXTURE_CHANNELS}
"""
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"""
Q Light Controller Plus
4.12.7
Jimmy
{fixture_xml(0, "Small Eye 7 #1", address)}
{fixture_xml(1, "Small Eye 7 #2", address2)}
{functions}
Arial,12,-1,5,50,0,0,0,0,0
0
0
None
Default
Default
None
Default
{widgets}
"""
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()