#!/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()