OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / f270315

blender-addon: Cthuloid Lights, a real addon to replace quadpanel.py
Succeeds the quadpanel.py text block embedded in LightArm/blender/single_light1.blend, which could no longer run on modern Blender: getLamp() tested child.type == 'LAMP' (renamed 'LIGHT' in 2.80) so it always returned None, baseID was undefined, operator filepath props needed annotations, and frame_change_post.clear() removed every other addon's handlers. Wiring moves out of object names and into custom properties. The old code parsed "bgr-<slot>-<host>" on every frame, so renaming an object silently repointed it at another fixture, and str.split('-') mangled any host containing a hyphen (show-light01 -> host "show"). Names are now kept in sync for readability only; adopt_scene() imports legacy rigs, splitting with maxsplit=2. Add Fixture builds and maintains the quad.<host> + four bgr-<slot>-<host> hierarchy; rebuild is idempotent. Red Max PWM is a per-fixture setting, defaulting to the 30000 the original rig shipped with. Red diodes tolerate less voltage than green/blue, and the firmware rejects the entire 12-channel command if any value exceeds 45000, so that is the hard maximum and values are clamped before sending. Networking runs on one worker thread. quadpanel.py called sendall() inline in the frame handler, so an absent fixture stalled the UI for the socket timeout on every frame. Frames coalesce (newest wins, no backlog), are rate limited to 25 Hz by default because the single-threaded firmware cannot keep up with a full 30 fps stream, and reconnect with 1s-to-15s backoff. Channel order is B,G,R throughout, matching the firmware: parsePWM caps index count % 3 == 2 at RED_MAX_PWM, and gamma echoes back as B= G= R=. Verified against LightArm/blender/single_light1.blend (adopts its rig, all four slots mapped) and live against a fixture at 192.168.50.155:1337. Not ported: the arm. robot-arm path, the light. single-light path, and saveToFile/loadFromFile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f270315458d3433a71d2b3553d24070aa7aec9de
by plankatron <fleeky@gmail.com> · 2026-08-30T18:59:14.000Z

Changed files (11) +1359 -0

blender-addon/.gitignore +2 -0
……@@ -0,0 +1,2 @@
1__pycache__/
2*.pyc
blender-addon/README.md +107 -0
……@@ -0,0 +1,107 @@
1# Cthuloid Lights — Blender addon
2
3Drives ESP32 PWM light fixtures (`esp324xlight` firmware) from Blender lights
4and animation. Successor to the `quadpanel.py` text block embedded in
5`LightArm/blender/single_light1.blend`.
6
7## Install
8
9The repo directory *is* the live extension directory:
10
11```bash
12ln -sfn /home/fleeky/cthuloid/blender-addon/cthuloid_lights \
13 ~/.config/blender/5.2/extensions/user_default/cthuloid_lights
14```
15
16Then enable **Cthuloid Lights** in Preferences → Add-ons. Panel appears in the
173D viewport N-panel under the **Cthuloid** tab.
18
19## The hierarchy
20
21`Add Fixture` builds and manages this automatically:
22
23```
24quad.<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>`
32named lights, creates a fixture per host, and tags the objects.
33
34**Names are no longer the configuration.** quadpanel.py parsed the object name
35on every frame, so renaming an object silently repointed it, and any host
36containing a hyphen (`show-light01`) was parsed into the wrong host. Wiring now
37lives in custom properties; names are kept in sync only for readability.
38
39## Protocol
40
41Channel 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,
43and `gamma` echoes its arguments back as `B= G= R=`.
44
45Empty slots pad `0 0 0`. With `INVERT_HIGH_AND_LOW` in the firmware, input 0 is
46LED-off, so padding is dark.
47
48## Red Max PWM
49
50Per-fixture setting (the fleet is mixed, so a single global value would be
51wrong). Defaults to **30000**, the value the original Blender rig shipped with.
52
53Red diodes have a lower voltage tolerance than green/blue. The firmware
54hard-rejects anything above **45000** — and rejects the *entire* command, so one
55out-of-range value drops all 12 channels. That is the property's hard maximum,
56and the addon clamps before sending.
57
58## Animating multiple fixtures together
59
60Light *data* animation (colour, power) is no longer excluded from object-level
61animation operations. Since Blender 4.4's slotted actions, one action holds both
62in typed slots (`OBFix1` for the object, `LAFix1` for the lamp data), and
63Ctrl+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
91Six 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
104The `arm.` robot-arm path (dynamixel angles from pose bones `base`/`forearm`)
105and the `light.` single-light path are **not** implemented — this addon covers
106the 4-slot `bgr-` quad fixtures only. `saveToFile`/`loadFromFile` are likewise
107not ported. The original script remains in the .blend if those are needed.
blender-addon/cthuloid_lights/__init__.py +23 -0
……@@ -0,0 +1,23 @@
1"""Cthuloid Lights -- drive ESP32 PWM light fixtures from Blender.
2
3Successor to the quadpanel.py text block embedded in
4LightArm/blender/single_light1.blend.
5"""
6
7from . import engine, net, ops, props, ui
8
9
10def register():
11 props.register()
12 ops.register()
13 ui.register()
14 net.SENDER.start()
15 engine.register_handlers()
16
17
18def unregister():
19 engine.unregister_handlers()
20 net.SENDER.stop()
21 ui.unregister()
22 ops.unregister()
23 props.unregister()
blender-addon/cthuloid_lights/blender_manifest.toml +13 -0
……@@ -0,0 +1,13 @@
1schema_version = "1.0.0"
2
3id = "cthuloid_lights"
4version = "0.1.0"
5name = "Cthuloid Lights"
6tagline = "Drive ESP32 PWM light fixtures from Blender lights and animation"
7maintainer = "circusautomatic"
8type = "add-on"
9
10tags = ["Lighting", "Animation"]
11blender_version_min = "4.2.0"
12
13license = ["SPDX:GPL-3.0-or-later"]
blender-addon/cthuloid_lights/engine.py +108 -0
……@@ -0,0 +1,108 @@
1"""Scene -> wire. Owns the Blender handlers.
2
3Handlers are added and removed by identity. quadpanel.py called
4frame_change_post.clear(), which silently removed every other addon's handlers
5too -- a rig with any other frame-driven addon in the scene would quietly stop
6working after running it.
7"""
8
9import bpy
10
11from . import protocol, rig
12from .net import SENDER
13
14
15def 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
28def 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
42def 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
49def 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
67def _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
75def _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
98def register_handlers():
99 for handler_list, fn in _HANDLERS:
100 if fn not in handler_list:
101 handler_list.append(fn)
102
103
104def 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)
blender-addon/cthuloid_lights/net.py +201 -0
……@@ -0,0 +1,201 @@
1"""Non-blocking sender for ESP32 light fixtures.
2
3One worker thread owns every socket. Blender's main thread only ever drops the
4latest desired state into a per-host mailbox and returns immediately -- it never
5calls connect() or sendall(). The original quadpanel.py did its sendall() inline
6in the frame_change handler, so a fixture that had gone away stalled the UI for
7the socket timeout on every single frame.
8
9Design 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
21import socket
22import threading
23import time
24
25CONNECT_TIMEOUT = 1.0
26SEND_TIMEOUT = 1.0
27BACKOFF_START = 1.0
28BACKOFF_MAX = 15.0
29
30
31class _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
48class 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
201SENDER = Sender()
blender-addon/cthuloid_lights/ops.py +214 -0
……@@ -0,0 +1,214 @@
1"""Operators."""
2
3import bpy
4from bpy.props import BoolProperty, StringProperty
5from bpy.types import Operator
6
7from . import engine, protocol, rig
8from .net import SENDER
9
10
11def _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
18class 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
46class 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
78class 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
97class 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
113class 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
128class 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
157class 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
168class 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
183class 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
194CLASSES = (
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
207def register():
208 for cls in CLASSES:
209 bpy.utils.register_class(cls)
210
211
212def unregister():
213 for cls in reversed(CLASSES):
214 bpy.utils.unregister_class(cls)
blender-addon/cthuloid_lights/props.py +141 -0
……@@ -0,0 +1,141 @@
1"""Property definitions.
2
3The object NAME is no longer the configuration. quadpanel.py parsed
4"bgr-<slot>-<ip>" out of object names, which meant renaming an object silently
5repointed it at a different fixture -- and broke outright for any host whose
6name contains a hyphen (e.g. "show-light01"). Here the custom properties are
7the source of truth and names are merely kept in sync for readability and for
8compatibility with older .blend files.
9"""
10
11import bpy
12from bpy.props import (BoolProperty, CollectionProperty, EnumProperty,
13 FloatProperty, IntProperty, PointerProperty, StringProperty)
14from bpy.types import Object, PropertyGroup, Scene
15
16from . import protocol
17
18
19class 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
35class 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
103class 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
127CLASSES = (CthuloidLightProps, CthuloidFixture, CthuloidSceneProps)
128
129
130def 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
137def unregister():
138 del Object.cthuloid_light
139 del Scene.cthuloid
140 for cls in reversed(CLASSES):
141 bpy.utils.unregister_class(cls)
blender-addon/cthuloid_lights/protocol.py +96 -0
……@@ -0,0 +1,96 @@
1"""Wire protocol for the esp324xlight firmware.
2
3The firmware exposes a line-oriented command surface on TCP 1337. The only
4commands this addon emits are `pwm`, `pwm2`, `stopfade`, `gamma` and `freq`.
5
6Channel order is B, G, R -- not RGB. This is not a quirk of the Blender side:
7the firmware caps every value at index `count % 3 == 2` to RED_MAX_PWM, so the
8third slot of each triple *is* the red channel, and `gamma` prints its three
9arguments back as "B=.. G=.. R=..". The original quadpanel.py emitted
10color[2], color[1], color[0] for the same reason.
11
12A fixture is 12 channels = 4 slots x 3 channels. Slots with no light assigned
13are padded with `0 0 0`; with INVERT_HIGH_AND_LOW in the firmware, input 0 is
14LED-off, so padding is dark rather than full-on.
15"""
16
17CHANNELS_PER_SLOT = 3
18SLOTS_PER_FIXTURE = 4
19NUM_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.
24FIRMWARE_MAX_PWM = 65535
25FIRMWARE_RED_MAX_PWM = 45000
26
27
28def clamp(v, lo, hi):
29 return lo if v < lo else (hi if v > hi else v)
30
31
32def 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
37def 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
59def 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
69def pwm_command(values):
70 return "pwm " + " ".join(str(v) for v in values) + "\n"
71
72
73def 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
85def 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
91def freq_command(hz):
92 return "freq %d\n" % int(clamp(hz, 1, 40000))
93
94
95def blackout_command():
96 return "stopfade\n" + pwm_command([0] * NUM_CHANNELS)
blender-addon/cthuloid_lights/rig.py +229 -0
……@@ -0,0 +1,229 @@
1"""Creating, adopting and maintaining the fixture hierarchy.
2
3Layout produced (matching what quadpanel.py expected, minus the vestigial
4armature -- an Empty is the honest container for a light panel; the armature in
5single_light1.blend is a leftover from the robot-arm rigs, which are the only
6things 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
15import uuid
16
17import bpy
18from mathutils import Vector
19
20from . import protocol
21
22ROOT_PREFIX = "quad."
23LIGHT_PREFIX = "bgr-"
24SPACING = 0.6
25
26
27def new_fixture_id():
28 return uuid.uuid4().hex[:8]
29
30
31def light_name(host, slot):
32 return "%s%d-%s" % (LIGHT_PREFIX, slot, host)
33
34
35def root_name(host):
36 return "%s%s" % (ROOT_PREFIX, host)
37
38
39def 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
46def 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
62def 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
75def 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
89def _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
108def 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
133def 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
152def 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
170def 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
191def 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
blender-addon/cthuloid_lights/ui.py +225 -0
……@@ -0,0 +1,225 @@
1"""N-panel UI in the 3D viewport, tab "Cthuloid"."""
2
3import bpy
4from bpy.types import Panel, UIList
5
6from . import protocol, rig
7from .net import SENDER
8
9
10class 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
28class CthuloidPanelBase:
29 bl_space_type = 'VIEW_3D'
30 bl_region_type = 'UI'
31 bl_category = "Cthuloid"
32
33
34class 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
59class 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
104class 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
141class 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
165class 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
180class 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
207CLASSES = (
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
218def register():
219 for cls in CLASSES:
220 bpy.utils.register_class(cls)
221
222
223def unregister():
224 for cls in reversed(CLASSES):
225 bpy.utils.unregister_class(cls)