EG/templates/webgl/viewer.js
2026-03-24 08:54:28 +08:00

2086 lines
75 KiB
JavaScript

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");
});