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>
1013 lines
34 KiB
Python
1013 lines
34 KiB
Python
#!/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()
|