# SPDX-License-Identifier: GPL-3.0-or-later
# Wallu for Blender - https://wallu.ai
# Copyright (C) 2026 Wallu
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details:
# https://www.gnu.org/licenses/gpl-3.0.html
"""
Wallu for Blender: Claude, through the Wallu connector, builds scenes in this Blender, looks through its cameras and
renders them on this computer, and the renders go to your Wallu media library for the Pro Editor.

How it works: once paired (a code Claude gives you, typed in the Wallu tab of the 3D Viewport sidebar), this add-on asks
wallu.ai every few seconds whether Claude has a command for this Blender, runs it here and sends the answer back.
Renders run in a second Blender in the background, so this window stays usable, and upload straight to your library.
While connected, Claude can run Python in this Blender: Pause or Disconnect in the Wallu tab stop it at any time.
Free assets come from Poly Haven (polyhaven.com, CC0).
"""

bl_info = {
    "name": "Wallu",
    "author": "Wallu",
    "version": (1, 0, 1),
    "blender": (3, 6, 0),
    "location": "3D Viewport > Sidebar (N) > Wallu",
    "description": "Claude builds, looks at and renders scenes here through the Wallu connector; renders go to the Wallu Pro Editor",
    "doc_url": "https://wallu.ai/pro-editor",
    "category": "3D View",
}

import atexit
import base64
import collections
import contextlib
import hashlib
import io
import json
import math
import os
import platform
import queue
import re
import shutil
import ssl
import subprocess
import tempfile
import threading
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request

import bpy

ADDON_VERSION = "1.0.1"
SERVER = os.environ.get("WALLU_SERVER", "https://wallu.ai").rstrip("/")
API = SERVER + "/api/v1/mcp/blender"
UA = "WalluBlender/%s (+https://wallu.ai)" % ADDON_VERSION
POLL_WAIT = 25

# ---------------------------------------------------------------------------------------------------------------------
# State: written by the bridge threads and the main thread; bpy is only ever touched on the main thread.
# ---------------------------------------------------------------------------------------------------------------------

_state = {
    "token": "", "device": "", "account": "",
    "status": "Not connected", "paused": False, "busy": "",
    "log": collections.deque(maxlen=8), "credits": set(), "dirty": True,
    "elsewhere": False,     # another Blender window on this computer holds the connection
}
_inbox = queue.Queue()      # commands from wallu.ai, run on the main thread
_outbox = queue.Queue()     # answers and progress, posted by the sender thread
_bridge = {"stop": None, "threads": []}
_snapshot = {"version": "", "file": ""}   # read by the poller thread, written on the main thread
_jobs = {}                  # render job id -> its state (the background Blender, its progress)


def _ssl_context():
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except Exception:
        return ssl.create_default_context()


_CTX = _ssl_context()


_paths = {"dir": ""}


def _config_dir():
    # Resolved once on the main thread (it needs bpy); the bridge threads reuse it.
    if not _paths["dir"]:
        _paths["dir"] = bpy.utils.user_resource("CONFIG", path="wallu", create=True)
    return _paths["dir"]


def _config_path():
    return os.path.join(_config_dir(), "wallu.json")


def _load_config():
    try:
        with open(_config_path(), "r", encoding="utf-8") as f:
            c = json.load(f)
        if c.get("server", SERVER) == SERVER:
            _state["token"] = c.get("token", "")
            _state["device"] = c.get("device", "")
            _state["account"] = c.get("account", "")
    except Exception:
        pass


def _save_config():
    try:
        with open(_config_path(), "w", encoding="utf-8") as f:
            json.dump({"server": SERVER, "token": _state["token"], "device": _state["device"], "account": _state["account"]}, f)
    except Exception as e:
        print("[Wallu] could not save the connection:", e)


def _saved_token():
    try:
        with open(_config_path(), "r", encoding="utf-8") as f:
            return json.load(f).get("token", "")
    except Exception:
        return ""


# One Blender window per computer talks to Wallu. Every Blender of this version shares the saved connection, and two windows
# polling with it would take turns receiving Claude's commands. The window that connects holds this lock file (refreshed on
# every poll); the others say so and offer "Use this Blender". A lock nobody refreshed for LOCK_STALE seconds is free.
LOCK_STALE = 90


def _lock_path():
    return os.path.join(_config_dir(), "wallu.lock")


def _read_lock():
    try:
        with open(_lock_path(), "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def _write_lock():
    try:
        with open(_lock_path(), "w", encoding="utf-8") as f:
            json.dump({"pid": os.getpid(), "at": time.time()}, f)
    except Exception as e:
        print("[Wallu] could not write the lock file:", e)


def _lock_holder():
    """The process id of ANOTHER live Blender window holding the connection, or None."""
    lk = _read_lock()
    if not lk or lk.get("pid") == os.getpid():
        return None
    try:
        if time.time() - float(lk.get("at") or 0) > LOCK_STALE:
            return None
    except (TypeError, ValueError):
        return None
    return lk.get("pid")


def _release_lock():
    lk = _read_lock()
    if lk and lk.get("pid") == os.getpid():
        try:
            os.remove(_lock_path())
        except Exception:
            pass


def _set_status(text):
    if _state["status"] != text:
        _state["status"] = text
        _state["dirty"] = True


def _log(op, ok, note=""):
    _state["log"].append((time.strftime("%H:%M:%S"), op, ok, str(note)[:90]))
    _state["dirty"] = True


def _online_ok():
    return getattr(bpy.app, "online_access", True)


# ---------------------------------------------------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------------------------------------------------

def _http(method, url, body=None, timeout=40, auth=True):
    headers = {"User-Agent": UA, "Accept": "application/json"}
    if auth and _state["token"]:
        headers["Authorization"] = "Bearer " + _state["token"]
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=_CTX) as r:
            raw = r.read()
            return r.status, (json.loads(raw) if raw else {})
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"error": raw[:300].decode("utf-8", "replace")}


def _download(url, dest):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    tmp = dest + ".part"
    with urllib.request.urlopen(req, timeout=120, context=_CTX) as r, open(tmp, "wb") as f:
        shutil.copyfileobj(r, f, 1 << 20)
    os.replace(tmp, dest)
    return dest


def _upload(path, url, content_type):
    size = os.path.getsize(path)
    with open(path, "rb") as f:
        req = urllib.request.Request(url, data=f, method="PUT", headers={"User-Agent": UA, "Content-Type": content_type, "Content-Length": str(size)})
        try:
            with urllib.request.urlopen(req, timeout=900, context=_CTX) as r:
                return json.loads(r.read() or b"{}")
        except urllib.error.HTTPError as e:
            try:
                msg = json.loads(e.read()).get("error")
            except Exception:
                msg = None
            raise RuntimeError("upload refused (%s): %s" % (e.code, msg or e.reason))


# ---------------------------------------------------------------------------------------------------------------------
# The bridge: a poller asks wallu.ai for Claude's commands, a sender posts the answers. Neither touches bpy.
# ---------------------------------------------------------------------------------------------------------------------

def _poll_loop(stop):
    backoff = 2
    while not stop.is_set():
        if not _state["token"]:
            return
        if _lock_holder() is not None:
            _state["elsewhere"] = True
            _set_status("Moved to another Blender window")
            return
        _write_lock()
        if not _online_ok():
            _set_status("Online access is off (Preferences > System)")
            stop.wait(3)
            continue
        q = urllib.parse.urlencode({
            "wait": POLL_WAIT, "v": _snapshot["version"], "a": ADDON_VERSION, "f": _snapshot["file"],
            "p": "1" if _state["paused"] else "0", "b": _state["busy"],
        })
        try:
            code, data = _http("GET", API + "/next?" + q, timeout=POLL_WAIT + 15)
        except Exception as e:
            _set_status("Offline (%s): retrying" % e.__class__.__name__)
            stop.wait(backoff)
            backoff = min(30, backoff * 2)
            continue
        if stop.is_set():
            return
        if code == 401:
            used = _state["token"]
            _state["token"] = ""
            if _saved_token() == used:  # another window may have saved a newer connection since: leave that one alone
                _save_config()
            _release_lock()
            _set_status("Disconnected by Wallu: ask Claude for a new code")
            return
        if code != 200:
            _set_status("Wallu answered %s: retrying" % code)
            stop.wait(backoff)
            backoff = min(30, backoff * 2)
            continue
        backoff = 2
        _set_status("Paused" if _state["paused"] else "Connected: waiting for Claude")
        cmd = data.get("cmd") if isinstance(data, dict) else None
        if cmd:
            _inbox.put(cmd)


def _send_loop(stop):
    while not stop.is_set() or not _outbox.empty():
        try:
            path, body = _outbox.get(timeout=0.5)
        except queue.Empty:
            continue
        for attempt in range(4):
            try:
                code, _ = _http("POST", API + path, body=body, timeout=60)
                if code < 500:
                    break
            except Exception:
                pass
            time.sleep(1 + attempt * 2)


def _start_bridge(force=False):
    """force: the user acted in THIS window (Connect, Use this Blender, Allow online access), so it takes the connection."""
    if not _state["token"] or bpy.app.background and not os.environ.get("WALLU_TEST_BRIDGE"):
        return
    if _bridge["stop"] is not None and any(t.is_alive() for t in _bridge["threads"]):
        return
    if not force and _lock_holder() is not None:
        _state["elsewhere"] = True
        _set_status("Connected in another Blender window")
        return
    _state["elsewhere"] = False
    _write_lock()
    stop = threading.Event()
    _bridge["stop"] = stop
    _bridge["threads"] = [
        threading.Thread(target=_poll_loop, args=(stop,), name="wallu-poll", daemon=True),
        threading.Thread(target=_send_loop, args=(stop,), name="wallu-send", daemon=True),
    ]
    for t in _bridge["threads"]:
        t.start()
    _set_status("Connecting...")


def _stop_bridge():
    if _bridge["stop"] is not None:
        _bridge["stop"].set()
    _bridge["stop"] = None
    _bridge["threads"] = []


# ---------------------------------------------------------------------------------------------------------------------
# The main thread: a timer runs Claude's commands one at a time.
# ---------------------------------------------------------------------------------------------------------------------

def _tick():
    try:
        _snapshot["version"] = bpy.app.version_string
        _snapshot["file"] = os.path.basename(bpy.data.filepath) or "(not saved)"
        try:
            cmd = _inbox.get_nowait()
        except queue.Empty:
            cmd = None
        if cmd:
            _run_command(cmd)
        if _state["dirty"]:
            _state["dirty"] = False
            _redraw()
        return 0.05 if cmd else 0.2
    except Exception:
        traceback.print_exc()
        return 0.5


def _redraw():
    try:
        for win in bpy.context.window_manager.windows:
            for area in win.screen.areas:
                if area.type == "VIEW_3D":
                    area.tag_redraw()
    except Exception:
        pass


def _run_command(cmd):
    cid, op, args = cmd.get("id"), str(cmd.get("op") or ""), cmd.get("args") or {}
    if _state["paused"] and op not in ("status", "cancel"):
        _outbox.put(("/result", {"id": cid, "error": "PAUSED: the user paused Claude in Blender (Wallu tab). Wait until they press Resume."}))
        _log(op, False, "paused")
        return
    handler = _HANDLERS.get(op)
    _state["busy"] = op
    _state["dirty"] = True
    _redraw()
    t0 = time.time()
    try:
        if handler is None:
            raise ValueError("Unknown command %r: this Wallu add-on is older than Wallu; install the new one from %s/api/v1/mcp/blender/addon" % (op, SERVER))
        result = handler(args)
        _outbox.put(("/result", {"id": cid, "result": result}))
        _log(op, True, "%.1f s" % (time.time() - t0))
    except Exception as e:
        _outbox.put(("/result", {"id": cid, "error": _error_text(e)}))
        _log(op, False, str(e))
    finally:
        _state["busy"] = ""
        _state["dirty"] = True


class _CodeError(Exception):
    def __init__(self, message, output):
        super().__init__(message)
        self.output = output


def _error_text(e):
    if isinstance(e, _CodeError):
        text = str(e)
        if e.output.strip():
            text += "\n--- printed before the error ---\n" + e.output[-3000:]
        return text
    return "%s: %s" % (e.__class__.__name__, e)


@contextlib.contextmanager
def _ctx():
    """A window, screen and 3D View for operators that need one (a timer runs without), when there is a window."""
    wm = bpy.context.window_manager
    win = wm.windows[0] if wm is not None and len(wm.windows) else None
    if win is None or not hasattr(bpy.context, "temp_override"):
        yield
        return
    kw = {"window": win, "screen": win.screen}
    area = next((a for a in win.screen.areas if a.type == "VIEW_3D"), None)
    if area is not None:
        kw["area"] = area
        region = next((r for r in area.regions if r.type == "WINDOW"), None)
        if region is not None:
            kw["region"] = region
    with bpy.context.temp_override(**kw):
        yield


def _undo_push(message):
    try:
        with _ctx():
            bpy.ops.ed.undo_push(message=message)
    except Exception:
        pass


def _jsonable(v, depth=0):
    if v is None or isinstance(v, (bool, int, float, str)):
        return v
    if depth > 4:
        return repr(v)[:300]
    if isinstance(v, dict):
        return {str(k): _jsonable(x, depth + 1) for k, x in list(v.items())[:200]}
    if isinstance(v, (list, tuple, set)):
        return [_jsonable(x, depth + 1) for x in list(v)[:500]]
    if hasattr(v, "name") and hasattr(v, "bl_rna"):
        return "<%s %r>" % (v.bl_rna.identifier, v.name)
    try:
        return [float(x) for x in v][:64]  # vectors, colours
    except Exception:
        return repr(v)[:500]


# ---------------------------------------------------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------------------------------------------------

def _engine_id(name):
    """eevee / cycles / workbench -> this Blender's engine identifier (EEVEE is BLENDER_EEVEE_NEXT in 4.2-4.5)."""
    name = (name or "eevee").lower()
    ids = [e.identifier for e in bpy.types.RenderSettings.bl_rna.properties["engine"].enum_items]
    if name.startswith("eevee"):
        for cand in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"):
            if cand in ids:
                return cand
        return "BLENDER_EEVEE"
    if name.startswith("cycles"):
        return "CYCLES"
    if name.startswith("work"):
        return "BLENDER_WORKBENCH"
    return name.upper()


def _set_samples(scene, samples):
    try:
        n = int(samples)
    except Exception:
        return
    if scene.render.engine == "CYCLES":
        scene.cycles.samples = n
    elif hasattr(scene, "eevee") and hasattr(scene.eevee, "taa_render_samples"):
        scene.eevee.taa_render_samples = n


def _h_status(args):
    sc = bpy.context.scene
    r = sc.render
    objs = []
    for o in list(sc.objects)[:60]:
        try:
            hidden = o.hide_get()
        except Exception:
            hidden = o.hide_viewport
        item = {"name": o.name, "type": o.type, "location": [round(v, 3) for v in o.location], "hidden": hidden}
        if o.type in ("MESH", "CURVE", "FONT", "EMPTY") and any(o.dimensions):
            item["size"] = [round(v, 3) for v in o.dimensions]
        if o.parent:
            item["parent"] = o.parent.name
        objs.append(item)
    hdri = None
    try:
        for n in sc.world.node_tree.nodes:
            if n.type == "TEX_ENVIRONMENT" and n.image:
                hdri = n.image.name
    except Exception:
        pass
    return {
        "blender": bpy.app.version_string, "addon": ADDON_VERSION, "file": bpy.data.filepath or "",
        "scene": sc.name, "frames": [sc.frame_start, sc.frame_end], "frame": sc.frame_current, "fps": r.fps / (r.fps_base or 1),
        "resolution": [r.resolution_x, r.resolution_y, r.resolution_percentage], "engine": r.engine,
        "camera": sc.camera.name if sc.camera else None, "view": sc.view_settings.view_transform,
        "cameras": [o.name for o in sc.objects if o.type == "CAMERA"], "objects": objs, "object_count": len(sc.objects),
        "world": sc.world.name if sc.world else None, "world_hdri": hdri, "paused": _state["paused"],
        "jobs": [{"id": j["id"], "status": j["status"], "done": j["done"], "total": j["total"]} for j in _jobs.values() if j["status"] in ("rendering", "uploading")],
    }


def _h_run(args):
    code = str(args.get("code") or "")
    if not code.strip():
        raise ValueError("No code: pass Blender Python in `code`.")
    import mathutils
    ns = {
        "__name__": "__wallu_claude__", "bpy": bpy, "mathutils": mathutils, "Vector": mathutils.Vector, "Euler": mathutils.Euler,
        "Matrix": mathutils.Matrix, "math": math, "wallu": wallu, "result": None,
    }
    buf = io.StringIO()
    t0 = time.time()
    try:
        with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf), _ctx():
            exec(compile(code, "<claude>", "exec"), ns)
    except Exception as e:
        tb = traceback.extract_tb(e.__traceback__)
        mine = [f for f in tb if f.filename == "<claude>"]
        where = (" at line %d" % mine[-1].lineno) if mine else ""
        here = os.path.normcase(os.path.abspath(__file__))
        public = [f.name for f in tb if f.filename != "<claude>" and os.path.normcase(os.path.abspath(f.filename or "")) == here and not f.name.startswith("_")]
        helper = (" (in wallu.%s)" % public[-1]) if public else ""
        raise _CodeError("Python error%s%s: %s: %s" % (where, helper, e.__class__.__name__, e), buf.getvalue())
    _undo_push("Claude (Wallu)")
    return {"output": buf.getvalue()[-12000:], "result": _jsonable(ns.get("result")), "seconds": round(time.time() - t0, 2)}


def _keep_render(sc):
    r = sc.render
    im = r.image_settings
    k = {
        "engine": r.engine, "pct": r.resolution_percentage, "camera": sc.camera, "frame": sc.frame_current,
        "fmt": im.file_format, "mode": im.color_mode, "quality": im.quality, "media": getattr(im, "media_type", None),
        "filepath": r.filepath, "eevee": getattr(getattr(sc, "eevee", None), "taa_render_samples", None),
    }
    try:
        k["cycles"] = sc.cycles.samples
    except Exception:
        k["cycles"] = None
    return k


def _restore_render(sc, k):
    r = sc.render
    im = r.image_settings
    for fn in (
        lambda: setattr(r, "engine", k["engine"]),
        lambda: setattr(r, "resolution_percentage", k["pct"]),
        lambda: k["media"] is not None and setattr(im, "media_type", k["media"]),
        lambda: setattr(im, "file_format", k["fmt"]),
        lambda: setattr(im, "color_mode", k["mode"]),
        lambda: setattr(im, "quality", k["quality"]),
        lambda: setattr(r, "filepath", k["filepath"]),
        lambda: setattr(sc, "camera", k["camera"]),
        lambda: sc.frame_set(k["frame"]),
        lambda: k["eevee"] is not None and setattr(sc.eevee, "taa_render_samples", k["eevee"]),
        lambda: k["cycles"] is not None and setattr(sc.cycles, "samples", k["cycles"]),
    ):
        try:
            fn()
        except Exception:
            pass


def _h_look(args):
    sc = bpy.context.scene
    cams = [str(c) for c in (args.get("cameras") or [])][:4] or ([str(args["camera"])] if args.get("camera") else [None])
    frames = [int(f) for f in (args.get("frames") or [])][:6] or [int(args.get("frame") or sc.frame_current)]
    if len(cams) > 1:
        frames = frames[:1]
    width = max(128, min(1280, int(args.get("width") or 540)))
    keep = _keep_render(sc)
    images = []
    try:
        r = sc.render
        r.engine = _engine_id(args.get("engine") or "eevee")
        _set_samples(sc, args.get("samples") or (32 if r.engine == "CYCLES" else 16))
        r.resolution_percentage = max(1, min(100, int(round(width * 100.0 / max(1, r.resolution_x)))))
        im = r.image_settings
        if hasattr(im, "media_type"):
            im.media_type = "IMAGE"
        im.file_format = "JPEG"
        im.color_mode = "RGB"
        im.quality = 85
        tmpdir = tempfile.mkdtemp(prefix="wallu_look_")
        try:
            for name in cams:
                if name:
                    cam = bpy.data.objects.get(name)
                    if cam is None or cam.type != "CAMERA":
                        have = ", ".join(o.name for o in sc.objects if o.type == "CAMERA") or "none"
                        raise ValueError("No camera named %r (cameras: %s)." % (name, have))
                    sc.camera = cam
                if sc.camera is None:
                    raise ValueError("The scene has no camera: add one with wallu.camera(...).")
                for f in frames:
                    sc.frame_set(f)
                    t0 = time.time()
                    with _ctx():
                        bpy.ops.render.render(write_still=False)
                    path = os.path.join(tmpdir, "look_%d.jpg" % len(images))
                    bpy.data.images["Render Result"].save_render(path)
                    with open(path, "rb") as fh:
                        b64 = base64.b64encode(fh.read()).decode("ascii")
                    images.append({"label": "camera %s, frame %d" % (sc.camera.name, f), "b64": b64, "mime": "image/jpeg", "seconds": round(time.time() - t0, 2)})
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)
    finally:
        _restore_render(sc, keep)
    return {"images": images}


# ---- Renders: a second Blender in the background renders a copy of this file, then the file goes to Wallu -------------------

_RENDER_SETUP = r'''
import bpy, json
S = json.loads(%r)
sc = bpy.context.scene
r = sc.render
if S.get("camera"):
    cam = bpy.data.objects.get(S["camera"])
    if cam is not None and cam.type == "CAMERA":
        sc.camera = cam
r.engine = S["engine"]
if S.get("samples"):
    if r.engine == "CYCLES":
        sc.cycles.samples = int(S["samples"])
    elif hasattr(sc.eevee, "taa_render_samples"):
        sc.eevee.taa_render_samples = int(S["samples"])
r.resolution_x, r.resolution_y, r.resolution_percentage = int(S["width"]), int(S["height"]), 100
r.fps, r.fps_base = int(S["fps"]), 1.0
sc.frame_start, sc.frame_end = int(S["start"]), int(S["end"])
r.film_transparent = bool(S["transparent"])
im = r.image_settings
if S["format"] == "png":
    if hasattr(im, "media_type"):
        im.media_type = "IMAGE"
    im.file_format = "PNG"
    im.color_mode = "RGBA" if S["transparent"] else "RGB"
    im.color_depth = "8"
else:
    if hasattr(im, "media_type"):
        im.media_type = "VIDEO"
    im.file_format = "FFMPEG"
    f = r.ffmpeg
    if S["format"] == "webm":
        f.format, f.codec = "WEBM", "WEBM"
        im.color_mode = "RGBA" if S["transparent"] else "RGB"
    else:
        f.format, f.codec = "MPEG4", "H264"
        im.color_mode = "RGB"
    f.constant_rate_factor = "HIGH"
    f.ffmpeg_preset = "GOOD"
    f.gopsize = int(S["fps"])
    f.audio_codec = "NONE"
r.use_file_extension = True
r.filepath = S["out"]
print("WALLU_SETUP_OK", r.engine, r.resolution_x, r.resolution_y, sc.frame_start, sc.frame_end, flush=True)
'''


def _h_render(args):
    for j in _jobs.values():
        if j["status"] in ("rendering", "uploading"):
            raise ValueError("A render is already running in this Blender (%s, %d of %d frames). Wait for it, or cancel it." % (j["id"], j["done"], j["total"]))
    job = str(args.get("job") or "")
    url = str(args.get("uploadUrl") or "")
    if not job or not url:
        raise ValueError("render needs a job id and an upload link (Wallu sends both).")
    fmt = str(args.get("format") or "mp4")
    if fmt not in ("mp4", "webm", "png"):
        raise ValueError("format must be mp4, webm or png")
    sc = bpy.context.scene
    r = sc.render
    width = int(args.get("width") or round(r.resolution_x * r.resolution_percentage / 100.0))
    height = int(args.get("height") or round(r.resolution_y * r.resolution_percentage / 100.0))
    width, height = width - width % 2, height - height % 2  # video encoders want even sizes
    fps = int(args.get("fps") or round(r.fps / (r.fps_base or 1)))
    if fmt == "png":
        start = end = int(args.get("frame") or sc.frame_current)
    else:
        start = int(args.get("start") or sc.frame_start)
        end = int(args.get("end") or sc.frame_end)
        if end < start:
            raise ValueError("end (%d) is before start (%d)." % (end, start))
    camera = str(args.get("camera") or (sc.camera.name if sc.camera else ""))
    if not camera or bpy.data.objects.get(camera) is None:
        raise ValueError("No camera %r to render from: add one with wallu.camera(...)." % camera)
    engine = _engine_id(args["engine"]) if args.get("engine") else r.engine
    transparent = bool(args["transparent"]) if "transparent" in args else (fmt == "webm" or (fmt == "png" and r.film_transparent))
    tmpdir = tempfile.mkdtemp(prefix="wallu_render_")
    blend = os.path.join(tmpdir, "scene.blend")
    with _ctx():
        bpy.ops.wm.save_as_mainfile(filepath=blend, copy=True)
    settings = {"camera": camera, "engine": engine, "samples": args.get("samples"), "width": width, "height": height, "fps": fps,
                "start": start, "end": end, "transparent": transparent, "format": fmt, "out": os.path.join(tmpdir, "out_")}
    setup = os.path.join(tmpdir, "wallu_setup.py")
    with open(setup, "w", encoding="utf-8") as fh:
        fh.write(_RENDER_SETUP % json.dumps(settings))
    cmd = [bpy.app.binary_path, "-b", blend, "--python", setup] + (["-f", str(start)] if fmt == "png" else ["-a"])
    env = dict(os.environ, WALLU_NO_BRIDGE="1")
    flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace",
                            env=env, cwd=tmpdir, creationflags=flags)
    total = end - start + 1
    j = {"id": job, "proc": proc, "status": "rendering", "frame": None, "done": 0, "total": total, "start": start, "started": time.time(),
         "dir": tmpdir, "format": fmt, "url": url, "log": collections.deque(maxlen=60), "cancelled": False}
    _jobs[job] = j
    threading.Thread(target=_watch_render, args=(j,), name="wallu-render", daemon=True).start()
    _state["dirty"] = True
    return {"started": True, "job": job, "frames": total, "width": width, "height": height, "fps": fps, "engine": engine, "format": fmt, "transparent": transparent}


_FRAME_RE = re.compile(r"Fra:\s*(\d+)")
_APPEND_RE = re.compile(r"Append frame (\d+)")
_SAVED_RE = re.compile(r"Saved: '([^']+)'")


def _progress(j, **kw):
    body = {"job": j["id"], "status": j["status"], "frame": j["frame"], "done": j["done"], "total": j["total"]}
    body.update(kw)
    _outbox.put(("/progress", body))
    _state["dirty"] = True


def _watch_render(j):
    proc = j["proc"]
    last = 0.0
    saved = None
    for line in proc.stdout:
        line = line.rstrip()
        if not line:
            continue
        j["log"].append(line)
        m = _APPEND_RE.search(line) or _FRAME_RE.search(line)
        if m:
            f = int(m.group(1))
            j["frame"] = f
            j["done"] = max(j["done"], min(j["total"], f - j["start"] + (1 if _APPEND_RE.search(line) else 0)))
        s = _SAVED_RE.search(line)
        if s:
            saved = s.group(1)
            if j["format"] == "png":
                j["done"] = j["total"]
        if time.time() - last > 2.5:
            last = time.time()
            _progress(j)
    code = proc.wait()
    try:
        if j["cancelled"]:
            j["status"] = "cancelled"
            _progress(j)
            return
        ext = {"mp4": ".mp4", "webm": ".webm", "png": ".png"}[j["format"]]
        out = saved if saved and os.path.isfile(saved) and saved.endswith(ext) else None
        if out is None:
            found = [os.path.join(j["dir"], n) for n in os.listdir(j["dir"]) if n.endswith(ext)]
            out = max(found, key=os.path.getmtime) if found else None
        if code != 0 or out is None:
            j["status"] = "failed"
            tail = "\n".join(list(j["log"])[-14:])
            _progress(j, error="Blender's render stopped (exit %s) and wrote no %s file. Its last lines:\n%s" % (code, ext, tail))
            return
        j["done"] = j["total"]
        j["status"] = "uploading"
        _progress(j)
        ctype = {"mp4": "video/mp4", "webm": "video/webm", "png": "image/png"}[j["format"]]
        try:
            res = _upload(out, j["url"], ctype)
        except Exception as e:
            j["status"] = "failed"
            _progress(j, error="The render worked but the upload failed: %s" % e)
            return
        if not res.get("ok"):
            j["status"] = "failed"
            _progress(j, error="Wallu refused the upload: %s" % (res.get("error") or res))
            return
        j["status"] = "done"
        _progress(j, result=res)
    finally:
        _log("render", j["status"] == "done", "%s %s" % (j["id"], j["status"]))
        shutil.rmtree(j["dir"], ignore_errors=True)


def _h_cancel(args):
    jid = str(args.get("job") or "")
    j = _jobs.get(jid) or next((x for x in _jobs.values() if x["status"] in ("rendering", "uploading")), None)
    if j is None or j["status"] not in ("rendering", "uploading"):
        return {"cancelled": False, "note": "no render running"}
    j["cancelled"] = True
    try:
        j["proc"].kill()
    except Exception:
        pass
    return {"cancelled": True, "job": j["id"]}


_HANDLERS = {"status": _h_status, "run": _h_run, "look": _h_look, "render": _h_render, "cancel": _h_cancel}


# ---------------------------------------------------------------------------------------------------------------------
# Helpers for Claude's code: `wallu.<name>(...)` in blender_run
# ---------------------------------------------------------------------------------------------------------------------

def _cache_dir(*parts):
    base = bpy.utils.user_resource("CONFIG", path="wallu", create=True)
    d = os.path.join(base, "cache", *parts)
    os.makedirs(d, exist_ok=True)
    return d


def _link(obj):
    coll = bpy.context.collection or bpy.context.scene.collection
    try:
        coll.objects.link(obj)
    except RuntimeError:
        bpy.context.scene.collection.objects.link(obj)
    return obj


def _as_vector(v):
    from mathutils import Vector
    if hasattr(v, "matrix_world"):
        return v.matrix_world.translation.copy()
    return Vector(tuple(float(x) for x in v))


def _use_nodes(idblock):
    """Materials and worlds always have nodes in Blender 5; older ones need use_nodes."""
    try:
        if not idblock.use_nodes:
            idblock.use_nodes = True
    except Exception:
        pass
    return idblock.node_tree


def _set_blended(mat):
    """Transparent pixels show what is behind (EEVEE: blended surfaces; Cycles needs nothing)."""
    for attr, value in (("surface_render_method", "BLENDED"), ("blend_method", "BLEND")):
        if attr in mat.bl_rna.properties:
            try:
                setattr(mat, attr, value)
                break
            except Exception:
                pass
    for attr, value in (("shadow_method", "NONE"), ("use_backface_culling", False)):
        if attr in mat.bl_rna.properties:
            try:
                setattr(mat, attr, value)
            except Exception:
                pass


class _Wallu:
    """Shortcuts for Claude's Blender Python (blender_run): wallu.setup, camera, look_at, clear, fetch, polyhaven_search, hdri,
    texture, model, image_plane, contact_shadow."""

    def __repr__(self):
        return "<wallu helpers: %s>" % ", ".join(n for n in dir(self) if not n.startswith("_"))

    # ---- scene -------------------------------------------------------------------------------------------------------
    def setup(self, width=1080, height=1920, fps=30, start=1, end=None, engine="eevee", samples=None, transparent=False, view="AgX"):
        """Render settings: size in px, fps, frame range, engine (eevee / cycles / workbench), samples, transparent background."""
        sc = bpy.context.scene
        r = sc.render
        r.resolution_x, r.resolution_y, r.resolution_percentage = int(width), int(height), 100
        r.fps, r.fps_base = int(fps), 1.0
        sc.frame_start = int(start)
        if end is not None:
            sc.frame_end = int(end)
        r.engine = _engine_id(engine)
        if samples:
            _set_samples(sc, samples)
        r.film_transparent = bool(transparent)
        for v in ([view] if view else []) + ["AgX", "Filmic", "Standard"]:
            try:
                sc.view_settings.view_transform = v
                break
            except Exception:
                continue
        return sc

    def clear(self, keep_cameras=False, keep_lights=False):
        """Deletes the scene's objects (and data no longer used)."""
        sc = bpy.context.scene
        n = 0
        for o in list(sc.objects):
            if keep_cameras and o.type == "CAMERA" or keep_lights and o.type == "LIGHT":
                continue
            bpy.data.objects.remove(o, do_unlink=True)
            n += 1
        try:
            bpy.data.orphans_purge(do_recursive=True)
        except Exception:
            pass
        return n

    def look_at(self, obj, target):
        """Turns obj (a camera, a light) to face target (a point or an object)."""
        bpy.context.view_layer.update()
        t = _as_vector(target)
        d = t - obj.matrix_world.translation
        obj.rotation_mode = "XYZ"
        obj.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
        return obj

    def camera(self, name="Camera", location=(0, -8, 1.6), target=(0, 0, 1.0), lens=35.0, focus=None, fstop=None):
        """Makes (or moves) a camera at location aimed at target, sets its lens, optional depth of field, and makes it the scene camera."""
        cam = bpy.data.objects.get(name)
        if cam is None or cam.type != "CAMERA":
            cam = _link(bpy.data.objects.new(name, bpy.data.cameras.new(name)))
        cam.location = tuple(location)
        cam.data.lens = float(lens)
        bpy.context.view_layer.update()
        self.look_at(cam, target)
        if focus is not None:
            cam.data.dof.use_dof = True
            if hasattr(focus, "name") and hasattr(focus, "location"):
                cam.data.dof.focus_object = focus
            else:
                cam.data.dof.focus_distance = float(focus)
            if fstop:
                cam.data.dof.aperture_fstop = float(fstop)
        bpy.context.scene.camera = cam
        return cam

    # ---- files ---------------------------------------------------------------------------------------------------------
    def fetch(self, url, name=None):
        """Downloads a file once (kept in a cache) and returns its local path."""
        url = str(url)
        if os.path.isfile(url):
            return url
        ext = os.path.splitext(urllib.parse.urlparse(url).path)[1][:8] or ""
        key = hashlib.sha1(url.encode("utf-8")).hexdigest()[:16]
        dest = os.path.join(_cache_dir("files"), (name or key) + ext)
        if not os.path.isfile(dest):
            _download(url, dest)
        return dest

    # ---- Poly Haven (polyhaven.com, CC0) ----------------------------------------------------------------------------------
    _ph_lists = {}

    def _ph(self, path):
        req = urllib.request.Request("https://api.polyhaven.com" + path, headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=60, context=_CTX) as r:
            return json.loads(r.read())

    def polyhaven_search(self, kind="hdris", query="", limit=12):
        """Free CC0 assets from Poly Haven: kind 'hdris' | 'textures' | 'models'; returns [{id, name, categories, tags}], best matches first."""
        kind = {"hdri": "hdris", "texture": "textures", "model": "models"}.get(kind, kind)
        if kind not in ("hdris", "textures", "models"):
            raise ValueError("kind must be 'hdris', 'textures' or 'models'")
        if kind not in self._ph_lists:
            self._ph_lists[kind] = self._ph("/assets?type=" + kind)
        words = [w for w in re.split(r"[^a-z0-9]+", str(query).lower()) if w]
        rows = []
        for aid, a in self._ph_lists[kind].items():
            hay = " ".join([aid, a.get("name", "")] + list(a.get("tags", [])) + list(a.get("categories", []))).lower()
            score = sum(3 if w in a.get("name", "").lower() else 2 if w in " ".join(a.get("tags", [])).lower() else 1 for w in words if w in hay)
            if words and score == 0:
                continue
            rows.append((score, a.get("download_count", 0), {"id": aid, "name": a.get("name", aid), "categories": a.get("categories", [])[:6], "tags": a.get("tags", [])[:8]}))
        rows.sort(key=lambda x: (-x[0], -x[1]))
        return [r[2] for r in rows[: max(1, int(limit))]]

    def _ph_files(self, asset):
        try:
            return self._ph("/files/" + urllib.parse.quote(str(asset)))
        except urllib.error.HTTPError as e:
            if e.code == 404:
                raise ValueError("No Poly Haven asset %r: find ids with wallu.polyhaven_search(...)" % asset)
            raise

    def _credit(self, what, asset):
        _state["credits"].add(asset)
        _state["dirty"] = True
        print("Poly Haven %s: %s (CC0, polyhaven.com)" % (what, asset))

    def hdri(self, asset, resolution="2k", strength=1.0, rotation=0.0):
        """World lighting from a Poly Haven HDRI (id from polyhaven_search('hdris', ...)); rotation in degrees."""
        files = self._ph_files(asset).get("hdri") or {}
        res = resolution if resolution in files else next((r for r in ("2k", "1k", "4k") if r in files), None)
        if res is None:
            raise ValueError("No HDRI files for %r" % asset)
        f = files[res].get("hdr") or files[res].get("exr")
        path = self.fetch(f["url"], "%s_%s" % (asset, res))
        sc = bpy.context.scene
        world = sc.world or bpy.data.worlds.new("World")
        sc.world = world
        nt = _use_nodes(world)
        nt.nodes.clear()
        coord = nt.nodes.new("ShaderNodeTexCoord")
        mapping = nt.nodes.new("ShaderNodeMapping")
        env = nt.nodes.new("ShaderNodeTexEnvironment")
        bg = nt.nodes.new("ShaderNodeBackground")
        out = nt.nodes.new("ShaderNodeOutputWorld")
        env.image = bpy.data.images.load(path, check_existing=True)
        mapping.inputs["Rotation"].default_value[2] = math.radians(float(rotation))
        bg.inputs["Strength"].default_value = float(strength)
        nt.links.new(coord.outputs["Generated"], mapping.inputs["Vector"])
        nt.links.new(mapping.outputs["Vector"], env.inputs["Vector"])
        nt.links.new(env.outputs["Color"], bg.inputs["Color"])
        nt.links.new(bg.outputs["Background"], out.inputs["Surface"])
        for i, n in enumerate((coord, mapping, env, bg, out)):
            n.location = (i * 260 - 600, 0)
        self._credit("HDRI", asset)
        return world

    def texture(self, asset, resolution="1k", scale=1.0, name=None):
        """A PBR material from a Poly Haven texture (colour, roughness, normal). Assign it: obj.data.materials.append(mat)."""
        files = self._ph_files(asset)

        def url(*keys):
            for k in keys:
                if k in files:
                    by = files[k]
                    res = resolution if resolution in by else next((r for r in ("1k", "2k", "4k") if r in by), None)
                    if res and ("jpg" in by[res] or "png" in by[res]):
                        return (by[res].get("jpg") or by[res].get("png"))["url"]
            return None

        mat = bpy.data.materials.new(name or asset)
        nt = _use_nodes(mat)
        bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
        coord = nt.nodes.new("ShaderNodeTexCoord")
        mapping = nt.nodes.new("ShaderNodeMapping")
        mapping.inputs["Scale"].default_value = (float(scale), float(scale), float(scale))
        nt.links.new(coord.outputs["UV"], mapping.inputs["Vector"])

        def tex(u, colour, y):
            t = nt.nodes.new("ShaderNodeTexImage")
            t.image = bpy.data.images.load(self.fetch(u), check_existing=True)
            if not colour:
                try:
                    t.image.colorspace_settings.name = "Non-Color"
                except Exception:
                    pass
            t.location = (-500, y)
            nt.links.new(mapping.outputs["Vector"], t.inputs["Vector"])
            return t

        u = url("Diffuse", "diff")
        if u:
            nt.links.new(tex(u, True, 300).outputs["Color"], bsdf.inputs["Base Color"])
        u = url("Rough", "rough")
        if u:
            nt.links.new(tex(u, False, 0).outputs["Color"], bsdf.inputs["Roughness"])
        u = url("nor_gl", "Normal")
        if u:
            nm = nt.nodes.new("ShaderNodeNormalMap")
            nm.location = (-200, -300)
            nt.links.new(tex(u, False, -300).outputs["Color"], nm.inputs["Color"])
            nt.links.new(nm.outputs["Normal"], bsdf.inputs["Normal"])
        self._credit("texture", asset)
        return mat

    def model(self, asset, resolution="1k", location=(0, 0, 0), scale=1.0):
        """Imports a Poly Haven model (glTF) and returns its objects; its top objects go to location, scaled."""
        files = self._ph_files(asset).get("gltf") or {}
        res = resolution if resolution in files else next((r for r in ("1k", "2k", "4k") if r in files), None)
        if res is None:
            raise ValueError("No glTF files for %r" % asset)
        g = files[res]["gltf"]
        folder = _cache_dir("models", "%s_%s" % (asset, res))
        main = os.path.join(folder, os.path.basename(urllib.parse.urlparse(g["url"]).path))
        if not os.path.isfile(main):
            for rel, inc in (g.get("include") or {}).items():
                dest = os.path.normpath(os.path.join(folder, rel))
                if not dest.startswith(os.path.normpath(folder)):
                    continue
                os.makedirs(os.path.dirname(dest), exist_ok=True)
                if not os.path.isfile(dest):
                    _download(inc["url"], dest)
            _download(g["url"], main)
        before = set(bpy.data.objects)
        with _ctx():
            bpy.ops.import_scene.gltf(filepath=main)
        new = [o for o in bpy.data.objects if o not in before]
        for o in new:
            if o.parent is None or o.parent not in new:
                o.location = tuple(a + b for a, b in zip(o.location, location))
                o.scale = tuple(v * float(scale) for v in o.scale)
        self._credit("model", asset)
        return new

    # ---- 2D in 3D: cut-out characters and props -------------------------------------------------------------------------------
    def image_plane(self, source, name=None, height=1.8, location=(0, 0, 0), rotation_z=0.0, emission=True, shadow=True,
                    shadow_strength=0.6, face_camera=False):
        """A flat picture standing upright (facing -Y), its bottom edge at location, `height` metres tall; emission=True keeps it
        unlit by the set (a flat 2D character); shadow adds a soft contact shadow under it; face_camera keeps it turned to the camera."""
        path = self.fetch(source) if not os.path.isfile(str(source)) else str(source)
        img = bpy.data.images.load(path, check_existing=True)
        w, h = img.size
        if not w or not h:
            raise ValueError("Could not read the picture %r" % source)
        height = float(height)
        width = height * w / float(h)
        label = name or os.path.splitext(os.path.basename(path))[0][:40]
        mesh = bpy.data.meshes.new(label)
        mesh.from_pydata([(-width / 2, 0, 0), (width / 2, 0, 0), (width / 2, 0, height), (-width / 2, 0, height)], [], [(0, 1, 2, 3)])
        uv = mesh.uv_layers.new(name="UVMap")
        for i, co in enumerate(((0, 0), (1, 0), (1, 1), (0, 1))):
            uv.data[i].uv = co
        mesh.update()
        obj = _link(bpy.data.objects.new(label, mesh))
        obj.location = tuple(location)
        obj.rotation_euler = (0, 0, math.radians(float(rotation_z)))
        mat = bpy.data.materials.new(label)
        nt = _use_nodes(mat)
        nt.nodes.clear()
        tex = nt.nodes.new("ShaderNodeTexImage")
        tex.image = img
        tex.interpolation = "Linear"
        tex.extension = "CLIP"
        out = nt.nodes.new("ShaderNodeOutputMaterial")
        mix = nt.nodes.new("ShaderNodeMixShader")
        clear = nt.nodes.new("ShaderNodeBsdfTransparent")
        if emission:
            surf = nt.nodes.new("ShaderNodeEmission")
            nt.links.new(tex.outputs["Color"], surf.inputs["Color"])
            surf.inputs["Strength"].default_value = 1.0
            nt.links.new(surf.outputs["Emission"], mix.inputs[2])
        else:
            surf = nt.nodes.new("ShaderNodeBsdfPrincipled")
            nt.links.new(tex.outputs["Color"], surf.inputs["Base Color"])
            nt.links.new(surf.outputs["BSDF"], mix.inputs[2])
        nt.links.new(tex.outputs["Alpha"], mix.inputs["Fac"])
        nt.links.new(clear.outputs["BSDF"], mix.inputs[1])
        nt.links.new(mix.outputs["Shader"], out.inputs["Surface"])
        for i, n in enumerate((tex, clear, surf, mix, out)):
            n.location = (i * 240 - 600, 0)
        _set_blended(mat)
        mesh.materials.append(mat)
        if shadow:
            self.contact_shadow(obj, strength=shadow_strength)
        if face_camera and bpy.context.scene.camera:
            c = obj.constraints.new("DAMPED_TRACK")
            c.target = bpy.context.scene.camera
            c.track_axis = "TRACK_NEGATIVE_Y"
        return obj

    def contact_shadow(self, obj, strength=0.6, width=None, depth=None):
        """A soft dark ellipse on the floor under obj (at its lowest point), parented to it so it follows."""
        from mathutils import Vector
        bpy.context.view_layer.update()
        corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
        xs = [c.x for c in corners]
        ys = [c.y for c in corners]
        z = min(c.z for c in corners) + 0.003
        width = float(width) if width else max(0.2, (max(xs) - min(xs)) * 0.95)
        depth = float(depth) if depth else max(0.12, width * 0.35)
        cx, cy = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2
        mesh = bpy.data.meshes.new(obj.name + "_shadow")
        mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], [], [(0, 1, 2, 3)])
        uv = mesh.uv_layers.new(name="UVMap")
        for i, co in enumerate(((0, 0), (1, 0), (1, 1), (0, 1))):
            uv.data[i].uv = co
        sh = _link(bpy.data.objects.new(obj.name + "_shadow", mesh))
        sh.location = (cx, cy, z)
        sh.scale = (width, depth, 1)
        mat = bpy.data.materials.new(obj.name + "_shadow")
        nt = _use_nodes(mat)
        nt.nodes.clear()
        coord = nt.nodes.new("ShaderNodeTexCoord")
        mapping = nt.nodes.new("ShaderNodeMapping")
        mapping.inputs["Location"].default_value = (-1.0, -1.0, 0.0)
        mapping.inputs["Scale"].default_value = (2.0, 2.0, 1.0)
        grad = nt.nodes.new("ShaderNodeTexGradient")
        grad.gradient_type = "SPHERICAL"
        ramp = nt.nodes.new("ShaderNodeValToRGB")
        ramp.color_ramp.interpolation = "EASE"
        ramp.color_ramp.elements[0].position = 0.0
        ramp.color_ramp.elements[0].color = (0, 0, 0, 1)
        ramp.color_ramp.elements[1].position = 0.85
        ramp.color_ramp.elements[1].color = (1, 1, 1, 1)
        amount = nt.nodes.new("ShaderNodeMath")
        amount.operation = "MULTIPLY"
        amount.inputs[1].default_value = float(strength)
        dark = nt.nodes.new("ShaderNodeEmission")  # black, unlit: only darkens what is under it
        dark.inputs["Color"].default_value = (0, 0, 0, 1)
        dark.inputs["Strength"].default_value = 0.0
        clear = nt.nodes.new("ShaderNodeBsdfTransparent")
        mix = nt.nodes.new("ShaderNodeMixShader")
        out = nt.nodes.new("ShaderNodeOutputMaterial")
        nt.links.new(coord.outputs["UV"], mapping.inputs["Vector"])
        nt.links.new(mapping.outputs["Vector"], grad.inputs["Vector"])
        nt.links.new(grad.outputs["Fac"], ramp.inputs["Fac"])
        nt.links.new(ramp.outputs["Color"], amount.inputs[0])
        nt.links.new(amount.outputs["Value"], mix.inputs["Fac"])
        nt.links.new(clear.outputs["BSDF"], mix.inputs[1])
        nt.links.new(dark.outputs[0], mix.inputs[2])
        nt.links.new(mix.outputs["Shader"], out.inputs["Surface"])
        for i, n in enumerate((coord, mapping, grad, ramp, amount, clear, dark, mix, out)):
            n.location = (i * 200 - 900, 0)
        _set_blended(mat)
        mesh.materials.append(mat)
        sh.parent = obj
        sh.matrix_parent_inverse = obj.matrix_world.inverted()
        return sh


wallu = _Wallu()


# ---------------------------------------------------------------------------------------------------------------------
# The Wallu tab (3D Viewport sidebar)
# ---------------------------------------------------------------------------------------------------------------------

class WALLU_OT_connect(bpy.types.Operator):
    bl_idname = "wallu.connect"
    bl_label = "Connect"
    bl_description = "Connect this Blender to your Wallu account with the code Claude gave you"

    def execute(self, context):
        code = context.window_manager.wallu_code.strip()
        if not code:
            self.report({"ERROR"}, "Type the code Claude gave you (ask Claude: \"connect my Blender\").")
            return {"CANCELLED"}
        if not _online_ok():
            self.report({"ERROR"}, "Blender's online access is off: press \"Allow online access\" first.")
            return {"CANCELLED"}
        name = "%s, Blender %s" % (platform.node() or "this computer", bpy.app.version_string)
        try:
            status, data = _http("POST", API + "/pair", body={"code": code, "name": name, "blender": bpy.app.version_string, "addon": ADDON_VERSION}, timeout=30, auth=False)
        except Exception as e:
            self.report({"ERROR"}, "Could not reach Wallu: %s" % e)
            return {"CANCELLED"}
        if status != 200 or not data.get("token"):
            self.report({"ERROR"}, data.get("error") or ("Wallu answered %s" % status))
            return {"CANCELLED"}
        _stop_bridge()
        _state.update(token=data["token"], device=data.get("device", ""), account=data.get("account", ""), paused=False)
        _save_config()
        context.window_manager.wallu_code = ""
        _start_bridge(force=True)
        _log("connect", True, data.get("account", ""))
        if data.get("addon") and data["addon"] != ADDON_VERSION:
            self.report({"WARNING"}, "Connected. A newer Wallu add-on (%s) is out: %s/api/v1/mcp/blender/addon" % (data["addon"], SERVER))
        else:
            self.report({"INFO"}, "Connected to Wallu: tell Claude it can start.")
        return {"FINISHED"}


class WALLU_OT_disconnect(bpy.types.Operator):
    bl_idname = "wallu.disconnect"
    bl_label = "Disconnect"
    bl_description = "Stop Claude using this Blender (pairing again needs a new code)"

    def execute(self, context):
        if _state["token"]:
            try:
                _http("POST", API + "/unpair", body={}, timeout=15)
            except Exception:
                pass
        _stop_bridge()
        _release_lock()
        _state.update(token="", device="", account="", paused=False, elsewhere=False)
        _save_config()
        _set_status("Not connected")
        _log("disconnect", True)
        return {"FINISHED"}


class WALLU_OT_pause(bpy.types.Operator):
    bl_idname = "wallu.pause"
    bl_label = "Pause"
    bl_description = "Pause or resume Claude's commands in this Blender"

    def execute(self, context):
        _state["paused"] = not _state["paused"]
        _set_status("Paused" if _state["paused"] else "Connected: waiting for Claude")
        return {"FINISHED"}


class WALLU_OT_allow_online(bpy.types.Operator):
    bl_idname = "wallu.allow_online"
    bl_label = "Allow online access"
    bl_description = "Blender keeps add-ons offline until you allow it (Preferences > System > Network)"

    def execute(self, context):
        try:
            context.preferences.system.use_online_access = True
            bpy.ops.wm.save_userpref()
        except Exception as e:
            self.report({"ERROR"}, "Turn it on in Edit > Preferences > System > Network: %s" % e)
            return {"CANCELLED"}
        if not _online_ok():
            self.report({"WARNING"}, "Blender was started offline (--offline-mode): restart it normally.")
        _start_bridge(force=True)
        return {"FINISHED"}


class WALLU_OT_take_over(bpy.types.Operator):
    bl_idname = "wallu.take_over"
    bl_label = "Use this Blender"
    bl_description = "Connect this window to Wallu instead of the other Blender window (one window per computer is connected)"

    def execute(self, context):
        _load_config()  # the other window may have paired again since this one started
        if not _state["token"]:
            self.report({"ERROR"}, "This Blender is not connected: ask Claude for a code.")
            return {"CANCELLED"}
        _stop_bridge()
        _start_bridge(force=True)
        _log("switch", True, "this window")
        return {"FINISHED"}


class WALLU_OT_cancel_render(bpy.types.Operator):
    bl_idname = "wallu.cancel_render"
    bl_label = "Stop render"
    bl_description = "Stop the render Claude started"

    def execute(self, context):
        _h_cancel({})
        return {"FINISHED"}


class WALLU_PT_panel(bpy.types.Panel):
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"
    bl_category = "Wallu"
    bl_label = "Wallu: Claude"

    def draw(self, context):
        col = self.layout.column(align=False)
        if not _online_ok():
            box = col.box()
            box.label(text="Blender keeps add-ons offline.", icon="ERROR")
            box.operator("wallu.allow_online", icon="WORLD")
        if not _state["token"]:
            col.label(text="Ask Claude: \"connect my Blender\"", icon="INFO")
            col.prop(context.window_manager, "wallu_code", text="Code")
            col.operator("wallu.connect", icon="LINKED")
            if _state["status"] not in ("Not connected", ""):
                col.label(text=_state["status"])
            return
        col.label(text=_state["account"] or "Connected", icon="CHECKMARK")
        if _state["elsewhere"]:
            box = col.box()
            box.label(text=_state["status"], icon="INFO")
            box.operator("wallu.take_over", icon="LINKED")
            return
        col.label(text=_state["status"], icon="PAUSE" if _state["paused"] else "PLAY")
        if _state["busy"]:
            col.label(text="Working: %s" % _state["busy"], icon="TIME")
        for j in _jobs.values():
            if j["status"] in ("rendering", "uploading"):
                box = col.box()
                box.label(text=("Rendering: %d of %d frames" % (j["done"], j["total"])) if j["status"] == "rendering" else "Uploading the render to Wallu", icon="RENDER_ANIMATION")
                box.operator("wallu.cancel_render", icon="CANCEL")
        row = col.row(align=True)
        row.operator("wallu.pause", text="Resume" if _state["paused"] else "Pause", icon="PLAY" if _state["paused"] else "PAUSE")
        row.operator("wallu.disconnect", icon="UNLINKED")
        if _state["log"]:
            box = col.box()
            for t, op, ok, note in list(_state["log"])[-6:]:
                box.label(text="%s %s %s" % (t, op, note), icon="CHECKMARK" if ok else "ERROR")
        col.label(text="Claude can run Python here while connected.", icon="CONSOLE")
        if _state["credits"]:
            col.label(text="Assets: Poly Haven (polyhaven.com), CC0", icon="WORLD")


_CLASSES = (WALLU_OT_connect, WALLU_OT_disconnect, WALLU_OT_pause, WALLU_OT_allow_online, WALLU_OT_take_over, WALLU_OT_cancel_render,
            WALLU_PT_panel)


def register():
    for c in _CLASSES:
        bpy.utils.register_class(c)
    bpy.types.WindowManager.wallu_code = bpy.props.StringProperty(name="Code", description="The pairing code Claude gave you")
    # The background Blender that renders for this add-on loads it too: it must not connect.
    if os.environ.get("WALLU_NO_BRIDGE") or (bpy.app.background and not os.environ.get("WALLU_TEST_BRIDGE")):
        return
    _load_config()
    if not _paths.get("atexit"):
        atexit.register(_release_lock)  # closing Blender frees the connection for another window at once
        _paths["atexit"] = True
    if not bpy.app.timers.is_registered(_tick):
        bpy.app.timers.register(_tick, first_interval=1.0, persistent=True)
    _start_bridge()


def unregister():
    _stop_bridge()
    _release_lock()
    if bpy.app.timers.is_registered(_tick):
        bpy.app.timers.unregister(_tick)
    for j in list(_jobs.values()):
        if j["status"] == "rendering":
            j["cancelled"] = True
            try:
                j["proc"].kill()
            except Exception:
                pass
    try:
        del bpy.types.WindowManager.wallu_code
    except Exception:
        pass
    for c in reversed(_CLASSES):
        try:
            bpy.utils.unregister_class(c)
        except Exception:
            pass


if __name__ == "__main__":
    register()
