const statusEl = document.getElementById("status");
const canvas = document.getElementById("scene-canvas");

function setStatus(message, level = "warn") {
  if (!statusEl) return;
  statusEl.textContent = message;
  statusEl.className = `status ${level}`;
}

function rowMajorToMatrix4(THREE, m) {
  const mat = new THREE.Matrix4();
  mat.set(
    m[0], m[1], m[2], m[3],
    m[4], m[5], m[6], m[7],
    m[8], m[9], m[10], m[11],
    m[12], m[13], m[14], m[15],
  );
  return mat;
}

function pandaRowMajorToMatrix4(THREE, m) {
  const mat = new THREE.Matrix4();
  // Panda's matrix data uses row-vector convention (translation on last row).
  // Three.js expects column-vector convention (translation on last column).
  mat.set(
    m[0], m[4], m[8], m[12],
    m[1], m[5], m[9], m[13],
    m[2], m[6], m[10], m[14],
    m[3], m[7], m[11], m[15],
  );
  return mat;
}

function convertNodeMatrix(THREE, sourceMatRowMajor, basis, basisInv, matrixConvention = "panda_row_vector_row_major") {
  const src = matrixConvention === "panda_row_vector_row_major"
    ? pandaRowMajorToMatrix4(THREE, sourceMatRowMajor)
    : rowMajorToMatrix4(THREE, sourceMatRowMajor);
  return basis.clone().multiply(src).multiply(basisInv);
}

function toColorArray(color, fallback = [1, 1, 1]) {
  if (!Array.isArray(color) || color.length < 3) return fallback;
  return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
}

function applyMaterialOverride(THREE, root, override) {
  if (!override) return;

  root.traverse((obj) => {
    if (!obj.isMesh || !obj.material) return;

    const list = Array.isArray(obj.material) ? obj.material : [obj.material];
    for (const mat of list) {
      if (mat.color && Array.isArray(override.base_color)) {
        const [r, g, b] = override.base_color;
        mat.color.setRGB(r ?? 1, g ?? 1, b ?? 1);
      }
      if (Object.prototype.hasOwnProperty.call(override, "roughness") && "roughness" in mat) {
        mat.roughness = Number(override.roughness);
      }
      if (Object.prototype.hasOwnProperty.call(override, "metallic") && "metalness" in mat) {
        mat.metalness = Number(override.metallic);
      }
      if (Object.prototype.hasOwnProperty.call(override, "opacity")) {
        const opacity = THREE.MathUtils.clamp(Number(override.opacity), 0, 1);
        const isTransparent = opacity < 0.999;
        mat.opacity = opacity;
        mat.transparent = isTransparent;
        // Prevent "see-through solid mesh" when source GLTF had transparent pipeline state.
        mat.depthWrite = !isTransparent;
        mat.depthTest = true;
        mat.blending = isTransparent ? THREE.NormalBlending : THREE.NoBlending;
        if (!isTransparent && "alphaTest" in mat) {
          mat.alphaTest = 0;
        }
        if (!isTransparent && "transmission" in mat) {
          mat.transmission = 0;
        }
      }
      mat.needsUpdate = true;
    }
  });
}

function textureSlotByStage(stageName) {
  const key = String(stageName || "").toLowerCase();
  if (key.includes("normal")) return "normalMap";
  if (key.includes("rough")) return "roughnessMap";
  if (key.includes("metal")) return "metalnessMap";
  if (key.includes("emission") || key.includes("emissive")) return "emissiveMap";
  if (key.includes("ao")) return "aoMap";
  if (key.includes("alpha") || key.includes("opacity")) return "alphaMap";
  return "map";
}

function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) {
  if (!Array.isArray(textureOverrides) || textureOverrides.length === 0) return;

  const texBySlot = new Map();
  for (const item of textureOverrides) {
    if (!item || !item.uri) continue;
    const slot = textureSlotByStage(item.stage);
    if (texBySlot.has(slot)) continue;

    try {
      const tex = textureLoader.load(item.uri);
      tex.flipY = false;
      texBySlot.set(slot, tex);
    } catch (err) {
      console.warn("Texture load failed:", item.uri, err);
    }
  }

  if (texBySlot.size === 0) return;

  root.traverse((obj) => {
    if (!obj.isMesh || !obj.material) return;

    const list = Array.isArray(obj.material) ? obj.material : [obj.material];
    for (const mat of list) {
      for (const [slot, tex] of texBySlot.entries()) {
        if (slot in mat) {
          mat[slot] = tex;
        }
      }
      mat.needsUpdate = true;
    }
  });
}

function pathKey(parts) {
  if (!Array.isArray(parts)) return "";
  return parts.map((v) => String(v)).join("/");
}

function normalizeNamePath(parts) {
  if (!Array.isArray(parts)) return [];
  const out = [];
  for (const raw of parts) {
    const s = String(raw ?? "").trim();
    if (!s) continue;
    if (out.length > 0 && out[out.length - 1] === s) continue;
    out.push(s);
  }
  return out;
}

function stripOccurrenceSuffix(segment) {
  return String(segment ?? "").replace(/#\d+$/, "");
}

function normalizeNamePathLoose(parts) {
  return normalizeNamePath(parts).map((s) => stripOccurrenceSuffix(s));
}

function stripBlenderDuplicateSuffix(segment) {
  return String(segment ?? "").replace(/\.\d{3}$/, "");
}

function normalizeNamePathVeryLoose(parts) {
  return normalizeNamePathLoose(parts).map((s) => stripBlenderDuplicateSuffix(s));
}

function canonicalizeNameSegment(segment) {
  const noOcc = stripOccurrenceSuffix(segment).toLowerCase();
  return noOcc.replace(/[^a-z0-9]+/g, "");
}

function normalizeNamePathCanonical(parts) {
  return normalizeNamePath(parts)
    .map((s) => canonicalizeNameSegment(s))
    .filter((s) => !!s);
}

function matrixSignatureRowMajor(values, digits = 5) {
  if (!Array.isArray(values) || values.length !== 16) return "";
  const out = [];
  for (let i = 0; i < 16; i += 1) {
    const num = Number(values[i]);
    if (!Number.isFinite(num)) return "";
    out.push(num.toFixed(digits));
  }
  return out.join(",");
}

function isSuffixPath(shorter, longer) {
  if (!Array.isArray(shorter) || !Array.isArray(longer)) return false;
  if (shorter.length > longer.length) return false;
  const offset = longer.length - shorter.length;
  for (let i = 0; i < shorter.length; i += 1) {
    if (shorter[i] !== longer[offset + i]) return false;
  }
  return true;
}

function suffixMatchScore(hintParts, candidateParts) {
  if (!Array.isArray(hintParts) || !Array.isArray(candidateParts)) return 0;
  let i = hintParts.length - 1;
  let j = candidateParts.length - 1;
  let score = 0;
  while (i >= 0 && j >= 0) {
    if (String(hintParts[i]) !== String(candidateParts[j])) break;
    score += 1;
    i -= 1;
    j -= 1;
  }
  return score;
}

function isIdentityRowMajorMatrix(m, eps = 1e-6) {
  if (!Array.isArray(m) || m.length !== 16) return false;
  const identity = [
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ];
  for (let i = 0; i < 16; i += 1) {
    if (Math.abs(Number(m[i]) - identity[i]) > eps) return false;
  }
  return true;
}

function applySubnodeOverrides(THREE, root, overrides, basis, basisInv, matrixConvention) {
  if (!root || !overrides || typeof overrides !== "object") return 0;
  const preferName = String(overrides.source || "").toLowerCase() === "ssbo";
  const matrixSpace = String(overrides.matrix_space || (preferName ? "model_root" : "local_parent")).toLowerCase();
  const modelRootSpace = matrixSpace === "model_root";

  const byIndex = new Map();
  const byName = new Map();
  const byNameNormalized = new Map();
  const byNameLoose = new Map();
  const byNameVeryLoose = new Map();
  const byNameCanonical = new Map();
  const overrideTerminalLooseCount = new Map();
  const overrideTerminalLooseFirst = new Map();
  const overrideTerminalLooseUnique = new Map();
  const overrideTerminalCanonicalCount = new Map();
  const overrideTerminalCanonicalFirst = new Map();
  const overrideTerminalCanonicalUnique = new Map();
  const tempParentInv = new THREE.Matrix4();
  const rootWorldInv = new THREE.Matrix4();
  const tempModelRoot = new THREE.Matrix4();
  const matrixMatchEps = 0.25;
  const matrixTieEps = 1e-5;
  const maxDepthDiffForMatrixFallback = 3;
  const depthHintsByMatrixSig = new Map();
  const terminalHintsByMatrixSig = new Map(); // very loose terminal hints
  const strictTerminalHintsByMatrixSig = new Map(); // keep .001
  const canonicalTerminalHintsByMatrixSig = new Map(); // punctuation-insensitive + keep digits
  const strictPathHintsByMatrixSig = new Map(); // strip only #occurrence
  const veryLoosePathHintsByMatrixSig = new Map(); // strip #occurrence + .001
  const canonicalPathHintsByMatrixSig = new Map(); // punctuation-insensitive + keep digits

  const makeOverrideEntry = (item, path) => {
    if (!item || !Array.isArray(path) || !Array.isArray(item.matrix_local_row_major)) return null;
    if (item.matrix_local_row_major.length !== 16) return null;
    const matrixSig = matrixSignatureRowMajor(item.matrix_local_row_major);
    const baseMatrix = Array.isArray(item.base_matrix_model_root_row_major) && item.base_matrix_model_root_row_major.length === 16
      ? item.base_matrix_model_root_row_major
      : null;
    const depth = Array.isArray(path) ? path.length : 0;
    return {
      path,
      matrix: item.matrix_local_row_major,
      baseMatrix,
      matrixSig,
      expectedDepth: depth,
      depthHints: depth > 0 ? [depth] : [],
      terminalHints: [],
      terminalHintsStrict: [],
      terminalHintsCanonical: [],
      pathHintsStrict: [],
      pathHintsVeryLoose: [],
      pathHintsCanonical: [],
    };
  };

  for (const item of Array.isArray(overrides.by_index) ? overrides.by_index : []) {
    const entry = makeOverrideEntry(item, Array.isArray(item?.path) ? item.path : null);
    if (!entry) continue;
    byIndex.set(pathKey(entry.path), entry);
  }

  for (const item of Array.isArray(overrides.by_name) ? overrides.by_name : []) {
    const entry = makeOverrideEntry(item, Array.isArray(item?.path) ? item.path : null);
    if (!entry) continue;

    const rawKey = pathKey(entry.path);
    byName.set(rawKey, entry);
    const normParts = normalizeNamePath(item.path);
    const normKey = pathKey(normParts);
    if (normKey && !byNameNormalized.has(normKey)) {
      byNameNormalized.set(normKey, entry);
    }
    const looseParts = normalizeNamePathLoose(item.path);
    const looseKey = pathKey(looseParts);
    if (looseKey && !byNameLoose.has(looseKey)) {
      byNameLoose.set(looseKey, entry);
    }
    const veryLooseParts = normalizeNamePathVeryLoose(item.path);
    const veryLooseKey = pathKey(veryLooseParts);
    if (veryLooseKey && !byNameVeryLoose.has(veryLooseKey)) {
      byNameVeryLoose.set(veryLooseKey, entry);
    }
    const canonicalParts = normalizeNamePathCanonical(item.path);
    const canonicalKey = pathKey(canonicalParts);
    if (canonicalKey && !byNameCanonical.has(canonicalKey)) {
      byNameCanonical.set(canonicalKey, entry);
    }
    const terminalLoose = looseParts.length > 0 ? looseParts[looseParts.length - 1] : "";
    const terminalStrict = looseParts.length > 0 ? looseParts[looseParts.length - 1] : "";
    const terminalCanonical = canonicalParts.length > 0 ? canonicalParts[canonicalParts.length - 1] : "";
    if (terminalLoose) {
      overrideTerminalLooseCount.set(
        terminalLoose,
        (overrideTerminalLooseCount.get(terminalLoose) ?? 0) + 1,
      );
      if (!overrideTerminalLooseFirst.has(terminalLoose)) {
        overrideTerminalLooseFirst.set(terminalLoose, entry);
      }
    }
    if (terminalCanonical) {
      overrideTerminalCanonicalCount.set(
        terminalCanonical,
        (overrideTerminalCanonicalCount.get(terminalCanonical) ?? 0) + 1,
      );
      if (!overrideTerminalCanonicalFirst.has(terminalCanonical)) {
        overrideTerminalCanonicalFirst.set(terminalCanonical, entry);
      }
    }

    const depth = Array.isArray(item.path) ? item.path.length : 0;
    if (entry.matrixSig && depth > 0) {
      const depthList = depthHintsByMatrixSig.get(entry.matrixSig) || [];
      depthList.push(depth);
      depthHintsByMatrixSig.set(entry.matrixSig, depthList);
    }
    const terminalVeryLoose = veryLooseParts.length > 0 ? veryLooseParts[veryLooseParts.length - 1] : "";
    if (entry.matrixSig && terminalVeryLoose) {
      const termSet = terminalHintsByMatrixSig.get(entry.matrixSig) || new Set();
      termSet.add(terminalVeryLoose);
      terminalHintsByMatrixSig.set(entry.matrixSig, termSet);
    }
    if (entry.matrixSig && terminalStrict) {
      const termSetStrict = strictTerminalHintsByMatrixSig.get(entry.matrixSig) || new Set();
      termSetStrict.add(terminalStrict);
      strictTerminalHintsByMatrixSig.set(entry.matrixSig, termSetStrict);
    }
    if (entry.matrixSig && terminalCanonical) {
      const termSetCanonical = canonicalTerminalHintsByMatrixSig.get(entry.matrixSig) || new Set();
      termSetCanonical.add(terminalCanonical);
      canonicalTerminalHintsByMatrixSig.set(entry.matrixSig, termSetCanonical);
    }
    if (entry.matrixSig && looseParts.length > 0) {
      const strictPaths = strictPathHintsByMatrixSig.get(entry.matrixSig) || [];
      strictPaths.push(looseParts);
      strictPathHintsByMatrixSig.set(entry.matrixSig, strictPaths);
    }
    if (entry.matrixSig && veryLooseParts.length > 0) {
      const veryLoosePaths = veryLoosePathHintsByMatrixSig.get(entry.matrixSig) || [];
      veryLoosePaths.push(veryLooseParts);
      veryLoosePathHintsByMatrixSig.set(entry.matrixSig, veryLoosePaths);
    }
    if (entry.matrixSig && canonicalParts.length > 0) {
      const canonicalPaths = canonicalPathHintsByMatrixSig.get(entry.matrixSig) || [];
      canonicalPaths.push(canonicalParts);
      canonicalPathHintsByMatrixSig.set(entry.matrixSig, canonicalPaths);
    }
  }

  for (const entry of byIndex.values()) {
    if (!entry) continue;
    if (entry.matrixSig && depthHintsByMatrixSig.has(entry.matrixSig)) {
      const extra = depthHintsByMatrixSig.get(entry.matrixSig) || [];
      entry.depthHints = Array.from(new Set([...(entry.depthHints || []), ...extra]));
      if (entry.depthHints.length > 0) {
        entry.expectedDepth = Math.min(...entry.depthHints);
      }
    }
    if (entry.matrixSig && terminalHintsByMatrixSig.has(entry.matrixSig)) {
      entry.terminalHints = Array.from(terminalHintsByMatrixSig.get(entry.matrixSig) || []);
    }
    if (entry.matrixSig && strictTerminalHintsByMatrixSig.has(entry.matrixSig)) {
      entry.terminalHintsStrict = Array.from(strictTerminalHintsByMatrixSig.get(entry.matrixSig) || []);
    }
    if (entry.matrixSig && canonicalTerminalHintsByMatrixSig.has(entry.matrixSig)) {
      entry.terminalHintsCanonical = Array.from(canonicalTerminalHintsByMatrixSig.get(entry.matrixSig) || []);
    }
    if (entry.matrixSig && strictPathHintsByMatrixSig.has(entry.matrixSig)) {
      entry.pathHintsStrict = strictPathHintsByMatrixSig.get(entry.matrixSig) || [];
    }
    if (entry.matrixSig && veryLoosePathHintsByMatrixSig.has(entry.matrixSig)) {
      entry.pathHintsVeryLoose = veryLoosePathHintsByMatrixSig.get(entry.matrixSig) || [];
    }
    if (entry.matrixSig && canonicalPathHintsByMatrixSig.has(entry.matrixSig)) {
      entry.pathHintsCanonical = canonicalPathHintsByMatrixSig.get(entry.matrixSig) || [];
    }
  }

  for (const [name, count] of overrideTerminalLooseCount.entries()) {
    if (count === 1 && overrideTerminalLooseFirst.has(name)) {
      overrideTerminalLooseUnique.set(name, overrideTerminalLooseFirst.get(name));
    }
  }
  for (const [name, count] of overrideTerminalCanonicalCount.entries()) {
    if (count === 1 && overrideTerminalCanonicalFirst.has(name)) {
      overrideTerminalCanonicalUnique.set(name, overrideTerminalCanonicalFirst.get(name));
    }
  }

  const byNameEntries = Array.from(byName.keys()).map((k) => {
    const rawParts = k ? k.split("/") : [];
    const looseParts = normalizeNamePathLoose(rawParts);
    const veryLooseParts = normalizeNamePathVeryLoose(rawParts);
    const canonicalParts = normalizeNamePathCanonical(rawParts);
    return {
      rawParts,
      normParts: normalizeNamePath(rawParts),
      looseParts,
      veryLooseParts,
      canonicalParts,
      entry: byName.get(k),
    };
  });

  if (byIndex.size === 0 && byName.size === 0) return 0;

  const nodeEntries = [];
  const nodeTerminalLooseCount = new Map();
  const nodeTerminalCanonicalCount = new Map();
  const collectNodes = (node, indexPath, namePath) => {
    if (indexPath.length > 0) {
      const normalizedNameParts = normalizeNamePath(namePath);
      const looseNameParts = normalizeNamePathLoose(namePath);
      const veryLooseNameParts = normalizeNamePathVeryLoose(namePath);
      const canonicalNameParts = normalizeNamePathCanonical(namePath);
      const terminalLoose = looseNameParts.length > 0 ? looseNameParts[looseNameParts.length - 1] : "";
      const terminalCanonical = canonicalNameParts.length > 0 ? canonicalNameParts[canonicalNameParts.length - 1] : "";
      if (terminalLoose) {
        nodeTerminalLooseCount.set(
          terminalLoose,
          (nodeTerminalLooseCount.get(terminalLoose) ?? 0) + 1,
        );
      }
      if (terminalCanonical) {
        nodeTerminalCanonicalCount.set(
          terminalCanonical,
          (nodeTerminalCanonicalCount.get(terminalCanonical) ?? 0) + 1,
        );
      }
      nodeEntries.push({
        node,
        indexPath,
        namePath,
        normalizedNameParts,
        looseNameParts,
        veryLooseNameParts,
        canonicalNameParts,
        terminalLoose,
        terminalCanonical,
        modelRootMatrix: null,
      });
    }

    const nameCount = new Map();
    for (let i = 0; i < node.children.length; i += 1) {
      const child = node.children[i];
      const childName = String(child?.name ?? "");
      const occur = nameCount.get(childName) ?? 0;
      nameCount.set(childName, occur + 1);
      collectNodes(
        child,
        indexPath.concat(i),
        namePath.concat(`${childName}#${occur}`),
      );
    }
  };
  collectNodes(root, [], []);

  // Ensure parent.matrixWorld is valid before resolving model-root-space overrides.
  root.updateMatrixWorld(true);
  rootWorldInv.copy(root.matrixWorld).invert();
  for (const nodeEntry of nodeEntries) {
    nodeEntry.modelRootMatrix = tempModelRoot.copy(rootWorldInv).multiply(nodeEntry.node.matrixWorld).clone();
  }

  let applied = 0;
  let matrixFallbackApplied = 0;
  const appliedDebug = [];
  const usedOverrideEntries = new Set();
  const usedOverrideSigs = new Set();
  const usedNodes = new Set();

  const applyOverrideToNode = (node, overrideEntry, nodeEntry = null) => {
    if (!overrideEntry || !Array.isArray(overrideEntry.matrix) || overrideEntry.matrix.length !== 16) return "";
    if (isIdentityRowMajorMatrix(overrideEntry.matrix)) return "";

    const convertedMat = convertNodeMatrix(
      THREE,
      overrideEntry.matrix,
      basis,
      basisInv,
      matrixConvention,
    );

    // Some Panda paths include an extra Geom wrapper level (same-name duplicate),
    // but GLTFLoader may collapse it. In this case absolute target matrix can map to
    // a slightly different runtime node. Use model-space delta replay to avoid jumps.
    const overrideDepth = Array.isArray(overrideEntry.path) ? overrideEntry.path.length : 0;
    const runtimeDepth = Array.isArray(nodeEntry?.namePath) ? nodeEntry.namePath.length : 0;
    const collapsedWrapperDepth = overrideDepth > runtimeDepth;
    let targetNode = node;
    let targetNodeModel = nodeEntry?.modelRootMatrix
      ? nodeEntry.modelRootMatrix.clone()
      : tempModelRoot.copy(rootWorldInv).multiply(node.matrixWorld).clone();

    if (collapsedWrapperDepth && Array.isArray(overrideEntry.path) && overrideEntry.path.length > 0) {
      const expectedTail = canonicalizeNameSegment(overrideEntry.path[overrideEntry.path.length - 1]);
      const sameNameChildren = (Array.isArray(node.children) ? node.children : []).filter(
        (c) => canonicalizeNameSegment(c?.name ?? "") === expectedTail,
      );
      if (sameNameChildren.length === 1) {
        targetNode = sameNameChildren[0];
        targetNode.updateMatrixWorld(true);
        targetNodeModel = tempModelRoot.copy(rootWorldInv).multiply(targetNode.matrixWorld).clone();
      }
    }
    if (
      modelRootSpace
      && collapsedWrapperDepth
      && Array.isArray(overrideEntry.baseMatrix)
      && overrideEntry.baseMatrix.length === 16
    ) {
      const baseConverted = convertNodeMatrix(
        THREE,
        overrideEntry.baseMatrix,
        basis,
        basisInv,
        matrixConvention,
      );

      const deltaModel = convertedMat.clone().multiply(baseConverted.clone().invert());
      if (
        targetNode
        && targetNode.isSkinnedMesh
        && targetNode.skeleton
        && Array.isArray(targetNode.skeleton.bones)
        && targetNode.skeleton.bones.length > 0
      ) {
        const bones = targetNode.skeleton.bones;
        const boneSet = new Set(bones);
        const rootBones = bones.filter((b) => !b.parent || !boneSet.has(b.parent));
        if (rootBones.length > 0) {
          const tx = targetNodeModel.elements[12];
          const ty = targetNodeModel.elements[13];
          const tz = targetNodeModel.elements[14];
          let bestRoot = rootBones[0];
          let bestDist = Number.POSITIVE_INFINITY;
          for (const b of rootBones) {
            const bModel = tempModelRoot.copy(rootWorldInv).multiply(b.matrixWorld);
            const dx = Number(bModel.elements[12]) - Number(tx);
            const dy = Number(bModel.elements[13]) - Number(ty);
            const dz = Number(bModel.elements[14]) - Number(tz);
            const dist = (dx * dx) + (dy * dy) + (dz * dz);
            if (dist < bestDist) {
              bestDist = dist;
              bestRoot = b;
            }
          }

          const bestRootModel = tempModelRoot.copy(rootWorldInv).multiply(bestRoot.matrixWorld).clone();
          const rootTargetModel = deltaModel.clone().multiply(bestRootModel);
          let rootLocal = rootTargetModel;
          if (bestRoot.parent) {
            tempParentInv.copy(bestRoot.parent.matrixWorld).invert();
            rootLocal = tempParentInv.multiply(rootTargetModel);
          }
          bestRoot.matrixAutoUpdate = false;
          bestRoot.matrix.copy(rootLocal);
          bestRoot.matrix.decompose(bestRoot.position, bestRoot.quaternion, bestRoot.scale);
          bestRoot.updateMatrixWorld(true);
          return "skeleton_delta";
        }
      }

      const targetModel = deltaModel.multiply(targetNodeModel);
      let localDeltaMat = targetModel;
      if (targetNode.parent) {
        tempParentInv.copy(targetNode.parent.matrixWorld).invert();
        localDeltaMat = tempParentInv.multiply(targetModel);
      }
      targetNode.matrixAutoUpdate = false;
      targetNode.matrix.copy(localDeltaMat);
      targetNode.matrix.decompose(targetNode.position, targetNode.quaternion, targetNode.scale);
      targetNode.updateMatrixWorld(false);
      if (nodeEntry && targetNode === node) {
        nodeEntry.modelRootMatrix = targetModel.clone();
      }
      return targetNode === node ? "matrix_delta" : "matrix_delta_child";
    }

    let localMat = convertedMat;

    // SSBO edits are exported in model-root space (same as Panda's obj_np.getMat(model_root)).
    if (modelRootSpace && targetNode.parent) {
      tempParentInv.copy(targetNode.parent.matrixWorld).invert();
      localMat = tempParentInv.multiply(convertedMat);
    }

    targetNode.matrixAutoUpdate = false;
    targetNode.matrix.copy(localMat);
    targetNode.matrix.decompose(targetNode.position, targetNode.quaternion, targetNode.scale);
    targetNode.updateMatrixWorld(false);
    return targetNode === node ? "matrix" : "matrix_child";
  };

  const matrixMaxAbsDiff = (a, b) => {
    if (!a || !b) return Number.POSITIVE_INFINITY;
    const ae = a.elements;
    const be = b.elements;
    let diff = 0;
    for (let i = 0; i < 16; i += 1) {
      const d = Math.abs(Number(ae[i]) - Number(be[i]));
      if (d > diff) diff = d;
    }
    return diff;
  };

  for (const entry of nodeEntries) {
    const {
      node,
      indexPath,
      namePath,
      normalizedNameParts,
      looseNameParts,
      veryLooseNameParts,
      canonicalNameParts,
      terminalLoose,
      terminalCanonical,
    } = entry;
    const indexKey = pathKey(indexPath);
    const nameKey = pathKey(namePath);
    const normalizedNameKey = pathKey(normalizedNameParts);
    const looseNameKey = pathKey(looseNameParts);
    const veryLooseNameKey = pathKey(veryLooseNameParts);
    const canonicalNameKey = pathKey(canonicalNameParts);

    let matchedOverrideEntry = preferName
      ? (
        byName.get(nameKey)
        || byNameNormalized.get(normalizedNameKey)
        || byNameLoose.get(looseNameKey)
        || byNameVeryLoose.get(veryLooseNameKey)
        || byNameCanonical.get(canonicalNameKey)
        || byIndex.get(indexKey)
      )
      : (
        byIndex.get(indexKey)
        || byName.get(nameKey)
        || byNameNormalized.get(normalizedNameKey)
        || byNameLoose.get(looseNameKey)
        || byNameVeryLoose.get(veryLooseNameKey)
        || byNameCanonical.get(canonicalNameKey)
      );

    // Fallback A: tolerate wrappers when occurrence indices still match.
    if (!matchedOverrideEntry && normalizedNameParts.length > 0) {
      for (const info of byNameEntries) {
        if (!info || !info.entry) continue;
        if (isSuffixPath(info.normParts, normalizedNameParts) || isSuffixPath(normalizedNameParts, info.normParts)) {
          matchedOverrideEntry = info.entry;
          break;
        }
      }
    }

    // Fallback B: ignore occurrence suffix like "#0" when matching.
    if (!matchedOverrideEntry && looseNameParts.length > 0) {
      for (const info of byNameEntries) {
        if (!info || !info.entry) continue;
        if (isSuffixPath(info.looseParts, looseNameParts) || isSuffixPath(looseNameParts, info.looseParts)) {
          matchedOverrideEntry = info.entry;
          break;
        }
      }
    }

    // Fallback C1: ignore both '#occurrence' and Blender '.001/.002' suffixes.
    if (!matchedOverrideEntry && veryLooseNameParts.length > 0) {
      for (const info of byNameEntries) {
        if (!info || !info.entry) continue;
        if (isSuffixPath(info.veryLooseParts, veryLooseNameParts) || isSuffixPath(veryLooseNameParts, info.veryLooseParts)) {
          matchedOverrideEntry = info.entry;
          break;
        }
      }
    }

    // Fallback C2: punctuation-insensitive path matching while keeping numeric tails.
    if (!matchedOverrideEntry && canonicalNameParts.length > 0) {
      for (const info of byNameEntries) {
        if (!info || !info.entry) continue;
        if (isSuffixPath(info.canonicalParts, canonicalNameParts) || isSuffixPath(canonicalNameParts, info.canonicalParts)) {
          matchedOverrideEntry = info.entry;
          break;
        }
      }
    }

    // Fallback C: unique terminal-name match (only when both sides are unique).
    if (!matchedOverrideEntry && terminalLoose) {
      const nodeTerminalCount = nodeTerminalLooseCount.get(terminalLoose) ?? 0;
      if (nodeTerminalCount === 1 && overrideTerminalLooseUnique.has(terminalLoose)) {
        matchedOverrideEntry = overrideTerminalLooseUnique.get(terminalLoose);
      }
    }

    // Fallback C3: punctuation-insensitive unique terminal match.
    if (!matchedOverrideEntry && terminalCanonical) {
      const nodeTerminalCount = nodeTerminalCanonicalCount.get(terminalCanonical) ?? 0;
      if (nodeTerminalCount === 1 && overrideTerminalCanonicalUnique.has(terminalCanonical)) {
        matchedOverrideEntry = overrideTerminalCanonicalUnique.get(terminalCanonical);
      }
    }

    const applyMode = applyOverrideToNode(node, matchedOverrideEntry, entry);
    if (applyMode) {
      usedOverrideEntries.add(matchedOverrideEntry);
      if (matchedOverrideEntry.matrixSig) usedOverrideSigs.add(matchedOverrideEntry.matrixSig);
      const indexEntry = byIndex.get(indexKey);
      if (indexEntry) {
        usedOverrideEntries.add(indexEntry);
        if (indexEntry.matrixSig) usedOverrideSigs.add(indexEntry.matrixSig);
      }
      usedNodes.add(node);
      applied += 1;
      if (appliedDebug.length < 8) {
        appliedDebug.push({
          runtimePath: pathKey(namePath),
          runtimeIndex: pathKey(indexPath),
          overridePath: Array.isArray(matchedOverrideEntry.path) ? pathKey(matchedOverrideEntry.path) : "",
          via: applyMode,
        });
      }
    }
  }

  // Fallback D: matrix-based matching for SSBO when names/index do not align.
  const unmatchedIndexEntries = Array.from(byIndex.values()).filter(
    (v) => (
      !usedOverrideEntries.has(v)
      && !(v.matrixSig && usedOverrideSigs.has(v.matrixSig))
      && Array.isArray(v.baseMatrix)
      && v.baseMatrix.length === 16
    ),
  );
  for (const overrideEntry of unmatchedIndexEntries) {
    const baseConverted = convertNodeMatrix(
      THREE,
      overrideEntry.baseMatrix,
      basis,
      basisInv,
      matrixConvention,
    );

    let best = null;
    let bestErr = Number.POSITIVE_INFINITY;
    let bestDepthErr = Number.POSITIVE_INFINITY;
    let tieCount = 0;
    let tieCandidates = [];
    const depthHints = Array.isArray(overrideEntry.depthHints) && overrideEntry.depthHints.length > 0
      ? overrideEntry.depthHints
      : [Number(overrideEntry.expectedDepth ?? 0)];
    for (const nodeEntry of nodeEntries) {
      if (!nodeEntry || !nodeEntry.node || usedNodes.has(nodeEntry.node)) continue;
      const nodeDepth = Number(nodeEntry.indexPath?.length ?? 0);
      let depthErr = Number.POSITIVE_INFINITY;
      for (const hint of depthHints) {
        const v = Math.abs(nodeDepth - Number(hint));
        if (v < depthErr) depthErr = v;
      }
      if (depthErr > maxDepthDiffForMatrixFallback) continue;

      const err = matrixMaxAbsDiff(nodeEntry.modelRootMatrix, baseConverted);
      const better =
        depthErr < bestDepthErr ||
        (depthErr === bestDepthErr && err < bestErr - matrixTieEps);
      const tie =
        depthErr === bestDepthErr &&
        Math.abs(err - bestErr) <= matrixTieEps;

      if (better) {
        bestErr = err;
        bestDepthErr = depthErr;
        best = nodeEntry;
        tieCount = 1;
        tieCandidates = [nodeEntry];
      } else if (tie) {
        tieCount += 1;
        tieCandidates.push(nodeEntry);
      }
    }

    if (best && tieCount > 1) {
      const strictPathHints = Array.isArray(overrideEntry.pathHintsStrict) ? overrideEntry.pathHintsStrict : [];
      const veryLoosePathHints = Array.isArray(overrideEntry.pathHintsVeryLoose) ? overrideEntry.pathHintsVeryLoose : [];
      const canonicalPathHints = Array.isArray(overrideEntry.pathHintsCanonical) ? overrideEntry.pathHintsCanonical : [];
      const termHintsStrict = Array.isArray(overrideEntry.terminalHintsStrict) ? overrideEntry.terminalHintsStrict : [];
      const termHints = Array.isArray(overrideEntry.terminalHints) ? overrideEntry.terminalHints : [];
      const termHintsCanonical = Array.isArray(overrideEntry.terminalHintsCanonical) ? overrideEntry.terminalHintsCanonical : [];
      const ranked = tieCandidates.map((nodeEntry) => {
        const strictTail = Array.isArray(nodeEntry.looseNameParts) && nodeEntry.looseNameParts.length > 0
          ? nodeEntry.looseNameParts[nodeEntry.looseNameParts.length - 1]
          : "";
        const veryLooseTail = Array.isArray(nodeEntry.veryLooseNameParts) && nodeEntry.veryLooseNameParts.length > 0
          ? nodeEntry.veryLooseNameParts[nodeEntry.veryLooseNameParts.length - 1]
          : "";
        const canonicalTail = Array.isArray(nodeEntry.canonicalNameParts) && nodeEntry.canonicalNameParts.length > 0
          ? nodeEntry.canonicalNameParts[nodeEntry.canonicalNameParts.length - 1]
          : "";
        let strictPathScore = 0;
        for (const hint of strictPathHints) {
          strictPathScore = Math.max(strictPathScore, suffixMatchScore(hint, nodeEntry.looseNameParts || []));
        }
        let veryLoosePathScore = 0;
        for (const hint of veryLoosePathHints) {
          veryLoosePathScore = Math.max(veryLoosePathScore, suffixMatchScore(hint, nodeEntry.veryLooseNameParts || []));
        }
        let canonicalPathScore = 0;
        for (const hint of canonicalPathHints) {
          canonicalPathScore = Math.max(canonicalPathScore, suffixMatchScore(hint, nodeEntry.canonicalNameParts || []));
        }
        const strictTailHit = (strictTail && termHintsStrict.includes(strictTail)) ? 1 : 0;
        const veryLooseTailHit = (veryLooseTail && termHints.includes(veryLooseTail)) ? 1 : 0;
        const canonicalTailHit = (canonicalTail && termHintsCanonical.includes(canonicalTail)) ? 1 : 0;
        return {
          nodeEntry,
          strictPathScore,
          canonicalPathScore,
          veryLoosePathScore,
          strictTailHit,
          canonicalTailHit,
          veryLooseTailHit,
        };
      });

      ranked.sort((a, b) => {
        if (b.strictPathScore !== a.strictPathScore) return b.strictPathScore - a.strictPathScore;
        if (b.strictTailHit !== a.strictTailHit) return b.strictTailHit - a.strictTailHit;
        if (b.canonicalPathScore !== a.canonicalPathScore) return b.canonicalPathScore - a.canonicalPathScore;
        if (b.canonicalTailHit !== a.canonicalTailHit) return b.canonicalTailHit - a.canonicalTailHit;
        if (b.veryLoosePathScore !== a.veryLoosePathScore) return b.veryLoosePathScore - a.veryLoosePathScore;
        if (b.veryLooseTailHit !== a.veryLooseTailHit) return b.veryLooseTailHit - a.veryLooseTailHit;
        return 0;
      });

      if (ranked.length > 0) {
        const top = ranked[0];
        const second = ranked.length > 1 ? ranked[1] : null;
        const topTuple = [
          top.strictPathScore,
          top.strictTailHit,
          top.canonicalPathScore,
          top.canonicalTailHit,
          top.veryLoosePathScore,
          top.veryLooseTailHit,
        ].join("|");
        const secondTuple = second
          ? [
            second.strictPathScore,
            second.strictTailHit,
            second.canonicalPathScore,
            second.canonicalTailHit,
            second.veryLoosePathScore,
            second.veryLooseTailHit,
          ].join("|")
          : "";
        const topHasSignal = (
          top.strictPathScore > 0
          || top.strictTailHit > 0
          || top.canonicalPathScore > 0
          || top.canonicalTailHit > 0
          || top.veryLoosePathScore > 0
          || top.veryLooseTailHit > 0
        );
        const uniqueTop = !second || topTuple !== secondTuple;
        if (topHasSignal && uniqueTop) {
          best = top.nodeEntry;
          tieCount = 1;
        }
      }
    }

    if (best && tieCount > 1) {
      console.warn("[WebGLPack] Matrix fallback ambiguous, skip override:", {
        path: overrideEntry.path,
        depthHints,
        bestDepthErr,
        bestErr,
        tieCount,
      });
      continue;
    }

    const fallbackMode = (best && bestErr <= matrixMatchEps)
      ? applyOverrideToNode(best.node, overrideEntry, best)
      : "";
    if (fallbackMode) {
      usedOverrideEntries.add(overrideEntry);
      if (overrideEntry.matrixSig) usedOverrideSigs.add(overrideEntry.matrixSig);
      usedNodes.add(best.node);
      applied += 1;
      matrixFallbackApplied += 1;
      if (appliedDebug.length < 8) {
        appliedDebug.push({
          runtimePath: pathKey(best.namePath || []),
          runtimeIndex: pathKey(best.indexPath || []),
          overridePath: Array.isArray(overrideEntry.path) ? pathKey(overrideEntry.path) : "",
          via: `matrix_fallback:${fallbackMode}`,
        });
      }
    }
  }

  const unusedIndex = Array.from(byIndex.entries())
    .filter(([, entry]) => !usedOverrideEntries.has(entry) && !(entry.matrixSig && usedOverrideSigs.has(entry.matrixSig)))
    .map(([key]) => key);
  const unusedName = Array.from(byName.entries())
    .filter(([, entry]) => !usedOverrideEntries.has(entry) && !(entry.matrixSig && usedOverrideSigs.has(entry.matrixSig)))
    .map(([key]) => key);

  if (unusedIndex.length > 0 || unusedName.length > 0) {
    console.warn("[WebGLPack] Subnode overrides partially unmatched:", {
      rootName: root.name || "(unnamed)",
      applied,
      matrixFallbackApplied,
      totalIndexOverrides: byIndex.size,
      totalNameOverrides: byName.size,
      unmatchedIndex: unusedIndex.slice(0, 20),
      unmatchedName: unusedName.slice(0, 20),
    });
    console.info(
      "[WebGLPack] Sample runtime node paths:",
      nodeEntries.slice(0, 20).map((v) => pathKey(v.namePath)),
    );
  } else {
    console.info("[WebGLPack] Subnode overrides matched:", {
      rootName: root.name || "(unnamed)",
      applied,
      matrixFallbackApplied,
      totalIndexOverrides: byIndex.size,
      totalNameOverrides: byName.size,
      matrixSpace,
      appliedSample: appliedDebug,
    });
  }

  return applied;
}

function directionToThree(THREE, direction, basis) {
  const d = new THREE.Vector3(
    Number(direction?.[0] ?? 0),
    Number(direction?.[1] ?? 0),
    Number(direction?.[2] ?? -1),
  );
  d.applyMatrix4(basis);
  if (d.lengthSq() < 1e-6) d.set(0, 0, -1);
  return d.normalize();
}

function applyShadowFlags(root, enabled) {
  if (!root || !root.traverse) return;
  root.traverse((obj) => {
    if (obj.isMesh) {
      obj.castShadow = !!enabled;
      obj.receiveShadow = !!enabled;
    }
  });
}

function normalizeColorInput(color, fallback = [1, 1, 1]) {
  const out = toColorArray(color, fallback);
  if (out.some((v) => Number(v) > 1.0)) {
    return out.map((v) => Number(v) / 255.0);
  }
  return out;
}

function mapToneMappingOperator(THREE, operator) {
  const op = String(operator || "").trim().toLowerCase();
  if (op === "none") return THREE.NoToneMapping;
  if (op === "reinhard") return THREE.ReinhardToneMapping;
  if (op === "exponential" || op === "exponential2") return THREE.CineonToneMapping;
  // RenderPipeline's optimized/uncharted2 are filmic-like; ACES is the closest built-in curve.
  return THREE.ACESFilmicToneMapping;
}

function clampPositive(value, fallback) {
  const v = toFiniteNumber(value, fallback);
  return Number.isFinite(v) && v > 0 ? v : fallback;
}

function applyRenderPipelineApproximation(THREE, renderer, scene, profile) {
  const cfg = (profile && typeof profile === "object") ? profile : {};
  const tone = (cfg.tone_mapping && typeof cfg.tone_mapping === "object") ? cfg.tone_mapping : {};
  const shadows = (cfg.shadows && typeof cfg.shadows === "object") ? cfg.shadows : {};
  const fog = (cfg.fog && typeof cfg.fog === "object") ? cfg.fog : {};
  const bloom = (cfg.bloom && typeof cfg.bloom === "object") ? cfg.bloom : {};

  // Keep lighting calibration closer to the editor defaults.
  renderer.physicallyCorrectLights = false;
  const useToneMapping = toBoolean(tone.web_use_tone_mapping, false);
  if (useToneMapping) {
    renderer.toneMapping = mapToneMappingOperator(THREE, tone.operator);
    const webExposureBoost = Math.max(0.5, Math.min(3.0, toFiniteNumber(tone.web_exposure_boost, 1.0)));
    renderer.toneMappingExposure = clampPositive(tone.exposure_scale, 1.0) * webExposureBoost;
  } else {
    renderer.toneMapping = THREE.NoToneMapping;
    renderer.toneMappingExposure = 1.0;
  }

  const shadowEnabled = toBoolean(shadows.enabled, false) && toBoolean(shadows.web_enable, false);
  const shadowRes = Math.max(256, Math.min(4096, Math.round(clampPositive(shadows.resolution, 1024))));
  renderer.shadowMap.enabled = shadowEnabled;
  renderer.shadowMap.type = toBoolean(shadows.use_pcf, true) ? THREE.PCFSoftShadowMap : THREE.PCFShadowMap;

  let fogApplied = false;
  if (toBoolean(fog.enabled, false)) {
    const fogColor = normalizeColorInput(fog.color, [0.55, 0.6, 0.7]);
    const fogIntensity = Math.max(0.0, toFiniteNumber(fog.intensity, 0));
    const fogRamp = Math.max(1.0, toFiniteNumber(fog.ramp_size, 2000));
    const density = Math.max(0.00001, Math.min(0.2, (fogIntensity / fogRamp) * 0.2));
    scene.fog = new THREE.FogExp2(new THREE.Color(fogColor[0], fogColor[1], fogColor[2]), density);
    fogApplied = true;
  } else {
    scene.fog = null;
  }

  return {
    shadowEnabled,
    shadowResolution: shadowRes,
    toneMappingEnabled: useToneMapping,
    fogApplied,
    bloom: {
      enabled: toBoolean(bloom.enabled, false),
      strength: Math.max(0.0, toFiniteNumber(bloom.strength, 0)),
      vendorAvailable: toBoolean(bloom.vendor_available, false),
      mipmaps: Math.max(2, Math.min(10, Math.round(clampPositive(bloom.mipmaps, 6)))),
    },
  };
}

async function tryCreateBloomComposer(THREE, renderer, scene, camera, bloomConfig) {
  const bloomEnabled = toBoolean(bloomConfig?.enabled, false);
  const bloomStrength = Math.max(0.0, toFiniteNumber(bloomConfig?.strength, 0));
  const vendorAvailable = toBoolean(bloomConfig?.vendorAvailable, true);
  if (!bloomEnabled || bloomStrength <= 1e-4) {
    return { composer: null, enabled: false, reason: "disabled" };
  }
  if (!vendorAvailable) {
    return { composer: null, enabled: false, reason: "vendor_missing" };
  }

  try {
    const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }] = await Promise.all([
      import("../vendor/EffectComposer.js"),
      import("../vendor/RenderPass.js"),
      import("../vendor/UnrealBloomPass.js"),
    ]);

    const composer = new EffectComposer(renderer);
    composer.addPass(new RenderPass(scene, camera));

    const bloomPass = new UnrealBloomPass(
      new THREE.Vector2(window.innerWidth, window.innerHeight),
      bloomStrength,
      0.35,
      0.9,
    );
    composer.addPass(bloomPass);
    return { composer, enabled: true, reason: "ok" };
  } catch (err) {
    console.warn("[WebGLPack] Bloom pass unavailable, fallback to base render:", err);
    return { composer: null, enabled: false, reason: "module_missing" };
  }
}

function applySkyboxConfig(THREE, scene, env, textureLoader) {
  const sky = (env && typeof env.skybox === "object") ? env.skybox : null;
  if (!sky || !toBoolean(sky.enabled, false) || !sky.uri) {
    return Promise.resolve({ enabled: false, reason: "disabled", update: null });
  }

  const skyType = String(sky.type || "equirectangular").toLowerCase();
  if (skyType !== "equirectangular" && skyType !== "skydome") {
    console.warn("[WebGLPack] Unsupported skybox type:", skyType);
    return Promise.resolve({ enabled: false, reason: "unsupported_type", update: null });
  }

  return new Promise((resolve) => {
    textureLoader.load(
      sky.uri,
      (tex) => {
        if ("colorSpace" in tex) {
          tex.colorSpace = THREE.SRGBColorSpace;
        } else if ("encoding" in tex) {
          tex.encoding = THREE.sRGBEncoding;
        }
        tex.wrapS = THREE.RepeatWrapping;
        tex.wrapT = THREE.ClampToEdgeWrapping;

        if (skyType === "equirectangular") {
          tex.mapping = THREE.EquirectangularReflectionMapping;
          scene.background = tex;
          if (toBoolean(sky.apply_environment, true)) {
            scene.environment = tex;
          }
          resolve({ enabled: true, reason: "ok", update: null });
          return;
        }

        // RenderPipeline default sky uses skydome projection (u = atan(x, y), v = z in Panda Z-up).
        // In Web viewer (Three Y-up), equivalent direction is:
        // u = atan(dir.x, -dir.z), v = dir.y.
        const skyRadius = Math.max(1000, Number(sky.radius ?? 20000));
        const clipLowerHemisphere = toBoolean(sky.clip_lower_hemisphere, true);
        const lowerColor = toColorArray(sky.lower_hemisphere_color, [0.08, 0.10, 0.13]);
        const horizonBlend = THREE.MathUtils.clamp(Number(sky.horizon_blend ?? 0.06), 0.001, 0.35);
        const horizonSampleV = THREE.MathUtils.clamp(Number(sky.horizon_sample_v ?? 0.01), 0.0, 0.2);
        const lowerTintStrength = THREE.MathUtils.clamp(Number(sky.lower_tint_strength ?? 0.65), 0.0, 1.0);
        const skyGeom = new THREE.SphereGeometry(skyRadius, 32, 16);
        const skyMat = new THREE.ShaderMaterial({
          uniforms: {
            uSkyTex: { value: tex },
            uRotationU: { value: Number(sky.rotation_u ?? 0.0) },
            uClipLowerHemisphere: { value: clipLowerHemisphere ? 1.0 : 0.0 },
            uLowerColor: { value: new THREE.Color(lowerColor[0], lowerColor[1], lowerColor[2]) },
            uHorizonBlend: { value: horizonBlend },
            uHorizonSampleV: { value: horizonSampleV },
            uLowerTintStrength: { value: lowerTintStrength },
          },
          vertexShader: `
            uniform float uRotationU;
            varying vec2 vSkyUv;
            varying float vDirY;
            const float PI = 3.1415926535897932384626433832795;
            void main() {
              vec3 dir = normalize(position.xyz);
              vDirY = dir.y;
              float u = (atan(dir.x, -dir.z) + PI) / (2.0 * PI);
              vSkyUv = vec2(fract(u + uRotationU), clamp(dir.y, 0.0, 1.0));
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: `
            uniform sampler2D uSkyTex;
            uniform float uClipLowerHemisphere;
            uniform vec3 uLowerColor;
            uniform float uHorizonBlend;
            uniform float uHorizonSampleV;
            uniform float uLowerTintStrength;
            varying vec2 vSkyUv;
            varying float vDirY;

            void main() {
              vec3 skyCol = texture2D(uSkyTex, vSkyUv).rgb;
              if (uClipLowerHemisphere > 0.5) {
                vec3 horizonCol = texture2D(uSkyTex, vec2(vSkyUv.x, uHorizonSampleV)).rgb;
                vec3 lowerCol = mix(uLowerColor, horizonCol, uLowerTintStrength);
                float t = smoothstep(-uHorizonBlend, uHorizonBlend, vDirY);
                vec3 col = mix(lowerCol, skyCol, t);
                gl_FragColor = vec4(col, 1.0);
              } else {
                gl_FragColor = vec4(skyCol, 1.0);
              }
            }
          `,
          side: THREE.BackSide,
          depthWrite: false,
          fog: false,
          transparent: false,
        });
        skyMat.toneMapped = false;

        const skydome = new THREE.Mesh(skyGeom, skyMat);
        skydome.name = "WebGLPackSkydome";
        skydome.matrixAutoUpdate = true;
        skydome.frustumCulled = false;
        scene.add(skydome);

        if (!toBoolean(sky.apply_environment, false)) {
          scene.environment = null;
        }

        const update = (camera) => {
          if (!camera) return;
          skydome.position.copy(camera.position);
        };
        resolve({ enabled: true, reason: "ok", update });
      },
      undefined,
      (err) => {
        console.warn("[WebGLPack] Skybox load failed:", sky.uri, err);
        resolve({ enabled: false, reason: "load_failed", update: null });
      },
    );
  });
}

function normalizeAnimationName(name) {
  return canonicalizeNameSegment(String(name ?? ""));
}

function pickAnimationClip(clips, requestedName) {
  if (!Array.isArray(clips) || clips.length === 0) return null;
  const requested = String(requestedName ?? "").trim();
  if (!requested) return clips[0];

  let clip = clips.find((c) => String(c?.name ?? "") === requested);
  if (clip) return clip;

  const reqLower = requested.toLowerCase();
  clip = clips.find((c) => String(c?.name ?? "").toLowerCase() === reqLower);
  if (clip) return clip;

  const reqNorm = normalizeAnimationName(requested);
  if (reqNorm) {
    clip = clips.find((c) => normalizeAnimationName(c?.name ?? "") === reqNorm);
    if (clip) return clip;

    clip = clips.find((c) => {
      const n = normalizeAnimationName(c?.name ?? "");
      return n && (n.includes(reqNorm) || reqNorm.includes(n));
    });
    if (clip) return clip;
  }

  return clips[0];
}

function setupModelAnimation(THREE, root, gltf, animationConfig, nodeName) {
  const config = animationConfig && typeof animationConfig === "object" ? animationConfig : {};
  if (config.enabled === false) {
    return { mixer: null, clipName: "", mode: "stop", started: false };
  }

  const clips = Array.isArray(gltf?.animations) ? gltf.animations : [];
  if (clips.length === 0) {
    if (config.clip_name || config.mode) {
      console.warn("[WebGLPack] Animation requested but no clips found:", nodeName || "(unnamed)");
    }
    return { mixer: null, clipName: "", mode: "stop", started: false };
  }

  const mode = String(config.mode || "loop").toLowerCase();
  const clip = pickAnimationClip(clips, config.clip_name);
  if (!clip) {
    return { mixer: null, clipName: "", mode, started: false };
  }

  const mixer = new THREE.AnimationMixer(root);
  const action = mixer.clipAction(clip);
  const speedRaw = Number(config.speed ?? 1.0);
  const speed = Number.isFinite(speedRaw) && Math.abs(speedRaw) > 1e-4 ? speedRaw : 1.0;

  action.enabled = true;
  action.clampWhenFinished = (mode === "play");
  if (mode === "play") {
    action.setLoop(THREE.LoopOnce, 1);
  } else {
    action.setLoop(THREE.LoopRepeat, Infinity);
  }
  action.setEffectiveTimeScale(speed);

  const shouldPlay = Boolean(config.autoplay ?? (mode !== "stop"));
  const startTimeRaw = Number(config.start_time ?? 0);
  const duration = Math.max(0.0001, Number(clip.duration || 0.0001));
  const startTime = Number.isFinite(startTimeRaw) ? (startTimeRaw % duration) : 0;

  action.play();
  action.time = startTime;
  if (!shouldPlay || mode === "stop" || mode === "pause") {
    action.paused = true;
  }

  return {
    mixer,
    clipName: String(clip.name || ""),
    mode,
    started: shouldPlay && mode !== "stop" && mode !== "pause",
  };
}

function toFiniteNumber(value, fallback) {
  const v = Number(value);
  return Number.isFinite(v) ? v : fallback;
}

function toBoolean(value, fallback = true) {
  if (typeof value === "boolean") return value;
  if (typeof value === "number") return value !== 0;
  if (typeof value === "string") {
    const t = value.trim().toLowerCase();
    if (["1", "true", "yes", "on"].includes(t)) return true;
    if (["0", "false", "no", "off"].includes(t)) return false;
  }
  return fallback;
}

function parseColorVec4(raw, fallback = [1, 1, 1, 1]) {
  if (Array.isArray(raw) && raw.length >= 3) {
    return [
      toFiniteNumber(raw[0], fallback[0]),
      toFiniteNumber(raw[1], fallback[1]),
      toFiniteNumber(raw[2], fallback[2]),
      toFiniteNumber(raw[3], fallback[3]),
    ];
  }
  if (raw && typeof raw === "object") {
    return [
      toFiniteNumber(raw.r, fallback[0]),
      toFiniteNumber(raw.g, fallback[1]),
      toFiniteNumber(raw.b, fallback[2]),
      toFiniteNumber(raw.a, fallback[3]),
    ];
  }
  return fallback.slice();
}

function normalizeScriptName(name) {
  const text = String(name ?? "").trim().replace(/\.py$/i, "");
  return canonicalizeNameSegment(text);
}

function markObjectTransformDirty(obj) {
  if (!obj || !obj.isObject3D) return;
  obj.updateMatrix();
  obj.updateMatrixWorld(false);
}

function applyPandaAxisDeltaToThreeLocal(obj, axis, delta) {
  const ax = String(axis || "x").toLowerCase();
  if (ax === "x") {
    obj.position.x += delta;
  } else if (ax === "y") {
    obj.position.z -= delta;
  } else {
    obj.position.y += delta;
  }
}

function ensureUniqueMaterialsForObject(root) {
  if (!root || !root.traverse) return;
  root.traverse((obj) => {
    if (!obj.isMesh || !obj.material) return;
    if (Array.isArray(obj.material)) {
      obj.material = obj.material.map((mat) => {
        if (!mat) return mat;
        if (mat.userData?.__webglPackUniqueClone) return mat;
        const cloned = mat.clone();
        cloned.userData = cloned.userData || {};
        cloned.userData.__webglPackUniqueClone = true;
        return cloned;
      });
      return;
    }
    const mat = obj.material;
    if (!mat.userData?.__webglPackUniqueClone) {
      const cloned = mat.clone();
      cloned.userData = cloned.userData || {};
      cloned.userData.__webglPackUniqueClone = true;
      obj.material = cloned;
    }
  });
}

function applyColorToObject(root, rgba) {
  if (!root || !root.traverse) return;
  const r = toFiniteNumber(rgba?.[0], 1);
  const g = toFiniteNumber(rgba?.[1], 1);
  const b = toFiniteNumber(rgba?.[2], 1);
  const a = Math.min(1, Math.max(0, toFiniteNumber(rgba?.[3], 1)));

  root.traverse((obj) => {
    if (!obj.isMesh || !obj.material) return;
    const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
    for (const mat of mats) {
      if (!mat) continue;
      if (mat.color) {
        mat.color.setRGB(r, g, b);
      }
      if ("opacity" in mat) {
        mat.opacity = a;
        const transparent = a < 0.999;
        mat.transparent = transparent;
        mat.depthWrite = !transparent;
        if (!transparent && "alphaTest" in mat) mat.alphaTest = 0;
      }
      mat.needsUpdate = true;
    }
  });
}

function hueToRgb(h) {
  const hue = ((h % 1) + 1) % 1;
  const i = Math.floor(hue * 6);
  const f = hue * 6 - i;
  const p = 0;
  const q = 1 - f;
  const t = f;
  switch (i % 6) {
    case 0: return [1, t, p];
    case 1: return [q, 1, p];
    case 2: return [p, 1, t];
    case 3: return [p, q, 1];
    case 4: return [t, p, 1];
    default: return [1, p, q];
  }
}

function buildNodeNameLookup(nodeMap) {
  const byCanonicalName = new Map();
  for (const obj of nodeMap.values()) {
    const key = canonicalizeNameSegment(obj?.name ?? "");
    if (!key) continue;
    if (!byCanonicalName.has(key)) {
      byCanonicalName.set(key, []);
    }
    byCanonicalName.get(key).push(obj);
  }
  return byCanonicalName;
}

function findDescendantByNameChain(root, chain) {
  if (!root || !Array.isArray(chain) || chain.length === 0) return null;
  let candidates = [root];
  for (const segment of chain) {
    const token = canonicalizeNameSegment(segment ?? "");
    if (!token) continue;
    const next = [];
    for (const candidate of candidates) {
      for (const child of candidate.children || []) {
        const childToken = canonicalizeNameSegment(child?.name ?? "");
        if (childToken === token) {
          next.push(child);
        }
      }
    }
    if (next.length === 0) return null;
    candidates = next;
  }
  return candidates[0] || null;
}

function resolveScriptNodeRef(refValue, nodeMap, nodeNameLookup) {
  let ref = refValue;
  if (ref && typeof ref === "object" && ref.__node_ref__ && typeof ref.__node_ref__ === "object") {
    ref = ref.__node_ref__;
  }
  if (typeof ref === "string") {
    const byId = nodeMap.get(ref);
    if (byId) return byId;
    const token = canonicalizeNameSegment(ref);
    const byName = token ? (nodeNameLookup.get(token) || []) : [];
    return byName.length > 0 ? byName[0] : null;
  }
  if (!ref || typeof ref !== "object") return null;

  const nodeId = String(ref.node_id ?? "").trim();
  if (nodeId && nodeMap.has(nodeId)) {
    return nodeMap.get(nodeId);
  }

  const ancestorId = String(ref.ancestor_node_id ?? "").trim();
  if (ancestorId && nodeMap.has(ancestorId) && Array.isArray(ref.child_name_chain)) {
    const ancestor = nodeMap.get(ancestorId);
    const desc = findDescendantByNameChain(ancestor, ref.child_name_chain);
    if (desc) return desc;
  }

  const nodeName = String(ref.node_name ?? "").trim();
  if (nodeName) {
    const token = canonicalizeNameSegment(nodeName);
    const byName = token ? (nodeNameLookup.get(token) || []) : [];
    if (byName.length > 0) return byName[0];
  }

  return null;
}

function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, ownerName) {
  const scriptName = String(scriptCfg?.name ?? "").trim();
  if (!scriptName) return null;
  const key = normalizeScriptName(scriptName);
  const params = (scriptCfg?.params && typeof scriptCfg.params === "object") ? scriptCfg.params : {};
  const enabled = toBoolean(scriptCfg?.enabled, true);

  if (key === "moverscript" || key === "mover") {
    const state = {
      name: scriptName,
      key,
      enabled,
      axis: String(params.move_axis ?? "x").toLowerCase(),
      moveDistance: Math.max(0, Math.abs(toFiniteNumber(params.move_distance, 5.0))),
      moveSpeed: toFiniteNumber(params.move_speed, 2.0),
      currentDirection: toFiniteNumber(params.current_direction, 1) >= 0 ? 1 : -1,
      currentDistance: Math.max(0, Math.abs(toFiniteNumber(params.current_distance, 0.0))),
      isMoving: toBoolean(params.is_moving, true),
      update(dt) {
        if (!this.enabled || !this.isMoving) return;
        const delta = this.moveSpeed * dt * this.currentDirection;
        this.currentDistance += Math.abs(delta);
        if (this.moveDistance > 1e-6 && this.currentDistance >= this.moveDistance) {
          this.currentDirection *= -1;
          this.currentDistance = 0;
        }
        applyPandaAxisDeltaToThreeLocal(object3d, this.axis, delta);
        markObjectTransformDirty(object3d);
      },
    };
    return state;
  }

  if (key === "rotatorscript" || key === "rotator") {
    return {
      name: scriptName,
      key,
      enabled,
      speedDeg: toFiniteNumber(params.rotation_speed_y, 30.0),
      isRotating: toBoolean(params.is_rotating, true),
      update(dt) {
        if (!this.enabled || !this.isRotating) return;
        object3d.rotation.y += THREE.MathUtils.degToRad(this.speedDeg * dt);
        markObjectTransformDirty(object3d);
      },
    };
  }

  if (key === "scalerscript" || key === "scaler") {
    return {
      name: scriptName,
      key,
      enabled,
      baseScale: toFiniteNumber(params.base_scale, 1.0),
      scaleAmplitude: toFiniteNumber(params.scale_amplitude, 0.3),
      scaleSpeed: toFiniteNumber(params.scale_speed, 2.0),
      uniformScale: toBoolean(params.uniform_scale, true),
      timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
      isScaling: toBoolean(params.is_scaling, true),
      update(dt) {
        if (!this.enabled || !this.isScaling) return;
        this.timeAccumulator += dt;
        const sine = Math.sin(this.timeAccumulator * this.scaleSpeed * 2 * Math.PI);
        const scaleFactor = this.baseScale + (this.scaleAmplitude * sine);
        if (this.uniformScale) {
          object3d.scale.setScalar(scaleFactor);
        } else {
          object3d.scale.y = scaleFactor;
        }
        markObjectTransformDirty(object3d);
      },
    };
  }

  if (key === "colorchangerscript" || key === "colorchanger") {
    ensureUniqueMaterialsForObject(object3d);
    return {
      name: scriptName,
      key,
      enabled,
      colorSpeed: toFiniteNumber(params.color_speed, 1.0),
      colorMode: String(params.color_mode ?? "rainbow").toLowerCase(),
      baseColor: parseColorVec4(params.base_color, [1, 1, 1, 1]),
      intensity: toFiniteNumber(params.intensity, 1.0),
      timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
      isChanging: toBoolean(params.is_changing, true),
      update(dt) {
        if (!this.enabled || !this.isChanging) return;
        this.timeAccumulator += dt;

        let rgba = this.baseColor.slice();
        if (this.colorMode === "rainbow") {
          const rgb = hueToRgb(this.timeAccumulator * this.colorSpeed);
          rgba = [rgb[0] * this.intensity, rgb[1] * this.intensity, rgb[2] * this.intensity, 1.0];
        } else if (this.colorMode === "pulse") {
          const pulse = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0;
          const m = pulse * this.intensity;
          rgba = [
            this.baseColor[0] * m,
            this.baseColor[1] * m,
            this.baseColor[2] * m,
            this.baseColor[3],
          ];
        } else if (this.colorMode === "fade") {
          const fade = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0;
          rgba = [
            this.baseColor[0],
            this.baseColor[1],
            this.baseColor[2],
            fade * this.intensity,
          ];
        } else if (this.colorMode === "strobe") {
          const safeSpeed = Math.max(0.0001, Math.abs(this.colorSpeed));
          const interval = 1.0 / (safeSpeed * 2.0);
          const on = (Math.floor(this.timeAccumulator / interval) % 2) === 0;
          rgba = on
            ? [
              this.baseColor[0] * this.intensity,
              this.baseColor[1] * this.intensity,
              this.baseColor[2] * this.intensity,
              this.baseColor[3],
            ]
            : [0.1, 0.1, 0.1, this.baseColor[3]];
        }
        applyColorToObject(object3d, rgba);
      },
    };
  }

  if (key === "bouncerscript" || key === "bouncer") {
    return {
      name: scriptName,
      key,
      enabled,
      jumpHeight: toFiniteNumber(params.jump_height, 2.0),
      jumpSpeed: toFiniteNumber(params.jump_speed, 3.0),
      bounceType: String(params.bounce_type ?? "sine").toLowerCase(),
      timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0),
      isBouncing: toBoolean(params.is_bouncing, true),
      bounceDirection: toFiniteNumber(params.bounce_direction, 1) >= 0 ? 1 : -1,
      originalHeight: toFiniteNumber(params.original_y, object3d.position.y),
      update(dt) {
        if (!this.enabled || !this.isBouncing) return;
        this.timeAccumulator += dt * this.bounceDirection;
        let offset = 0;
        const sineValue = Math.sin(this.timeAccumulator * this.jumpSpeed * 2 * Math.PI);
        if (this.bounceType === "sine") {
          offset = sineValue * this.jumpHeight;
        } else if (this.bounceType === "abs_sine") {
          offset = Math.abs(sineValue) * this.jumpHeight;
        } else if (this.bounceType === "square") {
          offset = sineValue > 0 ? this.jumpHeight : 0;
        }
        object3d.position.y = this.originalHeight + offset;
        markObjectTransformDirty(object3d);
      },
    };
  }

  if (key === "followerscript" || key === "follower") {
    const targetHint = Object.prototype.hasOwnProperty.call(params, "target")
      ? params.target
      : (Object.prototype.hasOwnProperty.call(params, "target_ref") ? params.target_ref : null);
    return {
      name: scriptName,
      key,
      enabled,
      followSpeed: Math.max(0, toFiniteNumber(params.follow_speed, 5.0)),
      followDistance: Math.max(0, toFiniteNumber(params.follow_distance, 2.0)),
      isFollowing: toBoolean(params.is_following, true),
      targetHint,
      targetObject: null,
      tempTargetPos: new THREE.Vector3(),
      tempCurrentPos: new THREE.Vector3(),
      tempMoveDir: new THREE.Vector3(),
      update(dt) {
        if (!this.enabled || !this.isFollowing) return;
        if (!this.targetObject && this.targetHint) {
          this.targetObject = resolveScriptNodeRef(this.targetHint, nodeMap, nodeNameLookup);
        }
        if (!this.targetObject) return;

        this.targetObject.getWorldPosition(this.tempTargetPos);
        object3d.getWorldPosition(this.tempCurrentPos);

        this.tempMoveDir.subVectors(this.tempTargetPos, this.tempCurrentPos);
        const distance = this.tempMoveDir.length();
        if (distance > this.followDistance && distance > 1e-6) {
          this.tempMoveDir.normalize();
          const targetFollowPos = this.tempTargetPos.clone().addScaledVector(this.tempMoveDir, -this.followDistance);
          const moveDirection = targetFollowPos.sub(this.tempCurrentPos);
          const moveDistance = moveDirection.length();
          if (moveDistance > 1e-6) {
            moveDirection.normalize();
            const moveAmount = Math.min(this.followSpeed * dt, moveDistance);
            const newWorldPos = this.tempCurrentPos.clone().addScaledVector(moveDirection, moveAmount);
            if (object3d.parent) {
              object3d.parent.worldToLocal(newWorldPos);
            }
            object3d.position.copy(newWorldPos);
          }
        }
        object3d.lookAt(this.tempTargetPos);
        markObjectTransformDirty(object3d);
      },
    };
  }

  if (key === "comboanimatorscript" || key === "comboanimator") {
    return {
      name: scriptName,
      key,
      enabled,
      time: toFiniteNumber(params.time, 0.0),
      isActive: toBoolean(params.is_active, true),
      originalHeight: object3d.position.y,
      update(dt) {
        if (!this.enabled || !this.isActive) return;
        this.time += dt;
        object3d.rotation.y += THREE.MathUtils.degToRad(45.0 * dt);
        object3d.position.y = this.originalHeight + Math.abs(Math.sin(this.time * 3.0));
        markObjectTransformDirty(object3d);
      },
    };
  }

  console.warn("[WebGLPack] Unsupported script in Web viewer:", {
    node: ownerName || object3d?.name || "(unnamed)",
    script: scriptName,
  });
  return null;
}

function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
  const runtimes = [];
  let unsupportedCount = 0;
  const nodeNameLookup = buildNodeNameLookup(nodeMap);

  for (const node of nodes) {
    const obj = nodeMap.get(node?.id);
    if (!obj) continue;
    const scripts = Array.isArray(node?.scripts) ? node.scripts : [];
    for (const scriptCfg of scripts) {
      const state = createScriptState(
        THREE,
        scriptCfg,
        obj,
        nodeMap,
        nodeNameLookup,
        node?.name || node?.id || "",
      );
      if (state) {
        runtimes.push(state);
      } else {
        unsupportedCount += 1;
      }
    }
  }

  return { runtimes, unsupportedCount };
}

async function bootstrap() {
  setStatus("Loading WebGL dependencies...");

  let THREE;
  let OrbitControls;
  let GLTFLoader;

  try {
    THREE = await import("../vendor/three.module.min.js");
    ({ OrbitControls } = await import("../vendor/OrbitControls.js"));
    ({ GLTFLoader } = await import("../vendor/GLTFLoader.js"));
  } catch (err) {
    setStatus(
      [
        "Failed to load local Three.js vendor files.",
        "Please replace vendor placeholders with official files:",
        "- vendor/three.module.min.js",
        "- vendor/OrbitControls.js",
        "- vendor/GLTFLoader.js",
        "",
        String(err),
      ].join("\n"),
      "error",
    );
    throw err;
  }

  setStatus("Loading scene manifest...");

  const response = await fetch("../scene/scene_webgl.json", { cache: "no-cache" });
  if (!response.ok) {
    throw new Error(`Failed to load scene manifest: HTTP ${response.status}`);
  }

  const data = await response.json();
  const sceneNodes = Array.isArray(data.nodes) ? data.nodes : [];

  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true,
    alpha: false,
  });
  renderer.setPixelRatio(window.devicePixelRatio || 1);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x11151c);

  const cameraData = data.camera || {};
  const camera = new THREE.PerspectiveCamera(
    Number(cameraData.fov_deg ?? 80),
    window.innerWidth / Math.max(1, window.innerHeight),
    Number(cameraData.near ?? 0.1),
    Number(cameraData.far ?? 10000),
  );

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.target.set(0, 0, 0);

  const basis = rowMajorToMatrix4(
    THREE,
    Array.isArray(data.coordinate?.basis_matrix)
      ? data.coordinate.basis_matrix
      : [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1],
  );
  const basisInv = basis.clone().invert();
  const matrixConvention = String(data.coordinate?.matrix_convention || "panda_row_vector_row_major");

  if (Array.isArray(cameraData.matrix_local_row_major) && cameraData.matrix_local_row_major.length === 16) {
    const camMat = convertNodeMatrix(
      THREE,
      cameraData.matrix_local_row_major,
      basis,
      basisInv,
      matrixConvention,
    );
    camera.matrix.copy(camMat);
    camera.matrix.decompose(camera.position, camera.quaternion, camera.scale);
    camera.matrixAutoUpdate = true;
    camera.updateMatrix();
  } else {
    camera.position.set(0, -50, 20);
    camera.lookAt(0, 0, 0);
  }

  const env = data.environment || {};
  const rpProfile = (env.render_pipeline && typeof env.render_pipeline === "object")
    ? env.render_pipeline
    : {};
  const renderProfile = applyRenderPipelineApproximation(THREE, renderer, scene, rpProfile);
  const textureLoader = new THREE.TextureLoader();
  const skyboxPromise = applySkyboxConfig(THREE, scene, env, textureLoader);

  if (env.ambient_light) {
    const c = toColorArray(env.ambient_light.color, [0.2, 0.2, 0.2]);
    const amb = new THREE.AmbientLight(new THREE.Color(c[0], c[1], c[2]), Number(env.ambient_light.intensity ?? 1));
    scene.add(amb);
  }

  if (env.directional_light) {
    const c = toColorArray(env.directional_light.color, [0.8, 0.8, 0.8]);
    const dirLight = new THREE.DirectionalLight(
      new THREE.Color(c[0], c[1], c[2]),
      Number(env.directional_light.intensity ?? 1),
    );

    const dir = directionToThree(THREE, env.directional_light.direction, basis);
    dirLight.position.copy(dir.clone().multiplyScalar(-40));
    dirLight.target.position.set(0, 0, 0);
    dirLight.castShadow = !!renderProfile.shadowEnabled;
    if (dirLight.shadow && dirLight.shadow.mapSize) {
      dirLight.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
    }

    scene.add(dirLight);
    scene.add(dirLight.target);
  }

  const nodeMap = new Map();
  const pendingModelLoads = [];
  const animationMixers = [];
  const animationStates = [];

  const gltfLoader = new GLTFLoader();

  for (const node of sceneNodes) {
    let obj;

    if (node.kind === "point_light") {
      const light = node.light || {};
      const c = toColorArray(light.color, [1, 1, 1]);
      obj = new THREE.PointLight(
        new THREE.Color(c[0], c[1], c[2]),
        Number(light.intensity ?? 1),
        Number(light.range ?? 0),
      );
      obj.castShadow = !!renderProfile.shadowEnabled;
      if (obj.shadow && obj.shadow.mapSize) {
        obj.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
      }
    } else if (node.kind === "spot_light") {
      const light = node.light || {};
      const c = toColorArray(light.color, [1, 1, 1]);
      const angle = THREE.MathUtils.degToRad(Number(light.spot_angle_deg ?? 45));
      const spot = new THREE.SpotLight(
        new THREE.Color(c[0], c[1], c[2]),
        Number(light.intensity ?? 1),
        Number(light.range ?? 0),
        angle,
        1 - Number(light.inner_cone_ratio ?? 0.4),
      );
      spot.target.position.set(0, 0, -1);
      spot.castShadow = !!renderProfile.shadowEnabled;
      if (spot.shadow && spot.shadow.mapSize) {
        spot.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution);
      }
      obj = spot;
    } else if (node.kind === "ground") {
      const g = node.ground || {};
      const width = Number(g.width ?? 100);
      const height = Number(g.height ?? 100);
      const m = node.material_override || {};
      const bc = Array.isArray(m.base_color) ? m.base_color : [0.8, 0.8, 0.8, 1];
      const mat = new THREE.MeshStandardMaterial({
        color: new THREE.Color(Number(bc[0] ?? 0.8), Number(bc[1] ?? 0.8), Number(bc[2] ?? 0.8)),
        roughness: Number(m.roughness ?? 1),
        metalness: Number(m.metallic ?? 0),
        transparent: Number(m.opacity ?? 1) < 1,
        opacity: Number(m.opacity ?? 1),
        side: THREE.DoubleSide,
      });
      obj = new THREE.Mesh(new THREE.PlaneGeometry(width, height), mat);
      obj.receiveShadow = !!renderProfile.shadowEnabled;
      obj.castShadow = false;
    } else {
      obj = new THREE.Group();
      const modelUri = node.model?.uri;
      if (modelUri) {
        const p = new Promise((resolve) => {
          gltfLoader.load(
            modelUri,
            (gltf) => {
              const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null);
              if (root) {
                applySubnodeOverrides(
                  THREE,
                  root,
                  node.subnode_overrides || null,
                  basis,
                  basisInv,
                  matrixConvention,
                );
                applyMaterialOverride(THREE, root, node.material_override || null);
                applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader);
                applyShadowFlags(root, renderProfile.shadowEnabled);
                const anim = setupModelAnimation(
                  THREE,
                  root,
                  gltf,
                  node.animation || null,
                  node.name || node.id || "",
                );
                if (anim.mixer) {
                  animationMixers.push(anim.mixer);
                }
                if (anim.clipName) {
                  animationStates.push({
                    node: node.name || node.id || "node",
                    clip: anim.clipName,
                    mode: anim.mode,
                    started: anim.started,
                  });
                }
                obj.add(root);
              }
              resolve();
            },
            undefined,
            (err) => {
              console.warn(`Failed to load model ${modelUri}:`, err);
              resolve();
            },
          );
        });
        pendingModelLoads.push(p);
      }
    }

    obj.name = node.name || node.id || "node";

    if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) {
      const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention);
      obj.matrixAutoUpdate = false;
      obj.matrix.copy(converted);
      obj.matrix.decompose(obj.position, obj.quaternion, obj.scale);
    }

    nodeMap.set(node.id, obj);
  }

  for (const node of sceneNodes) {
    const obj = nodeMap.get(node.id);
    if (!obj) continue;

    const parent = node.parent_id ? nodeMap.get(node.parent_id) : null;
    if (parent) {
      parent.add(obj);
    } else {
      scene.add(obj);
    }

    if (obj.isSpotLight && obj.target) {
      obj.add(obj.target);
    }
  }

  await Promise.all(pendingModelLoads);
  const skyboxState = await skyboxPromise;
  const bloomSetup = await tryCreateBloomComposer(
    THREE,
    renderer,
    scene,
    camera,
    renderProfile.bloom,
  );
  const composer = bloomSetup.composer;
  const scriptRuntime = buildScriptRuntimeStates(THREE, sceneNodes, nodeMap);
  const scriptStates = scriptRuntime.runtimes;
  const unsupportedScriptCount = scriptRuntime.unsupportedCount;

  const resize = () => {
    const w = window.innerWidth;
    const h = Math.max(1, window.innerHeight);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
    if (composer && typeof composer.setSize === "function") {
      composer.setSize(w, h);
    }
  };

  window.addEventListener("resize", resize);
  resize();

  setStatus(
    `Scene ready. Nodes: ${sceneNodes.length}, Animations: ${animationStates.length}, Scripts: ${scriptStates.length}${unsupportedScriptCount > 0 ? ` (unsupported: ${unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`,
    "ok",
  );

  const clock = new THREE.Clock();
  const tick = () => {
    requestAnimationFrame(tick);
    const dt = clock.getDelta();
    if (dt >= 0) {
      for (const mixer of animationMixers) {
        try {
          mixer.update(dt);
        } catch (err) {
          // Keep render loop alive even if one mixer fails.
        }
      }
      scene.updateMatrixWorld(true);
      for (const state of scriptStates) {
        try {
          state.update(dt);
        } catch (err) {
          // Keep render loop alive even if one script fails.
        }
      }
      controls.update();
      if (skyboxState && typeof skyboxState.update === "function") {
        skyboxState.update(camera);
      }
      if (composer) {
        composer.render();
      } else {
        renderer.render(scene, camera);
      }
    }
  };

  tick();
}

bootstrap().catch((err) => {
  console.error(err);
  setStatus(`Viewer bootstrap failed:\n${String(err)}`, "error");
});
