OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / blender-addon / cthuloid_lights / engine.py

205 lines · python ref:
"""Scene -> wire. Owns the Blender handlers.

Handlers are added and removed by identity. quadpanel.py called
frame_change_post.clear(), which silently removed every other addon's handlers
too -- a rig with any other frame-driven addon in the scene would quietly stop
working after running it.
"""

import bpy

from . import anim, protocol, rig
from . import props as props_mod
from .net import SENDER

# Sentinel: this fixture's current animation segment has already been commanded,
# so send nothing. Distinct from None, which means "no keyframe ahead, fall back
# to a plain pwm".
SKIP = object()

# fixture id -> the target tuple of the pwm2 currently in flight.
_last_pwm2_target = {}


def reset_state():
    _last_pwm2_target.clear()


def _keyframe_payload(scene, fixture, slot_map):
    """One pwm2 per animation segment, faded to the next keyframe.

    Rather than streaming the interpolated colour every frame -- which the
    single-threaded firmware cannot absorb, and which is redundant because the
    firmware interpolates by itself -- evaluate the curves at the next keyframe
    and hand the firmware that target plus the wall-clock time to reach it.
    """
    per_slot = {}
    all_curves = []
    for slot, obj in slot_map.items():
        data = getattr(obj, "data", None)
        if data is None:
            continue
        color_curves = anim.curves_for(data, "color")
        energy_curves = anim.curves_for(data, "energy") if fixture.use_energy else []
        per_slot[slot] = (data, color_curves, energy_curves)
        all_curves.extend(color_curves)
        all_curves.extend(energy_curves)

    frame = scene.frame_current_final
    nxt = anim.next_keyframe_time(all_curves, frame)
    if nxt is None:
        return None  # nothing ahead -- caller falls back to plain pwm

    values = []
    for slot in range(1, protocol.SLOTS_PER_FIXTURE + 1):
        entry = per_slot.get(slot)
        if entry is None:
            values.extend([0, 0, 0])
            continue
        data, color_curves, energy_curves = entry
        r, g, b, energy = anim.sample_light(data, nxt, color_curves, energy_curves)
        values.extend(protocol.channels_from_rgbe(
            r, g, b, energy,
            fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref))

    target = tuple(values)
    if _last_pwm2_target.get(fixture.id) == target:
        # Same segment; the fade is already running on the hardware. Sending
        # anything now would restart it from the current position.
        return SKIP
    _last_pwm2_target[fixture.id] = target

    return protocol.Frame(target, anim.frames_to_ms(scene, nxt - frame))


def build_command(scene, fixture):
    """A protocol.Frame for one fixture, or SKIP to send nothing this tick.

    The sender turns the Frame into whatever the unit speaks: a whole-unit
    pwm/pwm2 (proto <= 2) or per-slot grants and `slot` lines (proto 3)."""
    slot_map = rig.fixture_lights(scene, fixture)

    if fixture.fade_mode == 'KEYFRAME' and anim.is_playing():
        payload = _keyframe_payload(scene, fixture, slot_map)
        if payload is not None:
            return payload
        # No keyframe ahead: fall through to a plain pwm at the current value.

    values = protocol.frame_values(
        slot_map, fixture.red_max_pwm, fixture.use_energy, fixture.energy_ref)

    if fixture.fade_mode == 'FIXED':
        return protocol.Frame(tuple(values), fixture.fade_ms)

    # Plain pwm also cancels any fade still running on the firmware, so drop the
    # remembered target -- the next segment must be re-sent.
    _last_pwm2_target.pop(fixture.id, None)
    return protocol.Frame(tuple(values), 0)


def push_scene(scene, force=False):
    props = getattr(scene, "cthuloid", None)
    if props is None:
        return 0
    SENDER.min_interval = 1.0 / max(1, props.max_hz)
    sent = 0
    for fixture in props.fixtures:
        if push_fixture(scene, fixture, force):
            sent += 1
    return sent


def push_fixture(scene, fixture, force=False):
    """Send one fixture its current state. False if disabled or nothing to send."""
    if not fixture.enabled or not fixture.host:
        return False
    payload = build_command(scene, fixture)
    if payload is SKIP:
        return False
    SENDER.send(fixture.host, fixture.port, payload, force=force)
    return True


def push_settings(scene, fixture):
    """Push the firmware-side settings (gamma, freq) for one fixture."""
    payload = protocol.gamma_command(fixture.gamma_b, fixture.gamma_g, fixture.gamma_r)
    payload += protocol.freq_command(fixture.freq)
    SENDER.send(fixture.host, fixture.port, payload, force=True)


def blackout(scene):
    props = getattr(scene, "cthuloid", None)
    if props is None:
        return 0
    reset_state()
    count = 0
    for fixture in props.fixtures:
        if not fixture.host:
            continue
        # Blackout works connected or not. Disconnected, it is a one-shot:
        # connect, deliver, let go -- it must not leave connections behind.
        SENDER.send(fixture.host, fixture.port, protocol.blackout_command(),
                    force=True, once=not props_mod._connected)
        count += 1
    return count


# ---------------------------------------------------------------------------
# handlers


@bpy.app.handlers.persistent
def _on_frame_change(scene, depsgraph=None):
    props = getattr(scene, "cthuloid", None)
    if props is None or not props.live or not props.send_on_frame:
        return
    push_scene(scene)


@bpy.app.handlers.persistent
def _on_depsgraph_update(scene, depsgraph=None):
    props = getattr(scene, "cthuloid", None)
    if props is None or not props.live or not props.send_on_edit:
        return
    if anim.is_playing():
        # During playback the frame handler owns output; depsgraph updates would
        # otherwise fire mid-segment and cancel the running fade.
        return
    if depsgraph is not None:
        interesting = False
        for update in depsgraph.updates:
            if isinstance(update.id, (bpy.types.Light, bpy.types.Object)):
                interesting = True
                break
        if not interesting:
            return
    push_scene(scene)


@bpy.app.handlers.persistent
def _on_load_post(*args):
    # A newly opened file starts disconnected, like a fresh session.
    props_mod.reset_connection()
    reset_state()


_HANDLERS = (
    (bpy.app.handlers.frame_change_post, _on_frame_change),
    (bpy.app.handlers.depsgraph_update_post, _on_depsgraph_update),
    (bpy.app.handlers.load_post, _on_load_post),
)


def register_handlers():
    for handler_list, fn in _HANDLERS:
        if fn not in handler_list:
            handler_list.append(fn)


def unregister_handlers():
    for handler_list, fn in _HANDLERS:
        # Remove only our own callback -- never clear() the list.
        while fn in handler_list:
            handler_list.remove(fn)
    reset_state()