| … | … | @@ -0,0 +1,2 @@ |
| 1 | __pycache__/ | |
| 2 | *.pyc |
| … | … | @@ -0,0 +1,107 @@ |
| 1 | # Cthuloid Lights — Blender addon | |
| 2 | ||
| 3 | Drives ESP32 PWM light fixtures (`esp324xlight` firmware) from Blender lights | |
| 4 | and animation. Successor to the `quadpanel.py` text block embedded in | |
| 5 | `LightArm/blender/single_light1.blend`. | |
| 6 | ||
| 7 | ## Install | |
| 8 | ||
| 9 | The repo directory *is* the live extension directory: | |
| 10 | ||
| 11 | ```bash | |
| 12 | ln -sfn /home/fleeky/cthuloid/blender-addon/cthuloid_lights \ | |
| 13 | ~/.config/blender/5.2/extensions/user_default/cthuloid_lights | |
| 14 | ``` | |
| 15 | ||
| 16 | Then enable **Cthuloid Lights** in Preferences → Add-ons. Panel appears in the | |
| 17 | 3D viewport N-panel under the **Cthuloid** tab. | |
| 18 | ||
| 19 | ## The hierarchy | |
| 20 | ||
| 21 | `Add Fixture` builds and manages this automatically: | |
| 22 | ||
| 23 | ``` | |
| 24 | quad.<host> EMPTY | |
| 25 | ├─ bgr-1-<host> LIGHT slot 1 → channels 1-3 | |
| 26 | ├─ bgr-2-<host> LIGHT slot 2 → channels 4-6 | |
| 27 | ├─ bgr-3-<host> LIGHT slot 3 → channels 7-9 | |
| 28 | └─ bgr-4-<host> LIGHT slot 4 → channels 10-12 | |
| 29 | ``` | |
| 30 | ||
| 31 | `Adopt Existing Lights` imports a legacy rig — it finds `bgr-<slot>-<host>` | |
| 32 | named lights, creates a fixture per host, and tags the objects. | |
| 33 | ||
| 34 | **Names are no longer the configuration.** quadpanel.py parsed the object name | |
| 35 | on every frame, so renaming an object silently repointed it, and any host | |
| 36 | containing a hyphen (`show-light01`) was parsed into the wrong host. Wiring now | |
| 37 | lives in custom properties; names are kept in sync only for readability. | |
| 38 | ||
| 39 | ## Protocol | |
| 40 | ||
| 41 | Channel order is **B, G, R** — not RGB. The firmware caps every value at index | |
| 42 | `count % 3 == 2` to `RED_MAX_PWM`, so the third slot of each triple *is* red, | |
| 43 | and `gamma` echoes its arguments back as `B= G= R=`. | |
| 44 | ||
| 45 | Empty slots pad `0 0 0`. With `INVERT_HIGH_AND_LOW` in the firmware, input 0 is | |
| 46 | LED-off, so padding is dark. | |
| 47 | ||
| 48 | ## Red Max PWM | |
| 49 | ||
| 50 | Per-fixture setting (the fleet is mixed, so a single global value would be | |
| 51 | wrong). Defaults to **30000**, the value the original Blender rig shipped with. | |
| 52 | ||
| 53 | Red diodes have a lower voltage tolerance than green/blue. The firmware | |
| 54 | hard-rejects anything above **45000** — and rejects the *entire* command, so one | |
| 55 | out-of-range value drops all 12 channels. That is the property's hard maximum, | |
| 56 | and the addon clamps before sending. | |
| 57 | ||
| 58 | ## Animating multiple fixtures together | |
| 59 | ||
| 60 | Light *data* animation (colour, power) is no longer excluded from object-level | |
| 61 | animation operations. Since Blender 4.4's slotted actions, one action holds both | |
| 62 | in typed slots (`OBFix1` for the object, `LAFix1` for the lamp data), and | |
| 63 | Ctrl+L → Link Animation Data carries light data across. | |
| 64 | ||
| 65 | - **All fixtures in unison** — assign the same action *and the same slot* to each | |
| 66 | light data-block. Verified: three lights sharing one action evaluate | |
| 67 | identically at every frame. | |
| 68 | - **Chase / delay** — put that shared action in an NLA strip per fixture and | |
| 69 | offset the strip start. | |
| 70 | - **Per-fixture variation** — drivers on top of the shared action. | |
| 71 | ||
| 72 | ## Sending | |
| 73 | ||
| 74 | `LIVE` arms output. Frames are sent on frame change and on viewport edit | |
| 75 | (both toggleable under Output). | |
| 76 | ||
| 77 | - **Coalescing** — one pending frame per fixture; if the UI outruns the wire, | |
| 78 | intermediate frames are dropped rather than queued, so output never plays back | |
| 79 | late. | |
| 80 | - **Rate limited** — `Max Rate`, default 25 Hz. The firmware is single-threaded; | |
| 81 | a full 30 fps stream overwhelms it (the original script carried the same note). | |
| 82 | - **Never blocks the UI** — one worker thread owns every socket. quadpanel.py | |
| 83 | called `sendall()` inline in the frame handler, so an absent fixture stalled | |
| 84 | Blender for the socket timeout on every frame. | |
| 85 | - **Reconnects with backoff**, 1 s doubling to 15 s. | |
| 86 | ||
| 87 | `Blackout` sends `stopfade` + all-zero to every fixture regardless of arm state. | |
| 88 | ||
| 89 | ## Differences from quadpanel.py | |
| 90 | ||
| 91 | Six things that stopped it running on modern Blender: | |
| 92 | ||
| 93 | | quadpanel.py | | | |
| 94 | |---|---| | |
| 95 | | `child.type == 'LAMP'` | renamed `'LIGHT'` in 2.80 — `getLamp()` always returned `None` | | |
| 96 | | `baseID` (line 132) | never defined; `NameError` on any `arm.` object | | |
| 97 | | `filepath = bpy.props.…` | must be an annotation since 2.80 | | |
| 98 | | `frame_change_post.clear()` | removed every *other* addon's handlers too | | |
| 99 | | `if s in quad_last_command_dict[ip]` | substring test where `==` was meant | | |
| 100 | | `speed 200gZ78` on connect | not a command this firmware knows | | |
| 101 | ||
| 102 | ## Not carried over | |
| 103 | ||
| 104 | The `arm.` robot-arm path (dynamixel angles from pose bones `base`/`forearm`) | |
| 105 | and the `light.` single-light path are **not** implemented — this addon covers | |
| 106 | the 4-slot `bgr-` quad fixtures only. `saveToFile`/`loadFromFile` are likewise | |
| 107 | not ported. The original script remains in the .blend if those are needed. |
| … | … | @@ -0,0 +1,23 @@ |
| 1 | """Cthuloid Lights -- drive ESP32 PWM light fixtures from Blender. | |
| 2 | ||
| 3 | Successor to the quadpanel.py text block embedded in | |
| 4 | LightArm/blender/single_light1.blend. | |
| 5 | """ | |
| 6 | ||
| 7 | from . import engine, net, ops, props, ui | |
| 8 | ||
| 9 | ||
| 10 | def register(): | |
| 11 | props.register() | |
| 12 | ops.register() | |
| 13 | ui.register() | |
| 14 | net.SENDER.start() | |
| 15 | engine.register_handlers() | |
| 16 | ||
| 17 | ||
| 18 | def unregister(): | |
| 19 | engine.unregister_handlers() | |
| 20 | net.SENDER.stop() | |
| 21 | ui.unregister() | |
| 22 | ops.unregister() | |
| 23 | props.unregister() |
| … | … | @@ -0,0 +1,13 @@ |
| 1 | schema_version = "1.0.0" | |
| 2 | ||
| 3 | id = "cthuloid_lights" | |
| 4 | version = "0.1.0" | |
| 5 | name = "Cthuloid Lights" | |
| 6 | tagline = "Drive ESP32 PWM light fixtures from Blender lights and animation" | |
| 7 | maintainer = "circusautomatic" | |
| 8 | type = "add-on" | |
| 9 | ||
| 10 | tags = ["Lighting", "Animation"] | |
| 11 | blender_version_min = "4.2.0" | |
| 12 | ||
| 13 | license = ["SPDX:GPL-3.0-or-later"] |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | """Scene -> wire. Owns the Blender handlers. | |
| 2 | ||
| 3 | Handlers are added and removed by identity. quadpanel.py called | |
| 4 | frame_change_post.clear(), which silently removed every other addon's handlers | |
| 5 | too -- a rig with any other frame-driven addon in the scene would quietly stop | |
| 6 | working after running it. | |
| 7 | """ | |
| 8 | ||
| 9 | import bpy | |
| 10 | ||
| 11 | from . import protocol, rig | |
| 12 | from .net import SENDER | |
| 13 | ||
| 14 | ||
| 15 | def build_command(scene, fixture): | |
| 16 | slot_map = rig.fixture_lights(scene, fixture) | |
| 17 | values = protocol.frame_values( | |
| 18 | slot_map, | |
| 19 | fixture.red_max_pwm, | |
| 20 | fixture.use_energy, | |
| 21 | fixture.energy_ref, | |
| 22 | ) | |
| 23 | if fixture.use_fade: | |
| 24 | return protocol.pwm2_command(values, fixture.fade_ms) | |
| 25 | return protocol.pwm_command(values) | |
| 26 | ||
| 27 | ||
| 28 | def push_scene(scene, force=False): | |
| 29 | props = getattr(scene, "cthuloid", None) | |
| 30 | if props is None: | |
| 31 | return 0 | |
| 32 | SENDER.min_interval = 1.0 / max(1, props.max_hz) | |
| 33 | sent = 0 | |
| 34 | for fixture in props.fixtures: | |
| 35 | if not fixture.enabled or not fixture.host: | |
| 36 | continue | |
| 37 | SENDER.send(fixture.host, fixture.port, build_command(scene, fixture), force=force) | |
| 38 | sent += 1 | |
| 39 | return sent | |
| 40 | ||
| 41 | ||
| 42 | def push_settings(scene, fixture): | |
| 43 | """Push the firmware-side settings (gamma, freq) for one fixture.""" | |
| 44 | payload = protocol.gamma_command(fixture.gamma_b, fixture.gamma_g, fixture.gamma_r) | |
| 45 | payload += protocol.freq_command(fixture.freq) | |
| 46 | SENDER.send(fixture.host, fixture.port, payload, force=True) | |
| 47 | ||
| 48 | ||
| 49 | def blackout(scene): | |
| 50 | props = getattr(scene, "cthuloid", None) | |
| 51 | if props is None: | |
| 52 | return 0 | |
| 53 | count = 0 | |
| 54 | for fixture in props.fixtures: | |
| 55 | if not fixture.host: | |
| 56 | continue | |
| 57 | SENDER.send(fixture.host, fixture.port, protocol.blackout_command(), force=True) | |
| 58 | count += 1 | |
| 59 | return count | |
| 60 | ||
| 61 | ||
| 62 | # --------------------------------------------------------------------------- | |
| 63 | # handlers | |
| 64 | ||
| 65 | ||
| 66 | @bpy.app.handlers.persistent | |
| 67 | def _on_frame_change(scene, depsgraph=None): | |
| 68 | props = getattr(scene, "cthuloid", None) | |
| 69 | if props is None or not props.live or not props.send_on_frame: | |
| 70 | return | |
| 71 | push_scene(scene) | |
| 72 | ||
| 73 | ||
| 74 | @bpy.app.handlers.persistent | |
| 75 | def _on_depsgraph_update(scene, depsgraph=None): | |
| 76 | props = getattr(scene, "cthuloid", None) | |
| 77 | if props is None or not props.live or not props.send_on_edit: | |
| 78 | return | |
| 79 | if depsgraph is not None: | |
| 80 | # Only react to light or transform changes; ignore unrelated churn. | |
| 81 | interesting = False | |
| 82 | for update in depsgraph.updates: | |
| 83 | idblock = update.id | |
| 84 | if isinstance(idblock, (bpy.types.Light, bpy.types.Object)): | |
| 85 | interesting = True | |
| 86 | break | |
| 87 | if not interesting: | |
| 88 | return | |
| 89 | push_scene(scene) | |
| 90 | ||
| 91 | ||
| 92 | _HANDLERS = ( | |
| 93 | (bpy.app.handlers.frame_change_post, _on_frame_change), | |
| 94 | (bpy.app.handlers.depsgraph_update_post, _on_depsgraph_update), | |
| 95 | ) | |
| 96 | ||
| 97 | ||
| 98 | def register_handlers(): | |
| 99 | for handler_list, fn in _HANDLERS: | |
| 100 | if fn not in handler_list: | |
| 101 | handler_list.append(fn) | |
| 102 | ||
| 103 | ||
| 104 | def unregister_handlers(): | |
| 105 | for handler_list, fn in _HANDLERS: | |
| 106 | # Remove only our own callback -- never clear() the list. | |
| 107 | while fn in handler_list: | |
| 108 | handler_list.remove(fn) |
| … | … | @@ -0,0 +1,201 @@ |
| 1 | """Non-blocking sender for ESP32 light fixtures. | |
| 2 | ||
| 3 | One worker thread owns every socket. Blender's main thread only ever drops the | |
| 4 | latest desired state into a per-host mailbox and returns immediately -- it never | |
| 5 | calls connect() or sendall(). The original quadpanel.py did its sendall() inline | |
| 6 | in the frame_change handler, so a fixture that had gone away stalled the UI for | |
| 7 | the socket timeout on every single frame. | |
| 8 | ||
| 9 | Design notes: | |
| 10 | * Coalescing, not queueing. A mailbox holds ONE pending frame per host. If the | |
| 11 | UI produces frames faster than the wire drains, intermediate frames are | |
| 12 | dropped rather than backing up -- for lighting, the newest state is the only | |
| 13 | one that matters, and a backlog would play back visibly late. | |
| 14 | * Rate limited. The original's comment ("sending commands every 30 times a | |
| 15 | second overwhelms the ESP32") is the reason min_interval exists. | |
| 16 | * Deduplicated. Identical consecutive frames are not resent. | |
| 17 | * Reconnect with backoff, so an unplugged fixture costs one attempt per | |
| 18 | backoff window instead of one per frame. | |
| 19 | """ | |
| 20 | ||
| 21 | import socket | |
| 22 | import threading | |
| 23 | import time | |
| 24 | ||
| 25 | CONNECT_TIMEOUT = 1.0 | |
| 26 | SEND_TIMEOUT = 1.0 | |
| 27 | BACKOFF_START = 1.0 | |
| 28 | BACKOFF_MAX = 15.0 | |
| 29 | ||
| 30 | ||
| 31 | class _Target: | |
| 32 | __slots__ = ("host", "port", "sock", "pending", "last_sent", "last_send_time", | |
| 33 | "next_retry", "backoff", "status", "error") | |
| 34 | ||
| 35 | def __init__(self, host, port): | |
| 36 | self.host = host | |
| 37 | self.port = port | |
| 38 | self.sock = None | |
| 39 | self.pending = None | |
| 40 | self.last_sent = None | |
| 41 | self.last_send_time = 0.0 | |
| 42 | self.next_retry = 0.0 | |
| 43 | self.backoff = BACKOFF_START | |
| 44 | self.status = "idle" | |
| 45 | self.error = "" | |
| 46 | ||
| 47 | ||
| 48 | class Sender: | |
| 49 | def __init__(self): | |
| 50 | self._targets = {} | |
| 51 | self._lock = threading.Lock() | |
| 52 | self._wake = threading.Event() | |
| 53 | self._thread = None | |
| 54 | self._running = False | |
| 55 | self.min_interval = 1.0 / 25.0 | |
| 56 | ||
| 57 | # ---- main-thread API ------------------------------------------------- | |
| 58 | ||
| 59 | def start(self): | |
| 60 | if self._running: | |
| 61 | return | |
| 62 | self._running = True | |
| 63 | self._thread = threading.Thread(target=self._run, name="cthuloid-net", daemon=True) | |
| 64 | self._thread.start() | |
| 65 | ||
| 66 | def stop(self): | |
| 67 | self._running = False | |
| 68 | self._wake.set() | |
| 69 | t = self._thread | |
| 70 | if t is not None and t.is_alive(): | |
| 71 | t.join(timeout=2.0) | |
| 72 | self._thread = None | |
| 73 | with self._lock: | |
| 74 | for tgt in self._targets.values(): | |
| 75 | self._close(tgt) | |
| 76 | self._targets.clear() | |
| 77 | ||
| 78 | def send(self, host, port, payload, force=False): | |
| 79 | """Queue the latest payload for a host. Never blocks.""" | |
| 80 | if not host or not payload: | |
| 81 | return | |
| 82 | key = (host, port) | |
| 83 | with self._lock: | |
| 84 | tgt = self._targets.get(key) | |
| 85 | if tgt is None: | |
| 86 | tgt = _Target(host, port) | |
| 87 | self._targets[key] = tgt | |
| 88 | tgt.pending = (payload, force) | |
| 89 | self._wake.set() | |
| 90 | ||
| 91 | def forget(self, host, port): | |
| 92 | with self._lock: | |
| 93 | tgt = self._targets.pop((host, port), None) | |
| 94 | if tgt is not None: | |
| 95 | self._close(tgt) | |
| 96 | ||
| 97 | def status_of(self, host, port): | |
| 98 | with self._lock: | |
| 99 | tgt = self._targets.get((host, port)) | |
| 100 | if tgt is None: | |
| 101 | return ("idle", "") | |
| 102 | return (tgt.status, tgt.error) | |
| 103 | ||
| 104 | # ---- worker thread --------------------------------------------------- | |
| 105 | ||
| 106 | def _run(self): | |
| 107 | while self._running: | |
| 108 | self._wake.wait(timeout=0.25) | |
| 109 | self._wake.clear() | |
| 110 | now = time.monotonic() | |
| 111 | with self._lock: | |
| 112 | targets = list(self._targets.values()) | |
| 113 | for tgt in targets: | |
| 114 | try: | |
| 115 | self._service(tgt, now) | |
| 116 | except Exception as exc: # never let the worker die | |
| 117 | tgt.status = "error" | |
| 118 | tgt.error = repr(exc) | |
| 119 | ||
| 120 | def _service(self, tgt, now): | |
| 121 | with self._lock: | |
| 122 | item = tgt.pending | |
| 123 | tgt.pending = None | |
| 124 | if item is None: | |
| 125 | return | |
| 126 | payload, force = item | |
| 127 | ||
| 128 | if not force: | |
| 129 | if payload == tgt.last_sent: | |
| 130 | return | |
| 131 | if now - tgt.last_send_time < self.min_interval: | |
| 132 | # Put it back; we'll pick it up on a later tick. | |
| 133 | with self._lock: | |
| 134 | if tgt.pending is None: | |
| 135 | tgt.pending = item | |
| 136 | return | |
| 137 | ||
| 138 | if tgt.sock is None: | |
| 139 | if now < tgt.next_retry: | |
| 140 | return | |
| 141 | if not self._connect(tgt): | |
| 142 | return | |
| 143 | ||
| 144 | try: | |
| 145 | tgt.sock.sendall(payload.encode("utf-8")) | |
| 146 | tgt.last_sent = payload | |
| 147 | tgt.last_send_time = now | |
| 148 | tgt.status = "connected" | |
| 149 | tgt.error = "" | |
| 150 | except (socket.timeout, OSError) as exc: | |
| 151 | tgt.error = str(exc) | |
| 152 | tgt.status = "lost" | |
| 153 | self._close(tgt) | |
| 154 | tgt.next_retry = now + tgt.backoff | |
| 155 | tgt.backoff = min(tgt.backoff * 2.0, BACKOFF_MAX) | |
| 156 | ||
| 157 | def _connect(self, tgt): | |
| 158 | tgt.status = "connecting" | |
| 159 | try: | |
| 160 | sock = socket.create_connection((tgt.host, tgt.port), timeout=CONNECT_TIMEOUT) | |
| 161 | sock.settimeout(SEND_TIMEOUT) | |
| 162 | # TCP_NODELAY: these are tiny latency-sensitive frames; Nagle would | |
| 163 | # coalesce them into visible stutter. | |
| 164 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) | |
| 165 | tgt.sock = sock | |
| 166 | tgt.status = "connected" | |
| 167 | tgt.error = "" | |
| 168 | tgt.backoff = BACKOFF_START | |
| 169 | # The firmware greets with "<hostname> ready"; drain it so it does | |
| 170 | # not sit in the buffer. Non-fatal if nothing arrives. | |
| 171 | try: | |
| 172 | sock.settimeout(0.2) | |
| 173 | sock.recv(256) | |
| 174 | except (socket.timeout, OSError): | |
| 175 | pass | |
| 176 | finally: | |
| 177 | try: | |
| 178 | sock.settimeout(SEND_TIMEOUT) | |
| 179 | except OSError: | |
| 180 | pass | |
| 181 | return True | |
| 182 | except (socket.timeout, OSError) as exc: | |
| 183 | tgt.status = "unreachable" | |
| 184 | tgt.error = str(exc) | |
| 185 | tgt.sock = None | |
| 186 | tgt.next_retry = time.monotonic() + tgt.backoff | |
| 187 | tgt.backoff = min(tgt.backoff * 2.0, BACKOFF_MAX) | |
| 188 | return False | |
| 189 | ||
| 190 | @staticmethod | |
| 191 | def _close(tgt): | |
| 192 | if tgt.sock is not None: | |
| 193 | try: | |
| 194 | tgt.sock.close() | |
| 195 | except OSError: | |
| 196 | pass | |
| 197 | tgt.sock = None | |
| 198 | tgt.last_sent = None | |
| 199 | ||
| 200 | ||
| 201 | SENDER = Sender() |
| … | … | @@ -0,0 +1,214 @@ |
| 1 | """Operators.""" | |
| 2 | ||
| 3 | import bpy | |
| 4 | from bpy.props import BoolProperty, StringProperty | |
| 5 | from bpy.types import Operator | |
| 6 | ||
| 7 | from . import engine, protocol, rig | |
| 8 | from .net import SENDER | |
| 9 | ||
| 10 | ||
| 11 | def _active_fixture(context): | |
| 12 | props = context.scene.cthuloid | |
| 13 | if 0 <= props.active_index < len(props.fixtures): | |
| 14 | return props.fixtures[props.active_index] | |
| 15 | return None | |
| 16 | ||
| 17 | ||
| 18 | class CTHULOID_OT_add_fixture(Operator): | |
| 19 | bl_idname = "cthuloid.add_fixture" | |
| 20 | bl_label = "Add Fixture" | |
| 21 | bl_description = "Create a fixture and build its four-light hierarchy" | |
| 22 | bl_options = {'REGISTER', 'UNDO'} | |
| 23 | ||
| 24 | host: StringProperty(name="Host", default="showlight01.local") | |
| 25 | ||
| 26 | def invoke(self, context, event): | |
| 27 | return context.window_manager.invoke_props_dialog(self) | |
| 28 | ||
| 29 | def execute(self, context): | |
| 30 | scene = context.scene | |
| 31 | host = self.host.strip() | |
| 32 | if not host: | |
| 33 | self.report({'ERROR'}, "Host must not be empty") | |
| 34 | return {'CANCELLED'} | |
| 35 | ||
| 36 | fixture = scene.cthuloid.fixtures.add() | |
| 37 | fixture.id = rig.new_fixture_id() | |
| 38 | fixture.host = host | |
| 39 | scene.cthuloid.active_index = len(scene.cthuloid.fixtures) - 1 | |
| 40 | ||
| 41 | root, created = rig.create_fixture_rig(context, fixture) | |
| 42 | self.report({'INFO'}, "Fixture %s: created %d object(s)" % (host, len(created))) | |
| 43 | return {'FINISHED'} | |
| 44 | ||
| 45 | ||
| 46 | class CTHULOID_OT_remove_fixture(Operator): | |
| 47 | bl_idname = "cthuloid.remove_fixture" | |
| 48 | bl_label = "Remove Fixture" | |
| 49 | bl_description = "Remove the active fixture. Its objects are kept unless Delete Objects is enabled" | |
| 50 | bl_options = {'REGISTER', 'UNDO'} | |
| 51 | ||
| 52 | delete_objects: BoolProperty(name="Delete Objects", default=False) | |
| 53 | ||
| 54 | def invoke(self, context, event): | |
| 55 | return context.window_manager.invoke_props_dialog(self) | |
| 56 | ||
| 57 | def execute(self, context): | |
| 58 | scene = context.scene | |
| 59 | props = scene.cthuloid | |
| 60 | fixture = _active_fixture(context) | |
| 61 | if fixture is None: | |
| 62 | self.report({'ERROR'}, "No active fixture") | |
| 63 | return {'CANCELLED'} | |
| 64 | ||
| 65 | SENDER.forget(fixture.host, fixture.port) | |
| 66 | if self.delete_objects: | |
| 67 | rig.delete_fixture_rig(scene, fixture) | |
| 68 | else: | |
| 69 | for obj in rig.fixture_lights(scene, fixture).values(): | |
| 70 | obj.cthuloid_light.is_fixture_light = False | |
| 71 | obj.cthuloid_light.fixture_id = "" | |
| 72 | ||
| 73 | props.fixtures.remove(props.active_index) | |
| 74 | props.active_index = max(0, props.active_index - 1) | |
| 75 | return {'FINISHED'} | |
| 76 | ||
| 77 | ||
| 78 | class CTHULOID_OT_rebuild_rig(Operator): | |
| 79 | bl_idname = "cthuloid.rebuild_rig" | |
| 80 | bl_label = "Rebuild Hierarchy" | |
| 81 | bl_description = "Recreate any missing lights or parent for the active fixture" | |
| 82 | bl_options = {'REGISTER', 'UNDO'} | |
| 83 | ||
| 84 | def execute(self, context): | |
| 85 | fixture = _active_fixture(context) | |
| 86 | if fixture is None: | |
| 87 | self.report({'ERROR'}, "No active fixture") | |
| 88 | return {'CANCELLED'} | |
| 89 | root, created = rig.create_fixture_rig(context, fixture) | |
| 90 | if created: | |
| 91 | self.report({'INFO'}, "Created: %s" % ", ".join(created)) | |
| 92 | else: | |
| 93 | self.report({'INFO'}, "Hierarchy already complete") | |
| 94 | return {'FINISHED'} | |
| 95 | ||
| 96 | ||
| 97 | class CTHULOID_OT_sync_names(Operator): | |
| 98 | bl_idname = "cthuloid.sync_names" | |
| 99 | bl_label = "Sync Names" | |
| 100 | bl_description = "Rename objects to match the fixture host (cosmetic; wiring is held in properties)" | |
| 101 | bl_options = {'REGISTER', 'UNDO'} | |
| 102 | ||
| 103 | def execute(self, context): | |
| 104 | fixture = _active_fixture(context) | |
| 105 | if fixture is None: | |
| 106 | self.report({'ERROR'}, "No active fixture") | |
| 107 | return {'CANCELLED'} | |
| 108 | n = rig.sync_names(context.scene, fixture) | |
| 109 | self.report({'INFO'}, "Renamed %d object(s)" % n) | |
| 110 | return {'FINISHED'} | |
| 111 | ||
| 112 | ||
| 113 | class CTHULOID_OT_adopt_scene(Operator): | |
| 114 | bl_idname = "cthuloid.adopt_scene" | |
| 115 | bl_label = "Adopt Existing Lights" | |
| 116 | bl_description = "Import legacy bgr-<slot>-<host> named lights into fixtures" | |
| 117 | bl_options = {'REGISTER', 'UNDO'} | |
| 118 | ||
| 119 | def execute(self, context): | |
| 120 | hosts, lights = rig.adopt_scene(context) | |
| 121 | if not lights: | |
| 122 | self.report({'WARNING'}, "No legacy 'bgr-<slot>-<host>' lights found") | |
| 123 | return {'CANCELLED'} | |
| 124 | self.report({'INFO'}, "Adopted %d light(s) into %d new fixture(s)" % (lights, len(hosts))) | |
| 125 | return {'FINISHED'} | |
| 126 | ||
| 127 | ||
| 128 | class CTHULOID_OT_assign_light(Operator): | |
| 129 | bl_idname = "cthuloid.assign_light" | |
| 130 | bl_label = "Assign Selected" | |
| 131 | bl_description = "Assign selected lights to the active fixture, filling free slots" | |
| 132 | bl_options = {'REGISTER', 'UNDO'} | |
| 133 | ||
| 134 | def execute(self, context): | |
| 135 | fixture = _active_fixture(context) | |
| 136 | if fixture is None: | |
| 137 | self.report({'ERROR'}, "No active fixture") | |
| 138 | return {'CANCELLED'} | |
| 139 | taken = set(rig.fixture_lights(context.scene, fixture).keys()) | |
| 140 | free = [s for s in range(1, protocol.SLOTS_PER_FIXTURE + 1) if s not in taken] | |
| 141 | assigned = 0 | |
| 142 | for obj in context.selected_objects: | |
| 143 | if obj.type != 'LIGHT' or not free: | |
| 144 | continue | |
| 145 | props = obj.cthuloid_light | |
| 146 | props.is_fixture_light = True | |
| 147 | props.fixture_id = fixture.id | |
| 148 | props.slot = free.pop(0) | |
| 149 | assigned += 1 | |
| 150 | if not assigned: | |
| 151 | self.report({'WARNING'}, "Nothing assigned (no free slots or no lights selected)") | |
| 152 | return {'CANCELLED'} | |
| 153 | self.report({'INFO'}, "Assigned %d light(s)" % assigned) | |
| 154 | return {'FINISHED'} | |
| 155 | ||
| 156 | ||
| 157 | class CTHULOID_OT_send_now(Operator): | |
| 158 | bl_idname = "cthuloid.send_now" | |
| 159 | bl_label = "Send Now" | |
| 160 | bl_description = "Push the current scene state to all enabled fixtures" | |
| 161 | ||
| 162 | def execute(self, context): | |
| 163 | n = engine.push_scene(context.scene, force=True) | |
| 164 | self.report({'INFO'}, "Sent to %d fixture(s)" % n) | |
| 165 | return {'FINISHED'} | |
| 166 | ||
| 167 | ||
| 168 | class CTHULOID_OT_push_settings(Operator): | |
| 169 | bl_idname = "cthuloid.push_settings" | |
| 170 | bl_label = "Push Gamma & Frequency" | |
| 171 | bl_description = "Send gamma and PWM frequency to the active fixture" | |
| 172 | ||
| 173 | def execute(self, context): | |
| 174 | fixture = _active_fixture(context) | |
| 175 | if fixture is None: | |
| 176 | self.report({'ERROR'}, "No active fixture") | |
| 177 | return {'CANCELLED'} | |
| 178 | engine.push_settings(context.scene, fixture) | |
| 179 | self.report({'INFO'}, "Pushed settings to %s" % fixture.host) | |
| 180 | return {'FINISHED'} | |
| 181 | ||
| 182 | ||
| 183 | class CTHULOID_OT_blackout(Operator): | |
| 184 | bl_idname = "cthuloid.blackout" | |
| 185 | bl_label = "Blackout" | |
| 186 | bl_description = "Stop any fade and drive every channel of every fixture to zero" | |
| 187 | ||
| 188 | def execute(self, context): | |
| 189 | n = engine.blackout(context.scene) | |
| 190 | self.report({'INFO'}, "Blacked out %d fixture(s)" % n) | |
| 191 | return {'FINISHED'} | |
| 192 | ||
| 193 | ||
| 194 | CLASSES = ( | |
| 195 | CTHULOID_OT_add_fixture, | |
| 196 | CTHULOID_OT_remove_fixture, | |
| 197 | CTHULOID_OT_rebuild_rig, | |
| 198 | CTHULOID_OT_sync_names, | |
| 199 | CTHULOID_OT_adopt_scene, | |
| 200 | CTHULOID_OT_assign_light, | |
| 201 | CTHULOID_OT_send_now, | |
| 202 | CTHULOID_OT_push_settings, | |
| 203 | CTHULOID_OT_blackout, | |
| 204 | ) | |
| 205 | ||
| 206 | ||
| 207 | def register(): | |
| 208 | for cls in CLASSES: | |
| 209 | bpy.utils.register_class(cls) | |
| 210 | ||
| 211 | ||
| 212 | def unregister(): | |
| 213 | for cls in reversed(CLASSES): | |
| 214 | bpy.utils.unregister_class(cls) |
| … | … | @@ -0,0 +1,141 @@ |
| 1 | """Property definitions. | |
| 2 | ||
| 3 | The object NAME is no longer the configuration. quadpanel.py parsed | |
| 4 | "bgr-<slot>-<ip>" out of object names, which meant renaming an object silently | |
| 5 | repointed it at a different fixture -- and broke outright for any host whose | |
| 6 | name contains a hyphen (e.g. "show-light01"). Here the custom properties are | |
| 7 | the source of truth and names are merely kept in sync for readability and for | |
| 8 | compatibility with older .blend files. | |
| 9 | """ | |
| 10 | ||
| 11 | import bpy | |
| 12 | from bpy.props import (BoolProperty, CollectionProperty, EnumProperty, | |
| 13 | FloatProperty, IntProperty, PointerProperty, StringProperty) | |
| 14 | from bpy.types import Object, PropertyGroup, Scene | |
| 15 | ||
| 16 | from . import protocol | |
| 17 | ||
| 18 | ||
| 19 | class CthuloidLightProps(PropertyGroup): | |
| 20 | """Attached to every light object the addon drives.""" | |
| 21 | ||
| 22 | is_fixture_light: BoolProperty( | |
| 23 | name="Cthuloid Light", default=False, | |
| 24 | description="This light drives a channel triple on a hardware fixture") | |
| 25 | ||
| 26 | fixture_id: StringProperty( | |
| 27 | name="Fixture", default="", | |
| 28 | description="Which fixture this light belongs to") | |
| 29 | ||
| 30 | slot: IntProperty( | |
| 31 | name="Slot", default=1, min=1, max=protocol.SLOTS_PER_FIXTURE, | |
| 32 | description="Which of the fixture's four channel triples this light drives") | |
| 33 | ||
| 34 | ||
| 35 | class CthuloidFixture(PropertyGroup): | |
| 36 | """One ESP32 running esp324xlight: 4 slots x 3 channels.""" | |
| 37 | ||
| 38 | id: StringProperty(name="ID", default="") | |
| 39 | ||
| 40 | host: StringProperty( | |
| 41 | name="Host", default="showlight01.local", | |
| 42 | description="Hostname (mDNS .local works) or IP address of the fixture") | |
| 43 | ||
| 44 | port: IntProperty( | |
| 45 | name="Port", default=1337, min=1, max=65535, | |
| 46 | description="TCP port of the firmware's command server") | |
| 47 | ||
| 48 | enabled: BoolProperty( | |
| 49 | name="Enabled", default=True, | |
| 50 | description="Send to this fixture while Live is armed") | |
| 51 | ||
| 52 | # --- output shaping --------------------------------------------------- | |
| 53 | ||
| 54 | red_max_pwm: IntProperty( | |
| 55 | name="Red Max PWM", | |
| 56 | default=30000, min=0, soft_max=protocol.FIRMWARE_RED_MAX_PWM, | |
| 57 | max=protocol.FIRMWARE_RED_MAX_PWM, | |
| 58 | description=( | |
| 59 | "Ceiling for the red channel of every slot. Red diodes have a lower " | |
| 60 | "voltage tolerance than green/blue, so they are capped to keep them " | |
| 61 | "from being driven to full. 30000 is the value the original Blender " | |
| 62 | "rig shipped with; the firmware itself hard-rejects anything above " | |
| 63 | "45000 and will discard the entire command, so that is the maximum")) | |
| 64 | ||
| 65 | use_energy: BoolProperty( | |
| 66 | name="Scale by Energy", default=False, | |
| 67 | description=( | |
| 68 | "Multiply each light's colour by its Power, normalised against " | |
| 69 | "Energy Reference. Off matches the original script, which read " | |
| 70 | "colour only and ignored Power")) | |
| 71 | ||
| 72 | energy_ref: FloatProperty( | |
| 73 | name="Energy Reference", default=1000.0, min=0.001, | |
| 74 | description="Light Power that counts as full brightness when scaling by energy") | |
| 75 | ||
| 76 | # --- motion ----------------------------------------------------------- | |
| 77 | ||
| 78 | use_fade: BoolProperty( | |
| 79 | name="Smooth", default=False, | |
| 80 | description=( | |
| 81 | "Send pwm2 (firmware-side interpolation in perceptual space) instead " | |
| 82 | "of pwm. Smooths coarse frame steps at the cost of trailing the " | |
| 83 | "timeline slightly")) | |
| 84 | ||
| 85 | fade_ms: IntProperty( | |
| 86 | name="Fade", default=60, min=1, max=1000, subtype='TIME', | |
| 87 | description="Duration of the firmware-side interpolation, in milliseconds") | |
| 88 | ||
| 89 | # --- firmware-side settings pushed on demand -------------------------- | |
| 90 | ||
| 91 | gamma_b: FloatProperty(name="Gamma B", default=1.0, min=0.5, max=5.0) | |
| 92 | gamma_g: FloatProperty(name="Gamma G", default=1.0, min=0.5, max=5.0) | |
| 93 | gamma_r: FloatProperty(name="Gamma R", default=1.0, min=0.5, max=5.0) | |
| 94 | ||
| 95 | freq: IntProperty( | |
| 96 | name="PWM Frequency", default=1000, min=1, max=40000, | |
| 97 | description=( | |
| 98 | "Firmware PWM frequency in Hz. 1 kHz matches the bandwidth of an " | |
| 99 | "ESP32-driven MOSFET with no gate driver; expect dimming above " | |
| 100 | "roughly 1.2 kHz on that hardware")) | |
| 101 | ||
| 102 | ||
| 103 | class CthuloidSceneProps(PropertyGroup): | |
| 104 | fixtures: CollectionProperty(type=CthuloidFixture) | |
| 105 | active_index: IntProperty(name="Active Fixture", default=0) | |
| 106 | ||
| 107 | live: BoolProperty( | |
| 108 | name="Live", default=False, | |
| 109 | description="Send lighting state to hardware as the scene changes") | |
| 110 | ||
| 111 | send_on_frame: BoolProperty( | |
| 112 | name="On Frame Change", default=True, | |
| 113 | description="Send when the timeline frame changes (playback and scrubbing)") | |
| 114 | ||
| 115 | send_on_edit: BoolProperty( | |
| 116 | name="On Viewport Edit", default=True, | |
| 117 | description="Send when a light's colour or power is changed interactively") | |
| 118 | ||
| 119 | max_hz: IntProperty( | |
| 120 | name="Max Rate", default=25, min=1, max=60, | |
| 121 | description=( | |
| 122 | "Upper bound on frames per second sent to any one fixture. The " | |
| 123 | "firmware is single-threaded and a full-rate 30 fps stream will " | |
| 124 | "overwhelm it")) | |
| 125 | ||
| 126 | ||
| 127 | CLASSES = (CthuloidLightProps, CthuloidFixture, CthuloidSceneProps) | |
| 128 | ||
| 129 | ||
| 130 | def register(): | |
| 131 | for cls in CLASSES: | |
| 132 | bpy.utils.register_class(cls) | |
| 133 | Scene.cthuloid = PointerProperty(type=CthuloidSceneProps) | |
| 134 | Object.cthuloid_light = PointerProperty(type=CthuloidLightProps) | |
| 135 | ||
| 136 | ||
| 137 | def unregister(): | |
| 138 | del Object.cthuloid_light | |
| 139 | del Scene.cthuloid | |
| 140 | for cls in reversed(CLASSES): | |
| 141 | bpy.utils.unregister_class(cls) |
| … | … | @@ -0,0 +1,96 @@ |
| 1 | """Wire protocol for the esp324xlight firmware. | |
| 2 | ||
| 3 | The firmware exposes a line-oriented command surface on TCP 1337. The only | |
| 4 | commands this addon emits are `pwm`, `pwm2`, `stopfade`, `gamma` and `freq`. | |
| 5 | ||
| 6 | Channel order is B, G, R -- not RGB. This is not a quirk of the Blender side: | |
| 7 | the firmware caps every value at index `count % 3 == 2` to RED_MAX_PWM, so the | |
| 8 | third slot of each triple *is* the red channel, and `gamma` prints its three | |
| 9 | arguments back as "B=.. G=.. R=..". The original quadpanel.py emitted | |
| 10 | color[2], color[1], color[0] for the same reason. | |
| 11 | ||
| 12 | A fixture is 12 channels = 4 slots x 3 channels. Slots with no light assigned | |
| 13 | are padded with `0 0 0`; with INVERT_HIGH_AND_LOW in the firmware, input 0 is | |
| 14 | LED-off, so padding is dark rather than full-on. | |
| 15 | """ | |
| 16 | ||
| 17 | CHANNELS_PER_SLOT = 3 | |
| 18 | SLOTS_PER_FIXTURE = 4 | |
| 19 | NUM_CHANNELS = CHANNELS_PER_SLOT * SLOTS_PER_FIXTURE | |
| 20 | ||
| 21 | # Firmware absolute ceilings (esp324xlight.ino: MAX_PWM / RED_MAX_PWM). | |
| 22 | # parsePWM() rejects the WHOLE command if any single value exceeds these, so we | |
| 23 | # always clamp before sending -- one bad value would drop all 12 channels. | |
| 24 | FIRMWARE_MAX_PWM = 65535 | |
| 25 | FIRMWARE_RED_MAX_PWM = 45000 | |
| 26 | ||
| 27 | ||
| 28 | def clamp(v, lo, hi): | |
| 29 | return lo if v < lo else (hi if v > hi else v) | |
| 30 | ||
| 31 | ||
| 32 | def pwm_from_unit(value, pwm_max): | |
| 33 | """Map a 0..1 Blender channel to a 0..pwm_max integer PWM input.""" | |
| 34 | return int(clamp(round(abs(value) * pwm_max), 0, pwm_max)) | |
| 35 | ||
| 36 | ||
| 37 | def slot_channels(light_obj, red_max, use_energy, energy_ref): | |
| 38 | """Return [B, G, R] PWM ints for one light object, or None if unusable.""" | |
| 39 | data = getattr(light_obj, "data", None) | |
| 40 | if data is None or not hasattr(data, "color"): | |
| 41 | return None | |
| 42 | ||
| 43 | r, g, b = data.color[0], data.color[1], data.color[2] | |
| 44 | ||
| 45 | if use_energy: | |
| 46 | # Scale colour by normalised energy so animators can key intensity the | |
| 47 | # way they normally would. energy_ref is what counts as "full". | |
| 48 | ref = energy_ref if energy_ref > 0.0 else 1.0 | |
| 49 | scale = clamp(getattr(data, "energy", ref) / ref, 0.0, 1.0) | |
| 50 | r, g, b = r * scale, g * scale, b * scale | |
| 51 | ||
| 52 | return [ | |
| 53 | pwm_from_unit(b, FIRMWARE_MAX_PWM), | |
| 54 | pwm_from_unit(g, FIRMWARE_MAX_PWM), | |
| 55 | pwm_from_unit(r, min(red_max, FIRMWARE_RED_MAX_PWM)), | |
| 56 | ] | |
| 57 | ||
| 58 | ||
| 59 | def frame_values(slot_map, red_max, use_energy, energy_ref): | |
| 60 | """Build the 12 channel values for a fixture from {slot: light_object}.""" | |
| 61 | values = [] | |
| 62 | for slot in range(1, SLOTS_PER_FIXTURE + 1): | |
| 63 | obj = slot_map.get(slot) | |
| 64 | chans = slot_channels(obj, red_max, use_energy, energy_ref) if obj else None | |
| 65 | values.extend(chans if chans else [0, 0, 0]) | |
| 66 | return values | |
| 67 | ||
| 68 | ||
| 69 | def pwm_command(values): | |
| 70 | return "pwm " + " ".join(str(v) for v in values) + "\n" | |
| 71 | ||
| 72 | ||
| 73 | def pwm2_command(values, fade_ms): | |
| 74 | """Interpolated move. Firmware clamps both numPeriods and periodMs to 1..1000. | |
| 75 | ||
| 76 | The firmware fades in perceptual space and keeps PinCurrentValues live, so a | |
| 77 | new pwm2 mid-fade picks up from where the LEDs actually are. | |
| 78 | """ | |
| 79 | fade_ms = int(clamp(fade_ms, 1, 1000 * 1000)) | |
| 80 | periods = int(clamp(fade_ms // 10 or 1, 1, 1000)) | |
| 81 | period_ms = int(clamp(fade_ms // periods or 1, 1, 1000)) | |
| 82 | return ("pwm2 %d %d " % (periods, period_ms)) + " ".join(str(v) for v in values) + "\n" | |
| 83 | ||
| 84 | ||
| 85 | def gamma_command(b, g, r): | |
| 86 | # Firmware silently ignores any value outside 0.5..5.0. | |
| 87 | return "gamma %.2f %.2f %.2f\n" % ( | |
| 88 | clamp(b, 0.5, 5.0), clamp(g, 0.5, 5.0), clamp(r, 0.5, 5.0)) | |
| 89 | ||
| 90 | ||
| 91 | def freq_command(hz): | |
| 92 | return "freq %d\n" % int(clamp(hz, 1, 40000)) | |
| 93 | ||
| 94 | ||
| 95 | def blackout_command(): | |
| 96 | return "stopfade\n" + pwm_command([0] * NUM_CHANNELS) |
| … | … | @@ -0,0 +1,229 @@ |
| 1 | """Creating, adopting and maintaining the fixture hierarchy. | |
| 2 | ||
| 3 | Layout produced (matching what quadpanel.py expected, minus the vestigial | |
| 4 | armature -- an Empty is the honest container for a light panel; the armature in | |
| 5 | single_light1.blend is a leftover from the robot-arm rigs, which are the only | |
| 6 | things that ever needed pose bones): | |
| 7 | ||
| 8 | quad.<host> EMPTY, plain axes | |
| 9 | +- bgr-1-<host> LIGHT | |
| 10 | +- bgr-2-<host> LIGHT | |
| 11 | +- bgr-3-<host> LIGHT | |
| 12 | +- bgr-4-<host> LIGHT | |
| 13 | """ | |
| 14 | ||
| 15 | import uuid | |
| 16 | ||
| 17 | import bpy | |
| 18 | from mathutils import Vector | |
| 19 | ||
| 20 | from . import protocol | |
| 21 | ||
| 22 | ROOT_PREFIX = "quad." | |
| 23 | LIGHT_PREFIX = "bgr-" | |
| 24 | SPACING = 0.6 | |
| 25 | ||
| 26 | ||
| 27 | def new_fixture_id(): | |
| 28 | return uuid.uuid4().hex[:8] | |
| 29 | ||
| 30 | ||
| 31 | def light_name(host, slot): | |
| 32 | return "%s%d-%s" % (LIGHT_PREFIX, slot, host) | |
| 33 | ||
| 34 | ||
| 35 | def root_name(host): | |
| 36 | return "%s%s" % (ROOT_PREFIX, host) | |
| 37 | ||
| 38 | ||
| 39 | def find_fixture(scene, fixture_id): | |
| 40 | for f in scene.cthuloid.fixtures: | |
| 41 | if f.id == fixture_id: | |
| 42 | return f | |
| 43 | return None | |
| 44 | ||
| 45 | ||
| 46 | def fixture_lights(scene, fixture): | |
| 47 | """{slot: object} for every light tagged as belonging to this fixture.""" | |
| 48 | out = {} | |
| 49 | for obj in scene.objects: | |
| 50 | props = getattr(obj, "cthuloid_light", None) | |
| 51 | if props is None or not props.is_fixture_light: | |
| 52 | continue | |
| 53 | if props.fixture_id != fixture.id: | |
| 54 | continue | |
| 55 | if obj.type != 'LIGHT': | |
| 56 | continue | |
| 57 | # Last one wins on a duplicate slot; report_conflicts() surfaces these. | |
| 58 | out[props.slot] = obj | |
| 59 | return out | |
| 60 | ||
| 61 | ||
| 62 | def slot_conflicts(scene, fixture): | |
| 63 | seen, dupes = {}, [] | |
| 64 | for obj in scene.objects: | |
| 65 | props = getattr(obj, "cthuloid_light", None) | |
| 66 | if props is None or not props.is_fixture_light or props.fixture_id != fixture.id: | |
| 67 | continue | |
| 68 | if props.slot in seen: | |
| 69 | dupes.append((props.slot, seen[props.slot], obj.name)) | |
| 70 | else: | |
| 71 | seen[props.slot] = obj.name | |
| 72 | return dupes | |
| 73 | ||
| 74 | ||
| 75 | def find_root(scene, fixture): | |
| 76 | """The Empty parenting this fixture's lights, if there is one.""" | |
| 77 | lights = fixture_lights(scene, fixture) | |
| 78 | for obj in lights.values(): | |
| 79 | if obj.parent is not None: | |
| 80 | return obj.parent | |
| 81 | name = root_name(fixture.host) | |
| 82 | return scene.objects.get(name) | |
| 83 | ||
| 84 | ||
| 85 | # --------------------------------------------------------------------------- | |
| 86 | # creation | |
| 87 | ||
| 88 | ||
| 89 | def _make_light(context, fixture, slot, root): | |
| 90 | name = light_name(fixture.host, slot) | |
| 91 | data = bpy.data.lights.new(name=name, type='POINT') | |
| 92 | data.color = (1.0, 1.0, 1.0) | |
| 93 | data.energy = fixture.energy_ref | |
| 94 | ||
| 95 | obj = bpy.data.objects.new(name, data) | |
| 96 | context.collection.objects.link(obj) | |
| 97 | ||
| 98 | obj.parent = root | |
| 99 | obj.location = Vector(((slot - 2.5) * SPACING, 0.0, 0.0)) | |
| 100 | ||
| 101 | props = obj.cthuloid_light | |
| 102 | props.is_fixture_light = True | |
| 103 | props.fixture_id = fixture.id | |
| 104 | props.slot = slot | |
| 105 | return obj | |
| 106 | ||
| 107 | ||
| 108 | def create_fixture_rig(context, fixture): | |
| 109 | """Create any missing part of the hierarchy. Idempotent -- safe to re-run.""" | |
| 110 | scene = context.scene | |
| 111 | created = [] | |
| 112 | ||
| 113 | root = find_root(scene, fixture) | |
| 114 | if root is None: | |
| 115 | root = bpy.data.objects.new(root_name(fixture.host), None) | |
| 116 | root.empty_display_type = 'PLAIN_AXES' | |
| 117 | root.empty_display_size = 0.4 | |
| 118 | context.collection.objects.link(root) | |
| 119 | created.append(root.name) | |
| 120 | ||
| 121 | existing = fixture_lights(scene, fixture) | |
| 122 | for slot in range(1, protocol.SLOTS_PER_FIXTURE + 1): | |
| 123 | if slot in existing: | |
| 124 | obj = existing[slot] | |
| 125 | if obj.parent is None: | |
| 126 | obj.parent = root | |
| 127 | continue | |
| 128 | created.append(_make_light(context, fixture, slot, root).name) | |
| 129 | ||
| 130 | return root, created | |
| 131 | ||
| 132 | ||
| 133 | def sync_names(scene, fixture): | |
| 134 | """Rename root and lights to match the fixture host. Cosmetic only.""" | |
| 135 | renamed = 0 | |
| 136 | root = find_root(scene, fixture) | |
| 137 | if root is not None: | |
| 138 | want = root_name(fixture.host) | |
| 139 | if root.name != want: | |
| 140 | root.name = want | |
| 141 | renamed += 1 | |
| 142 | for slot, obj in fixture_lights(scene, fixture).items(): | |
| 143 | want = light_name(fixture.host, slot) | |
| 144 | if obj.name != want: | |
| 145 | obj.name = want | |
| 146 | if obj.data is not None and obj.data.users == 1: | |
| 147 | obj.data.name = want | |
| 148 | renamed += 1 | |
| 149 | return renamed | |
| 150 | ||
| 151 | ||
| 152 | def delete_fixture_rig(scene, fixture): | |
| 153 | """Remove the objects belonging to a fixture. Data-blocks are left to | |
| 154 | Blender's normal orphan handling rather than force-purged.""" | |
| 155 | removed = 0 | |
| 156 | root = find_root(scene, fixture) | |
| 157 | for obj in list(fixture_lights(scene, fixture).values()): | |
| 158 | bpy.data.objects.remove(obj, do_unlink=True) | |
| 159 | removed += 1 | |
| 160 | if root is not None and not root.children: | |
| 161 | bpy.data.objects.remove(root, do_unlink=True) | |
| 162 | removed += 1 | |
| 163 | return removed | |
| 164 | ||
| 165 | ||
| 166 | # --------------------------------------------------------------------------- | |
| 167 | # adoption of legacy name-encoded rigs | |
| 168 | ||
| 169 | ||
| 170 | def parse_legacy_name(name): | |
| 171 | """"bgr-1-192.168.50.138" -> (1, "192.168.50.138"). | |
| 172 | ||
| 173 | maxsplit=2 matters: a hostname may itself contain hyphens ("show-light01"), | |
| 174 | which the original str.split('-') mangled into the wrong host. | |
| 175 | """ | |
| 176 | if not name.startswith(LIGHT_PREFIX): | |
| 177 | return None | |
| 178 | parts = name.split("-", 2) | |
| 179 | if len(parts) != 3: | |
| 180 | return None | |
| 181 | try: | |
| 182 | slot = int(parts[1]) | |
| 183 | except ValueError: | |
| 184 | return None | |
| 185 | host = parts[2].strip() | |
| 186 | if not host or slot < 1 or slot > protocol.SLOTS_PER_FIXTURE: | |
| 187 | return None | |
| 188 | return slot, host | |
| 189 | ||
| 190 | ||
| 191 | def adopt_scene(context): | |
| 192 | """Import a legacy name-encoded rig into the property-based model. | |
| 193 | ||
| 194 | Objects keep their names and parenting; they simply gain the custom | |
| 195 | properties, and a fixture is created per distinct host found. | |
| 196 | """ | |
| 197 | scene = context.scene | |
| 198 | by_host = {} | |
| 199 | ||
| 200 | for obj in scene.objects: | |
| 201 | if obj.type != 'LIGHT': | |
| 202 | continue | |
| 203 | parsed = parse_legacy_name(obj.name) | |
| 204 | if parsed is None: | |
| 205 | continue | |
| 206 | slot, host = parsed | |
| 207 | by_host.setdefault(host, []).append((slot, obj)) | |
| 208 | ||
| 209 | adopted_hosts, adopted_lights = [], 0 | |
| 210 | for host, entries in by_host.items(): | |
| 211 | fixture = None | |
| 212 | for f in scene.cthuloid.fixtures: | |
| 213 | if f.host == host: | |
| 214 | fixture = f | |
| 215 | break | |
| 216 | if fixture is None: | |
| 217 | fixture = scene.cthuloid.fixtures.add() | |
| 218 | fixture.id = new_fixture_id() | |
| 219 | fixture.host = host | |
| 220 | adopted_hosts.append(host) | |
| 221 | ||
| 222 | for slot, obj in entries: | |
| 223 | props = obj.cthuloid_light | |
| 224 | props.is_fixture_light = True | |
| 225 | props.fixture_id = fixture.id | |
| 226 | props.slot = slot | |
| 227 | adopted_lights += 1 | |
| 228 | ||
| 229 | return adopted_hosts, adopted_lights |
| … | … | @@ -0,0 +1,225 @@ |
| 1 | """N-panel UI in the 3D viewport, tab "Cthuloid".""" | |
| 2 | ||
| 3 | import bpy | |
| 4 | from bpy.types import Panel, UIList | |
| 5 | ||
| 6 | from . import protocol, rig | |
| 7 | from .net import SENDER | |
| 8 | ||
| 9 | ||
| 10 | class CTHULOID_UL_fixtures(UIList): | |
| 11 | def draw_item(self, context, layout, data, item, icon, active_data, active_prop, index): | |
| 12 | row = layout.row(align=True) | |
| 13 | row.prop(item, "enabled", text="") | |
| 14 | row.label(text=item.host or "(no host)", icon='LIGHT_DATA') | |
| 15 | ||
| 16 | status, _err = SENDER.status_of(item.host, item.port) | |
| 17 | icons = { | |
| 18 | "connected": 'CHECKMARK', | |
| 19 | "connecting": 'SORTTIME', | |
| 20 | "unreachable": 'ERROR', | |
| 21 | "lost": 'ERROR', | |
| 22 | "idle": 'RADIOBUT_OFF', | |
| 23 | "error": 'ERROR', | |
| 24 | } | |
| 25 | row.label(text="", icon=icons.get(status, 'RADIOBUT_OFF')) | |
| 26 | ||
| 27 | ||
| 28 | class CthuloidPanelBase: | |
| 29 | bl_space_type = 'VIEW_3D' | |
| 30 | bl_region_type = 'UI' | |
| 31 | bl_category = "Cthuloid" | |
| 32 | ||
| 33 | ||
| 34 | class CTHULOID_PT_main(CthuloidPanelBase, Panel): | |
| 35 | bl_label = "Fixtures" | |
| 36 | ||
| 37 | def draw(self, context): | |
| 38 | layout = self.layout | |
| 39 | props = context.scene.cthuloid | |
| 40 | ||
| 41 | row = layout.row(align=True) | |
| 42 | row.scale_y = 1.4 | |
| 43 | row.prop(props, "live", text="LIVE", icon='PLAY' if props.live else 'PAUSE', toggle=True) | |
| 44 | row.operator("cthuloid.blackout", text="", icon='CANCEL') | |
| 45 | ||
| 46 | row = layout.row() | |
| 47 | row.template_list("CTHULOID_UL_fixtures", "", props, "fixtures", props, "active_index", rows=3) | |
| 48 | ||
| 49 | col = row.column(align=True) | |
| 50 | col.operator("cthuloid.add_fixture", text="", icon='ADD') | |
| 51 | col.operator("cthuloid.remove_fixture", text="", icon='REMOVE') | |
| 52 | col.separator() | |
| 53 | col.operator("cthuloid.adopt_scene", text="", icon='IMPORT') | |
| 54 | ||
| 55 | row = layout.row(align=True) | |
| 56 | row.operator("cthuloid.send_now", icon='EXPORT') | |
| 57 | ||
| 58 | ||
| 59 | class CTHULOID_PT_fixture(CthuloidPanelBase, Panel): | |
| 60 | bl_label = "Fixture" | |
| 61 | bl_parent_id = "CTHULOID_PT_main" | |
| 62 | ||
| 63 | @classmethod | |
| 64 | def poll(cls, context): | |
| 65 | props = context.scene.cthuloid | |
| 66 | return 0 <= props.active_index < len(props.fixtures) | |
| 67 | ||
| 68 | def draw(self, context): | |
| 69 | layout = self.layout | |
| 70 | layout.use_property_split = True | |
| 71 | props = context.scene.cthuloid | |
| 72 | fixture = props.fixtures[props.active_index] | |
| 73 | ||
| 74 | col = layout.column(align=True) | |
| 75 | col.prop(fixture, "host") | |
| 76 | col.prop(fixture, "port") | |
| 77 | ||
| 78 | status, err = SENDER.status_of(fixture.host, fixture.port) | |
| 79 | box = layout.box() | |
| 80 | box.label(text="Status: %s" % status) | |
| 81 | if err: | |
| 82 | box.label(text=err, icon='ERROR') | |
| 83 | ||
| 84 | layout.separator() | |
| 85 | col = layout.column(align=True) | |
| 86 | col.prop(fixture, "red_max_pwm") | |
| 87 | if fixture.red_max_pwm > protocol.FIRMWARE_RED_MAX_PWM: | |
| 88 | col.label(text="Above firmware limit; command will be rejected", icon='ERROR') | |
| 89 | ||
| 90 | col = layout.column(align=True) | |
| 91 | col.prop(fixture, "use_energy") | |
| 92 | sub = col.column() | |
| 93 | sub.enabled = fixture.use_energy | |
| 94 | sub.prop(fixture, "energy_ref") | |
| 95 | ||
| 96 | layout.separator() | |
| 97 | col = layout.column(align=True) | |
| 98 | col.prop(fixture, "use_fade") | |
| 99 | sub = col.column() | |
| 100 | sub.enabled = fixture.use_fade | |
| 101 | sub.prop(fixture, "fade_ms") | |
| 102 | ||
| 103 | ||
| 104 | class CTHULOID_PT_hierarchy(CthuloidPanelBase, Panel): | |
| 105 | bl_label = "Hierarchy" | |
| 106 | bl_parent_id = "CTHULOID_PT_main" | |
| 107 | bl_options = {'DEFAULT_CLOSED'} | |
| 108 | ||
| 109 | @classmethod | |
| 110 | def poll(cls, context): | |
| 111 | props = context.scene.cthuloid | |
| 112 | return 0 <= props.active_index < len(props.fixtures) | |
| 113 | ||
| 114 | def draw(self, context): | |
| 115 | layout = self.layout | |
| 116 | scene = context.scene | |
| 117 | props = scene.cthuloid | |
| 118 | fixture = props.fixtures[props.active_index] | |
| 119 | ||
| 120 | lights = rig.fixture_lights(scene, fixture) | |
| 121 | col = layout.column(align=True) | |
| 122 | for slot in range(1, protocol.SLOTS_PER_FIXTURE + 1): | |
| 123 | obj = lights.get(slot) | |
| 124 | row = col.row(align=True) | |
| 125 | row.label(text="Slot %d" % slot) | |
| 126 | if obj is None: | |
| 127 | row.label(text="empty -> 0 0 0", icon='RADIOBUT_OFF') | |
| 128 | else: | |
| 129 | row.label(text=obj.name, icon='LIGHT_DATA') | |
| 130 | ||
| 131 | for slot, a, b in rig.slot_conflicts(scene, fixture): | |
| 132 | layout.label(text="Slot %d claimed by %s and %s" % (slot, a, b), icon='ERROR') | |
| 133 | ||
| 134 | layout.separator() | |
| 135 | col = layout.column(align=True) | |
| 136 | col.operator("cthuloid.rebuild_rig", icon='FILE_REFRESH') | |
| 137 | col.operator("cthuloid.assign_light", icon='LINKED') | |
| 138 | col.operator("cthuloid.sync_names", icon='SORTALPHA') | |
| 139 | ||
| 140 | ||
| 141 | class CTHULOID_PT_firmware(CthuloidPanelBase, Panel): | |
| 142 | bl_label = "Firmware Settings" | |
| 143 | bl_parent_id = "CTHULOID_PT_main" | |
| 144 | bl_options = {'DEFAULT_CLOSED'} | |
| 145 | ||
| 146 | @classmethod | |
| 147 | def poll(cls, context): | |
| 148 | props = context.scene.cthuloid | |
| 149 | return 0 <= props.active_index < len(props.fixtures) | |
| 150 | ||
| 151 | def draw(self, context): | |
| 152 | layout = self.layout | |
| 153 | layout.use_property_split = True | |
| 154 | props = context.scene.cthuloid | |
| 155 | fixture = props.fixtures[props.active_index] | |
| 156 | ||
| 157 | col = layout.column(align=True) | |
| 158 | col.prop(fixture, "gamma_b") | |
| 159 | col.prop(fixture, "gamma_g") | |
| 160 | col.prop(fixture, "gamma_r") | |
| 161 | layout.prop(fixture, "freq") | |
| 162 | layout.operator("cthuloid.push_settings", icon='EXPORT') | |
| 163 | ||
| 164 | ||
| 165 | class CTHULOID_PT_output(CthuloidPanelBase, Panel): | |
| 166 | bl_label = "Output" | |
| 167 | bl_parent_id = "CTHULOID_PT_main" | |
| 168 | bl_options = {'DEFAULT_CLOSED'} | |
| 169 | ||
| 170 | def draw(self, context): | |
| 171 | layout = self.layout | |
| 172 | layout.use_property_split = True | |
| 173 | props = context.scene.cthuloid | |
| 174 | col = layout.column(align=True) | |
| 175 | col.prop(props, "send_on_frame") | |
| 176 | col.prop(props, "send_on_edit") | |
| 177 | col.prop(props, "max_hz") | |
| 178 | ||
| 179 | ||
| 180 | class CTHULOID_PT_light(CthuloidPanelBase, Panel): | |
| 181 | bl_label = "Cthuloid Light" | |
| 182 | bl_context = "objectmode" | |
| 183 | bl_options = {'DEFAULT_CLOSED'} | |
| 184 | ||
| 185 | @classmethod | |
| 186 | def poll(cls, context): | |
| 187 | return context.object is not None and context.object.type == 'LIGHT' | |
| 188 | ||
| 189 | def draw(self, context): | |
| 190 | layout = self.layout | |
| 191 | layout.use_property_split = True | |
| 192 | obj = context.object | |
| 193 | lp = obj.cthuloid_light | |
| 194 | layout.prop(lp, "is_fixture_light") | |
| 195 | col = layout.column(align=True) | |
| 196 | col.enabled = lp.is_fixture_light | |
| 197 | col.prop(lp, "slot") | |
| 198 | ||
| 199 | fixture = None | |
| 200 | for f in context.scene.cthuloid.fixtures: | |
| 201 | if f.id == lp.fixture_id: | |
| 202 | fixture = f | |
| 203 | break | |
| 204 | col.label(text="Fixture: %s" % (fixture.host if fixture else "unassigned")) | |
| 205 | ||
| 206 | ||
| 207 | CLASSES = ( | |
| 208 | CTHULOID_UL_fixtures, | |
| 209 | CTHULOID_PT_main, | |
| 210 | CTHULOID_PT_fixture, | |
| 211 | CTHULOID_PT_hierarchy, | |
| 212 | CTHULOID_PT_firmware, | |
| 213 | CTHULOID_PT_output, | |
| 214 | CTHULOID_PT_light, | |
| 215 | ) | |
| 216 | ||
| 217 | ||
| 218 | def register(): | |
| 219 | for cls in CLASSES: | |
| 220 | bpy.utils.register_class(cls) | |
| 221 | ||
| 222 | ||
| 223 | def unregister(): | |
| 224 | for cls in reversed(CLASSES): | |
| 225 | bpy.utils.unregister_class(cls) |