"""Renderer Metal natif via pyobjc.

Architecture :
  - MTKView         : NSView Metal-managee, callback drawInMTKView
  - MTLDevice       : GPU par defaut
  - MTLCommandQueue : queue de submission
  - 2 pipelines :
      bg_pipeline   : fullscreen tri + fbm fragment shader (1 draw call)
      skel_pipeline : lignes pour le squelette pose (max 16 segments)
  - Uniforms buffer 56 octets (14 floats) cf scene.metal::SceneUniforms

Le renderer ne possede PAS de state thread : il lit le State partage
sous lock a chaque frame (60 fps).
"""
from __future__ import annotations

import logging
import struct
import time
from pathlib import Path

import numpy as np

import objc
from Cocoa import NSObject, NSColor, NSMakeRect
from Metal import (
    MTLCreateSystemDefaultDevice,
    MTLCompileOptions,
    MTLPrimitiveTypeTriangle,
    MTLPrimitiveTypeLine,
    MTLRenderPipelineDescriptor,
    MTLResourceStorageModeShared,
    MTLVertexAttributeDescriptor,
    MTLVertexBufferLayoutDescriptor,
    MTLVertexDescriptor,
    MTLVertexFormatFloat,
    MTLVertexFormatFloat2,
    MTLVertexFormatFloat3,
    MTLVertexStepFunctionPerVertex,
)
# MTKViewDelegate est un @protocol Obj-C ; pas besoin d'import Python.
# pyobjc detecte automatiquement l'implementation par signature.
from MetalKit import MTKView  # noqa: F401  (utilise par d'autres modules)

from .mesh_topology import (
    BODY_TRIANGLES,
    FACE_TRIANGLES,
    HAND_TRIANGLES,
    build_face_triangles_dynamic,
)
from .state import State

LOG = logging.getLogger("renderer")

# Triangle primitive constant (Metal MTLPrimitiveType.triangle = 3)
MTL_PRIMITIVE_TRIANGLE = 3

# 17 keypoints COCO bones (16 paires) — legacy YOLO fallback
COCO_BONES: list[tuple[int, int]] = [
    (0, 1), (0, 2), (1, 3), (2, 4),
    (5, 6), (5, 7), (7, 9), (6, 8), (8, 10),
    (5, 11), (6, 12), (11, 12),
    (11, 13), (13, 15), (12, 14), (14, 16),
]


def _mediapipe_bones():
    """Charge les connexions MediaPipe (body+face+hands) au runtime.
    Retourne 4 listes : (body_bones, face_bones, lhand_bones, rhand_bones)
    avec chaque bone = (idx_a, idx_b).
    Retourne None si mediapipe absent (fallback COCO)."""
    try:
        from mediapipe.tasks.python.vision import (
            FaceLandmarksConnections as F,
            HandLandmarksConnections as H,
            PoseLandmarksConnections as P,
        )
    except ImportError:
        return None

    def to_pairs(cs):
        return [(c.start, c.end) for c in cs]

    # 35 body
    body = to_pairs(P.POSE_LANDMARKS)
    # Visage HAUTE DENSITE : tesselation complete (2556) + contours +
    # iris + nez. Donne ~2700 segments rien que pour le visage.
    face = (
        to_pairs(F.FACE_LANDMARKS_TESSELATION)   # 2556 segs (mesh dense)
        + to_pairs(F.FACE_LANDMARKS_FACE_OVAL)
        + to_pairs(F.FACE_LANDMARKS_LIPS)
        + to_pairs(F.FACE_LANDMARKS_LEFT_EYE)
        + to_pairs(F.FACE_LANDMARKS_RIGHT_EYE)
        + to_pairs(F.FACE_LANDMARKS_LEFT_EYEBROW)
        + to_pairs(F.FACE_LANDMARKS_RIGHT_EYEBROW)
        + to_pairs(F.FACE_LANDMARKS_LEFT_IRIS)
        + to_pairs(F.FACE_LANDMARKS_RIGHT_IRIS)
        + to_pairs(F.FACE_LANDMARKS_NOSE)
    )
    # 21 mains (chacune)
    hand = to_pairs(H.HAND_CONNECTIONS)
    return body, face, hand, hand   # left+right partagent les connexions


# Capacite max du vertex buffer skeleton.
# 16384 segs * 2 verts * 5 floats (xyz + conf + pid) * 4 bytes = 640 KB
# Couvre 4 personnes : 4×35 body + 4×2700 face + 8×21 hand = ~11000 segs.
SKEL_MAX_SEGS = 16384
SKEL_VERT_FLOATS = 5    # x, y, z, conf, person_id

# Mesh : ~8192 triangles × 3 verts × 5 floats × 4 = 480 KB.
# Couvre 4 personnes : 4×~80 face + 4×~16 body + 8×~18 hand ≈ 600 triangles
# pour le hardcode, ou ~4×~150 face Delaunay = ~600 triangles. Marge large.
MESH_MAX_TRIS = 8192
MESH_VERT_FLOATS = 5    # identique au skel
MESH_MAX_VERTS = 10475  # SMPL-X is the larger family; SMPL (6890) fits inside

# struct SceneUniforms : 17 floats packs = 68 octets, padding a 80 (multiple
# de 16, regle Metal). On y stocke (time, rms, kp_norm, netz_dev,
# lightning_flash, flare, wind_norm, bz_norm, social_rate, pose_alive,
# pose_count, width, height, viz_mode, _pad0, _pad1).
UNIFORM_FLOATS = 24   # +4 floats : hand_l_x/y, hand_r_x/y + hand_height, hand_openness, hand_speed, hand_dist
UNIFORM_SIZE = UNIFORM_FLOATS * 4   # 96 octets, aligne 16


class MetalRenderer(NSObject):
    """Delegate de MTKView, drive l'integralite du rendu."""

    def initWithState_(self, state: State):  # noqa: N802
        self = objc.super(MetalRenderer, self).init()
        if self is None:
            return None
        self._state = state
        self._device = MTLCreateSystemDefaultDevice()
        if self._device is None:
            raise RuntimeError("Metal non disponible sur ce systeme")
        LOG.info("device: %s", self._device.name())
        self._queue = self._device.newCommandQueue()
        self._uniforms_buf = self._device.newBufferWithLength_options_(
            UNIFORM_SIZE, MTLResourceStorageModeShared)
        # Skeleton : N segs × 2 verts × 5 floats (xyz + conf + pid)
        self._skel_buf = self._device.newBufferWithLength_options_(
            SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS * 4, MTLResourceStorageModeShared)
        # Mesh buffer : triangles face/hand/body
        self._mesh_buf = self._device.newBufferWithLength_options_(
            MESH_MAX_TRIS * 3 * MESH_VERT_FLOATS * 4, MTLResourceStorageModeShared)
        self._mp_bones = _mediapipe_bones()  # None si pas dispo
        self._init_skel_cpu_buffer()
        self._init_mesh_cpu_buffer()
        self._build_pipelines()
        self._last_lightning_emit = 0.0
        return self

    # ---- Pipelines -------------------------------------------------
    def _build_pipelines(self):
        src_path = Path(__file__).parent / "shaders" / "scene.metal"
        source = src_path.read_text()
        opts = MTLCompileOptions.alloc().init()
        lib, err = self._device.newLibraryWithSource_options_error_(
            source, opts, None)
        if lib is None:
            raise RuntimeError(f"shader compile failed: {err}")
        self._lib = lib

        # --- Background pipeline (no vertex buffer) ---
        bg = MTLRenderPipelineDescriptor.alloc().init()
        bg.setVertexFunction_(lib.newFunctionWithName_("bg_vertex"))
        bg.setFragmentFunction_(lib.newFunctionWithName_("bg_fragment"))
        bg.colorAttachments().objectAtIndexedSubscript_(0).setPixelFormat_(80)  # BGRA8Unorm
        p, err = self._device.newRenderPipelineStateWithDescriptor_error_(bg, None)
        if p is None:
            raise RuntimeError(f"bg pipeline failed: {err}")
        self._bg_pipe = p

        # --- Skeleton pipeline (vertex buffer pos+conf) ---
        # Skeleton vertex layout : pos (float3) + conf (float) + pid (float)
        # Stride = 5 floats × 4 bytes = 20 bytes
        vd = MTLVertexDescriptor.vertexDescriptor()
        a0 = vd.attributes().objectAtIndexedSubscript_(0)
        a0.setFormat_(MTLVertexFormatFloat3); a0.setOffset_(0); a0.setBufferIndex_(0)
        a1 = vd.attributes().objectAtIndexedSubscript_(1)
        a1.setFormat_(MTLVertexFormatFloat); a1.setOffset_(12); a1.setBufferIndex_(0)
        a2 = vd.attributes().objectAtIndexedSubscript_(2)
        a2.setFormat_(MTLVertexFormatFloat); a2.setOffset_(16); a2.setBufferIndex_(0)
        ld = vd.layouts().objectAtIndexedSubscript_(0)
        ld.setStride_(20); ld.setStepFunction_(MTLVertexStepFunctionPerVertex)

        sk = MTLRenderPipelineDescriptor.alloc().init()
        sk.setVertexFunction_(lib.newFunctionWithName_("skel_vertex"))
        sk.setFragmentFunction_(lib.newFunctionWithName_("skel_fragment"))
        sk.setVertexDescriptor_(vd)
        ca = sk.colorAttachments().objectAtIndexedSubscript_(0)
        ca.setPixelFormat_(80)  # BGRA8Unorm
        ca.setBlendingEnabled_(True)
        # SrcAlpha, OneMinusSrcAlpha (= 4, 5 dans MTLBlendFactor)
        ca.setSourceRGBBlendFactor_(4)
        ca.setDestinationRGBBlendFactor_(5)
        ca.setSourceAlphaBlendFactor_(4)
        ca.setDestinationAlphaBlendFactor_(5)
        p, err = self._device.newRenderPipelineStateWithDescriptor_error_(sk, None)
        if p is None:
            raise RuntimeError(f"skel pipeline failed: {err}")
        self._skel_pipe = p

        # --- Mesh pipeline (triangles face/hand/body) ---
        # Vertex layout strictement identique au skel (reutilise vd).
        vd_mesh = MTLVertexDescriptor.vertexDescriptor()
        a0 = vd_mesh.attributes().objectAtIndexedSubscript_(0)
        a0.setFormat_(MTLVertexFormatFloat3); a0.setOffset_(0); a0.setBufferIndex_(0)
        a1 = vd_mesh.attributes().objectAtIndexedSubscript_(1)
        a1.setFormat_(MTLVertexFormatFloat); a1.setOffset_(12); a1.setBufferIndex_(0)
        a2 = vd_mesh.attributes().objectAtIndexedSubscript_(2)
        a2.setFormat_(MTLVertexFormatFloat); a2.setOffset_(16); a2.setBufferIndex_(0)
        ld2 = vd_mesh.layouts().objectAtIndexedSubscript_(0)
        ld2.setStride_(20); ld2.setStepFunction_(MTLVertexStepFunctionPerVertex)

        mp = MTLRenderPipelineDescriptor.alloc().init()
        mp.setVertexFunction_(lib.newFunctionWithName_("mesh_vertex"))
        mp.setFragmentFunction_(lib.newFunctionWithName_("mesh_fragment"))
        mp.setVertexDescriptor_(vd_mesh)
        cm = mp.colorAttachments().objectAtIndexedSubscript_(0)
        cm.setPixelFormat_(80)
        cm.setBlendingEnabled_(True)
        # SrcAlpha, OneMinusSrcAlpha — alpha classique pour voile mesh
        cm.setSourceRGBBlendFactor_(4)
        cm.setDestinationRGBBlendFactor_(5)
        cm.setSourceAlphaBlendFactor_(4)
        cm.setDestinationAlphaBlendFactor_(5)
        p, err = self._device.newRenderPipelineStateWithDescriptor_error_(mp, None)
        if p is None:
            raise RuntimeError(f"mesh pipeline failed: {err}")
        self._mesh_pipe = p

    # ---- CPU staging buffers --------------------------------------
    def _init_skel_cpu_buffer(self) -> None:
        """Preallocate the CPU staging buffer for skeleton segments.

        SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS floats : each segment = 2 verts × 5 floats
        (x, y, z, conf, pid).  Idempotent — no-op if already allocated.
        """
        if getattr(self, "_skel_cpu_buf", None) is None:
            self._skel_cpu_buf = np.zeros(SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS, dtype=np.float32)

    def _init_mesh_cpu_buffer(self) -> None:
        if getattr(self, "_mesh_cpu_buf", None) is None:
            self._mesh_cpu_buf = np.zeros(
                MESH_MAX_VERTS * MESH_VERT_FLOATS, dtype=np.float32,
            )

    # ---- Uniforms helpers ------------------------------------------
    def _update_uniforms(self) -> int:
        s = self._state
        now = time.monotonic()
        with s.lock():
            # Flash de foudre : decay exponentiel ~600 ms
            dt_flash = now - s.last_lightning_t
            flash = max(0.0, 1.0 - dt_flash * 1.7) if dt_flash < 1.0 else 0.0
            # Positions des mains (point 0 = poignet) — pour mode hands3d
            lh_wrist = s.left_hand_kp[0] if s.hands_present else None
            rh_wrist = s.right_hand_kp[0] if s.hands_present else None
            hlx = (lh_wrist.x if lh_wrist else 0.5) * 2 - 1
            hly = 1 - (lh_wrist.y if lh_wrist else 0.5) * 2
            hrx = (rh_wrist.x if rh_wrist else 0.5) * 2 - 1
            hry = 1 - (rh_wrist.y if rh_wrist else 0.5) * 2
            # Expressive hand features from HandFeatureExtractor
            hf = getattr(s, "hand_feats", None) or {}
            hl, hr = hf.get("L"), hf.get("R")
            ys = [h["cy"] for h in (hl, hr) if h]
            hand_height = (1.0 - min(ys)) if ys else 0.0
            hand_open = max([h["openness"] for h in (hl, hr) if h] or [0.0])
            hand_speed = max([h["speed"] for h in (hl, hr) if h] or [0.0])
            hand_dist = float(hf.get("dist", 0.0))
            uniforms = struct.pack(
                f"{UNIFORM_FLOATS}f",
                s.elapsed(),                                       # 1
                min(1.0, s.rms * 3.0),                             # 2
                min(1.0, s.swpc_kp / 9.0),                         # 3
                max(-0.1, min(0.1, s.netz_dev)),                   # 4
                flash,                                             # 5
                s.swpc_flare_norm,                                 # 6
                max(0.0, min(1.0, (s.swpc_wind_speed - 280.0) / 600.0)),  # 7
                max(-1.0, min(1.0, s.swpc_bz / 15.0)),             # 8
                min(1.0, s.social_rate / 50.0),                    # 9
                1.0 if s.pose_alive() else 0.0,                    # 10
                float(s.pose_count),                               # 11
                float(s.width),                                    # 12
                float(s.height),                                   # 13
                float(s.viz_mode),                                 # 14
                hlx, hly, hrx, hry,                                # 15-18 wrist xy (kept)
                hand_height, hand_open,                            # 19-20
                hand_speed, hand_dist,                             # 21-22
                0.0, 0.0,                                          # 23-24 pad (16-byte align)
            )
            n_segs = self._update_skeleton(s)
            n_tris = self._update_mesh(s)
        # Copie via memoryview (pyobjc API : varlist.as_buffer(N))
        mv = self._uniforms_buf.contents().as_buffer(UNIFORM_SIZE)
        mv[:] = uniforms
        # Log debug toutes les 120 frames (~2s) — confirme que mesh draw
        if not hasattr(self, "_dbg_n"):
            self._dbg_n = 0
        self._dbg_n += 1
        if self._dbg_n % 120 == 0:
            LOG.info("render: %d segs, %d tris (face=%d hand=%d body=%d)",
                     n_segs, n_tris,
                     len(self._state.persons_face),
                     len(self._state.persons_hands),
                     len(self._state.persons_body))
        return n_segs, n_tris

    def _update_skeleton(self, s: State) -> int:
        """Remplit self._skel_buf avec les segments visibles. Retourne le
        nombre de segments (2 verts chacun).

        Priorise MediaPipe (body 33 + face 478 + 2 mains 21) si disponible
        et present ; sinon fallback COCO 17 keypoints YOLO."""
        if not s.pose_alive():
            return 0

        buf = self._skel_cpu_buf
        segs = 0

        def push(A, B, conf, pid):
            """Empile un segment (2 verts) dans le buffer CPU prealloque."""
            nonlocal segs
            if segs >= SKEL_MAX_SEGS:
                return False
            ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
            bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
            i = segs * 10
            buf[i+0] = ax; buf[i+1] = ay; buf[i+2] = float(A.z); buf[i+3] = conf; buf[i+4] = float(pid)
            buf[i+5] = bx; buf[i+6] = by; buf[i+7] = float(B.z); buf[i+8] = conf; buf[i+9] = float(pid)
            segs += 1
            return True

        if self._mp_bones is not None and (
            s.persons_body or s.persons_face or s.persons_hands or
            s.body_present or s.face_present or s.hands_present
        ):
            body_bones, face_bones, lhand_bones, _ = self._mp_bones
            # ----- MULTI-PERSONNE : pid = ID stable du tracker -----
            # (track_id persiste entre frames, palette se stabilise)
            ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
            ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
            ids_h = s.persons_hands_ids or list(range(len(s.persons_hands)))
            for i, body_kp in enumerate(s.persons_body):
                pid = ids_b[i] if i < len(ids_b) else i
                for a, b in body_bones:
                    if a >= len(body_kp) or b >= len(body_kp): continue
                    A = body_kp[a]; B = body_kp[b]
                    if A.c < 0.15 or B.c < 0.15: continue
                    if not push(A, B, min(A.c, B.c), pid): break
            for i, face_kp in enumerate(s.persons_face):
                pid = ids_f[i] if i < len(ids_f) else i
                for a, b in face_bones:
                    if a >= len(face_kp) or b >= len(face_kp): continue
                    if not push(face_kp[a], face_kp[b], 1.0, pid): break
                if segs >= SKEL_MAX_SEGS: break
            for i, hand_kp in enumerate(s.persons_hands):
                pid = ids_h[i] if i < len(ids_h) else i
                for a, b in lhand_bones:
                    if a >= len(hand_kp) or b >= len(hand_kp): continue
                    # Decalage palette mains (+5) pour les distinguer
                    if not push(hand_kp[a], hand_kp[b], 1.0, pid + 5): break
            # ----- FALLBACK single-person si persons_* vides -----
            if not (s.persons_body or s.persons_face or s.persons_hands):
                if s.body_present:
                    for a, b in body_bones:
                        if a >= len(s.body_kp) or b >= len(s.body_kp): continue
                        A = s.body_kp[a]; B = s.body_kp[b]
                        if A.c < 0.15 or B.c < 0.15: continue
                        if not push(A, B, min(A.c, B.c), 0): break
                if s.face_present:
                    for a, b in face_bones:
                        if a >= len(s.face_kp) or b >= len(s.face_kp): continue
                        if not push(s.face_kp[a], s.face_kp[b], 1.0, 0): break
                if s.hands_present:
                    for kp_list in (s.left_hand_kp, s.right_hand_kp):
                        if not any(p.x != 0.0 or p.y != 0.0 for p in kp_list):
                            continue
                        for a, b in lhand_bones:
                            if a >= len(kp_list) or b >= len(kp_list): continue
                            if not push(kp_list[a], kp_list[b], 1.0, 0): break
        else:
            # Fallback COCO 17 (YOLO legacy)
            for a, b in COCO_BONES:
                A = s.pose_kp[a]; B = s.pose_kp[b]
                if A.c < 0.2 or B.c < 0.2: continue
                if not push(A, B, min(A.c, B.c), 0): break

        if segs == 0:
            return 0
        data = self._skel_cpu_buf[: segs * 2 * SKEL_VERT_FLOATS].tobytes()
        mv = self._skel_buf.contents().as_buffer(len(data))
        mv[:] = data
        return segs

    def _update_mesh(self, s: State) -> int:
        """Remplit self._mesh_buf avec des triangles face/hand/body.

        Retourne le nombre de triangles ecrits (chacun = 3 vertices).
        Filtre les triangles dont au moins un sommet a confiance < 0.3.
        """
        if not s.pose_alive():
            return 0
        if not (s.persons_face or s.persons_hands or s.persons_body):
            return 0

        n_verts = 0

        def push_tri(kp_list, i, j, k, pid: int) -> bool:
            """Pousse un triangle (3 verts). Retourne False si buffer plein
            ou triangle invalide (confiance basse)."""
            nonlocal n_verts
            tris = n_verts // 3
            if tris >= MESH_MAX_TRIS or n_verts + 3 > MESH_MAX_VERTS:
                return False
            if i >= len(kp_list) or j >= len(kp_list) or k >= len(kp_list):
                return True   # skip mais continue
            A = kp_list[i]; B = kp_list[j]; C = kp_list[k]
            if A.c < 0.15 or B.c < 0.15 or C.c < 0.15:
                return True
            ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
            bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
            cx = C.x * 2.0 - 1.0; cy = 1.0 - C.y * 2.0
            conf = min(A.c, B.c, C.c)
            fpid = float(pid)
            base = n_verts * 5
            self._mesh_cpu_buf[base + 0] = ax
            self._mesh_cpu_buf[base + 1] = ay
            self._mesh_cpu_buf[base + 2] = float(A.z)
            self._mesh_cpu_buf[base + 3] = conf
            self._mesh_cpu_buf[base + 4] = fpid
            self._mesh_cpu_buf[base + 5] = bx
            self._mesh_cpu_buf[base + 6] = by
            self._mesh_cpu_buf[base + 7] = float(B.z)
            self._mesh_cpu_buf[base + 8] = conf
            self._mesh_cpu_buf[base + 9] = fpid
            self._mesh_cpu_buf[base + 10] = cx
            self._mesh_cpu_buf[base + 11] = cy
            self._mesh_cpu_buf[base + 12] = float(C.z)
            self._mesh_cpu_buf[base + 13] = conf
            self._mesh_cpu_buf[base + 14] = fpid
            n_verts += 3
            return True

        ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
        ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
        ids_h = s.persons_hands_ids or list(range(len(s.persons_hands)))

        # Body
        for i, body_kp in enumerate(s.persons_body):
            pid = ids_b[i] if i < len(ids_b) else i
            for a, b, c in BODY_TRIANGLES:
                if not push_tri(body_kp, a, b, c, pid):
                    break
            if n_verts >= MESH_MAX_VERTS:
                break

        # Face — utilise triangulation Delaunay dynamique sur les XY,
        # fallback sur FACE_TRIANGLES statique si Delaunay echoue.
        for i, face_kp in enumerate(s.persons_face):
            pid = ids_f[i] if i < len(ids_f) else i
            pts_xy = [(kp.x, kp.y) for kp in face_kp]
            tri_list = build_face_triangles_dynamic(pts_xy) or FACE_TRIANGLES
            for a, b, c in tri_list:
                if not push_tri(face_kp, a, b, c, pid):
                    break
            if n_verts >= MESH_MAX_VERTS:
                break

        # Hands — decalage palette +5 comme dans le skel
        for i, hand_kp in enumerate(s.persons_hands):
            pid = (ids_h[i] if i < len(ids_h) else i) + 5
            for a, b, c in HAND_TRIANGLES:
                if not push_tri(hand_kp, a, b, c, pid):
                    break
            if n_verts >= MESH_MAX_VERTS:
                break

        if n_verts == 0:
            return 0
        # Slice is exact — stale floats beyond n_verts*MESH_VERT_FLOATS never reach the GPU.
        data = self._mesh_cpu_buf[: n_verts * MESH_VERT_FLOATS].tobytes()
        mv = self._mesh_buf.contents().as_buffer(len(data))
        mv[:] = data
        return n_verts // 3

    # ---- MTKViewDelegate ------------------------------------------
    def mtkView_drawableSizeWillChange_(self, view, size):  # noqa: N802
        with self._state.lock():
            self._state.width = int(size.width)
            self._state.height = int(size.height)

    def drawInMTKView_(self, view):  # noqa: N802
        n_segs, n_tris = self._update_uniforms()
        rpd = view.currentRenderPassDescriptor()
        drawable = view.currentDrawable()
        if rpd is None or drawable is None:
            return
        cb = self._queue.commandBuffer()
        enc = cb.renderCommandEncoderWithDescriptor_(rpd)

        # 1) background fullscreen tri
        enc.setRenderPipelineState_(self._bg_pipe)
        enc.setFragmentBuffer_offset_atIndex_(self._uniforms_buf, 0, 0)
        enc.drawPrimitives_vertexStart_vertexCount_(MTLPrimitiveTypeTriangle, 0, 3)

        # 2) mesh overlay (triangles face/hand/body) — DESSOUS le skel
        if n_tris > 0:
            enc.setRenderPipelineState_(self._mesh_pipe)
            enc.setVertexBuffer_offset_atIndex_(self._mesh_buf, 0, 0)
            enc.setVertexBuffer_offset_atIndex_(self._uniforms_buf, 0, 1)
            enc.drawPrimitives_vertexStart_vertexCount_(
                MTL_PRIMITIVE_TRIANGLE, 0, n_tris * 3)

        # 3) skeleton overlay (lignes par-dessus le mesh)
        if n_segs > 0:
            enc.setRenderPipelineState_(self._skel_pipe)
            enc.setVertexBuffer_offset_atIndex_(self._skel_buf, 0, 0)
            # SceneUniforms a buffer(1) du vertex pour acceder a U.rms etc
            enc.setVertexBuffer_offset_atIndex_(self._uniforms_buf, 0, 1)
            enc.drawPrimitives_vertexStart_vertexCount_(
                MTLPrimitiveTypeLine, 0, n_segs * 2)

        enc.endEncoding()
        cb.presentDrawable_(drawable)
        cb.commit()

    def device(self):
        return self._device
