"""
maya_gltf_exporter.py  —  glTF / GLB Exporter for Maya  (script tool)
══════════════════════════════════════════════════════════════════════

This is a Python script tool — NOT a Maya plug-in.
Copy to your Maya scripts folder, then import or run from the Script Editor.

Setup (one time)
----------------
Copy this file to:
  Windows : C:/Users/<you>/Documents/maya/2027/scripts/
  Mac/Linux: ~/Documents/maya/2027/scripts/

Run (Script Editor → Python)
----------------------------
import maya_gltf_exporter as gltf
gltf = gltf.load()          # reload from scripts folder (avoids stale plug-ins copy)
gltf.show_export_dialog()

gltf.export_gltf(r"C:/out/scene.glb")
gltf.export_gltf(r"C:/out/scene.gltf", export_all=False, scale=0.01, up_axis="Y")

Optional shelf button (run once):
  maya_gltf_exporter.setup_shelf()

Features
--------
  Mesh, multi-material, skinning, blend shapes, textures, animation
  GLB or glTF + .bin · TEXCOORD_0/1 · standardSurface / OpenPBR Surface PBR

Import: use  maya_gltf_importer.py  (companion script)
"""

import maya.api.OpenMaya as om
import maya.api.OpenMayaAnim as oma
import maya.OpenMayaUI as omui
import maya.cmds as cmds
import maya.mel as mel
import struct, json, math, os, base64, tempfile, traceback, time, functools
import html as _html
import sys
from collections import OrderedDict

# Qt — try PySide6 (Maya 2025+), fall back to PySide2, then stub so the
# module always imports even in contexts where Qt is not yet initialised.
try:
    from PySide6 import QtWidgets, QtCore, QtGui
    from shiboken6 import wrapInstance
except ImportError:
    try:
        from PySide2 import QtWidgets, QtCore, QtGui
        from shiboken2 import wrapInstance
    except ImportError:
        # Qt unavailable — export dialog won't open in headless Maya
        QtWidgets = QtCore = QtGui = None
        def wrapInstance(ptr, base): return None

_MODULE_NAME = "maya_gltf_exporter"


def _maya_scripts_dir():
    try:
        d = cmds.internalVar(userScriptDir=True)
        if d and os.path.isdir(d):
            return os.path.normpath(d)
    except Exception:
        pass
    return None


def _tool_script_path():
    scripts = _maya_scripts_dir()
    if scripts:
        p = os.path.join(scripts, _MODULE_NAME + ".py")
        if os.path.isfile(p):
            return p
    try:
        return os.path.abspath(__file__)
    except NameError:
        return None


try:
    _THIS_FILE = os.path.abspath(__file__)
except NameError:
    _THIS_FILE = _tool_script_path() or ""

_SCRIPT_DIR = os.path.dirname(_THIS_FILE) if _THIS_FILE else (_maya_scripts_dir() or "")
if _SCRIPT_DIR and _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)


def load():
    """Load or reload this tool from the Maya scripts folder."""
    import importlib.util
    path = _tool_script_path()
    if not path or not os.path.isfile(path):
        raise RuntimeError(
            "[glTF Exporter] Copy maya_gltf_exporter.py to your Maya scripts folder: "
            + (_maya_scripts_dir() or ".../Documents/maya/2027/scripts/"))
    for key in list(sys.modules):
        if key == _MODULE_NAME or key.startswith(_MODULE_NAME + "."):
            del sys.modules[key]
    spec = importlib.util.spec_from_file_location(_MODULE_NAME, path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[_MODULE_NAME] = mod
    spec.loader.exec_module(mod)
    if (_THIS_FILE and "plug-ins" in _THIS_FILE.replace("\\", "/").lower()
            and os.path.normcase(path) != os.path.normcase(_THIS_FILE)):
        cmds.warning(
            "[glTF Exporter] Stale copy in plug-ins/ was bypassed. "
            "Remove Documents/maya/plug-ins/maya_gltf_exporter.py to fix plain import.")
    return mod

# ─── glTF component / target constants ────────────────────────────────────
FLOAT          = 5126
UNSIGNED_SHORT = 5123
UNSIGNED_INT   = 5125
UNSIGNED_BYTE  = 5121

ARRAY_BUF   = 34962
ELEMENT_BUF = 34963

# ── Log routing ───────────────────────────────────────────────────────────────
# When the export dialog is open its `log()` method is stored here so that
# every part of the codebase can write to the panel without needing a direct
# reference to the dialog object.
_log_callback = None   # type: callable | None


def _log(message, level="INFO"):
    """Send *message* to the dialog log panel (if open) and Maya's channels.

    level values
    ------------
    INFO   plain progress information
    STEP   current pipeline step (shown in blue)
    OK     success confirmation (shown in green)
    WARN   non-fatal warning     → also goes to Maya's Script Editor
    ERROR  fatal error           → also goes to Maya's Script Editor
    """
    if _log_callback is not None:
        try:
            _log_callback(message, level)
        except Exception:
            pass
    if level == "ERROR":
        om.MGlobal.displayError(f"[glTF] {message}")
    elif level == "WARN":
        om.MGlobal.displayWarning(f"[glTF] {message}")


# ══════════════════════════════════════════════════════════════════════════════
# Binary buffer
# ══════════════════════════════════════════════════════════════════════════════
class _Buffer:
    """Accumulates binary data; tracks glTF bufferViews and accessors."""

    _fmt = {FLOAT: ('f',4), UNSIGNED_SHORT: ('H',2),
            UNSIGNED_INT: ('I',4), UNSIGNED_BYTE: ('B',1)}
    _nc  = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}

    def __init__(self):
        self.raw   = bytearray()
        self.views = []
        self.accs  = []

    def _pad(self, a=4):
        r = len(self.raw) % a
        if r:
            self.raw += b'\x00' * (a - r)

    def push(self, values, comp_type, acc_type,
             target=None, min_v=None, max_v=None):
        self._pad()
        offset = len(self.raw)
        ch, _  = self._fmt[comp_type]
        blob   = struct.pack(f'<{len(values)}{ch}', *values)
        self.raw += blob

        bv = {"buffer":0,"byteOffset":offset,"byteLength":len(blob)}
        if target:
            bv["target"] = target
        bv_i = len(self.views)
        self.views.append(bv)

        count = len(values) // self._nc[acc_type]
        acc   = {"bufferView":bv_i,"byteOffset":0,
                 "componentType":comp_type,"count":count,"type":acc_type}
        if min_v is not None: acc["min"] = [float(x) for x in min_v]
        if max_v is not None: acc["max"] = [float(x) for x in max_v]
        acc_i = len(self.accs)
        self.accs.append(acc)
        return acc_i


# ══════════════════════════════════════════════════════════════════════════════
# Math helpers
# ══════════════════════════════════════════════════════════════════════════════
def _mmatrix_col(mm):
    """om.MMatrix (row-major, row-vectors) → glTF column-major flat list.

    Maya:  v' = v × M   (row-vector × row-major matrix)
    glTF:  v' = M × v   (column-major matrix × column-vector)

    These are equivalent when M_glTF = M_maya^T (transpose).

    For column-major storage of M_maya^T:
        flat[c*4 + r] = M_glTF[r][c] = M_maya[c][r] = mm[c*4 + r]

    The flat output is therefore identical to the Maya flat array — the
    Maya row-major bytes are the column-major bytes of the transpose.
    A plain element-by-element copy is both correct and efficient.
    """
    return [mm[i] for i in range(16)]


def _local_trs(dag):
    fn = om.MFnTransform(dag)
    t  = fn.translation(om.MSpace.kTransform)
    q  = fn.rotation(om.MSpace.kTransform, asQuaternion=True)
    s  = fn.scale()

    # Maya joints have two static rotation attributes that MFnTransform.rotation()
    # does NOT include — they must be composed manually:
    #
    #   jointOrient (JO) — bone-axis offset set at rig creation time
    #   rotateAxis  (RA) — additional pre-rotation used in some IK rigs
    #
    # Full local rotation in Maya:  T × RA × R × JO × S
    # As quaternions (applied right-to-left):  q_RA × q_R × q_JO
    if dag.hasFn(om.MFn.kJoint):
        try:
            fn_dep = om.MFnDependencyNode(dag.node())

            # ── jointOrient ──────────────────────────────────────────────
            jox = fn_dep.findPlug("jointOrientX", False).asDouble()
            joy = fn_dep.findPlug("jointOrientY", False).asDouble()
            joz = fn_dep.findPlug("jointOrientZ", False).asDouble()
            if jox or joy or joz:
                q = q * om.MEulerRotation(jox, joy, joz).asQuaternion()

            # ── rotateAxis ────────────────────────────────────────────────
            rax = fn_dep.findPlug("rotateAxisX", False).asDouble()
            ray = fn_dep.findPlug("rotateAxisY", False).asDouble()
            raz = fn_dep.findPlug("rotateAxisZ", False).asDouble()
            if rax or ray or raz:
                q = om.MEulerRotation(rax, ray, raz).asQuaternion() * q
        except Exception:
            pass

    return [t.x,t.y,t.z], [q.x,q.y,q.z,q.w], list(s)


# ── Coordinate-system helpers (Y-up Maya → optional Z-up output) ──────────────
# Conversion = Rx(−90°): maps +Y→+Z, +Z→−Y, +X stays.
#   position  : (x,y,z)       → (x,  z, −y)
#   quaternion: (qx,qy,qz,qw) → (qx, qz,−qy, qw)
#   IBM (col-major flat, 16 floats per matrix): F·M·Fᵀ  (F = Rx(−90°))

def _zup_pos(x, y, z):
    return x, z, -y

def _zup_quat(qx, qy, qz, qw):
    return qx, qz, -qy, qw

def _zup_pos_inv(x, y, z):
    """Inverse of _zup_pos — glTF Z-up → Maya Y-up."""
    return x, -z, y

def _zup_quat_inv(qx, qy, qz, qw):
    """Inverse of _zup_quat — glTF Z-up → Maya Y-up."""
    return qx, -qz, qy, qw

def _zup_ibm(ibm_flat):
    """Apply F·M·Fᵀ  (F = Rx(−90°)) to every 16-element col-major IBM block."""
    out = []
    for b in range(0, len(ibm_flat), 16):
        f = ibm_flat[b:b + 16]
        out += [
             f[0],  f[2], -f[1],  f[3],   # col 0
             f[8], f[10], -f[9], f[11],   # col 1
            -f[4], -f[6],  f[5], -f[7],   # col 2
            f[12], f[14],-f[13], f[15],   # col 3
        ]
    return out


# ── Texture conversion helper ─────────────────────────────────────────────────
def _mimage_to_png(src_path):
    """Read any Maya-supported image (TGA, EXR, TIFF, BMP, IFF …) with
    om.MImage and return the result as in-memory PNG bytes.
    Returns None and emits a warning if conversion fails.

    MImage is part of Maya's core API — no third-party dependencies needed.
    A temporary file is used because MImage.writeToFile() requires a path;
    the file is deleted before the function returns.
    """
    tmp_path = None
    try:
        mimg = om.MImage()
        mimg.readFromFile(src_path)
        fd, tmp_path = tempfile.mkstemp(suffix=".png")
        os.close(fd)
        mimg.writeToFile(tmp_path, "png")
        with open(tmp_path, "rb") as fh:
            return fh.read()
    except Exception as exc:
        _log(f"Cannot convert '{src_path}' to PNG: {exc}", "WARN")
        return None
    finally:
        if tmp_path and os.path.exists(tmp_path):
            try:
                os.unlink(tmp_path)
            except Exception:
                pass


# ── Per-material glTF attribute names ────────────────────────────────────────
# These custom attributes can be added to any shader node in Maya.
# When present they override both the auto-detected values and the global
# dialog override on a per-material basis.
_GLTF_ALPHA_ATTR    = "glTF_alphaMode"        # enum  0=Auto 1=Opaque 2=Blend 3=Mask
_GLTF_CUTOFF_ATTR   = "glTF_alphaCutoff"      # float 0-1,  used with Mask
_GLTF_DBLSIDE_ATTR  = "glTF_doubleSided"      # bool
_GLTF_EM_ENABLED    = "glTF_emissiveEnabled"  # bool   enable/disable emissive export
_GLTF_EM_SCALE      = "glTF_emissiveScale"    # float  0-1 multiplier on emissiveFactor

# Shader types that the setup function targets
_SHADER_TYPES = (
    "lambert", "blinn", "phong", "phongE",
    "standardSurface", "aiStandardSurface",
    "openPBRSurface",
    "surfaceShader",
)
_PBR_SURFACE_TYPES = frozenset(("standardSurface", "aiStandardSurface"))
_OPENPBR_SURFACE_TYPES = frozenset(("openPBRSurface",))
_LEGACY_SURFACE_TYPES = frozenset(("lambert", "blinn", "phong", "phongE"))

# openPBRSurface (Maya 2027 OpenPBR Surface) — v1 export scope
# Mapped: baseColor×baseWeight, baseMetalness, specularRoughness,
#         geometryOpacity, emissionColor×emissionLuminance, normalCamera.
# Not mapped (no glTF 2.0 core / KHR extension in this exporter yet):
#   coat, fuzz, transmission, subsurface, thin-walled, anisotropy,
#   specularWeight/specularColor dielectric tint, dispersion, etc.
_GLTF_ALPHA_MODES = {1: "OPAQUE", 2: "BLEND", 3: "MASK"}

_DEFAULT_PBR_MATERIAL = {
    "pbrMetallicRoughness": {
        "baseColorFactor": [0.8, 0.8, 0.8, 1.0],
        "metallicFactor": 0.0,
        "roughnessFactor": 0.8,
    },
    "alphaMode": "OPAQUE",
}

_STANDARD_PBR_ATTRS = {
    "color": "baseColor",
    "weight": "base",
    "metalness": "metalness",
    "roughness": "specularRoughness",
    "opacity": "opacity",
    "emission_intensity": "emission",
    "emission_color": "emissionColor",
}
_OPENPBR_ATTRS = {
    "color": "baseColor",
    "weight": "baseWeight",
    "metalness": "baseMetalness",
    "roughness": "specularRoughness",
    "opacity": "geometryOpacity",
    "emission_intensity": "emissionLuminance",
    "emission_color": "emissionColor",
}


def _normalize_shader_typ(typ):
    if not typ:
        return typ
    if str(typ).lower() == "openpbrsurface":
        return "openPBRSurface"
    return typ


def _shader_has_attr(shader_name, attr):
    try:
        return bool(cmds.attributeQuery(attr, node=shader_name, exists=True))
    except Exception:
        return False


def _shader_plug_exists(plug):
    """Return True when plug is a valid node.attribute on a live DAG/non-DAG node."""
    if not plug or "." not in plug:
        try:
            return bool(cmds.objExists(plug))
        except Exception:
            return False
    node, attr = plug.rsplit(".", 1)
    try:
        return bool(cmds.objExists(node)) and _shader_has_attr(node, attr)
    except Exception:
        return False


def _pbr_shader_attrs(typ):
    """Maya attribute names for metallic-roughness mapping, or None if unsupported."""
    typ = _normalize_shader_typ(typ)
    if typ in _PBR_SURFACE_TYPES:
        return _STANDARD_PBR_ATTRS
    if typ in _OPENPBR_SURFACE_TYPES:
        return _OPENPBR_ATTRS
    return None


def _pbr_shader_attrs_for_node(shader_name, typ=None):
    """Resolve PBR attr map from node type and/or live attributes on *shader_name*."""
    if typ:
        attrs = _pbr_shader_attrs(typ)
        if attrs:
            return attrs
    if not shader_name:
        return None
    try:
        attrs = _pbr_shader_attrs(cmds.nodeType(shader_name))
        if attrs:
            return attrs
    except Exception:
        pass
    # Attribute fingerprint (handles typeName/cmds.nodeType mismatches).
    if (_shader_has_attr(shader_name, "baseMetalness")
            and _shader_has_attr(shader_name, "baseWeight")):
        return _OPENPBR_ATTRS
    if (_shader_has_attr(shader_name, "metalness")
            and _shader_has_attr(shader_name, "base")):
        return _STANDARD_PBR_ATTRS
    if (_shader_has_attr(shader_name, "geometryOpacity")
            and _shader_has_attr(shader_name, "baseColor")):
        return _OPENPBR_ATTRS
    return None


def _resolve_shader_typ(shader_name, api_typ):
    """Prefer a type string that maps to known PBR/OpenPBR handlers."""
    for candidate in (_normalize_shader_typ(api_typ),):
        if _pbr_shader_attrs(candidate):
            return candidate
    try:
        nt = _normalize_shader_typ(cmds.nodeType(shader_name))
        if _pbr_shader_attrs(nt):
            return nt
    except Exception:
        pass
    if _pbr_shader_attrs_for_node(shader_name, api_typ):
        return _normalize_shader_typ(api_typ) or api_typ
    return api_typ


def _is_metallic_roughness_shader(shader_name, typ=None):
    return _pbr_shader_attrs_for_node(shader_name, typ) is not None


def _is_legacy_surface(typ):
    return typ in _LEGACY_SURFACE_TYPES


def _list_history_nodes(dag_path, node_type):
    """Return history nodes of *node_type* for a DAG path (Maya 2027-safe)."""
    return cmds.ls(cmds.listHistory(dag_path) or [], type=node_type) or []


def _apply_gltf_extra_attrs(mat, shader_name, needs_blend):
    """Apply per-shader glTF Extra Attributes to an exported material dict."""
    per_alpha = None
    per_cutoff = None
    if cmds.attributeQuery(_GLTF_ALPHA_ATTR, node=shader_name, exists=True):
        per_alpha = _GLTF_ALPHA_MODES.get(
            int(cmds.getAttr(f"{shader_name}.{_GLTF_ALPHA_ATTR}")))
    if per_alpha == "MASK" and cmds.attributeQuery(_GLTF_CUTOFF_ATTR, node=shader_name, exists=True):
        per_cutoff = float(cmds.getAttr(f"{shader_name}.{_GLTF_CUTOFF_ATTR}"))

    if per_alpha:
        mat["alphaMode"] = per_alpha
        if per_alpha == "MASK":
            mat["alphaCutoff"] = per_cutoff if per_cutoff is not None else 0.5
        else:
            mat.pop("alphaCutoff", None)
    elif needs_blend:
        mat["alphaMode"] = "BLEND"
    else:
        mat["alphaMode"] = "OPAQUE"

    if cmds.attributeQuery(_GLTF_DBLSIDE_ATTR, node=shader_name, exists=True):
        if bool(cmds.getAttr(f"{shader_name}.{_GLTF_DBLSIDE_ATTR}")):
            mat["doubleSided"] = True

    if cmds.attributeQuery(_GLTF_EM_ENABLED, node=shader_name, exists=True):
        if not bool(cmds.getAttr(f"{shader_name}.{_GLTF_EM_ENABLED}")):
            mat.pop("emissiveFactor", None)
            mat.pop("emissiveTexture", None)
        elif cmds.attributeQuery(_GLTF_EM_SCALE, node=shader_name, exists=True):
            sc = max(0.0, min(1.0, float(cmds.getAttr(f"{shader_name}.{_GLTF_EM_SCALE}"))))
            if sc <= 0.0:
                mat.pop("emissiveFactor", None)
                mat.pop("emissiveTexture", None)
            elif sc != 1.0:
                if "emissiveFactor" in mat:
                    mat["emissiveFactor"] = [
                        max(0.0, min(1.0, v * sc)) for v in mat["emissiveFactor"]
                    ]
                elif "emissiveTexture" in mat:
                    mat["emissiveFactor"] = [sc, sc, sc]

    return mat


def setup_gltf_material_attrs(selected_only=False):
    """Add glTF export attributes to recognised shaders.

    When *selected_only* is True (triggered from the dialog button with an
    active selection), only the shaders assigned to the currently selected
    objects are processed.  When False, or when nothing is selected, every
    recognised shader in the scene is processed.

    Five attributes are added to each shader (skipped if they already exist):

    glTF_alphaMode       (enum)  — Auto / Opaque / Blend / Cutoff (Mask)
    glTF_alphaCutoff     (float) — alpha-test threshold, used with Cutoff mode
    glTF_doubleSided     (bool)  — render both sides of the geometry
    glTF_emissiveEnabled (bool)  — include emissive texture / factor in export
    glTF_emissiveScale   (float) — 0–1 multiplier on emissiveFactor

    Artists can adjust these values per-material in Maya's Attribute Editor
    (Extra Attributes section).  During export the per-material value takes
    precedence over the auto-detected transparency.

    Returns the number of shaders processed.
    """
    shaders = []

    if selected_only:
        # Collect shaders from the selected objects' shading groups
        sel = cmds.ls(selection=True, long=True) or []
        for obj in sel:
            try:
                shapes = cmds.listRelatives(obj, shapes=True, fullPath=True) or []
                for sh in (shapes or [obj]):
                    sgs = cmds.listConnections(sh, type="shadingEngine") or []
                    for sg in sgs:
                        surf = cmds.listConnections(f"{sg}.surfaceShader",
                                                    source=True, destination=False) or []
                        shaders.extend(surf)
            except Exception:
                pass

    if not shaders:
        # No selection or nothing usable — fall back to all scene shaders
        for typ in _SHADER_TYPES:
            shaders.extend(cmds.ls(type=typ) or [])

    shaders = list(set(shaders))   # deduplicate

    # Each entry: (attr_name, addAttr_kwargs, default_value)
    _ATTRS = [
        (_GLTF_ALPHA_ATTR,  dict(longName=_GLTF_ALPHA_ATTR,
                                  niceName="glTF Alpha Mode",
                                  attributeType="enum",
                                  enumName="Auto:Opaque:Blend:Cutoff",
                                  defaultValue=0, keyable=False),   0),
        (_GLTF_CUTOFF_ATTR, dict(longName=_GLTF_CUTOFF_ATTR,
                                  niceName="glTF Alpha Cutoff",
                                  attributeType="float",
                                  defaultValue=0.5,
                                  minValue=0.0, maxValue=1.0,
                                  keyable=False),                    0.5),
        (_GLTF_DBLSIDE_ATTR, dict(longName=_GLTF_DBLSIDE_ATTR,
                                   niceName="glTF Double Sided",
                                   attributeType="bool",
                                   defaultValue=False, keyable=False), False),
        (_GLTF_EM_ENABLED,  dict(longName=_GLTF_EM_ENABLED,
                                  niceName="glTF Emissive Enabled",
                                  attributeType="bool",
                                  defaultValue=True, keyable=False), True),
        (_GLTF_EM_SCALE,    dict(longName=_GLTF_EM_SCALE,
                                  niceName="glTF Emissive Scale",
                                  attributeType="float",
                                  defaultValue=1.0,
                                  minValue=0.0, maxValue=1.0,
                                  keyable=False),                    1.0),
    ]

    n_shaders = 0
    for sh in shaders:
        for attr, kwargs, default in _ATTRS:
            try:
                if not cmds.attributeQuery(attr, node=sh, exists=True):
                    # Attribute does not exist yet — create it
                    cmds.addAttr(sh, **kwargs)
                    cmds.setAttr(f"{sh}.{attr}", channelBox=True)
                    cmds.setAttr(f"{sh}.{attr}", default)
                elif attr == _GLTF_ALPHA_ATTR:
                    # Refresh enum labels without resetting the artist's value.
                    cmds.addAttr(f"{sh}.{attr}", edit=True,
                                 enumName="Auto:Opaque:Blend:Cutoff")
            except Exception as exc:
                _log(f"Could not set '{attr}' on '{sh}': {exc}", "WARN")
        n_shaders += 1

    _log(f"glTF attributes added / reset on {n_shaders} shader(s).", "OK")
    return n_shaders


# ══════════════════════════════════════════════════════════════════════════════
# Material builder  (Maya shaders → glTF PBR materials + textures)
# ══════════════════════════════════════════════════════════════════════════════
class _MatBuilder:
    """
    Converts Maya shading groups to glTF PBR materials.
    Supports: lambert, blinn, phong, phongE, standardSurface, aiStandardSurface,
    openPBRSurface (Maya 2027 OpenPBR Surface — metallic-roughness subset only).
    Textures are embedded as base64 data-URIs when embed_textures=True,
    otherwise referenced by absolute file path.
    """

    # One default sampler shared by all textures
    _SAMPLER = {"magFilter":9729,"minFilter":9987,"wrapS":10497,"wrapT":10497}

    # glTF 2.0 only supports PNG and JPEG as embedded image formats
    _MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}

    def __init__(self, gltf, embed_textures=True):
        self.gltf  = gltf
        self.embed = embed_textures
        self._sg_cache     = {}   # shadingGroup name → gltf material index
        self._img_cache    = {}   # file-texture node name → gltf image index
        self._img_has_alpha = {}  # file-texture node name → bool (PNG alpha)

        gltf.setdefault("materials", [])
        gltf.setdefault("textures",  [])
        gltf.setdefault("images",    [])
        # setdefault does nothing when the key already exists — even if the
        # value is an empty list.  SceneExporter.__init__ pre-creates
        # "samplers": [] so setdefault would leave it empty, the prune step
        # would then delete it, and every texture's "sampler": 0 reference
        # would be dangling (causing a TypeError in Blender's importer).
        # Always (re-)set the default sampler so the array is never empty.
        if not gltf.get("samplers"):
            gltf["samplers"] = [self._SAMPLER]

    # ── public ───────────────────────────────────────────────────────────
    def index_for_sg(self, sg_mob):
        """Return (creating if needed) the glTF material index for a shadingGroup MObject."""
        fn = om.MFnDependencyNode(sg_mob)
        # Use the node UUID as the cache key.  MFnDependencyNode.name() returns
        # only the local (un-namespaced) name, so two shading groups in different
        # namespaces can share the same name and collide in the cache.  The UUID
        # is globally unique and never changes for the lifetime of the node.
        try:
            key = fn.uuid().asString()
        except Exception:
            key = fn.name()         # fallback for very old Maya versions
        if key in self._sg_cache:
            return self._sg_cache[key]

        shader = self._surface_shader(sg_mob)
        mat    = self._build(shader) if shader else self._default_mat(key)
        idx    = len(self.gltf["materials"])
        self.gltf["materials"].append(mat)
        self._sg_cache[key] = idx
        return idx

    # ── internals ─────────────────────────────────────────────────────────
    def _surface_shader(self, sg_mob):
        fn   = om.MFnDependencyNode(sg_mob)
        plug = fn.findPlug("surfaceShader", False)
        srcs = plug.connectedTo(True, False)
        return srcs[0].node() if srcs else None

    def _default_mat(self, name):
        # The default material is a neutral grey placeholder used when no
        # shader is assigned to a mesh.  It must always be fully opaque —
        # global alpha-mode overrides and per-material glTF attributes do
        # not apply here because there is no underlying Maya shader to be
        # intentionally transparent.
        return dict(_DEFAULT_PBR_MATERIAL, name=name)

    def _build(self, shader_mob):
        fn   = om.MFnDependencyNode(shader_mob)
        name = fn.name()
        typ  = _resolve_shader_typ(name, fn.typeName)
        pbr_attrs = _pbr_shader_attrs_for_node(name, typ)
        if pbr_attrs is _OPENPBR_ATTRS and typ not in _OPENPBR_SURFACE_TYPES:
            _log(
                f"Shader '{name}' (typeName={fn.typeName!r}) mapped as OpenPBR via attributes.",
                "INFO",
            )
        mat  = {"name": name, "pbrMetallicRoughness": {}}
        pbr  = mat["pbrMetallicRoughness"]

        # ── base color ───────────────────────────────────────────────────
        color, color_tex = self._color(name, typ)
        pbr["baseColorFactor"] = color
        if color_tex is not None:
            pbr["baseColorTexture"] = {"index": color_tex}

        # ── metallic / roughness + occlusion ─────────────────────────────
        metal, rough, mr_tex, occ_tex = self._metallic_roughness(name, typ)
        pbr["metallicFactor"]  = metal
        pbr["roughnessFactor"] = rough
        if mr_tex is not None:
            pbr["metallicRoughnessTexture"] = {"index": mr_tex}
        if occ_tex is not None:
            mat["occlusionTexture"] = {"index": occ_tex}

        # ── normal map ───────────────────────────────────────────────────
        norm_entry = self._normal_texture(name)
        if norm_entry is not None:
            mat["normalTexture"] = norm_entry   # already a dict {index, [scale]}

        # ── emissive ─────────────────────────────────────────────────────
        if pbr_attrs:
            em_attr = pbr_attrs["emission_color"]
        elif _is_legacy_surface(typ):
            em_attr = "incandescence"   # Maya's legacy emissive attribute
        else:
            em_attr = None

        if em_attr:
            em_tex = self._tex_index(name, em_attr)
            if em_tex is not None:
                mat["emissiveTexture"] = {"index": em_tex}
                if pbr_attrs:
                    try:
                        ei = pbr_attrs["emission_intensity"]
                        intensity = float(cmds.getAttr(f"{name}.{ei}"))
                    except Exception:
                        intensity = 1.0
                    mat["emissiveFactor"] = [intensity, intensity, intensity]
                else:
                    mat["emissiveFactor"] = [1.0, 1.0, 1.0]
            else:
                em = self._emissive(name, typ)
                if any(v > 0 for v in em):
                    mat["emissiveFactor"] = em
        else:
            em = self._emissive(name, typ)
            if any(v > 0 for v in em):
                mat["emissiveFactor"] = em

        # ── alpha ────────────────────────────────────────────────────────
        needs_blend = color[3] < 1.0

        # Detect alpha from the base-color texture (PNG alpha channel).
        # Check both 'file' and 'aiImage' source nodes.
        if not needs_blend and color_tex is not None:
            attr    = self._get_color_attr(name, typ)
            bc_node = self._connected_tex_nodes(f"{name}.{attr}")
            if bc_node and self._img_has_alpha.get(bc_node, False):
                needs_blend = True

        # Detect transparency driven by a texture node (file or aiImage)
        if not needs_blend:
            if _is_legacy_surface(typ):
                if self._connected_tex_nodes(f"{name}.transparency"):
                    needs_blend = True
            elif pbr_attrs:
                op_attr = pbr_attrs["opacity"]
                if self._connected_tex_nodes(f"{name}.{op_attr}"):
                    needs_blend = True

        _apply_gltf_extra_attrs(mat, name, needs_blend)

        return mat


    # ─── attribute helpers ────────────────────────────────────────────────
    def _get_color_attr(self, name, typ):
        if _pbr_shader_attrs_for_node(name, typ):
            return "baseColor"
        if _shader_has_attr(name, "baseColor"):
            return "baseColor"
        return "color"

    def _base_weight(self, name, typ):
        """Return base layer weight (0–1) for PBR / OpenPBR surface shaders."""
        attrs = _pbr_shader_attrs_for_node(name, typ)
        if not attrs:
            return 1.0
        try:
            return max(0.0, min(1.0, float(cmds.getAttr(f"{name}.{attrs['weight']}"))))
        except Exception:
            return 1.0

    def _color(self, name, typ):
        attr = self._get_color_attr(name, typ)
        tex  = self._tex_index(name, attr)

        if tex is not None:
            # A texture drives albedo; baseColorFactor multiplies the map in glTF.
            # Read baseColor for tint, then apply the base weight scalar.
            try:
                c = cmds.getAttr(f"{name}.{attr}")[0]
                rgba = [float(c[0]), float(c[1]), float(c[2]), 1.0]
            except Exception:
                rgba = [1.0, 1.0, 1.0, 1.0]
            # Black baseColor with a connected texture is usually unset in Maya —
            # treat as white so the `base` weight controls the B/W multiplier.
            if max(rgba[0], rgba[1], rgba[2]) < 1e-4:
                rgba[0] = rgba[1] = rgba[2] = 1.0
        else:
            # No texture — read the Maya colour value as the factor.
            try:
                c = cmds.getAttr(f"{name}.{attr}")[0]
                rgba = [float(c[0]), float(c[1]), float(c[2]), 1.0]
            except Exception:
                rgba = [1.0, 1.0, 1.0, 1.0]   # glTF spec default

        pbr_attrs = _pbr_shader_attrs_for_node(name, typ)
        if pbr_attrs:
            bw = self._base_weight(name, typ)
            if abs(bw - 1.0) > 1e-6:
                rgba[0] *= bw
                rgba[1] *= bw
                rgba[2] *= bw

        rgba[3] = 1.0

        if _is_legacy_surface(typ):
            if not self._connected_tex_nodes(f"{name}.transparency"):
                try:
                    t = cmds.getAttr(f"{name}.transparency")[0]
                    trans = (float(t[0]) + float(t[1]) + float(t[2])) / 3.0
                    if trans > 0.001:           # only when explicitly transparent
                        rgba[3] = max(0.0, 1.0 - trans)
                except Exception:
                    pass

        elif pbr_attrs:
            op_attr = pbr_attrs["opacity"]
            if not self._connected_tex_nodes(f"{name}.{op_attr}"):
                try:
                    op  = cmds.getAttr(f"{name}.{op_attr}")
                    val = 1.0
                    if isinstance(op, (int, float)):
                        val = float(op)
                    elif isinstance(op, (list, tuple)) and op:
                        v = op[0]
                        if isinstance(v, (list, tuple)):
                            val = (float(v[0])+float(v[1])+float(v[2])) / 3.0
                        else:
                            val = float(v)
                    if val < 0.999:             # only when explicitly semi-transparent
                        rgba[3] = max(0.0, val)
                except Exception:
                    pass                        # keep alpha = 1.0

        return rgba, tex

    def _metallic_roughness(self, name, typ):
        """Return (metallicFactor, roughnessFactor, mr_tex_index, occ_tex_index).

        mr_tex_index  : index into gltf["textures"] for metallicRoughnessTexture,
                        or None when there is no texture.
        occ_tex_index : index for occlusionTexture (R channel = AO), or None.
        """
        metal   = 0.0
        rough   = 1.0
        mr_tex  = None
        occ_tex = None

        if _is_metallic_roughness_shader(name, typ):
            pbr_attrs = _pbr_shader_attrs_for_node(name, typ)
            rough_attr = pbr_attrs["roughness"]
            metal_attr = pbr_attrs["metalness"]
            # Retrieve the texture NODES before creating texture entries so
            # we can detect an ORM map (same file node used for both roughness
            # and metalness) and share a single texture entry for both.
            rough_node = self._connected_tex_nodes(f"{name}.{rough_attr}")
            metal_node = self._connected_tex_nodes(f"{name}.{metal_attr}")

            if rough_node or metal_node:
                if rough_node and metal_node and rough_node == metal_node:
                    # ORM map: one texture drives both channels.
                    # glTF packs G=roughness, B=metallic in the same image.
                    mr_tex = self._tex_from_file_node(rough_node)
                elif rough_node:
                    mr_tex = self._tex_from_file_node(rough_node)
                else:
                    mr_tex = self._tex_from_file_node(metal_node)

                rough = 1.0
                metal = 1.0 if metal_node else 0.0
            else:
                try: rough = float(cmds.getAttr(f"{name}.{rough_attr}"))
                except Exception: pass
                try: metal = float(cmds.getAttr(f"{name}.{metal_attr}"))
                except Exception: pass

        elif typ == "blinn":
            try: rough = math.sqrt(max(0, float(cmds.getAttr(f"{name}.eccentricity"))))
            except Exception: pass
            # Specular colour texture (blinn) → roughness texture approximation
            spec_node = self._connected_tex_nodes(f"{name}.specularColor")
            if spec_node:
                mr_tex = self._tex_from_file_node(spec_node)
                rough  = 1.0

        elif typ in ("phong","phongE"):
            try:
                cos_power = float(cmds.getAttr(f"{name}.cosinePower"))
                rough = max(0.0, min(1.0, 1.0 - math.log(max(1,cos_power))/10.0))
            except Exception: pass

        # Ambient-colour slot used as ambient-occlusion (legacy shaders)
        if _is_legacy_surface(typ):
            ao_node = self._connected_tex_nodes(f"{name}.ambientColor")
            if ao_node:
                occ_tex = self._tex_from_file_node(ao_node)

        return metal, rough, mr_tex, occ_tex

    def _normal_texture(self, name):
        """Return a glTF normalTexture dict (with index and optional scale),
        or None when no normal/bump map is connected."""

        def _make_entry(tex_node, scale_attr_node, scale_attr_name):
            ti = self._tex_from_file_node(tex_node)
            if ti is None:
                return None
            entry = {"index": ti}
            try:
                sc = float(cmds.getAttr(f"{scale_attr_node}.{scale_attr_name}"))
                if abs(sc - 1.0) > 1e-4:
                    entry["scale"] = sc
            except Exception:
                pass
            return entry

        # ── Standard Maya bump2d ──────────────────────────────────────────
        bumps = cmds.listConnections(f"{name}.normalCamera", type="bump2d") or []
        if bumps:
            node = self._connected_tex_nodes(f"{bumps[0]}.bumpValue")
            if node:
                return _make_entry(node, bumps[0], "bumpDepth")

        # ── Arnold aiNormalMap ────────────────────────────────────────────
        ainorms = cmds.listConnections(f"{name}.normalCamera", type="aiNormalMap") or []
        if ainorms:
            for inp in ("input", "normal", "input1", "input2"):
                node = self._connected_tex_nodes(f"{ainorms[0]}.{inp}")
                if node:
                    return _make_entry(node, ainorms[0], "strength")

        # ── Direct file / aiImage → normalCamera (no bump utility) ──────
        node = self._connected_tex_nodes(f"{name}.normalCamera")
        if node:
            return _make_entry(node, name, "normalCamera")

        return None

    def _emissive(self, name, typ):
        try:
            pbr_attrs = _pbr_shader_attrs_for_node(name, typ)
            if pbr_attrs:
                ei = pbr_attrs["emission_intensity"]
                ec = pbr_attrs["emission_color"]
                intensity = float(cmds.getAttr(f"{name}.{ei}"))
                c = cmds.getAttr(f"{name}.{ec}")[0]
                return [c[0]*intensity, c[1]*intensity, c[2]*intensity]
            elif _is_legacy_surface(typ):
                # incandescence is Maya's legacy emissive attribute
                c = cmds.getAttr(f"{name}.incandescence")[0]
                return [float(c[0]), float(c[1]), float(c[2])]
        except Exception:
            pass
        return [0.0, 0.0, 0.0]

    # ─── texture helpers ──────────────────────────────────────────────────
    # Texture node types we recognise as image sources.
    # Standard Maya uses 'file'; Arnold uses 'aiImage' (attribute: filename).
    _TEX_NODE_TYPES = ("file", "aiImage")

    # Intermediate utility node types that should be traversed upstream to
    # find the actual texture source (file / aiImage).
    _PASS_THROUGH = frozenset((
        # Arnold
        "aiColorCorrect", "aiRange", "aiColorConvert", "aiMixShader",
        "aiMultiply", "aiAdd", "aiSubtract", "aiDivide", "aiMix",
        "aiClamp", "aiComposite", "aiLayerRgba", "aiShuffle", "aiExtract",
        "aiPremult", "aiUnpremult", "aiSwitch", "aiCompare",
        "aiAbs", "aiMin", "aiMax", "aiTrigo", "aiRampRgb", "aiRampFloat",
        # Maya / legacy
        "colorCorrect", "gammaCorrect", "clamp", "setRange", "contrast",
        "luminance", "hsvToRgb", "rgbToHsv", "multiplyDivide", "blendColors",
        "ramp", "remapColor", "remapValue", "reverse", "unitConversion",
    ))

    # Common input plug names probed on unknown upstream utility nodes.
    _UTILITY_INPUTS = (
        "input", "input1", "input2", "input3", "input4", "input5",
        "input6", "input7", "input8",
        "color", "color1", "color2", "inColor", "inColor1", "inColor2",
        "value", "alpha", "bumpValue", "normal",
    )

    def _connected_tex_nodes(self, attr_or_node, _depth=0):
        """Return the first file/aiImage node upstream of attr_or_node.

        attr_or_node may be either a plug name ("shader.baseColor") or a plain
        node name ("aiColorCorrect1").  Only NODE names are passed to recursive
        calls — never plug paths — so that listConnections correctly returns the
        nodes whose outputs feed into the queried attribute or node.

        Chain example that works:
            aiImage1 → aiColorCorrect1 → aiStandardSurface1.baseColor
        Call sequence:
            _connected_tex_nodes("aiStandardSurface1.baseColor")
              → sources = ["aiColorCorrect1"]      (node name, not plug path)
              → aiColorCorrect1 ∈ _PASS_THROUGH → recurse with NODE name
            _connected_tex_nodes("aiColorCorrect1", depth=1)
              → sources = ["aiImage1"]
              → nodeType "aiImage" ∈ _TEX_NODE_TYPES → return "aiImage1"  ✓
        """
        if _depth >= 8:
            return None

        if isinstance(attr_or_node, str) and "." in attr_or_node:
            if not _shader_plug_exists(attr_or_node):
                return None

        # listConnections without plugs=True returns node names.
        sources = cmds.listConnections(attr_or_node, source=True,
                                        destination=False) or []
        for src in sources:
            src_type = cmds.nodeType(src) or ""
            if src_type in self._TEX_NODE_TYPES:
                return src                          # found the texture node
            if src_type in self._PASS_THROUGH:
                # Recurse into the utility node using its NODE NAME so that
                # the next listConnections call returns upstream nodes, not
                # the downstream consumers of its output plugs.
                result = self._connected_tex_nodes(src, _depth + 1)
                if result:
                    return result
                continue
            # Unknown utility — walk common input plugs (aiLayerRgba, etc.).
            for plug in self._UTILITY_INPUTS:
                try:
                    if not cmds.attributeQuery(plug, node=src, exists=True):
                        continue
                    result = self._connected_tex_nodes(f"{src}.{plug}", _depth + 1)
                    if result:
                        return result
                except Exception:
                    pass
        return None

    def _tex_index(self, shader_name, attr):
        """Return glTF texture index for a shader attribute, or None."""
        plug = f"{shader_name}.{attr}"
        if not _shader_plug_exists(plug):
            return None
        node = self._connected_tex_nodes(plug)
        if node:
            return self._tex_from_file_node(node)
        return None

    def _tex_from_file_node(self, file_node):
        img_i = self._image_index(file_node)
        if img_i is None:
            return None
        # Each call creates a new, independent texture entry even when the
        # underlying image is shared.  Images are still deduplicated at the
        # image level (same file → same image index) so the embedded data is
        # only stored once, but every material attribute gets its own texture
        # reference so no two materials share a texture object.
        tex_i = len(self.gltf["textures"])
        self.gltf["textures"].append({"sampler": 0, "source": img_i})
        return tex_i

    # ── static path + image helpers ───────────────────────────────────────
    @staticmethod
    def _resolve_texture_path(raw_path):
        """Return the first existing absolute path for raw_path, or raw_path
        normalised to forward-slashes if no existing file can be found."""
        p = raw_path.replace("\\", "/")

        # 1. Already absolute and exists
        if os.path.isabs(p) and os.path.isfile(p):
            return p

        # 2. workspace(expandName) — honours file-rule entries and path maps
        try:
            c = cmds.workspace(expandName=p) or ""
            if c and os.path.isfile(c):
                return c.replace("\\", "/")
        except Exception:
            pass

        # 3. Project-root relative
        try:
            root = (cmds.workspace(q=True, rootDirectory=True) or "").rstrip("/\\")
            c = os.path.join(root, p)
            if os.path.isfile(c):
                return c.replace("\\", "/")
        except Exception:
            pass

        # 4. Inside the project sourceimages folder
        try:
            rule = cmds.workspace(fileRuleEntry="sourceImages") or "sourceimages"
            root = (cmds.workspace(q=True, rootDirectory=True) or "").rstrip("/\\")
            if not os.path.isabs(rule):
                rule = os.path.join(root, rule)
            c = os.path.join(rule, os.path.basename(p))
            if os.path.isfile(c):
                return c.replace("\\", "/")
        except Exception:
            pass

        # Not found — return normalised path anyway (used as URI fallback)
        return p

    @staticmethod
    def _png_has_alpha(data):
        """Return True when `data` (bytes) is a PNG with an alpha channel."""
        return (len(data) >= 26
                and data[:8] == b"\x89PNG\r\n\x1a\n"
                and data[25] in (4, 6))   # color_type: 4=gray+A, 6=RGBA

    def _image_index(self, file_node):
        if file_node in self._img_cache:
            return self._img_cache[file_node]

        # ── get raw texture path ──────────────────────────────────────────
        try:
            ntype = cmds.nodeType(file_node) or "file"
        except Exception:
            ntype = "file"
        path_attr = "filename" if ntype == "aiImage" else "fileTextureName"

        raw_path = cmds.getAttr(f"{file_node}.{path_attr}") or ""
        if not raw_path:
            return None

        path = self._resolve_texture_path(raw_path)
        ext  = os.path.splitext(path)[1].lower()
        mime = self._MIME.get(ext)          # None for non-PNG/JPEG formats

        has_alpha = False
        img       = None

        if self.embed:
            if mime and os.path.isfile(path):
                # ── native glTF format (PNG / JPEG): embed as-is ──────────
                try:
                    with open(path, "rb") as fh:
                        data = fh.read()
                    has_alpha = (ext == ".png" and self._png_has_alpha(data))
                    b64 = base64.b64encode(data).decode("ascii")
                    img = {"uri": f"data:{mime};base64,{b64}", "name": file_node}
                except Exception:
                    pass

            if img is None and os.path.isfile(path):
                # ── non-native format (TGA, EXR, TIFF …): convert to PNG ──
                # Maya's own MImage can read every format its renderer supports.
                png_data = _mimage_to_png(path)
                if png_data:
                    has_alpha = self._png_has_alpha(png_data)
                    b64 = base64.b64encode(png_data).decode("ascii")
                    img = {"uri": f"data:image/png;base64,{b64}",
                           "name": file_node}
                    mime = "image/png"

            if img is None and os.path.isfile(path):
                _log(f"Could not embed '{os.path.basename(path)}' — referenced by URI.", "WARN")

        if img is None:
            # Non-embed mode, or embed failed: use a URI reference.
            # Warn for formats that standard glTF viewers may not support.
            if not mime:
                _log(f"'{os.path.basename(path)}' has unsupported format '{ext}'. "
                     f"Enable 'Embed textures' for auto-conversion, or use PNG/JPEG.", "WARN")
            img = {"uri": path, "name": file_node}

        idx = len(self.gltf["images"])
        self.gltf["images"].append(img)
        self._img_cache[file_node]     = idx
        self._img_has_alpha[file_node] = has_alpha
        return idx


# ══════════════════════════════════════════════════════════════════════════════
# Skin builder  (skinCluster → JOINTS_0 / WEIGHTS_0 + inverseBindMatrices)
# ══════════════════════════════════════════════════════════════════════════════
class _SkinBuilder:
    """
    Finds the skinCluster for a mesh and extracts per-vertex joint indices
    (top 4, normalized weights) and inverse bind matrices.
    """

    def __init__(self, shape_dag):
        self.valid      = False
        self.skin_name  = None
        self.fn_skin    = None
        self.influences = []      # full DAG path strings, in influence order
        self._joints    = []      # [vi] → [j0,j1,j2,j3]
        self._weights   = []      # [vi] → [w0,w1,w2,w3]
        self._max_inf   = 4      # always use up to 4 influences (glTF JOINTS_0 is VEC4)
        self._load(shape_dag)

    def _load(self, shape_dag):
        hist = _list_history_nodes(shape_dag.fullPathName(), "skinCluster")
        if not hist:
            return
        self.skin_name = hist[0]

        sel = om.MSelectionList()
        sel.add(self.skin_name)
        self.fn_skin = oma.MFnSkinCluster(sel.getDependNode(0))

        inf_dags = self.fn_skin.influenceObjects()
        self.influences = [d.fullPathName() for d in inf_dags]

        fn_mesh = om.MFnMesh(shape_dag)
        n_verts = fn_mesh.numVertices

        comp_fn = om.MFnSingleIndexedComponent()
        all_v   = comp_fn.create(om.MFn.kMeshVertComponent)
        comp_fn.setCompleteData(n_verts)

        weights, n_inf = self.fn_skin.getWeights(shape_dag, all_v)
        n_inf = int(n_inf)

        if n_inf == 0 or not self.influences:
            return   # skinCluster with no influences — treat as unskinned

        for vi in range(n_verts):
            row = [(ji, float(weights[vi*n_inf+ji]))
                   for ji in range(n_inf) if weights[vi*n_inf+ji] > 1e-5]
            row.sort(key=lambda x: -x[1])
            row = row[:self._max_inf]
            total = sum(w for _,w in row)
            if total > 0:
                row = [(j, w/total) for j,w in row]
            while len(row) < 4:
                row.append((0, 0.0))
            self._joints.append([j for j,_ in row])
            self._weights.append([w for _,w in row])

        self.valid = True

    def joints_for(self, vi):
        return self._joints[vi] if vi < len(self._joints) else [0,0,0,0]

    def weights_for(self, vi):
        return self._weights[vi] if vi < len(self._weights) else [0.0,0.0,0.0,0.0]

    def inverse_bind_matrices(self):
        """Returns list-of-lists: one 16-float column-major matrix per influence."""
        result = []
        inf_dags = self.fn_skin.influenceObjects()
        for inf_dag in inf_dags:
            logical = self.fn_skin.indexForInfluenceObject(inf_dag)
            plug    = self.fn_skin.findPlug("bindPreMatrix", False)
            elem    = plug.elementByLogicalIndex(logical)
            mm      = om.MFnMatrixData(elem.asMObject()).matrix()
            result.append(_mmatrix_col(mm))
        return result


# ══════════════════════════════════════════════════════════════════════════════
# Blend shape builder  (blendShape → morph target POSITION deltas)
# ══════════════════════════════════════════════════════════════════════════════
class _BlendBuilder:
    """
    Reads blend shape target deltas directly from the blendShape node's
    internal plug data (inputTargetItem[6000]) — no scene-weight changes needed.
    """

    def __init__(self, shape_dag):
        self.valid        = False
        self.target_names = []
        self.bs_name      = None   # blendShape node name (for weight animation)
        self.bs_mob       = None   # blendShape MObject  (for MDGContext plug reads)
        self._deltas      = {}    # target_idx → [(dx,dy,dz), ...] per vertex
        self._load(shape_dag)

    def _load(self, shape_dag):
        hist = _list_history_nodes(shape_dag.fullPathName(), "blendShape")
        if not hist:
            return
        bs_name = hist[0]

        # Target count + name aliases
        n = cmds.blendShape(bs_name, query=True, weightCount=True) or 0
        if n == 0:
            return

        aliases = cmds.aliasAttr(bs_name, query=True) or []
        name_map = {}
        for k in range(0, len(aliases)-1, 2):
            alias = aliases[k]
            try:
                idx = int(aliases[k+1].split("[")[1].rstrip("]"))
                name_map[idx] = alias
            except Exception:
                pass
        self.target_names = [name_map.get(i, f"target_{i}") for i in range(n)]

        # MObject for the blendShape node — retained for weight animation
        sel = om.MSelectionList()
        sel.add(bs_name)
        bs_mob = sel.getDependNode(0)
        self.bs_name = bs_name
        self.bs_mob  = bs_mob

        # Base positions: use intermediate (undeformed) mesh if available
        base_pts = _intermediate_points(shape_dag)

        n_verts = len(base_pts)
        fn_dep  = om.MFnDependencyNode(bs_mob)

        for ti in range(n):
            self._deltas[ti] = self._read_target(fn_dep, ti, n_verts, base_pts)

        self.valid = True

    def _read_target(self, fn_dep, ti, n_verts, base_pts):
        """Read one blend shape target from node plugs; return per-vertex delta tuples."""
        try:
            # inputTarget[0].inputTargetGroup[ti].inputTargetItem[6000]
            it   = fn_dep.findPlug("inputTarget", False).elementByLogicalIndex(0)
            itg  = it.child(0).elementByLogicalIndex(ti)        # inputTargetGroup
            iti  = itg.child(0).elementByLogicalIndex(6000)     # inputTargetItem (full weight)
            ipt  = iti.child(0)                                 # inputPointsTarget
            ict  = iti.child(1)                                 # inputComponentsTarget

            pts_obj  = ipt.asMObject()
            comp_obj = ict.asMObject()

            if pts_obj.isNull() or comp_obj.isNull():
                return [(0.0,0.0,0.0)] * n_verts

            tgt_pts = om.MFnPointArrayData(pts_obj).array()

            fn_cl = om.MFnComponentListData(comp_obj)
            vert_indices = []
            for ci in range(fn_cl.length()):
                fn_si = om.MFnSingleIndexedComponent(fn_cl[ci])
                vert_indices.extend(fn_si.getElements())

        except Exception as e:
            _log(f"Blend target {ti} read error: {e}", "WARN")
            return [(0.0,0.0,0.0)] * n_verts

        deltas = [(0.0,0.0,0.0)] * n_verts
        for k, vi in enumerate(vert_indices):
            if vi < n_verts and k < len(tgt_pts):
                bp = base_pts[vi]
                tp = tgt_pts[k]
                deltas[vi] = (tp.x-bp.x, tp.y-bp.y, tp.z-bp.z)
        return deltas

    def delta_for(self, ti, vi):
        d = self._deltas.get(ti, [])
        return d[vi] if vi < len(d) else (0.0,0.0,0.0)


def _get_intermediate_fn(shape_dag):
    """
    Return an MFnMesh for the intermediate (bind-pose / orig) shape.
    Falls back to the live shape if no intermediate is found.

    For instanced meshes the intermediate shape only exists under the
    *original* parent transform, not under the instance transform.  We
    therefore search all parent transforms of the shape node so that both
    the original and every instance get the correct rest-pose geometry.
    """
    def _scan_parent(fn_par):
        for i in range(fn_par.childCount()):
            child = fn_par.child(i)
            if child.hasFn(om.MFn.kMesh) and om.MFnDagNode(child).isIntermediateObject:
                try:
                    return om.MFnMesh(om.MDagPath.getAPathTo(child))
                except Exception:
                    pass
        return None

    # ── 1. Immediate parent (fast path, covers the non-instanced case) ──────
    direct_parent = om.MDagPath(shape_dag)
    direct_parent.pop()
    result = _scan_parent(om.MFnDagNode(direct_parent))
    if result:
        return result

    # ── 2. All other parents (handles instances) ─────────────────────────────
    fn_shape = om.MFnDagNode(shape_dag.node())
    for i in range(fn_shape.parentCount()):
        par_mob = fn_shape.parent(i)
        result  = _scan_parent(om.MFnDagNode(par_mob))
        if result:
            return result

    return om.MFnMesh(shape_dag)   # fall back to live mesh


def _intermediate_points(shape_dag):
    """Return vertex positions from the intermediate (orig) mesh sibling, or live mesh."""
    return _get_intermediate_fn(shape_dag).getPoints(om.MSpace.kObject)


# ══════════════════════════════════════════════════════════════════════════════
# Scene exporter
# ══════════════════════════════════════════════════════════════════════════════
class SceneExporter:
    """Walks the Maya scene and builds the glTF dict + binary buffer."""

    def __init__(self, export_all=True, embed_textures=True, skip_hidden=False,
                 scale=1.0, up_axis="Y", include_anim=True):
        self.export_all   = export_all
        self.fps          = _maya_fps()
        self.skip_hidden  = skip_hidden
        self.scale        = scale
        self.up_axis      = up_axis          # "Y" (default) or "Z"
        self.include_anim = include_anim     # False → skip animation export
        self.buf            = _Buffer()
        self._node_index    = {}   # full dag path → gltf node index
        self._pending_skins = []   # (node_idx, _SkinBuilder) — resolved after walk
        self._blend_anims   = []   # (node_idx, _BlendBuilder) — for weight animation
        # ── instance / duplicate deduplication ───────────────────────────────
        # Maya allows the same shape node to be referenced by multiple transform
        # parents ("instances").  We must export the geometry only once and let
        # every instance node point at the same glTF mesh / skin index.
        self._shape_mesh_cache = {}  # shape UUID → mesh_idx
        self._skin_idx_cache   = {}  # skinCluster name → skin_idx

        self.gltf = OrderedDict([
            ("asset",      {"version":"2.0","generator":"Maya glTF Exporter 2.0"}),
            ("scene",      0),
            ("scenes",     [{"name":"Scene","nodes":[]}]),
            ("nodes",      []),
            ("meshes",     []),
            ("skins",      []),
            ("animations", []),
            ("materials",  []),
            ("textures",   []),
            ("images",     []),
            ("samplers",   []),
        ])

        self._mat = _MatBuilder(self.gltf, embed_textures=embed_textures)

    # ── entry point ───────────────────────────────────────────────────────
    def build(self, clips=None):
        """
        clips : list of {"name": str, "start": int, "end": int} dicts,
                or None to use the full Maya timeline as a single "Take 001".
        """
        # ── Read node transforms at the bind pose ─────────────────────────
        # Each joint's glTF node transform (TRS) must equal its bind-pose
        # world transform so that the skinning identity holds at rest:
        #
        #   J_world_bind × IBM  =  I   →  skinned_pos = rest_pos  ✓
        #
        # If the scene is on an animated frame the joint rotate attributes
        # differ from their bind-pose values.  The glTF node transforms would
        # then encode the animated pose while the IBMs still expect the bind
        # pose, making the mesh appear deformed even before animation plays.
        #
        # Strategy (most-reliable first):
        #  1. Restore Maya's stored dagPose (the exact pose used at skin-bind
        #     time — independent of which timeline frame was current then).
        #  2. Fall back to frame 0 if no bind pose is stored.
        _saved_frame = cmds.currentTime(q=True)
        _pose_restored = False
        try:
            all_poses  = cmds.ls(type="dagPose") or []
            bind_poses = [p for p in all_poses
                          if cmds.dagPose(p, query=True, bindPose=True)]
            if bind_poses:
                cmds.dagPose(bind_poses[0], restore=True)
                _pose_restored = True
                _log(f"Bind pose restored from dagPose '{bind_poses[0]}'.", "INFO")
        except Exception:
            pass

        if not _pose_restored:
            cmds.currentTime(0)
            _log("No stored bind pose found — using frame 0 as bind pose.", "WARN")

        try:
            roots = _dag_roots() if self.export_all else _selected_roots()
            for dag in roots:
                self._add_node(dag, parent_gltf=-1)
        finally:
            # Always restore scene state before animation sampling.
            cmds.currentTime(_saved_frame)

        self._finalize_skins()
        if self.include_anim:
            self._export_animations(clips=clips)

        # Prune empty top-level arrays
        for key in ("skins","animations","textures","images","samplers"):
            if key in self.gltf and not self.gltf[key]:
                del self.gltf[key]
        # Ensure at least a default material
        if not self.gltf.get("materials"):
            self.gltf["materials"] = [dict(_DEFAULT_PBR_MATERIAL, name="default")]

        return self.gltf, self.buf

    # ── DAG recursion ─────────────────────────────────────────────────────
    def _add_node(self, dag, parent_gltf):
        # Only handle transform-type nodes (joints are a kTransform subclass)
        if not dag.hasFn(om.MFn.kTransform):
            return

        # Skip hidden nodes when requested — but NEVER skip joints.
        # Skeleton hierarchies are commonly placed on hidden display layers;
        # if joints are excluded, _finalize_skins cannot resolve influence
        # paths and falls back to node 0 for every joint, breaking skinning.
        is_joint = dag.hasFn(om.MFn.kJoint)
        if self.skip_hidden and not is_joint and not _is_dag_visible(dag):
            return

        fn_dag   = om.MFnDagNode(dag)
        node_idx = len(self.gltf["nodes"])
        self._node_index[dag.fullPathName()] = node_idx

        node = {"name": fn_dag.name()}
        t, r, s = _local_trs(dag)
        if self.scale != 1.0:
            t = [v * self.scale for v in t]
        if self.up_axis == "Z":
            t = list(_zup_pos(*t))
            r = list(_zup_quat(*r))
        if t != [0.0,0.0,0.0]:            node["translation"] = t
        if r != [0.0,0.0,0.0,1.0]:        node["rotation"]    = r
        if s != [1.0,1.0,1.0]:            node["scale"]       = s
        self.gltf["nodes"].append(node)

        if parent_gltf == -1:
            self.gltf["scenes"][0]["nodes"].append(node_idx)
        else:
            self.gltf["nodes"][parent_gltf].setdefault("children",[]).append(node_idx)

        # Find a non-intermediate mesh shape among direct children
        for i in range(fn_dag.childCount()):
            child_mob = fn_dag.child(i)
            if child_mob.hasFn(om.MFn.kMesh):
                if om.MFnDagNode(child_mob).isIntermediateObject:
                    continue
                ch = om.MDagPath(dag)
                ch.push(child_mob)
                mesh_idx = self._export_mesh(ch, node_idx)
                if mesh_idx is not None:
                    node["mesh"] = mesh_idx
                break  # one mesh per transform

        # Recurse into transform children only — skips camera/light/mesh shapes
        for i in range(fn_dag.childCount()):
            child_mob = fn_dag.child(i)
            if not child_mob.hasFn(om.MFn.kTransform):
                continue
            ch = om.MDagPath(dag)
            ch.push(child_mob)
            self._add_node(ch, node_idx)

    # ── mesh export ───────────────────────────────────────────────────────
    def _export_mesh(self, dag, node_idx):
        shape = om.MDagPath(dag)
        if shape.hasFn(om.MFn.kTransform):
            shape.extendToShape()
        if not shape.hasFn(om.MFn.kMesh):
            return None

        # ── instance deduplication ────────────────────────────────────────
        # If this exact shape node has already been exported (another transform
        # instances the same shape), reuse the mesh index and queue the skin
        # reference so _finalize_skins will assign the correct skin to this node.
        try:
            shape_uuid = om.MFnDependencyNode(shape.node()).uuid().asString()
        except Exception:
            shape_uuid = om.MFnDependencyNode(shape.node()).name()

        if shape_uuid in self._shape_mesh_cache:
            cached_mesh_idx, cached_skin = self._shape_mesh_cache[shape_uuid]
            if cached_skin is not None:
                # Queue the skin so _finalize_skins assigns it to this node too
                self._pending_skins.append((node_idx, cached_skin))
            return cached_mesh_idx

        fn      = om.MFnMesh(shape)             # live mesh — shader & name queries only
        skin    = _SkinBuilder(shape)
        blend   = _BlendBuilder(shape)

        # ── choose the right mesh for geometry data ───────────────────────
        # Deformed meshes (skinCluster / blendShape): use the INTERMEDIATE
        # (orig) shape which holds the undeformed bind-pose geometry so that
        # skin weights can be applied on top without double-deformation.
        #
        # Non-deformed meshes: always use the LIVE mesh.  Using the
        # intermediate here would return a stale "Orig" shape left behind by
        # a previously deleted skinCluster, giving wrong vertex positions and
        # an apparent scale or shape error on the exported geometry.
        if skin.valid or blend.valid:
            fn_geo = _get_intermediate_fn(shape)
        else:
            fn_geo = om.MFnMesh(shape)           # no deformers → live mesh ✓

        # ── base geometry ─────────────────────────────────────────────────
        # For SKINNED meshes the glTF spec says the mesh node's own local
        # transform is NOT applied to the skinned geometry (compliant viewers
        # ignore it).  _finalize_skins therefore clears the node TRS after
        # assigning the skin.  To keep vertices in the correct position we
        # bake the mesh node's local matrix into them here, so they end up in
        # the parent node's coordinate space rather than the mesh node's.
        # The skinning IBMs are world-space inverses, so this is still correct.
        #
        # For UN-SKINNED meshes the standard model_matrix × v_local path
        # applies; vertices stay in the mesh node's kObject space.
        if skin.valid:
            pts     = fn_geo.getPoints(om.MSpace.kWorld)
            normals = fn_geo.getNormals(om.MSpace.kWorld)
            # We will divide out the PARENT world matrix after the loop so
            # the stored coordinates are relative to the parent node, not
            # absolute world space (parent transforms must still apply).
            # Compute parent-world-inverse for the transform above this shape.
            try:
                _par = om.MDagPath(shape)
                _par.pop()          # step to mesh transform node
                _par.pop()          # step to its parent
                _par_inv = _par.inclusiveMatrixInverse()
            except Exception:
                _par_inv = None     # fall back to world-space if hierarchy fails
        else:
            pts     = fn_geo.getPoints(om.MSpace.kObject)
            normals = fn_geo.getNormals(om.MSpace.kObject)
            _par_inv = None
        uv_set_names = list(fn_geo.getUVSetNames()) if fn_geo.numUVSets > 0 else []
        has_uv0 = len(uv_set_names) >= 1
        has_uv1 = len(uv_set_names) >= 2
        uv0_set = uv_set_names[0] if has_uv0 else None
        uv1_set = uv_set_names[1] if has_uv1 else None
        uv0_us, uv0_vs = fn_geo.getUVs(uvSet=uv0_set) if has_uv0 else ([], [])
        uv1_us, uv1_vs = fn_geo.getUVs(uvSet=uv1_set) if has_uv1 else ([], [])

        norm_ids = [list(fn_geo.getFaceNormalIds(fi)) for fi in range(fn_geo.numPolygons)]

        # ── material assignments ──────────────────────────────────────────
        try:
            shaders, face_mats = fn.getConnectedShaders(0)
        except Exception:
            shaders, face_mats = om.MObjectArray(), om.MIntArray()

        # ── group triangles by material index ─────────────────────────────
        # mat_bufs[mat_idx] = dict of per-primitive data
        mat_bufs = {}

        _, flat_tri_verts = fn_geo.getTriangles()
        flat_tri_verts = list(flat_tri_verts)
        tri_ptr = 0

        for fi in range(fn_geo.numPolygons):
            mat_idx = int(face_mats[fi]) if face_mats and fi < len(face_mats) else 0
            if mat_idx < 0:
                mat_idx = 0

            if mat_idx not in mat_bufs:
                mat_bufs[mat_idx] = {
                    "pos": [], "nor": [], "uv": [], "uv1": [],
                    "joints": [], "weights": [],
                    "blend": {ti: [] for ti in range(len(blend.target_names))},
                    "orig": [],   # original vertex index per expanded vertex
                    "idx": [],
                    "kmap": {},
                }

            buf = mat_bufs[mat_idx]
            fvc   = fn_geo.polygonVertexCount(fi)
            fvs   = list(fn_geo.getPolygonVertices(fi))
            # Build a O(1) lookup so the inner loop doesn't do a O(n) list
            # scan for every triangle corner (was the main per-mesh hot-spot).
            vi_to_loc = {v: i for i, v in enumerate(fvs)}
            nids  = norm_ids[fi]
            uv0_ids = ([fn_geo.getPolygonUVid(fi, k, uvSet=uv0_set)
                        for k in range(fvc)] if has_uv0 else [0] * fvc)
            uv1_ids = ([fn_geo.getPolygonUVid(fi, k, uvSet=uv1_set)
                        for k in range(fvc)] if has_uv1 else [0] * fvc)
            n_tris = fvc - 2

            for _ in range(n_tris):
                for corner in range(3):
                    vi  = flat_tri_verts[tri_ptr + corner]
                    loc = vi_to_loc.get(vi, 0)
                    ni  = nids[loc]
                    uvi0 = uv0_ids[loc]
                    uvi1 = uv1_ids[loc]
                    key = (vi, ni, uvi0, uvi1) if has_uv1 else (vi, ni, uvi0)

                    if key not in buf["kmap"]:
                        out_i = len(buf["pos"]) // 3
                        buf["kmap"][key] = out_i
                        buf["orig"].append(vi)

                        p = pts[vi]
                        sc = self.scale
                        if _par_inv is not None:
                            # Skinned mesh: pts are in world space.
                            # Multiply by parent-world-inverse → parent-local space.
                            _wp = om.MPoint(p.x, p.y, p.z) * _par_inv
                            px, py, pz = _wp.x * sc, _wp.y * sc, _wp.z * sc
                        else:
                            px, py, pz = p.x * sc, p.y * sc, p.z * sc
                        if self.up_axis == "Z":
                            px, py, pz = _zup_pos(px, py, pz)
                        buf["pos"] += [px, py, pz]
                        n = normals[ni]
                        if _par_inv is not None:
                            # Transform normal direction by the same matrix
                            # (use MVector to exclude translation).
                            _wn = om.MVector(n.x, n.y, n.z) * _par_inv
                            nx, ny, nz = _wn.x, _wn.y, _wn.z
                        else:
                            nx, ny, nz = n.x, n.y, n.z
                        if self.up_axis == "Z":
                            nx, ny, nz = _zup_pos(nx, ny, nz)
                        buf["nor"] += [nx, ny, nz]
                        buf["uv"]  += ([uv0_us[uvi0], 1.0 - uv0_vs[uvi0]]
                                       if has_uv0 and 0 <= uvi0 < len(uv0_us)
                                       else [0.0, 0.0])
                        if has_uv1:
                            buf["uv1"] += ([uv1_us[uvi1], 1.0 - uv1_vs[uvi1]]
                                           if 0 <= uvi1 < len(uv1_us)
                                           else [0.0, 0.0])

                        if skin.valid:
                            buf["joints"]  += skin.joints_for(vi)
                            buf["weights"] += skin.weights_for(vi)

                        for ti in range(len(blend.target_names)):
                            dx, dy, dz = blend.delta_for(ti, vi)
                            dx, dy, dz = dx * sc, dy * sc, dz * sc
                            if self.up_axis == "Z":
                                dx, dy, dz = _zup_pos(dx, dy, dz)
                            buf["blend"][ti] += [dx, dy, dz]

                    buf["idx"].append(buf["kmap"][key])
                tri_ptr += 3

        # ── build one glTF primitive per material ─────────────────────────
        primitives = []
        for mat_idx, buf in mat_bufs.items():
            if not buf["idx"]:
                continue

            gltf_mat = (self._mat.index_for_sg(shaders[mat_idx])
                        if shaders and mat_idx < len(shaders) else 0)
            prim = self._build_primitive(buf, gltf_mat, skin, blend, has_uv1)
            primitives.append(prim)

        if not primitives:
            return None

        mesh_entry = {"name": fn.name(), "primitives": primitives}
        if blend.valid:
            mesh_entry["weights"] = [0.0] * len(blend.target_names)
            mesh_entry.setdefault("extras",{})["targetNames"] = blend.target_names

        mesh_idx = len(self.gltf["meshes"])
        self.gltf["meshes"].append(mesh_entry)

        if skin.valid:
            self._pending_skins.append((node_idx, skin))
        if blend.valid:
            # Only add one weight-animation entry per unique blendShape node —
            # all instances share the same weights, so a second channel would
            # be redundant and some viewers reject duplicate target paths.
            known_bs = {b.bs_name for _, b in self._blend_anims}
            if blend.bs_name not in known_bs:
                self._blend_anims.append((node_idx, blend))

        # Cache so subsequent instances of this shape reuse the same mesh data
        self._shape_mesh_cache[shape_uuid] = (mesh_idx, skin if skin.valid else None)

        return mesh_idx

    def _build_primitive(self, buf, gltf_mat, skin, blend, has_uv1=False):
        pos = buf["pos"]
        n_v = len(pos) // 3
        xs  = pos[0::3]; ys = pos[1::3]; zs = pos[2::3]

        a_pos = self.buf.push(pos, FLOAT, "VEC3", target=ARRAY_BUF,
                              min_v=[min(xs),min(ys),min(zs)],
                              max_v=[max(xs),max(ys),max(zs)])
        a_nor = self.buf.push(buf["nor"], FLOAT, "VEC3", target=ARRAY_BUF)
        a_uv  = self.buf.push(buf["uv"],  FLOAT, "VEC2", target=ARRAY_BUF)

        idx   = buf["idx"]
        itype = UNSIGNED_INT if n_v > 65535 else UNSIGNED_SHORT
        a_idx = self.buf.push(idx, itype, "SCALAR", target=ELEMENT_BUF,
                              min_v=[min(idx)], max_v=[max(idx)])

        attrs = {"POSITION":a_pos, "NORMAL":a_nor, "TEXCOORD_0":a_uv}
        if has_uv1 and buf["uv1"]:
            a_uv1 = self.buf.push(buf["uv1"], FLOAT, "VEC2", target=ARRAY_BUF)
            attrs["TEXCOORD_1"] = a_uv1

        if skin.valid and buf["joints"]:
            jtype = UNSIGNED_BYTE if max(buf["joints"]) < 256 else UNSIGNED_SHORT
            a_j = self.buf.push(buf["joints"],  jtype, "VEC4", target=ARRAY_BUF)
            a_w = self.buf.push(buf["weights"], FLOAT, "VEC4", target=ARRAY_BUF)
            attrs["JOINTS_0"]  = a_j
            attrs["WEIGHTS_0"] = a_w

        prim = {"attributes": attrs, "indices": a_idx,
                "material": gltf_mat, "mode": 4}

        if blend.valid:
            targets = []
            for ti, tname in enumerate(blend.target_names):
                deltas = buf["blend"].get(ti, [])
                if any(v != 0.0 for v in deltas):
                    xs = deltas[0::3]; ys = deltas[1::3]; zs = deltas[2::3]
                    a_d = self.buf.push(deltas, FLOAT, "VEC3", target=ARRAY_BUF,
                                        min_v=[min(xs), min(ys), min(zs)],
                                        max_v=[max(xs), max(ys), max(zs)])
                else:
                    # zero-delta target — required to keep target count consistent
                    zero = [0.0] * (len(buf["orig"]) * 3)
                    a_d  = self.buf.push(zero, FLOAT, "VEC3", target=ARRAY_BUF,
                                         min_v=[0.0, 0.0, 0.0],
                                         max_v=[0.0, 0.0, 0.0])
                targets.append({"POSITION": a_d})
            if targets:
                prim["targets"] = targets

        return prim

    # ── skin post-processing ──────────────────────────────────────────────
    def _finalize_skins(self):
        for node_idx, skin in self._pending_skins:

            # If this skinCluster was already resolved for another instance,
            # reuse that skin index instead of writing duplicate IBMs/joints.
            if skin.skin_name and skin.skin_name in self._skin_idx_cache:
                self.gltf["nodes"][node_idx]["skin"] = self._skin_idx_cache[skin.skin_name]
                continue

            joint_indices = []
            for inf_path in skin.influences:
                if inf_path in self._node_index:
                    joint_indices.append(self._node_index[inf_path])
                else:
                    # Try short-name match (handles namespace-less rigs)
                    short = inf_path.split("|")[-1]
                    found = next((v for k,v in self._node_index.items()
                                  if k.split("|")[-1] == short), None)
                    if found is not None:
                        joint_indices.append(found)
                    else:
                        _log(f"Skin joint '{inf_path}' not found in exported "
                             f"hierarchy — influence will be skipped.", "WARN")
                        joint_indices.append(0)   # placeholder; IBM stays aligned

            ibms     = skin.inverse_bind_matrices()
            ibm_flat = [v for m in ibms for v in m]
            if not joint_indices or not ibm_flat:
                continue   # nothing to write (safety guard)

            # When a uniform scene scale is applied, all node translations are
            # multiplied by `scale`.  The IBM must be adjusted so that the
            # skinning identity (J_world × IBM = I) still holds at bind pose.
            # For a uniform scale s, only the translation part of each IBM
            # changes: IBM_glTF = IBM_maya with its translation row × s.
            # In the column-major flat layout produced by _mmatrix_col the
            # translation row occupies indices [3, 7, 11] of each 16-element block.
            if self.scale != 1.0:
                sc = self.scale
                for mi in range(len(ibm_flat) // 16):
                    b = mi * 16
                    # Translation is in the last column (col 3) at flat indices
                    # b+12, b+13, b+14 (row 0, 1, 2 of column 3).
                    ibm_flat[b + 12] *= sc
                    ibm_flat[b + 13] *= sc
                    ibm_flat[b + 14] *= sc

            if self.up_axis == "Z":
                ibm_flat = _zup_ibm(ibm_flat)

            a_ibm = self.buf.push(ibm_flat, FLOAT, "MAT4")

            # ── Find the skeleton root (Lowest Common Ancestor) ──────────────
            # A skin can span multiple skeleton branches (e.g. left arm,
            # right arm, spine).  Picking the first joint that is not a
            # direct child of another skin joint gives the wrong answer for
            # multi-branch skins — no single branch-root is an ancestor of
            # ALL other joints, which causes SKIN_SKELETON_INVALID.
            #
            # The spec says `skeleton` must be the closest common root of
            # ALL joints in this skin (or any ancestor of it).  We use a
            # proper LCA search in the exported glTF node tree.

            # 1. Build a parent map for the whole node list
            _parent = {}
            for _ni, _node in enumerate(self.gltf["nodes"]):
                for _ci in _node.get("children", []):
                    _parent[_ci] = _ni

            # 2. Compute the ancestor path (node → root) for one joint
            def _ancestors(idx):
                path, cur = [], idx
                while cur is not None:
                    path.append(cur)
                    cur = _parent.get(cur)
                return path   # [idx, parent, grandparent, ..., root]

            # 3. LCA: deepest node that appears in EVERY joint's ancestor path
            all_ancestor_sets = [set(_ancestors(ji)) for ji in joint_indices]
            # Walk from node → root along the first joint's path; the first
            # node that is in every set is the closest common ancestor.
            skeleton_root = joint_indices[0]   # fallback
            for _candidate in _ancestors(joint_indices[0]):
                if all(_candidate in s for s in all_ancestor_sets):
                    skeleton_root = _candidate
                    break

            skin_idx = len(self.gltf["skins"])
            self.gltf["skins"].append({
                "joints":              joint_indices,
                "inverseBindMatrices": a_ibm,
                "skeleton":            skeleton_root,
            })
            self.gltf["nodes"][node_idx]["skin"] = skin_idx

            # glTF spec §5.22: "Local transforms of a node with a skinned mesh
            # are ignored; the skin matrices define the geometry's position."
            # Validators raise NODE_SKINNED_MESH_LOCAL_TRANSFORMS when TRS is
            # non-identity on a skinned mesh node.  We baked the mesh's own
            # local matrix into the vertex positions above, so clearing the
            # TRS here is safe.
            for _k in ("translation", "rotation", "scale"):
                self.gltf["nodes"][node_idx].pop(_k, None)

            # Cache so subsequent instances reuse this skin entry
            if skin.skin_name:
                self._skin_idx_cache[skin.skin_name] = skin_idx

    # ── animation export ──────────────────────────────────────────────────
    def _export_animations(self, clips=None):
        # clips=None  → use the full playback timeline as a single clip.
        # clips=[]    → the table is empty or every row was invalid; warn and
        #               fall back to the full timeline rather than silently
        #               exporting nothing.
        if not clips:
            if clips is not None:   # was explicitly [] (empty table)
                _log("Animation clip table is empty — falling back to "
                     "full Maya timeline.", "WARN")
            ctrl  = oma.MAnimControl
            start = int(ctrl.minTime().value)
            end   = int(ctrl.maxTime().value)
            if start >= end:
                return
            clips = [{"name": "Take 001", "start": start, "end": end}]

        animated = self._gather_animated()

        # Build blend-shape lookup: node_idx → _BlendBuilder
        blend_by_idx = {}
        for b_node_idx, blend in self._blend_anims:
            if blend.bs_name and blend.target_names:
                blend_by_idx[b_node_idx] = blend

        if not animated and not blend_by_idx:
            _log("No animated nodes found in the exported hierarchy — "
                 "animation export skipped.", "WARN")
            return

        _log(f"Exporting {len(clips)} animation clip(s) across "
             f"{len(animated)} animated node(s)…", "INFO")

        saved = cmds.currentTime(q=True)
        sc    = self.scale

        for clip_idx, clip in enumerate(clips):
            clip_name = clip.get("name", f"clip_{clip_idx + 1}")
            start     = int(clip.get("start", 1))
            end       = int(clip.get("end", 1))
            if start >= end:
                _log(f"Clip '{clip_name}': start ({start}) >= end ({end}) "
                     f"— skipped.", "WARN")
                continue

            # Normalise times so every clip starts at t = 0 seconds.
            times   = [float(f - start) / self.fps for f in range(start, end + 1)]
            a_times = self.buf.push(times, FLOAT, "SCALAR",
                                    min_v=[times[0]], max_v=[times[-1]])

            # Per-node TRS accumulators
            node_trs = {dag.fullPathName(): ([], [], [])
                        for dag in animated if dag.fullPathName() in self._node_index}

            # Per-blend-shape weight accumulators
            blend_weights_v = {idx: [] for idx in blend_by_idx}

            # Sample every frame using cmds.currentTime.
            #
            # This forces a full DG evaluation — IK solvers, constraints,
            # expressions, SDK nodes — giving correct transforms for every rig
            # type without special-casing.  Viewport suspension keeps it fast.
            cmds.refresh(suspend=True)
            try:
                for f in range(start, end + 1):
                    cmds.currentTime(f)

                    # T/R/S for all potentially animated nodes
                    for dag in animated:
                        path = dag.fullPathName()
                        if path not in node_trs:
                            continue
                        t_v, r_v, s_v = node_trs[path]
                        t, r, s = _local_trs(dag)   # already includes JO + RA
                        if sc != 1.0:
                            t = [v * sc for v in t]
                        if self.up_axis == "Z":
                            t = list(_zup_pos(*t))
                            r = list(_zup_quat(*r))
                        t_v += t; r_v += r; s_v += s

                    # Blend-shape weights at this frame
                    for b_node_idx, blend in blend_by_idx.items():
                        wv = blend_weights_v[b_node_idx]
                        for i in range(len(blend.target_names)):
                            try:
                                wv.append(float(
                                    cmds.getAttr(f"{blend.bs_name}.weight[{i}]")
                                    or 0.0))
                            except Exception:
                                wv.append(0.0)
            finally:
                cmds.refresh(suspend=False)

            channels, samplers = [], []

            for dag in animated:
                path = dag.fullPathName()
                if path not in self._node_index:
                    continue
                ni    = self._node_index[path]
                t_v, r_v, s_v = node_trs[path]

                if _channel_varies(t_v, 3):
                    a  = self.buf.push(t_v, FLOAT, "VEC3")
                    si = len(samplers)
                    samplers.append({"input":a_times,"interpolation":"LINEAR","output":a})
                    channels.append({"sampler":si,"target":{"node":ni,"path":"translation"}})

                if _channel_varies(r_v, 4):
                    a  = self.buf.push(r_v, FLOAT, "VEC4")
                    si = len(samplers)
                    samplers.append({"input":a_times,"interpolation":"LINEAR","output":a})
                    channels.append({"sampler":si,"target":{"node":ni,"path":"rotation"}})

                if _channel_varies(s_v, 3):
                    a  = self.buf.push(s_v, FLOAT, "VEC3")
                    si = len(samplers)
                    samplers.append({"input":a_times,"interpolation":"LINEAR","output":a})
                    channels.append({"sampler":si,"target":{"node":ni,"path":"scale"}})

            # Blend-shape weight animation
            for b_node_idx, wv in blend_weights_v.items():
                blend = blend_by_idx[b_node_idx]
                n_tgt = len(blend.target_names)
                if not wv or n_tgt == 0:
                    continue
                first    = wv[:n_tgt]
                n_frames = len(wv) // n_tgt
                if all(wv[f * n_tgt + t] == first[t]
                       for f in range(n_frames) for t in range(n_tgt)):
                    continue
                a_w = self.buf.push(wv, FLOAT, "SCALAR")
                si  = len(samplers)
                samplers.append({"input":a_times,"interpolation":"LINEAR","output":a_w})
                channels.append({"sampler":si,
                                  "target":{"node":b_node_idx,"path":"weights"}})

            if channels:
                self.gltf["animations"].append({
                    "name": clip_name,
                    "channels": channels,
                    "samplers": samplers,
                })
                _log(f"Clip '{clip_name}'  ({start}–{end}):  "
                     f"{len(channels)} channel(s) exported.", "OK")
            else:
                # No variation detected in this frame range.  Rather than
                # silently dropping the clip, build a 2-frame loopable pose
                # clip from the already-sampled first-frame values.  Both
                # frames hold the same pose so the animation loops cleanly.
                _log(f"Clip '{clip_name}'  ({start}–{end}):  static pose — "
                     f"exporting as 2-frame loopable pose clip.", "INFO")

                clip_dur   = max(float(end - start) / self.fps, 1.0 / self.fps)
                pose_times = [0.0, clip_dur]
                a_pt = self.buf.push(pose_times, FLOAT, "SCALAR",
                                     min_v=[0.0], max_v=[clip_dur])

                for dag in animated:
                    path = dag.fullPathName()
                    if path not in self._node_index:
                        continue
                    ni = self._node_index[path]
                    t_v, r_v, s_v = node_trs[path]

                    # translation — duplicate first frame
                    if len(t_v) >= 3:
                        a = self.buf.push(t_v[:3] + t_v[:3], FLOAT, "VEC3")
                        si = len(samplers)
                        samplers.append({"input": a_pt,
                                         "interpolation": "LINEAR", "output": a})
                        channels.append({"sampler": si,
                                         "target": {"node": ni, "path": "translation"}})

                    # rotation — duplicate first frame
                    if len(r_v) >= 4:
                        a = self.buf.push(r_v[:4] + r_v[:4], FLOAT, "VEC4")
                        si = len(samplers)
                        samplers.append({"input": a_pt,
                                         "interpolation": "LINEAR", "output": a})
                        channels.append({"sampler": si,
                                         "target": {"node": ni, "path": "rotation"}})

                    # scale — only write when non-default (avoids redundant data)
                    if len(s_v) >= 3:
                        fs = s_v[:3]
                        if any(abs(v - 1.0) > 1e-4 for v in fs):
                            a = self.buf.push(fs + fs, FLOAT, "VEC3")
                            si = len(samplers)
                            samplers.append({"input": a_pt,
                                             "interpolation": "LINEAR", "output": a})
                            channels.append({"sampler": si,
                                             "target": {"node": ni, "path": "scale"}})

                if channels:
                    self.gltf["animations"].append({
                        "name": clip_name,
                        "channels": channels,
                        "samplers": samplers,
                    })
                    _log(f"Clip '{clip_name}'  ({start}–{end}):  "
                         f"pose clip exported ({len(channels)} channel(s)).", "OK")
                else:
                    _log(f"Clip '{clip_name}'  ({start}–{end}):  "
                         f"no animated nodes — clip skipped.", "WARN")

        cmds.currentTime(saved)

    def _gather_animated(self):
        """Return DAG paths of exportable nodes that may carry animation.

        Includes three categories:
          1. Nodes with direct animCurve connections (keyed objects / controls)
          2. All joint nodes — joints in production rigs are almost always driven
             by constraints, not keyed directly.  MDGContext evaluation in the
             frame loop resolves the constraint output at each frame, so including
             them here ensures their motion is captured even without baking.
          3. Nodes with any constraint connection (parent/point/orient/scale/aim)

        Using the full DAG path avoids short-name ambiguity after duplication.
        """
        result = []
        for path in self._node_index:
            try:
                sel = om.MSelectionList()
                sel.add(path)
                dag = sel.getDagPath(0)
            except Exception:
                continue

            is_joint = dag.hasFn(om.MFn.kJoint)
            if is_joint:
                # Joints are always included regardless of direct keyframes —
                # skip the two listConnections calls (saves ~2 API round-trips
                # per joint; a typical rig has 100-300 joints).
                result.append(dag)
                continue

            has_curves = bool(cmds.listConnections(
                path, source=True, destination=False, type="animCurve"))
            has_constraint = bool(cmds.listConnections(
                path, source=True, destination=False, type="constraint"))
            if has_curves or has_constraint:
                result.append(dag)
        return result


# ══════════════════════════════════════════════════════════════════════════════
# Visibility helper
# ══════════════════════════════════════════════════════════════════════════════
def _is_dag_visible(dag):
    """
    Return True only if the node AND every ancestor in the DAG hierarchy
    has its 'visibility' attribute set to True.

    Display-layer visibility is intentionally NOT checked here: display
    layers are a viewport-only concept and should not affect what gets
    exported.  Users who want to exclude a mesh should set its visibility
    attribute to off, not just hide its display layer.
    """
    try:
        current = om.MDagPath(dag)
        while current.length() > 0:
            fn = om.MFnDagNode(current)
            if not fn.findPlug("visibility", False).asBool():
                return False
            current.pop()
    except Exception:
        pass
    return True


# ══════════════════════════════════════════════════════════════════════════════
# Bake helper
# ══════════════════════════════════════════════════════════════════════════════
def _animated_nodes():
    """
    Return transforms/joints to bake before export.

    Includes:
      • Every transform/joint with a direct animCurve input (keyed objects).
      • Every joint in the scene — in production rigs joints are driven by
        constraints whose source controls carry the animCurves.  bakeResults
        on the joint resolves the full constraint chain into explicit keys so
        that the subsequent MDGContext-based export reads correct values.
    """
    all_curves = cmds.ls(type="animCurve") or []
    seen = set()

    if all_curves:
        connected = cmds.listConnections(all_curves, source=False, destination=True) or []
        for n in connected:
            if cmds.nodeType(n) in ("transform", "joint"):
                seen.add(n)

    # All joints — ensures constraint-driven joints get baked
    for j in (cmds.ls(type="joint") or []):
        seen.add(j)

    return [n for n in seen if cmds.objExists(n)]


def _bake_animation(start, end, euler_filter=True, simulation=False):
    """
    Bake only animated nodes (those with at least one animCurve) to explicit
    keyframes in a single bakeResults call.
    Must be called inside an undo chunk — caller closes the chunk and calls
    cmds.undo() so the scene is always restored automatically.

    start / end  : integer frame range
    euler_filter : apply Euler-filter to rotation curves after baking
    simulation   : False (default) — all frames evaluated simultaneously (fast);
                   correct for keyframe / rig / constraint animation.
                   True  — step frame-by-frame through the DG; required only
                   for nCloth, rigid body, or particle simulations.
    """
    nodes = _animated_nodes()
    if not nodes:
        return

    # Single bakeResults call — all animated nodes processed at once
    cmds.bakeResults(
        nodes,
        time=(start, end),
        simulation=simulation,
        sampleBy=1,
        oversamplingRate=1,
        disableImplicitControl=True,
        preserveOutsideKeys=True,
        sparseAnimCurveBake=False,
        removeBakedAttributeFromLayer=False,
        bakeOnOverrideLayer=False,
        minimizeRotation=True,
    )

    if euler_filter:
        rot_curves = (
            cmds.keyframe(
                nodes,
                attribute=["rotateX", "rotateY", "rotateZ"],
                query=True, name=True,
            ) or []
        )
        if rot_curves:
            cmds.filterCurve(*rot_curves, filter="euler")


# ══════════════════════════════════════════════════════════════════════════════
# Pre-export scene optimisation
# ══════════════════════════════════════════════════════════════════════════════
def _optimize_scene():
    """
    Pre-export scene cleanup — call inside an undo chunk so all changes
    are automatically reversed after export.

    1. Non-deformer history  — remove construction ops, keep deformers.
    2. Unknown nodes         — delete nodes left by missing plug-ins.
    3. Unused shading        — delete unassigned materials / textures.
    4. Empty transforms      — delete childless, unconnected groups.
    """

    # ── Safety sets — one batch call each, O(1) membership checks ────────
    # cmds.referenceQuery per-node is slow; batch the referenced check once.
    _referenced = set(cmds.ls(referencedNodes=True) or [])

    def _safe(node):
        """True when the node is neither referenced nor locked."""
        if node in _referenced:
            return False
        try:
            return not (cmds.lockNode(node, query=True) or [False])[0]
        except Exception:
            return False   # treat query failure as locked

    # ── 1. Non-deformer history ───────────────────────────────────────────
    _SKIP_TYPES  = frozenset(("mesh", "groupParts", "groupId"))
    live_shapes  = cmds.ls(type="mesh", noIntermediate=True) or []
    n_plain = n_baked = 0

    for shape in live_shapes:
        try:
            hist = cmds.listHistory(shape, pruneDagObjects=True) or []
            if not hist:
                continue
            deformers = set(cmds.ls(hist, type="geometryFilter") or [])
            if not deformers:
                cmds.delete(shape, constructionHistory=True)
                n_plain += 1
            elif any(n not in deformers and cmds.nodeType(n) not in _SKIP_TYPES
                     for n in hist):
                mel.eval('catchQuiet(`bakePartialHistory "{}"`);'
                         .format(shape.replace('"', '\\"')))
                n_baked += 1
        except Exception as exc:
            _log(f"History cleanup skipped '{shape}': {exc}", "WARN")

    if n_plain + n_baked:
        _log(f"Non-deformer history removed from {n_plain + n_baked} shape(s) "
             f"({n_plain} plain · {n_baked} baked).", "INFO")

    # ── 2. Unknown nodes ──────────────────────────────────────────────────
    n_unk = 0
    for n in (cmds.ls(type="unknown") or []):
        if _safe(n):
            try:
                cmds.delete(n)
                n_unk += 1
            except Exception:
                pass
    if n_unk:
        _log(f"Deleted {n_unk} unknown node(s).", "INFO")

    # ── 3. Unused shading / rendering nodes ───────────────────────────────
    try:
        mel.eval("MLdeleteUnused")
        _log("Unused shading nodes removed.", "INFO")
    except Exception:
        pass

    # ── 4. Empty transforms (animation-safe) ─────────────────────────────
    # Build the protected set once before the loop.
    protected = set()

    for cam in (cmds.ls(type="camera") or []):          # cameras + parents
        protected.add(cam)
        protected.update(cmds.listRelatives(cam, parent=True) or [])

    all_curves = cmds.ls(type="animCurve") or []
    if all_curves:                                        # animation targets
        protected.update(
            cmds.listConnections(all_curves,
                                 source=False, destination=True) or [])

    all_con = cmds.ls(type="constraint") or []
    if all_con:                                           # constraint targets
        protected.update(
            cmds.listConnections(all_con,
                                 source=False, destination=True) or [])
        protected.update(all_con)

    n_empty = 0
    for _ in range(8):          # repeat — deleting a node may empty its parent
        batch = [
            t for t in (cmds.ls(type="transform") or [])
            if t not in protected
            and _safe(t)
            and not cmds.listRelatives(t, children=True)
            and not cmds.listConnections(t, source=True, destination=False)
        ]
        if not batch:
            break
        deleted = 0
        for t in batch:
            try:
                cmds.delete(t)
                deleted += 1
            except Exception:
                pass
        n_empty += deleted
        if not deleted:
            break

    if n_empty:
        _log(f"Deleted {n_empty} empty transform(s).", "INFO")


# ══════════════════════════════════════════════════════════════════════════════
# FPS helper
# ══════════════════════════════════════════════════════════════════════════════
def _maya_unit_scale():
    """Return the multiplier that converts Maya's current linear unit to metres.

    glTF uses metres as its unit system.  Maya's default linear unit is
    centimetres, so the default conversion factor is 0.01.  The value is used
    to pre-populate the Scene Scale spinbox so that exported objects appear at
    their physically correct size in any glTF-compliant viewer.
    """
    unit = cmds.currentUnit(query=True, linear=True)
    return {
        "m":  1.0,       "dm":  0.1,      "cm": 0.01,   "mm": 0.001,
        "km": 1000.0,    "Mm":  1e6,
        "in": 0.0254,    "ft":  0.3048,   "yd": 0.9144, "mi": 1609.344,
    }.get(unit, 0.01)   # fall back to cm if Maya reports an unknown unit


def _maya_fps():
    """Return the current Maya scene FPS as a number."""
    unit = cmds.currentUnit(query=True, time=True)
    return {
        "game": 15, "film": 24, "pal": 25, "ntsc": 30,
        "show": 48, "palf": 50, "ntscf": 60,
        "23.976fps": 23.976, "29.97fps": 29.97,
        "47.952fps": 47.952, "59.94fps": 59.94,
        "2fps": 2, "3fps": 3, "4fps": 4, "5fps": 5,
        "6fps": 6, "8fps": 8, "10fps": 10, "12fps": 12,
        "16fps": 16, "20fps": 20,
    }.get(unit, 24)


# ══════════════════════════════════════════════════════════════════════════════
# Scene helpers
# ══════════════════════════════════════════════════════════════════════════════
def _dag_roots():
    """Top-level DAG transforms via cmds.ls(assemblies=True) — reliable across Maya versions."""
    result = []
    sel = om.MSelectionList()
    for name in (cmds.ls(assemblies=True) or []):
        try:
            sel.clear()
            sel.add(name)
            result.append(sel.getDagPath(0))
        except Exception:
            pass
    return result


def _selected_roots():
    """
    Dag paths for the active selection, deduplicated so that a node whose
    ancestor is also selected is not returned (prevents double-export when an
    entire hierarchy is selected).  Shape paths are promoted to their parent
    transform automatically.
    """
    raw_sel = om.MGlobal.getActiveSelectionList()
    dags = []
    for i in range(raw_sel.length()):
        try:
            dp = raw_sel.getDagPath(i)
            # Promote shape → parent transform
            if not dp.hasFn(om.MFn.kTransform) and dp.length() > 1:
                dp.pop()
            if dp.hasFn(om.MFn.kTransform):
                dags.append(dp)
        except Exception:
            pass

    # Remove nodes that are descendants of another node already in the list
    path_set = {d.fullPathName() for d in dags}
    result = []
    for dag in dags:
        path  = dag.fullPathName()
        parts = path.split("|")
        # Build all ancestor paths and check if any are in the selection
        ancestor_selected = any(
            "|".join(parts[:n]) in path_set
            for n in range(1, len(parts))
            if "|".join(parts[:n])
        )
        if not ancestor_selected:
            result.append(dag)
    return result


def _has_anim(path, attrs):
    """Return True if any of `attrs` on the node at `path` has keyframes."""
    for a in attrs:
        try:
            if cmds.keyframe(f"{path}.{a}", q=True, keyframeCount=True):
                return True
        except Exception:
            pass
    return False


def _channel_varies(values, n_components):
    """Return True if the sampled flat value list shows actual change over time.

    Works for both directly-keyed nodes (animCurves) and constraint-driven nodes
    where _has_anim would return False even though the values change each frame.
    `n_components` = 3 for VEC3 (T/S), 4 for VEC4 (R).
    """
    if len(values) < n_components * 2:
        return False
    first = values[:n_components]
    for i in range(n_components, len(values), n_components):
        if any(abs(values[i + j] - first[j]) > 1e-7 for j in range(n_components)):
            return True
    return False


# ══════════════════════════════════════════════════════════════════════════════
# glTF / GLB serialisers
# ══════════════════════════════════════════════════════════════════════════════
def _attach_gltf_buffer(gltf_dict, buf, bin_uri=None):
    """Return a glTF dict with buffer views/accessors attached."""
    d = OrderedDict(gltf_dict)
    buf_entry = {"byteLength": len(buf.raw)}
    if bin_uri:
        buf_entry["uri"] = bin_uri
    d["buffers"] = [buf_entry]
    d["bufferViews"] = buf.views
    d["accessors"] = buf.accs
    return d


def _assemble(gltf_dict, buf):
    return json.dumps(_attach_gltf_buffer(gltf_dict, buf), separators=(',',':'))


def write_gltf(path, gltf_dict, buf):
    bin_path = os.path.splitext(path)[0] + ".bin"
    d = _attach_gltf_buffer(gltf_dict, buf, bin_uri=os.path.basename(bin_path))
    with open(bin_path, "wb") as f:
        f.write(buf.raw)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(d, f, indent=2)


def _promote_images_to_buffer(gltf_dict, buf):
    """Convert any base64 data-URI images to binary buffer views.

    In a GLB container the spec requires image data to live in the binary BIN
    chunk (referenced via a bufferView), NOT as a base64 URI inside the JSON
    chunk.  Leaving them as data-URIs triggers the DATA_URI_GLB validator
    warning and inflates the JSON chunk by ~33 % (base64 overhead).

    This function is called by write_glb() before serialisation.  It is a
    no-op for images that are already external URIs or bufferView references.
    """
    for img in gltf_dict.get("images", []):
        uri = img.get("uri", "")
        if not uri.startswith("data:"):
            continue
        try:
            # Parse  data:<mime>;base64,<data>
            header, b64 = uri.split(",", 1)
            mime = header.split(";")[0][5:]      # strip leading "data:"
            raw  = base64.b64decode(b64)

            # Append raw bytes to the buffer, 4-byte aligned
            buf._pad(4)
            offset = len(buf.raw)
            buf.raw += bytearray(raw)
            buf._pad(4)                          # pad after, per GLB spec

            bv_idx = len(buf.views)
            buf.views.append({
                "buffer":     0,
                "byteOffset": offset,
                "byteLength": len(raw),
            })

            del img["uri"]
            img["bufferView"] = bv_idx
            img["mimeType"]   = mime
        except Exception:
            pass   # leave malformed data-URIs unchanged


def write_glb(path, gltf_dict, buf):
    # Move any embedded-texture data from JSON data-URIs into the binary chunk
    # so the file passes the DATA_URI_GLB validator check.
    _promote_images_to_buffer(gltf_dict, buf)

    js  = _assemble(gltf_dict, buf).encode("utf-8")
    js += b' ' * ((4 - len(js)%4) % 4)
    bn  = bytes(buf.raw) + b'\x00' * ((4 - len(buf.raw)%4) % 4)
    tot = 12 + 8 + len(js) + (8 + len(bn) if bn else 0)
    with open(path,"wb") as f:
        f.write(struct.pack('<III', 0x46546C67, 2, tot))
        f.write(struct.pack('<II',  len(js), 0x4E4F534A)); f.write(js)
        if bn:
            f.write(struct.pack('<II', len(bn), 0x004E4942)); f.write(bn)


# ══════════════════════════════════════════════════════════════════════════════
# Project folder helper
# ══════════════════════════════════════════════════════════════════════════════
def _normalize_export_path(path, ext=None):
    """Return (normalised_path, ext) ensuring the path ends with .glb or .gltf."""
    path = os.path.normpath(path.strip())
    low = path.lower()
    if ext is None:
        if low.endswith(".glb"):
            ext = ".glb"
        elif low.endswith(".gltf"):
            ext = ".gltf"
        else:
            ext = ".glb"
            path += ext
    elif not low.endswith(ext):
        path = os.path.splitext(path)[0] + ext
    return path, ext


def _bake_frame_range(clips):
    """Return (start, end) frame range for baking from clips or the timeline."""
    if clips:
        return min(c["start"] for c in clips), max(c["end"] for c in clips)
    return (int(cmds.playbackOptions(q=True, minTime=True)),
            int(cmds.playbackOptions(q=True, maxTime=True)))


def _restore_scene_undo(chunk_opened):
    """Close the export undo chunk and restore the scene."""
    if not chunk_opened:
        return
    try:
        cmds.undoInfo(closeChunk=True)
    except Exception:
        pass
    try:
        cmds.refresh(suspend=True)
    except Exception:
        pass
    try:
        cmds.undo()
    except Exception:
        pass
    finally:
        try:
            cmds.refresh(suspend=False)
            cmds.refresh()
        except Exception:
            pass
    _log("Scene restored via undo.", "INFO")


def _ensure_glbs_folder():
    """
    Return the path to <current project>/GLBs/, creating it if needed.
    Uses cmds.workspace to resolve the live project root so it always reflects
    the project that is currently set in Maya (File > Set Project).
    """
    root = cmds.workspace(query=True, rootDirectory=True)
    if not root:
        root = os.path.expanduser("~")
    # Strip trailing slash Maya sometimes adds, then normalise separators
    root     = os.path.normpath(root.rstrip("/\\"))
    glbs_dir = os.path.join(root, "GLBs")
    os.makedirs(glbs_dir, exist_ok=True)
    return glbs_dir


def _settings_file_path():
    """Return the path to the per-scene exporter settings JSON file.

    Location: <current maya project>/data/<scene_name>_gltf_settings.json

    The filename matches the current Maya scene so each scene file keeps its
    own independent export settings (clips, scale, format, etc.).  Opening
    the same scene restores its saved settings; opening a different scene
    loads that scene's own file (or starts from defaults if none exists yet).
    Unsaved / untitled scenes use 'untitled_gltf_settings.json'.
    """
    scene_file = cmds.file(query=True, sceneName=True) or ""
    scene_name = os.path.splitext(os.path.basename(scene_file))[0] or "untitled"
    filename   = f"{scene_name}_gltf_settings.json"

    try:
        root     = cmds.workspace(query=True, rootDirectory=True) or ""
        root     = os.path.normpath(root.rstrip("/\\"))
        data_dir = os.path.join(root, "data")
        os.makedirs(data_dir, exist_ok=True)
        return os.path.join(data_dir, filename)
    except Exception:
        # Fallback: store next to the scene file or in the home directory
        if scene_file:
            return os.path.join(os.path.dirname(scene_file), filename)
        return os.path.join(os.path.expanduser("~"), filename)


def _default_output_path(ext=".glb"):
    """
    Return <current project>/GLBs/<scene_name><ext>.
    Falls back to 'export' if no scene is open.
    """
    glbs_dir   = _ensure_glbs_folder()
    scene_file = cmds.file(query=True, sceneName=True) or ""
    scene_name = os.path.splitext(os.path.basename(scene_file))[0] or "export"
    # Use forward slashes so the path is readable in Maya's UI
    return os.path.normpath(os.path.join(glbs_dir, scene_name + ext))


# ══════════════════════════════════════════════════════════════════════════════
# Background export task  (runs via cmds.evalDeferred after dialog updates)
# ══════════════════════════════════════════════════════════════════════════════
def _run_export_task(dialog, path, ext, export_all, skip_hidden, embed_textures,
                     clips, do_bake, bake_start, bake_end, do_euler, do_sim,
                     scale=1.0, up_axis="Y", do_cleanup=True, include_anim=True):
    """
    Heavy lifting — scheduled with cmds.evalDeferred so Maya's event loop
    processes the dialog's 'Working…' state before this function runs.

    Sequence:
      1. Open one undo chunk  (if bake or cleanup modifies the scene)
      2. bakeResults on all animated nodes at once  (if requested)
      3. Scene optimisation — delete non-deformer history, unknown nodes,
         unused shading nodes, empty transforms  (if requested)
      4. Build + write glTF / GLB
      5. Close chunk + cmds.undo()  →  scene restored automatically
    """
    chunk_opened = False
    try:
        _log(f"Export started  →  {os.path.basename(path)}", "STEP")

        # ── progress window ───────────────────────────────────────────────
        steps = 4 + (2 if do_bake else 0) + (1 if do_cleanup else 0)
        cmds.progressWindow(
            title="glTF / GLB Export",
            status="Initialising…",
            isInterruptable=False,
            min=0, max=steps, progress=0,
        )

        # ── bake ─────────────────────────────────────────────────────────
        if do_bake:
            _log(f"Baking animation  (frames {bake_start} – {bake_end})…", "STEP")
            cmds.progressWindow(edit=True,
                                status=f"Baking animated nodes  ({bake_start}–{bake_end})…",
                                progress=1)
            cmds.undoInfo(openChunk=True, chunkName="glTF_export")
            chunk_opened = True          # set BEFORE bake so finally always cleans up

            # Suspend viewport redraws for the entire bake — no playback flicker
            cmds.refresh(suspend=True)
            try:
                _bake_animation(bake_start, bake_end,
                                euler_filter=do_euler,
                                simulation=do_sim)
            finally:
                cmds.refresh(suspend=False)

            _log("Bake complete.", "OK")
            cmds.progressWindow(edit=True, status="Bake complete.", progress=2)

        # ── scene optimisation ───────────────────────────────────────────
        if do_cleanup:
            # Open the undo chunk now if bake didn't already open it.
            if not chunk_opened:
                cmds.undoInfo(openChunk=True, chunkName="glTF_export")
                chunk_opened = True
            _log("Optimising scene…", "STEP")
            prog_cleanup = 3 if do_bake else 1
            cmds.progressWindow(edit=True, status="Optimising scene…",
                                progress=prog_cleanup)
            _optimize_scene()
            _log("Scene optimisation complete.", "OK")

        # ── build scene ───────────────────────────────────────────────────
        _log("Traversing scene and building data…", "STEP")
        prog_build = (4 if do_bake else 2) if do_cleanup else (3 if do_bake else 1)
        cmds.progressWindow(edit=True, status="Traversing scene…", progress=prog_build)
        exp = SceneExporter(export_all=export_all,
                            embed_textures=embed_textures, skip_hidden=skip_hidden,
                            scale=scale, up_axis=up_axis,
                            include_anim=include_anim)
        gltf_dict, buf = exp.build(clips=clips)

        n_meshes  = len(gltf_dict.get("meshes",     []))
        n_mats    = len(gltf_dict.get("materials",  []))
        n_texs    = len(gltf_dict.get("textures",   []))
        n_anims   = len(gltf_dict.get("animations", []))
        n_skins   = len(gltf_dict.get("skins",      []))
        _log(f"Scene built:  {n_meshes} mesh(es)  ·  {n_mats} material(s)  ·  "
             f"{n_texs} texture(s)  ·  {n_skins} skin(s)  ·  {n_anims} animation clip(s)", "OK")

        # ── write file ────────────────────────────────────────────────────
        _log(f"Writing {ext.upper()[1:]}  →  {path}", "STEP")
        cmds.progressWindow(edit=True, status="Writing file…", progress=steps - 1)
        if ext == ".glb":
            write_glb(path, gltf_dict, buf)
        else:
            write_gltf(path, gltf_dict, buf)

        file_kb = os.path.getsize(path) / 1024.0
        _log(f"Export complete  ·  {os.path.basename(path)}  ({file_kb:.1f} KB)", "OK")
        cmds.progressWindow(edit=True, status="Done.", progress=steps)

        restored = "  Scene restored via undo." if chunk_opened else ""
        try:
            cmds.inViewMessage(
                amg=(f"<hl>glTF Export complete</hl><br>"
                     f"{n_meshes} mesh(es) · {n_anims} clip(s)<br>"
                     f"{os.path.basename(path)}" + restored),
                pos="midCenter", fade=True, fadeStayTime=4000,
            )
        except Exception:
            pass

    except Exception as exc:
        tb = traceback.format_exc()
        _log(f"Export FAILED: {exc}", "ERROR")
        # Show the full traceback only in the log panel
        for line in tb.splitlines():
            if line.strip():
                _log(f"  {line}", "ERROR")

    finally:
        # ── always close progress and undo bake ───────────────────────────
        try:
            cmds.progressWindow(endProgress=True)
        except Exception:
            pass

        if chunk_opened:
            _restore_scene_undo(chunk_opened)

        # Re-enable dialog button
        if dialog is not None:
            try:
                dialog.export_btn.setEnabled(True)
                dialog.export_btn.setText("Export")
            except Exception:
                pass


# Export dialog
# ══════════════════════════════════════════════════════════════════════════════
class _ClipTable(QtWidgets.QTableWidget):
    """Editable table of animation clips (Name | Start | End)."""

    HEADERS = ["Clip Name", "Start Frame", "End Frame"]

    def __init__(self, parent=None):
        super().__init__(0, 3, parent)
        self.setHorizontalHeaderLabels(self.HEADERS)
        hh = self.horizontalHeader()
        hh.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
        hh.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
        hh.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents)
        self.verticalHeader().setVisible(False)
        self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        self.setMinimumHeight(130)

    def add_row(self, name="", start=None, end=None):
        if start is None:
            start = int(cmds.playbackOptions(q=True, minTime=True))
        if end is None:
            end = int(cmds.playbackOptions(q=True, maxTime=True))
        if not name:
            name = f"clip_{self.rowCount() + 1}"
        r = self.rowCount()
        self.insertRow(r)
        self.setItem(r, 0, QtWidgets.QTableWidgetItem(name))
        self.setItem(r, 1, QtWidgets.QTableWidgetItem(str(start)))
        self.setItem(r, 2, QtWidgets.QTableWidgetItem(str(end)))

    def remove_selected(self):
        rows = sorted({i.row() for i in self.selectedIndexes()}, reverse=True)
        for r in rows:
            self.removeRow(r)

    def clips(self):
        result = []
        for r in range(self.rowCount()):
            try:
                name  = (self.item(r, 0) or QtWidgets.QTableWidgetItem("")).text().strip()
                start = int((self.item(r, 1) or QtWidgets.QTableWidgetItem("1")).text())
                end   = int((self.item(r, 2) or QtWidgets.QTableWidgetItem("1")).text())
                if start < end:
                    result.append({"name": name or f"clip_{r+1}",
                                   "start": start, "end": end})
            except ValueError:
                pass
        return result


class ExportDialog(QtWidgets.QDialog):
    """Floating glTF / GLB export options window."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("glTF / GLB Exporter")
        self.setWindowFlags(self.windowFlags() |
                            QtCore.Qt.Window |
                            QtCore.Qt.WindowMinimizeButtonHint)
        self.setMinimumWidth(520)
        self._build_ui()
        self._load_defaults()

    # ── UI construction ───────────────────────────────────────────────────
    def _build_ui(self):
        root = QtWidgets.QVBoxLayout(self)
        root.setSpacing(8)

        # ── Output file ──────────────────────────────────────────────────
        out_grp = QtWidgets.QGroupBox("Output File")
        out_lay = QtWidgets.QGridLayout(out_grp)
        self.path_edit = QtWidgets.QLineEdit()
        self.path_edit.setPlaceholderText("Select output path…")
        browse_btn = QtWidgets.QPushButton("Browse…")
        browse_btn.setFixedWidth(80)
        browse_btn.clicked.connect(self._browse)
        out_lay.addWidget(QtWidgets.QLabel("Path:"), 0, 0)
        out_lay.addWidget(self.path_edit, 0, 1)
        out_lay.addWidget(browse_btn, 0, 2)
        root.addWidget(out_grp)

        # ── Options ──────────────────────────────────────────────────────
        opt_grp = QtWidgets.QGroupBox("Options")
        opt_lay = QtWidgets.QFormLayout(opt_grp)
        opt_lay.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)

        # Format
        fmt_w = QtWidgets.QWidget()
        fmt_l = QtWidgets.QHBoxLayout(fmt_w)
        fmt_l.setContentsMargins(0, 0, 0, 0)
        self.fmt_glb  = QtWidgets.QRadioButton("GLB  (single binary)")
        self.fmt_gltf = QtWidgets.QRadioButton("glTF + .bin  (separate files)")
        self.fmt_glb.setChecked(True)
        self.fmt_glb.toggled.connect(self._on_fmt_changed)
        fmt_l.addWidget(self.fmt_glb)
        fmt_l.addWidget(self.fmt_gltf)
        fmt_l.addStretch()
        opt_lay.addRow("Format:", fmt_w)

        # Scope
        scope_w = QtWidgets.QWidget()
        scope_l = QtWidgets.QHBoxLayout(scope_w)
        scope_l.setContentsMargins(0, 0, 0, 0)
        self.scope_all = QtWidgets.QRadioButton("Export All")
        self.scope_sel = QtWidgets.QRadioButton("Export Selection")
        self.scope_all.setChecked(True)
        scope_l.addWidget(self.scope_all)
        scope_l.addWidget(self.scope_sel)
        scope_l.addStretch()
        opt_lay.addRow("Scope:", scope_w)

        # Skip hidden nodes
        self.hidden_chk = QtWidgets.QCheckBox(
            "Skip nodes with visibility attribute off")
        self.hidden_chk.setChecked(True)
        opt_lay.addRow("Nodes:", self.hidden_chk)

        # Embed textures
        self.embed_chk = QtWidgets.QCheckBox("Embed textures as base64 (self-contained file)")
        self.embed_chk.setChecked(True)
        opt_lay.addRow("Textures:", self.embed_chk)

        # Scene optimisation
        self.cleanup_chk = QtWidgets.QCheckBox(
            "Delete non-deformer history, unknown nodes and empty transforms  "
            "(scene is restored via undo after export)")
        self.cleanup_chk.setChecked(True)
        opt_lay.addRow("Optimize:", self.cleanup_chk)

        # Scene scale
        scale_w = QtWidgets.QWidget()
        scale_l = QtWidgets.QHBoxLayout(scale_w)
        scale_l.setContentsMargins(0, 0, 0, 0)
        self.scale_spin = QtWidgets.QDoubleSpinBox()
        self.scale_spin.setRange(0.01, 100.0)
        self.scale_spin.setValue(1.0)
        self.scale_spin.setSingleStep(0.01)
        self.scale_spin.setDecimals(3)
        self.scale_spin.setFixedWidth(90)
        scale_l.addWidget(self.scale_spin)
        scale_l.addStretch()
        opt_lay.addRow("Scene Scale:", scale_w)

        # Up axis
        axis_w = QtWidgets.QWidget()
        axis_l = QtWidgets.QHBoxLayout(axis_w)
        axis_l.setContentsMargins(0, 0, 0, 0)
        self.axis_y = QtWidgets.QRadioButton("Y-up  (glTF default)")
        self.axis_z = QtWidgets.QRadioButton("Z-up  (Blender / Unity Z-forward)")
        self.axis_y.setChecked(True)
        axis_l.addWidget(self.axis_y)
        axis_l.addWidget(self.axis_z)
        axis_l.addStretch()
        opt_lay.addRow("Up Axis:", axis_w)

        # Per-material glTF attribute setup — framed box with info + button
        mat_frame = QtWidgets.QFrame()
        mat_frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
        mat_frame.setStyleSheet(
            "QFrame { border: 1px solid #666666; border-radius: 4px; }")
        mat_frame_lay = QtWidgets.QVBoxLayout(mat_frame)
        mat_frame_lay.setContentsMargins(8, 6, 8, 6)
        mat_frame_lay.setSpacing(6)

        mat_info_lbl = QtWidgets.QLabel(
            'Select object(s) and press "Add" to add the '
            "Alpha Mode to the material's Extra Attributes."
        )
        mat_info_lbl.setAlignment(QtCore.Qt.AlignCenter)
        mat_info_lbl.setStyleSheet(
            "font-weight: bold; color: #dddddd; font-size: 8pt; border: none;")
        mat_frame_lay.addWidget(mat_info_lbl)

        mat_attr_btn = QtWidgets.QPushButton(
            "Add Extra Attrs to Material(s)  (Alpha Mode and Double Sided)")
        mat_attr_btn.setStyleSheet("QPushButton { border: 1px solid #888888; }")
        mat_attr_btn.setToolTip(
            "Adds five custom attributes to the shader(s) of the selected\n"
            "object(s) — or to every shader when nothing is selected:\n"
            "  • glTF_alphaMode       — Auto / Opaque / Blend / Mask\n"
            "  • glTF_alphaCutoff     — cutoff threshold for Mask mode\n"
            "  • glTF_doubleSided     — render both sides of the mesh\n"
            "  • glTF_emissiveEnabled — on / off switch for emissive export\n"
            "  • glTF_emissiveScale   — 0–1 intensity multiplier on emissiveFactor\n\n"
            "Adjust these in the Attribute Editor (Extra Attributes section).\n"
            "Per-material values override auto-detected settings at export time."
        )
        mat_attr_btn.clicked.connect(self._on_setup_material_attrs)
        mat_frame_lay.addWidget(mat_attr_btn)

        opt_lay.addRow("Material Attrs:", mat_frame)

        root.addWidget(opt_grp)

        # ── Bake / Animation ──────────────────────────────────────────────
        bake_grp = QtWidgets.QGroupBox("Animation")
        bake_lay = QtWidgets.QFormLayout(bake_grp)
        bake_lay.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)

        # "Include animation" master switch — disables all animation controls
        # when unchecked so users can export a static mesh-only file.
        self.anim_chk = QtWidgets.QCheckBox(
            "Include animation in export")
        self.anim_chk.setChecked(True)
        self.anim_chk.toggled.connect(self._on_anim_toggled)
        bake_lay.addRow("Export Anim:", self.anim_chk)

        self.bake_chk = QtWidgets.QCheckBox(
            "Bake animation before export  (scene is restored via undo afterwards)")
        self.bake_chk.setChecked(False)
        self.bake_chk.toggled.connect(self._on_bake_toggled)
        bake_lay.addRow("Bake:", self.bake_chk)

        self.euler_chk = QtWidgets.QCheckBox(
            "Apply Euler Filter to rotation curves after baking")
        self.euler_chk.setChecked(True)
        bake_lay.addRow("Euler Filter:", self.euler_chk)

        self.sim_chk = QtWidgets.QCheckBox(
            "Simulation mode  (step frame-by-frame — required for nCloth / rigid body)")
        self.sim_chk.setChecked(False)
        bake_lay.addRow("Mode:", self.sim_chk)

        root.addWidget(bake_grp)
        self._on_bake_toggled(False)   # start with bake controls greyed out

        # ── Animation clips ───────────────────────────────────────────────
        anim_grp = QtWidgets.QGroupBox("Animation Clips")
        anim_lay = QtWidgets.QVBoxLayout(anim_grp)

        self.timeline_chk = QtWidgets.QCheckBox(
            "Use full Maya timeline as a single clip  (ignore table below)")
        self.timeline_chk.setChecked(True)
        self.timeline_chk.toggled.connect(self._on_timeline_toggled)
        anim_lay.addWidget(self.timeline_chk)

        self.clip_table = _ClipTable()
        anim_lay.addWidget(self.clip_table)

        clip_btn_lay = QtWidgets.QHBoxLayout()
        self.add_clip_btn = QtWidgets.QPushButton("＋  Add Clip")
        self.add_clip_btn.clicked.connect(self.clip_table.add_row)
        self.rem_clip_btn = QtWidgets.QPushButton("－  Remove Selected")
        self.rem_clip_btn.clicked.connect(self.clip_table.remove_selected)
        clip_btn_lay.addWidget(self.add_clip_btn)
        clip_btn_lay.addWidget(self.rem_clip_btn)
        clip_btn_lay.addStretch()
        anim_lay.addLayout(clip_btn_lay)

        root.addWidget(anim_grp)

        # ── Buttons ──────────────────────────────────────────────────────
        btn_lay = QtWidgets.QHBoxLayout()
        btn_lay.addStretch()
        cancel_btn = QtWidgets.QPushButton("Cancel")
        cancel_btn.setFixedWidth(90)
        cancel_btn.clicked.connect(self.reject)
        self.export_btn = QtWidgets.QPushButton("Export")
        self.export_btn.setFixedWidth(90)
        self.export_btn.setDefault(True)
        self.export_btn.clicked.connect(self._do_export)
        btn_lay.addWidget(cancel_btn)
        btn_lay.addWidget(self.export_btn)
        root.addLayout(btn_lay)

        # ── Log panel ────────────────────────────────────────────────────
        log_grp = QtWidgets.QGroupBox("Log")
        log_vlay = QtWidgets.QVBoxLayout(log_grp)
        log_vlay.setContentsMargins(6, 6, 6, 6)
        log_vlay.setSpacing(4)

        self._log_edit = QtWidgets.QTextEdit()
        self._log_edit.setReadOnly(True)
        self._log_edit.setMinimumHeight(120)
        self._log_edit.setMaximumHeight(200)
        self._log_edit.setStyleSheet(
            "QTextEdit {"
            "  background-color: #1c1c1c;"
            "  border: 1px solid #3a3a3a;"
            "  font-family: 'Courier New', Consolas, monospace;"
            "  font-size: 8pt;"
            "}")
        log_vlay.addWidget(self._log_edit)

        log_clear_lay = QtWidgets.QHBoxLayout()
        log_clear_lay.addStretch()
        clr_btn = QtWidgets.QPushButton("Clear")
        clr_btn.setFixedWidth(55)
        clr_btn.clicked.connect(self._log_edit.clear)
        log_clear_lay.addWidget(clr_btn)
        log_vlay.addLayout(log_clear_lay)

        root.addWidget(log_grp)

        self._on_timeline_toggled(True)

    # ── log panel ─────────────────────────────────────────────────────────
    _LOG_COLORS = {
        "INFO":  "#c8c8c8",
        "STEP":  "#6ab4ff",
        "OK":    "#6dda6d",
        "WARN":  "#f0a020",
        "ERROR": "#f05050",
    }

    def log(self, message, level="INFO"):
        """Append a timestamped, colour-coded line to the log panel."""
        ts    = time.strftime("%H:%M:%S")
        color = self._LOG_COLORS.get(level, "#c8c8c8")
        safe  = _html.escape(str(message))
        html  = (f'<span style="color:#555;">[{ts}]</span> '
                 f'<span style="color:{color};font-weight:bold;">[{level:5s}]</span> '
                 f'<span style="color:#d4d4d4;">{safe}</span>')
        self._log_edit.append(html)
        sb = self._log_edit.verticalScrollBar()
        sb.setValue(sb.maximum())
        QtWidgets.QApplication.processEvents()

    # ── settings persistence ──────────────────────────────────────────────
    def _save_settings(self):
        """Write all export option values to the project's data folder.

        File: <maya project>/data/maya_gltf_exporter_settings.json

        Called every time the Export button is pressed so the file is always
        up-to-date.  When the exporter dialog is opened again — even in a
        different scene inside the same project — the saved values are restored
        automatically.  Switching to a different Maya project loads that
        project's own settings file (or defaults when none exists yet).
        """
        rows = []
        for r in range(self.clip_table.rowCount()):
            rows.append({
                "name":  (self.clip_table.item(r, 0) or QtWidgets.QTableWidgetItem("")).text(),
                "start": (self.clip_table.item(r, 1) or QtWidgets.QTableWidgetItem("1")).text(),
                "end":   (self.clip_table.item(r, 2) or QtWidgets.QTableWidgetItem("1")).text(),
            })

        data = {
            "format":         "glb" if self.fmt_glb.isChecked() else "gltf",
            "scope":          "all" if self.scope_all.isChecked() else "selection",
            "skip_hidden":    self.hidden_chk.isChecked(),
            "embed_textures": self.embed_chk.isChecked(),
            "optimize":       self.cleanup_chk.isChecked(),
            "scale":          self.scale_spin.value(),
            "up_axis":        "Z" if self.axis_z.isChecked() else "Y",
            "include_anim":   self.anim_chk.isChecked(),
            "bake":           self.bake_chk.isChecked(),
            "euler_filter":   self.euler_chk.isChecked(),
            "sim_mode":       self.sim_chk.isChecked(),
            "use_timeline":   self.timeline_chk.isChecked(),
            "clips":          rows,
        }

        try:
            path = _settings_file_path()
            with open(path, "w", encoding="utf-8") as fh:
                json.dump(data, fh, indent=2, ensure_ascii=False)
            _log(f"Settings saved  →  {path}", "INFO")
        except Exception as exc:
            _log(f"Could not save settings: {exc}", "WARN")

    def _load_settings(self):
        """Read export option values from the project's data folder.

        If no settings file exists for the current project (first-time use or
        a different project), every control keeps its coded default value.
        The clip table is cleared then rebuilt from the saved clip list.
        """
        path = _settings_file_path()
        if not os.path.isfile(path):
            return   # no saved settings — use UI defaults

        try:
            with open(path, "r", encoding="utf-8") as fh:
                data = json.load(fh)

            # Output format
            fmt = data.get("format", "glb")
            self.fmt_glb.setChecked(fmt == "glb")
            self.fmt_gltf.setChecked(fmt != "glb")

            # Scope
            scope = data.get("scope", "all")
            self.scope_all.setChecked(scope == "all")
            self.scope_sel.setChecked(scope != "all")

            # Checkboxes
            self.hidden_chk.setChecked(bool(data.get("skip_hidden",    True)))
            self.embed_chk.setChecked(bool(data.get("embed_textures",  True)))
            self.cleanup_chk.setChecked(bool(data.get("optimize",      True)))
            self.anim_chk.setChecked(bool(data.get("include_anim",     True)))
            self.bake_chk.setChecked(bool(data.get("bake",             False)))
            self.euler_chk.setChecked(bool(data.get("euler_filter",    True)))
            self.sim_chk.setChecked(bool(data.get("sim_mode",          False)))
            self.timeline_chk.setChecked(bool(data.get("use_timeline", True)))

            # Scale — only override when an explicit value was saved so that
            # the Maya-unit auto-detect still applies on first run.
            if "scale" in data:
                self.scale_spin.setValue(float(data["scale"]))

            # Up axis
            up = data.get("up_axis", "Y")
            self.axis_y.setChecked(up == "Y")
            self.axis_z.setChecked(up == "Z")

            # Animation clips — clear the table then rebuild from saved rows
            while self.clip_table.rowCount():
                self.clip_table.removeRow(0)
            for clip in data.get("clips", []):
                try:
                    self.clip_table.add_row(
                        name=clip.get("name", ""),
                        start=int(clip.get("start", 1)),
                        end=int(clip.get("end", 1)),
                    )
                except Exception:
                    pass

            # Trigger dependent UI state (enable/disable sub-controls)
            self._on_anim_toggled(self.anim_chk.isChecked())
            self._on_timeline_toggled(self.timeline_chk.isChecked())

        except Exception as exc:
            _log(f"Could not load settings from '{path}': {exc}", "WARN")

    # ── defaults ─────────────────────────────────────────────────────────
    def _load_defaults(self):
        # Output path is always refreshed from the current scene name.
        self.path_edit.setText(_default_output_path(".glb"))
        # Scale defaults to Maya's unit → metre conversion; _load_settings
        # will override this with the user's last saved value if one exists.
        try:
            self.scale_spin.setValue(_maya_unit_scale())
        except Exception:
            pass
        # Restore all other settings from the last session.
        self._load_settings()

    # closeEvent — save settings whenever the dialog is closed (X button,
    # Cancel, or programmatic close).
    def closeEvent(self, event):
        self._save_settings()
        super().closeEvent(event)

    # ── slots ─────────────────────────────────────────────────────────────
    def _on_fmt_changed(self, glb_checked):
        path = self.path_edit.text()
        if not path:
            return
        base = os.path.splitext(path)[0]
        self.path_edit.setText(base + (".glb" if glb_checked else ".gltf"))

    def _on_timeline_toggled(self, checked):
        for w in (self.clip_table, self.add_clip_btn, self.rem_clip_btn):
            w.setEnabled(not checked)

    def _on_setup_material_attrs(self):
        """Run setup_gltf_material_attrs() on selected objects (or all shaders)."""
        try:
            n = setup_gltf_material_attrs(selected_only=True)
            if n:
                self.log(
                    f"Extra attributes added to {n} shader(s).  "
                    "Set glTF_alphaMode, glTF_doubleSided, glTF_emissiveEnabled / "
                    "glTF_emissiveScale in the Attribute Editor (Extra Attributes).", "OK")
            else:
                self.log("All target shaders already have the glTF attributes.", "INFO")
        except Exception as exc:
            self.log(f"Setup failed: {exc}", "ERROR")

    def _on_anim_toggled(self, anim_on):
        """Enable / disable all animation-related controls."""
        for w in (self.bake_chk, self.timeline_chk,
                  self.clip_table, self.add_clip_btn, self.rem_clip_btn):
            w.setEnabled(anim_on)
        # Bake sub-options only if both animation AND bake are on
        bake_on = anim_on and self.bake_chk.isChecked()
        for w in (self.euler_chk, self.sim_chk):
            w.setEnabled(bake_on)

    def _on_bake_toggled(self, checked):
        anim_on = self.anim_chk.isChecked()
        for w in (self.euler_chk, self.sim_chk):
            w.setEnabled(anim_on and checked)

    def _browse(self):
        ext  = "glb" if self.fmt_glb.isChecked() else "gltf"
        filt = f"{'GLB' if ext=='glb' else 'glTF'} Files (*.{ext})"
        start_dir = os.path.dirname(self.path_edit.text()) or os.path.expanduser("~")
        path, _ = QtWidgets.QFileDialog.getSaveFileName(
            self, "Export As", start_dir, filt)
        if path:
            self.path_edit.setText(path)

    # ── export ────────────────────────────────────────────────────────────
    def _do_export(self):
        global _log_callback
        path = self.path_edit.text().strip()
        if not path:
            QtWidgets.QMessageBox.warning(
                self, "No Output Path", "Please set an output file path first.")
            return

        # Persist current settings before the export runs so they survive
        # even if Maya is quit immediately after.
        self._save_settings()

        # Clear log and activate routing so every _log() call in the pipeline
        # writes to this dialog's panel for the duration of this export.
        self._log_edit.clear()
        _log_callback = self.log

        ext = ".glb" if self.fmt_glb.isChecked() else ".gltf"
        path, ext = _normalize_export_path(path, ext)

        export_all     = self.scope_all.isChecked()
        skip_hidden    = self.hidden_chk.isChecked()
        embed_textures = self.embed_chk.isChecked()
        do_cleanup     = self.cleanup_chk.isChecked()
        include_anim   = self.anim_chk.isChecked()
        scene_scale    = self.scale_spin.value()
        up_axis        = "Z" if self.axis_z.isChecked() else "Y"
        if self.timeline_chk.isChecked():
            clips = None   # full timeline → single "Take 001"
            self.log('Animation mode: full Maya timeline  →  "Take 001".', "INFO")
        else:
            clips = self.clip_table.clips()
            if not clips:
                self.log(
                    'Clip table is empty or all rows have invalid ranges.  '
                    'Enable "Use full Maya timeline" or add valid clips.', "WARN")
                # _export_animations will fall back to full timeline
            else:
                names = ", ".join(f'"{c["name"]}" ({c["start"]}–{c["end"]})'
                                  for c in clips)
                self.log(f"Animation mode: {len(clips)} clip(s) — {names}", "INFO")
        do_bake        = self.bake_chk.isChecked()
        do_euler       = self.euler_chk.isChecked()
        do_sim         = self.sim_chk.isChecked()

        # Bake range: span of all clips, or full timeline
        if do_bake:
            bake_start, bake_end = _bake_frame_range(clips)
        else:
            bake_start = bake_end = 0

        # Disable the button immediately so the user sees feedback
        self.export_btn.setEnabled(False)
        self.export_btn.setText("Working…")
        QtWidgets.QApplication.processEvents()

        # Schedule the actual work to run after this event loop cycle.
        # evalDeferred lets Maya process the button-state change before the
        # heavy work starts (bake + export + auto-undo all run in _run_export_task).
        cmds.evalDeferred(functools.partial(
            _run_export_task,
            dialog=self,
            path=path,
            ext=ext,
            export_all=export_all,
            skip_hidden=skip_hidden,
            embed_textures=embed_textures,
            clips=clips,
            do_bake=do_bake,
            bake_start=bake_start,
            bake_end=bake_end,
            do_euler=do_euler,
            do_sim=do_sim,
            scale=scene_scale,
            up_axis=up_axis,
            do_cleanup=do_cleanup,
            include_anim=include_anim,
        ))


_dialog_instance = None

def show_export_dialog():
    """Open (or raise) the glTF export dialog."""
    global _dialog_instance, _log_callback
    try:
        parent = wrapInstance(int(omui.MQtUtil.mainWindow()), QtWidgets.QWidget)
    except Exception:
        parent = None

    # Re-use existing window if still open
    if _dialog_instance is not None and _dialog_instance.isVisible():
        _dialog_instance.raise_()
        _dialog_instance.activateWindow()
        return

    _dialog_instance = ExportDialog(parent)

    # Reset the log callback when the dialog is closed so stale references
    # don't linger after the window is destroyed.
    def _on_close():
        global _log_callback
        _log_callback = None
    _dialog_instance.finished.connect(_on_close)

    _dialog_instance.show()


def export_gltf(path, export_all=True, embed_textures=True, skip_hidden=False,
                clips=None, do_bake=False, bake_start=0, bake_end=0,
                do_euler=True, do_sim=False, scale=None, up_axis="Y",
                do_cleanup=False, include_anim=True, deferred=False):
    """Export the current Maya scene to glTF (.gltf) or GLB (.glb).

    Matches the export dialog options.  When *deferred* is True the work is
    scheduled on Maya's idle queue (same as clicking Export in the UI).

    *scale* defaults to Maya linear-unit → metres (0.01 for centimetres).
    """
    path, ext = _normalize_export_path(path)

    if scale is None:
        try:
            scale = _maya_unit_scale()
        except Exception:
            scale = 1.0

    if do_bake and bake_start == bake_end == 0:
        bake_start, bake_end = _bake_frame_range(clips)

    kwargs = dict(
        dialog=None,
        path=path,
        ext=ext,
        export_all=export_all,
        skip_hidden=skip_hidden,
        embed_textures=embed_textures,
        clips=clips,
        do_bake=do_bake,
        bake_start=bake_start,
        bake_end=bake_end,
        do_euler=do_euler,
        do_sim=do_sim,
        scale=scale,
        up_axis=up_axis,
        do_cleanup=do_cleanup,
        include_anim=include_anim,
    )

    if deferred:
        cmds.evalDeferred(functools.partial(_run_export_task, **kwargs))
    else:
        _run_export_task(**kwargs)

    return path


# ══════════════════════════════════════════════════════════════════════════════
# Optional shelf button
# ══════════════════════════════════════════════════════════════════════════════
_SHELF_NAME   = "glTF_Tools"
_BUTTON_LABEL = "glTF Export"


def _shelf_export_cmd():
    d = (_maya_scripts_dir() or _SCRIPT_DIR).replace("\\", "/")
    return "\n".join([
        "import sys, importlib.util",
        f"_p = r'{d}/{_MODULE_NAME}.py'",
        f"_s = importlib.util.spec_from_file_location('{_MODULE_NAME}', _p)",
        "_m = importlib.util.module_from_spec(_s)",
        f"sys.modules['{_MODULE_NAME}'] = _m",
        "_s.loader.exec_module(_m)",
        "_m.show_export_dialog()",
    ])


# Base-64 encoded 32×32 PNG icon (simple white 'G' on teal background)
_ICON_B64 = (
    "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz"
    "AAALEwAACxMBAJqcGAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZxivOF4AAAPOSUR"
    "BVFiFY2AYBfgJAAACAAF/Bz+fAAAAAElFTkSuQmCC"
)


def _icon_path():
    """Write the shelf icon to Maya's user prefs icons dir and return the path."""
    icons_dir = os.path.join(
        cmds.internalVar(userPrefDir=True), "icons")
    os.makedirs(icons_dir, exist_ok=True)
    icon_file = os.path.join(icons_dir, "gltf_exporter.png")
    if not os.path.exists(icon_file):
        try:
            with open(icon_file, "wb") as fh:
                fh.write(base64.b64decode(_ICON_B64))
        except Exception:
            return "fileOpen.png"   # fall back to a built-in Maya icon
    return icon_file


def setup_shelf():
    """Create the glTF_Tools shelf tab and Export button (optional, run once)."""
    try:
        shelf_top = mel.eval("$_tmp = $gShelfTopLevel")
        if not cmds.shelfLayout(_SHELF_NAME, exists=True):
            cmds.shelfLayout(_SHELF_NAME, parent=shelf_top)
        existing = cmds.shelfLayout(_SHELF_NAME, query=True, childArray=True) or []
        for btn in existing:
            try:
                if cmds.shelfButton(btn, query=True, label=True) == _BUTTON_LABEL:
                    return
            except Exception:
                pass
        cmds.shelfButton(
            label=_BUTTON_LABEL, command=_shelf_export_cmd(), sourceType="python",
            image1=_icon_path(), imageOverlayLabel="glTF",
            overlayLabelColor=(1, 1, 1),
            overlayLabelBackColor=(0.1, 0.5, 0.5, 0.9),
            annotation="Open the glTF / GLB Export dialog",
            parent=_SHELF_NAME)
        cmds.saveAllShelves(shelf_top)
    except Exception as e:
        cmds.warning(f"[glTF Exporter] Could not create shelf button: {e}")
