EG/templates/webgl/viewer.js
2026-04-17 14:37:45 +08:00

3245 lines
113 KiB
JavaScript

const statusEl = document.getElementById("status");
const canvas = document.getElementById("scene-canvas");
const VIEWER_MODULE_URL = new URL(import.meta.url);
const PACK_BASE_URL = /\/js\/viewer\.js$/i.test(VIEWER_MODULE_URL.pathname)
? new URL("../", VIEWER_MODULE_URL)
: new URL("./", VIEWER_MODULE_URL);
function setStatus(message, level = "warn") {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = `status ${level}`;
}
function resolvePackUrl(relativePath) {
return new URL(relativePath, PACK_BASE_URL).href;
}
function resolveAssetUrl(rawUrl) {
const text = String(rawUrl || "").trim();
if (!text) return text;
if (/^(?:[a-z]+:)?\/\//i.test(text) || /^(?:data|blob|file):/i.test(text) || text.startsWith("/")) {
return text;
}
return resolvePackUrl(text.replace(/^\.\//, ""));
}
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(resolveAssetUrl(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(resolvePackUrl("vendor/EffectComposer.js")),
import(resolvePackUrl("vendor/RenderPass.js")),
import(resolvePackUrl("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(
resolveAssetUrl(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 createViewerError(code, message, details = null) {
const error = new Error(String(message || code || "Viewer error"));
error.name = "EGWebGLViewerError";
error.code = String(code || "viewer_error");
if (details !== null && details !== undefined) {
error.details = details;
}
return error;
}
function serializeViewerError(error, fallbackCode = "viewer_error") {
if (!error) {
return { code: fallbackCode, message: "Unknown error" };
}
return {
code: String(error.code || fallbackCode),
message: String(error.message || "Unknown error"),
details: error.details ?? null,
};
}
function cloneJsonSafe(value, fallback = null) {
if (value === undefined) return fallback;
try {
return JSON.parse(JSON.stringify(value));
} catch (err) {
return fallback;
}
}
function makeDeferred() {
const deferred = {
promise: null,
resolve: null,
reject: null,
};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
}
function createEventHub() {
const listeners = new Map();
return {
on(eventName, handler) {
const key = String(eventName || "").trim();
if (!key || typeof handler !== "function") {
return () => {};
}
if (!listeners.has(key)) {
listeners.set(key, new Set());
}
listeners.get(key).add(handler);
return () => this.off(key, handler);
},
off(eventName, handler) {
const key = String(eventName || "").trim();
const set = listeners.get(key);
if (!set) return false;
const removed = set.delete(handler);
if (set.size === 0) {
listeners.delete(key);
}
return removed;
},
emit(eventName, payload) {
const key = String(eventName || "").trim();
if (!key) return;
const set = listeners.get(key);
if (!set || set.size === 0) return;
for (const handler of Array.from(set)) {
try {
handler(payload);
} catch (err) {
console.warn("[EGWebGLViewer] Event handler failed:", key, err);
}
}
},
};
}
function createAnimationController(THREE, root, gltf, animationConfig, nodeInfo, emitEvent) {
const config = animationConfig && typeof animationConfig === "object" ? animationConfig : {};
if (config.enabled === false) {
return null;
}
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:", nodeInfo?.name || "(unnamed)");
}
return null;
}
const mixer = new THREE.AnimationMixer(root);
const clipNames = clips.map((clip) => String(clip?.name || "")).filter((name) => !!name);
const initialMode = String(config.mode || "loop").toLowerCase();
const initialSpeedRaw = Number(config.speed ?? 1.0);
const initialSpeed = Number.isFinite(initialSpeedRaw) && Math.abs(initialSpeedRaw) > 1e-4
? initialSpeedRaw
: 1.0;
const state = {
mixer,
clips,
clipNames,
currentClip: null,
currentAction: null,
speed: initialSpeed,
paused: false,
playing: false,
loop: true,
lastMode: initialMode,
};
const normalizeTimeForClip = (clip, timeValue) => {
const duration = Math.max(0.0001, Number(clip?.duration || 0.0001));
const time = Number(timeValue ?? 0);
if (!Number.isFinite(time)) return 0;
if (time < 0) return 0;
return duration > 0 ? (time % duration) : 0;
};
const selectClip = (requestedName) => {
const clip = pickAnimationClip(clips, requestedName || state.currentClip?.name || config.clip_name);
if (!clip) {
throw createViewerError("animation_clip_not_found", "Animation clip not found", {
requested: requestedName || config.clip_name || "",
availableClips: clipNames,
node: nodeInfo?.publicId || nodeInfo?.id || nodeInfo?.name || "",
});
}
return clip;
};
const applyAction = (requestedName, options = {}) => {
const clip = selectClip(requestedName);
const requestedLoop = Object.prototype.hasOwnProperty.call(options, "loop")
? !!options.loop
: !["play", "stop", "pause"].includes(String(options.mode || state.lastMode || initialMode).toLowerCase());
const restart = Object.prototype.hasOwnProperty.call(options, "restart") ? !!options.restart : true;
const pauseAfterSetup = !!options.pause;
const startTime = normalizeTimeForClip(clip, options.startTime);
mixer.stopAllAction();
const action = mixer.clipAction(clip);
action.enabled = true;
action.clampWhenFinished = !requestedLoop;
action.setLoop(requestedLoop ? THREE.LoopRepeat : THREE.LoopOnce, requestedLoop ? Infinity : 1);
action.setEffectiveTimeScale(state.speed);
if (restart) {
action.reset();
}
action.play();
action.time = startTime;
action.paused = pauseAfterSetup;
state.currentClip = clip;
state.currentAction = action;
state.paused = pauseAfterSetup;
state.playing = !pauseAfterSetup;
state.loop = requestedLoop;
state.lastMode = requestedLoop ? "loop" : "play";
return action;
};
const ensureCurrentAction = (requestedName = "") => {
if (state.currentAction && state.currentClip) {
if (!requestedName || String(state.currentClip.name || "") === String(requestedName)) {
return state.currentAction;
}
}
return applyAction(requestedName, {
restart: true,
pause: true,
loop: state.loop,
mode: state.lastMode,
startTime: 0,
});
};
const getSnapshot = () => {
const clip = state.currentClip;
const action = state.currentAction;
return {
available: true,
clip: String(clip?.name || ""),
clipNames: clipNames.slice(),
duration: Math.max(0, Number(clip?.duration || 0)),
time: action ? Math.max(0, Number(action.time || 0)) : 0,
paused: !!state.paused,
playing: !!state.playing,
speed: Number(state.speed || 1),
loop: !!state.loop,
};
};
mixer.addEventListener("finished", () => {
state.playing = false;
state.paused = false;
if (typeof emitEvent === "function") {
emitEvent("animationFinished", {
node: {
id: nodeInfo?.id || "",
publicId: nodeInfo?.publicId || "",
name: nodeInfo?.name || "",
kind: nodeInfo?.kind || "",
},
animation: getSnapshot(),
});
}
});
const controller = {
mixer,
clips,
getSnapshot,
play(options = {}) {
const opts = (options && typeof options === "object") ? options : {};
if (Object.prototype.hasOwnProperty.call(opts, "speed")) {
this.setSpeed(opts.speed);
}
const action = applyAction(opts.clip, {
loop: Object.prototype.hasOwnProperty.call(opts, "loop") ? !!opts.loop : state.loop,
restart: Object.prototype.hasOwnProperty.call(opts, "restart") ? !!opts.restart : true,
pause: false,
startTime: opts.startTime,
mode: Object.prototype.hasOwnProperty.call(opts, "loop") && !opts.loop ? "play" : state.lastMode,
});
action.paused = false;
state.paused = false;
state.playing = true;
return getSnapshot();
},
pause() {
const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || "");
action.paused = true;
state.paused = true;
state.playing = false;
return getSnapshot();
},
stop(options = {}) {
const opts = (options && typeof options === "object") ? options : {};
const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || "");
action.stop();
if (opts.reset !== false) {
action.reset();
action.time = 0;
}
action.paused = true;
state.paused = false;
state.playing = false;
return getSnapshot();
},
seek(timeValue) {
const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || "");
action.time = normalizeTimeForClip(state.currentClip, timeValue);
return getSnapshot();
},
setSpeed(speedValue) {
const numericSpeed = Number(speedValue);
if (!Number.isFinite(numericSpeed) || Math.abs(numericSpeed) < 1e-4) {
throw createViewerError("invalid_animation_speed", "Animation speed must be a finite non-zero number", {
speed: speedValue,
});
}
state.speed = numericSpeed;
if (state.currentAction) {
state.currentAction.setEffectiveTimeScale(state.speed);
}
return getSnapshot();
},
};
const shouldAutoplay = Boolean(config.autoplay ?? !["stop", "pause"].includes(initialMode));
const initialClip = pickAnimationClip(clips, config.clip_name);
if (initialClip && (shouldAutoplay || initialMode === "pause" || initialMode === "stop")) {
applyAction(initialClip.name || "", {
loop: initialMode === "loop",
restart: true,
pause: !shouldAutoplay || initialMode === "pause" || initialMode === "stop",
startTime: config.start_time,
mode: initialMode,
});
if (initialMode === "stop") {
controller.stop({ reset: true });
}
}
return controller;
}
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 vec3ToPlain(vec) {
return [
toFiniteNumber(vec?.x, 0),
toFiniteNumber(vec?.y, 0),
toFiniteNumber(vec?.z, 0),
];
}
function normalizeTripletInput(input, fallback, keyAliases = ["x", "y", "z"]) {
const out = Array.isArray(fallback) ? fallback.slice(0, 3) : [0, 0, 0];
while (out.length < 3) out.push(0);
if (Array.isArray(input)) {
for (let i = 0; i < 3; i += 1) {
if (input[i] !== undefined) {
out[i] = toFiniteNumber(input[i], out[i]);
}
}
return out;
}
if (input && typeof input === "object") {
for (let i = 0; i < 3; i += 1) {
const aliases = Array.isArray(keyAliases?.[i]) ? keyAliases[i] : [keyAliases[i]];
for (const alias of aliases) {
if (Object.prototype.hasOwnProperty.call(input, alias)) {
out[i] = toFiniteNumber(input[alias], out[i]);
break;
}
}
}
}
return out;
}
function pandaPositionToThreeArray(position) {
const value = normalizeTripletInput(position, [0, 0, 0]);
return [value[0], value[2], -value[1]];
}
function threePositionToPandaArray(position) {
const value = normalizeTripletInput(position, [0, 0, 0]);
return [value[0], -value[2], value[1]];
}
function pandaScaleToThreeArray(scale) {
const value = normalizeTripletInput(scale, [1, 1, 1]);
return [value[0], value[2], value[1]];
}
function threeScaleToPandaArray(scale) {
const value = normalizeTripletInput(scale, [1, 1, 1]);
return [value[0], value[2], value[1]];
}
function pandaHprToThreeQuaternion(THREE, hpr) {
const [h, p, r] = normalizeTripletInput(hpr, [0, 0, 0], [["h", "x"], ["p", "y"], ["r", "z"]]);
return new THREE.Quaternion().setFromEuler(new THREE.Euler(
THREE.MathUtils.degToRad(p),
THREE.MathUtils.degToRad(h),
THREE.MathUtils.degToRad(-r),
"YXZ",
));
}
function threeQuaternionToPandaHpr(THREE, quaternion) {
const euler = new THREE.Euler().setFromQuaternion(quaternion, "YXZ");
return [
THREE.MathUtils.radToDeg(euler.y),
THREE.MathUtils.radToDeg(euler.x),
-THREE.MathUtils.radToDeg(euler.z),
];
}
function threeQuaternionToEulerDegrees(THREE, quaternion, order = "XYZ") {
const euler = new THREE.Euler().setFromQuaternion(quaternion, order);
return [
THREE.MathUtils.radToDeg(euler.x),
THREE.MathUtils.radToDeg(euler.y),
THREE.MathUtils.radToDeg(euler.z),
];
}
function threeEulerDegreesToQuaternion(THREE, rotation, order = "XYZ") {
const [x, y, z] = normalizeTripletInput(rotation, [0, 0, 0]);
return new THREE.Quaternion().setFromEuler(new THREE.Euler(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z),
order,
));
}
function readObjectTransformState(THREE, object3d, coordSystem = "panda", space = "local") {
const coord = String(coordSystem || "panda").toLowerCase();
const refSpace = String(space || "local").toLowerCase();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
if (refSpace === "world") {
object3d.getWorldPosition(position);
object3d.getWorldQuaternion(quaternion);
object3d.getWorldScale(scale);
} else {
position.copy(object3d.position);
quaternion.copy(object3d.quaternion);
scale.copy(object3d.scale);
}
if (coord === "three") {
return {
position: vec3ToPlain(position),
rotation: threeQuaternionToEulerDegrees(THREE, quaternion, "XYZ"),
scale: vec3ToPlain(scale),
};
}
return {
position: threePositionToPandaArray(vec3ToPlain(position)),
rotation: threeQuaternionToPandaHpr(THREE, quaternion),
scale: threeScaleToPandaArray(vec3ToPlain(scale)),
};
}
function setObjectWorldTransform(THREE, object3d, worldPosition, worldQuaternion, worldScale) {
const parent = object3d.parent;
if (!parent) {
object3d.position.copy(worldPosition);
object3d.quaternion.copy(worldQuaternion);
object3d.scale.copy(worldScale);
markObjectTransformDirty(object3d);
return;
}
parent.updateMatrixWorld(true);
const parentPosition = new THREE.Vector3();
const parentQuaternion = new THREE.Quaternion();
const parentScale = new THREE.Vector3();
parent.matrixWorld.decompose(parentPosition, parentQuaternion, parentScale);
const localPosition = worldPosition.clone();
parent.worldToLocal(localPosition);
const parentQuaternionInverse = parentQuaternion.clone().invert();
const localQuaternion = parentQuaternionInverse.multiply(worldQuaternion.clone());
const localScale = new THREE.Vector3(
parentScale.x !== 0 ? worldScale.x / parentScale.x : worldScale.x,
parentScale.y !== 0 ? worldScale.y / parentScale.y : worldScale.y,
parentScale.z !== 0 ? worldScale.z / parentScale.z : worldScale.z,
);
object3d.position.copy(localPosition);
object3d.quaternion.copy(localQuaternion);
object3d.scale.copy(localScale);
markObjectTransformDirty(object3d);
}
function applyTransformPatchToEntry(THREE, entry, patch, options = {}) {
const object3d = entry?.obj;
if (!object3d || !object3d.isObject3D) {
throw createViewerError("node_not_ready", "Target node is not ready for transform updates", {
node: entry?.publicId || entry?.id || "",
});
}
const coordSystem = String(options.coordSystem || "panda").toLowerCase();
const space = String(options.space || "local").toLowerCase();
if (!["panda", "three"].includes(coordSystem)) {
throw createViewerError("invalid_coord_system", "coordSystem must be 'panda' or 'three'", {
coordSystem,
});
}
if (!["local", "world"].includes(space)) {
throw createViewerError("invalid_space", "space must be 'local' or 'world'", {
space,
});
}
const current = readObjectTransformState(THREE, object3d, coordSystem, space);
const nextPosition = Object.prototype.hasOwnProperty.call(patch || {}, "position")
? normalizeTripletInput(patch.position, current.position)
: current.position.slice();
const rotationAliases = coordSystem === "panda"
? [["h", "x"], ["p", "y"], ["r", "z"]]
: [["x"], ["y"], ["z"]];
const nextRotation = Object.prototype.hasOwnProperty.call(patch || {}, "rotation")
? normalizeTripletInput(patch.rotation, current.rotation, rotationAliases)
: current.rotation.slice();
const nextScale = Object.prototype.hasOwnProperty.call(patch || {}, "scale")
? normalizeTripletInput(patch.scale, current.scale)
: current.scale.slice();
const targetPositionArray = coordSystem === "three"
? nextPosition
: pandaPositionToThreeArray(nextPosition);
const targetScaleArray = coordSystem === "three"
? nextScale
: pandaScaleToThreeArray(nextScale);
const targetQuaternion = coordSystem === "three"
? threeEulerDegreesToQuaternion(THREE, nextRotation, "XYZ")
: pandaHprToThreeQuaternion(THREE, nextRotation);
const targetPosition = new THREE.Vector3(...targetPositionArray);
const targetScale = new THREE.Vector3(...targetScaleArray);
if (space === "world") {
setObjectWorldTransform(THREE, object3d, targetPosition, targetQuaternion, targetScale);
} else {
object3d.position.copy(targetPosition);
object3d.quaternion.copy(targetQuaternion);
object3d.scale.copy(targetScale);
markObjectTransformDirty(object3d);
}
object3d.updateMatrixWorld(true);
return readObjectTransformState(THREE, object3d, coordSystem, space);
}
function translateEntry(THREE, entry, delta, options = {}) {
const object3d = entry?.obj;
if (!object3d || !object3d.isObject3D) {
throw createViewerError("node_not_ready", "Target node is not ready for movement", {
node: entry?.publicId || entry?.id || "",
});
}
const coordSystem = String(options.coordSystem || "panda").toLowerCase();
const space = String(options.space || "local").toLowerCase();
const current = readObjectTransformState(THREE, object3d, coordSystem, space);
const numericDelta = normalizeTripletInput(delta, [0, 0, 0]);
const nextPosition = [
current.position[0] + numericDelta[0],
current.position[1] + numericDelta[1],
current.position[2] + numericDelta[2],
];
return applyTransformPatchToEntry(THREE, entry, { position: nextPosition }, { coordSystem, space });
}
function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, ownerName) {
const object3d = entry?.obj;
if (!object3d) return null;
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);
const withOwnerState = (state) => ({
ownerId: entry?.id || "",
ownerPublicId: entry?.publicId || "",
...state,
});
if (key === "moverscript" || key === "mover") {
const state = withOwnerState({
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 withOwnerState({
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 withOwnerState({
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 withOwnerState({
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 withOwnerState({
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 withOwnerState({
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 withOwnerState({
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 entry = node;
const obj = entry?.obj || nodeMap.get(entry?.id);
if (!obj || !entry) continue;
entry.scriptStates = [];
entry.unsupportedScripts = [];
const scripts = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
for (const scriptCfg of scripts) {
const state = createScriptState(
THREE,
scriptCfg,
entry,
nodeMap,
nodeNameLookup,
entry?.name || entry?.id || "",
);
if (state) {
entry.scriptStates.push(state);
runtimes.push(state);
} else {
unsupportedCount += 1;
entry.unsupportedScripts.push(String(scriptCfg?.name || ""));
}
}
}
return { runtimes, unsupportedCount };
}
function makeRuntimeEntry(node, obj) {
return {
id: String(node?.id || ""),
publicId: String(node?.public_id || node?.lookup?.public_id || ""),
name: String(node?.name || node?.id || "node"),
kind: String(node?.kind || "node"),
path: String(node?.path || node?.lookup?.path || ""),
parentId: node?.parent_id ? String(node.parent_id) : "",
parentPublicId: node?.parent_public_id ? String(node.parent_public_id) : "",
nodeData: node,
obj,
modelRoot: node?.kind === "model" ? null : obj,
modelLoaded: node?.kind !== "model",
modelError: "",
animationController: null,
scriptStates: [],
unsupportedScripts: [],
};
}
function registerRuntimeEntry(runtime, entry) {
runtime.nodeOrder.push(entry);
runtime.nodesById.set(entry.id, entry);
if (entry.publicId) {
runtime.nodesByPublicId.set(entry.publicId, entry);
}
if (entry.name) {
if (!runtime.nodesByName.has(entry.name)) {
runtime.nodesByName.set(entry.name, []);
}
runtime.nodesByName.get(entry.name).push(entry);
}
}
function buildNodeSelectorPayload(entry) {
return {
id: entry?.id || "",
publicId: entry?.publicId || "",
name: entry?.name || "",
kind: entry?.kind || "",
path: entry?.path || "",
};
}
function buildAnimationState(entry) {
if (entry?.animationController) {
return entry.animationController.getSnapshot();
}
const cfg = (entry?.nodeData?.animation && typeof entry.nodeData.animation === "object") ? entry.nodeData.animation : {};
return {
available: false,
clip: String(cfg.clip_name || ""),
clipNames: [],
duration: 0,
time: 0,
paused: String(cfg.mode || "").toLowerCase() === "pause",
playing: false,
speed: toFiniteNumber(cfg.speed, 1),
loop: String(cfg.mode || "loop").toLowerCase() === "loop",
};
}
function buildScriptStates(entry) {
const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
const runtimeByKey = new Map();
for (const state of entry?.scriptStates || []) {
runtimeByKey.set(normalizeScriptName(state?.name || state?.key || ""), state);
}
const out = [];
for (const scriptCfg of declared) {
const name = String(scriptCfg?.name || "");
const key = normalizeScriptName(name);
const runtimeState = runtimeByKey.get(key);
out.push({
name,
key,
enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true),
supported: !!runtimeState,
});
if (runtimeState) {
runtimeByKey.delete(key);
}
}
for (const runtimeState of runtimeByKey.values()) {
out.push({
name: String(runtimeState?.name || ""),
key: normalizeScriptName(runtimeState?.name || runtimeState?.key || ""),
enabled: !!runtimeState.enabled,
supported: true,
});
}
return out;
}
function buildNodeSummary(entry) {
return {
id: entry?.id || "",
publicId: entry?.publicId || "",
name: entry?.name || "",
kind: entry?.kind || "",
path: entry?.path || "",
parentId: entry?.parentId || "",
parentPublicId: entry?.parentPublicId || "",
visible: !!entry?.obj?.visible,
modelLoaded: !!entry?.modelLoaded,
hasAnimation: !!entry?.animationController || !!entry?.nodeData?.animation,
scriptCount: buildScriptStates(entry).length,
};
}
function buildNodeState(THREE, entry) {
return {
...buildNodeSummary(entry),
transform: {
local: {
panda: readObjectTransformState(THREE, entry.obj, "panda", "local"),
three: readObjectTransformState(THREE, entry.obj, "three", "local"),
},
world: {
panda: readObjectTransformState(THREE, entry.obj, "panda", "world"),
three: readObjectTransformState(THREE, entry.obj, "three", "world"),
},
},
animation: buildAnimationState(entry),
scripts: buildScriptStates(entry),
modelError: entry?.modelError || "",
};
}
function buildSceneInfo(runtime) {
return {
ready: !!runtime?.isReady,
meta: cloneJsonSafe(runtime?.manifest?.meta, {}),
coordinate: cloneJsonSafe(runtime?.manifest?.coordinate, {}),
environment: cloneJsonSafe(runtime?.manifest?.environment, {}),
nodeCount: runtime?.nodeOrder?.length || 0,
unsupportedScriptCount: runtime?.unsupportedScriptCount || 0,
nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)),
};
}
function assertRuntimeReady(runtime) {
if (!runtime?.isReady) {
throw createViewerError("viewer_not_ready", "Viewer is not ready yet. Await window.EGWebGLViewer.ready first.");
}
}
function resolveRuntimeEntry(runtime, selector) {
assertRuntimeReady(runtime);
let publicId = "";
let id = "";
let name = "";
if (typeof selector === "string") {
const text = String(selector).trim();
if (!text) {
throw createViewerError("invalid_selector", "Selector must not be empty");
}
publicId = text;
id = text;
name = text;
} else if (selector && typeof selector === "object") {
publicId = String(selector.publicId || selector.public_id || "").trim();
id = String(selector.id || "").trim();
name = String(selector.name || "").trim();
} else {
throw createViewerError("invalid_selector", "Selector must be a string or object");
}
if (publicId && runtime.nodesByPublicId.has(publicId)) {
return runtime.nodesByPublicId.get(publicId);
}
if (id && runtime.nodesById.has(id)) {
return runtime.nodesById.get(id);
}
if (name) {
const matches = runtime.nodesByName.get(name) || [];
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw createViewerError("ambiguous_node_name", `Multiple nodes matched name '${name}'`, {
name,
matches: matches.map((entry) => buildNodeSelectorPayload(entry)),
});
}
}
throw createViewerError("node_not_found", "Node not found", {
selector: cloneJsonSafe(selector, selector),
});
}
function emitRuntimeEvent(runtime, eventName, payload) {
const eventPayload = {
timestamp: Date.now(),
...cloneJsonSafe(payload, {}),
};
runtime.eventHub.emit(eventName, eventPayload);
if (runtime.bridge && typeof runtime.bridge.emitRemoteEvent === "function") {
runtime.bridge.emitRemoteEvent(eventName, eventPayload);
}
return eventPayload;
}
function reportRuntimeError(runtime, error, context = {}) {
const serialized = serializeViewerError(error);
console.warn("[EGWebGLViewer]", serialized.code, serialized.message, serialized.details || "");
emitRuntimeEvent(runtime, "error", {
error: serialized,
context: cloneJsonSafe(context, {}),
});
return serialized;
}
function runApiAction(runtime, actionName, fn) {
try {
return fn();
} catch (error) {
reportRuntimeError(runtime, error, {
source: "js_api",
action: actionName,
});
throw error;
}
}
function createViewerApi(runtime, THREE) {
const api = {
ready: runtime.readyDeferred.promise,
scene: {
getInfo() {
return runApiAction(runtime, "scene.getInfo", () => {
assertRuntimeReady(runtime);
return buildSceneInfo(runtime);
});
},
},
nodes: {
list() {
return runApiAction(runtime, "nodes.list", () => {
assertRuntimeReady(runtime);
return runtime.nodeOrder.map((entry) => buildNodeSummary(entry));
});
},
getState(selector) {
return runApiAction(runtime, "nodes.getState", () => {
const entry = resolveRuntimeEntry(runtime, selector);
return buildNodeState(THREE, entry);
});
},
setVisible(selector, visible) {
return runApiAction(runtime, "nodes.setVisible", () => {
const entry = resolveRuntimeEntry(runtime, selector);
entry.obj.visible = !!visible;
entry.obj.updateMatrixWorld(true);
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "visibility",
state: result,
});
return result;
});
},
setTransform(selector, patch, options = {}) {
return runApiAction(runtime, "nodes.setTransform", () => {
const entry = resolveRuntimeEntry(runtime, selector);
applyTransformPatchToEntry(THREE, entry, patch || {}, options || {});
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "transform",
state: result,
});
return result;
});
},
translate(selector, delta, options = {}) {
return runApiAction(runtime, "nodes.translate", () => {
const entry = resolveRuntimeEntry(runtime, selector);
translateEntry(THREE, entry, delta || [0, 0, 0], options || {});
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "transform",
state: result,
});
return result;
});
},
setScriptEnabled(selector, enabled, options = {}) {
return runApiAction(runtime, "nodes.setScriptEnabled", () => {
const entry = resolveRuntimeEntry(runtime, selector);
const wantedKey = String(options?.name || "").trim()
? normalizeScriptName(options.name)
: "";
let changed = 0;
for (const state of entry.scriptStates || []) {
if (wantedKey && normalizeScriptName(state?.name || state?.key || "") !== wantedKey) {
continue;
}
state.enabled = !!enabled;
changed += 1;
}
if (wantedKey && changed === 0) {
throw createViewerError("script_not_found", "Requested script was not found on the node", {
node: buildNodeSelectorPayload(entry),
script: options?.name || "",
});
}
if (!wantedKey && changed === 0) {
throw createViewerError("script_not_supported", "Node has no Web-controllable scripts to toggle", {
node: buildNodeSelectorPayload(entry),
});
}
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "script",
state: result,
});
return result;
});
},
},
animation: {
play(selector, options = {}) {
return runApiAction(runtime, "animation.play", () => {
const entry = resolveRuntimeEntry(runtime, selector);
if (!entry.animationController) {
throw createViewerError("animation_not_available", "Node has no controllable animation", {
node: buildNodeSelectorPayload(entry),
});
}
entry.animationController.play(options || {});
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "animation",
state: result,
});
return result;
});
},
pause(selector) {
return runApiAction(runtime, "animation.pause", () => {
const entry = resolveRuntimeEntry(runtime, selector);
if (!entry.animationController) {
throw createViewerError("animation_not_available", "Node has no controllable animation", {
node: buildNodeSelectorPayload(entry),
});
}
entry.animationController.pause();
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "animation",
state: result,
});
return result;
});
},
stop(selector, options = {}) {
return runApiAction(runtime, "animation.stop", () => {
const entry = resolveRuntimeEntry(runtime, selector);
if (!entry.animationController) {
throw createViewerError("animation_not_available", "Node has no controllable animation", {
node: buildNodeSelectorPayload(entry),
});
}
entry.animationController.stop(options || {});
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "animation",
state: result,
});
return result;
});
},
seek(selector, time) {
return runApiAction(runtime, "animation.seek", () => {
const entry = resolveRuntimeEntry(runtime, selector);
if (!entry.animationController) {
throw createViewerError("animation_not_available", "Node has no controllable animation", {
node: buildNodeSelectorPayload(entry),
});
}
entry.animationController.seek(time);
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "animation",
state: result,
});
return result;
});
},
setSpeed(selector, speed) {
return runApiAction(runtime, "animation.setSpeed", () => {
const entry = resolveRuntimeEntry(runtime, selector);
if (!entry.animationController) {
throw createViewerError("animation_not_available", "Node has no controllable animation", {
node: buildNodeSelectorPayload(entry),
});
}
entry.animationController.setSpeed(speed);
const result = buildNodeState(THREE, entry);
emitRuntimeEvent(runtime, "stateChanged", {
node: buildNodeSelectorPayload(entry),
changeType: "animation",
state: result,
});
return result;
});
},
},
events: {
on(eventName, handler) {
return runtime.eventHub.on(eventName, handler);
},
off(eventName, handler) {
return runtime.eventHub.off(eventName, handler);
},
},
};
window.EGWebGLViewer = api;
return api;
}
function createMessageBridge(runtime, api) {
const commandHandlers = new Map([
["scene.getInfo", () => api.scene.getInfo()],
["nodes.list", () => api.nodes.list()],
["nodes.getState", (payload) => api.nodes.getState(payload?.selector ?? payload)],
["nodes.setVisible", (payload) => api.nodes.setVisible(payload?.selector, payload?.visible)],
["nodes.setTransform", (payload) => api.nodes.setTransform(payload?.selector, payload?.patch ?? payload?.transform ?? {}, payload?.options ?? {})],
["nodes.translate", (payload) => api.nodes.translate(payload?.selector, payload?.delta ?? payload?.translation ?? [0, 0, 0], payload?.options ?? {})],
["nodes.setScriptEnabled", (payload) => api.nodes.setScriptEnabled(payload?.selector, payload?.enabled, payload?.options ?? {})],
["animation.play", (payload) => api.animation.play(payload?.selector, payload?.options ?? {})],
["animation.pause", (payload) => api.animation.pause(payload?.selector)],
["animation.stop", (payload) => api.animation.stop(payload?.selector, payload?.options ?? {})],
["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)],
["animation.setSpeed", (payload) => api.animation.setSpeed(payload?.selector, payload?.speed)],
]);
let trustedOrigin = "";
const sendEnvelope = (targetWindow, targetOrigin, envelope) => {
if (!targetWindow || typeof targetWindow.postMessage !== "function") return false;
try {
targetWindow.postMessage(envelope, targetOrigin || "*");
return true;
} catch (err) {
console.warn("[EGWebGLViewer] postMessage failed:", err);
return false;
}
};
const sendResponse = (targetWindow, targetOrigin, replyTo, ok, result, error) => {
const envelope = {
source: "eg-webgl",
type: "response",
replyTo: replyTo ?? null,
ok: !!ok,
};
if (ok) {
envelope.result = cloneJsonSafe(result, null);
} else {
envelope.error = serializeViewerError(error);
}
sendEnvelope(targetWindow, targetOrigin, envelope);
};
const emitRemoteEvent = (eventName, payload) => {
if (!trustedOrigin || window.parent === window) return false;
return sendEnvelope(window.parent, trustedOrigin, {
source: "eg-webgl",
type: "event",
event: String(eventName || ""),
payload: cloneJsonSafe(payload, {}),
});
};
const onMessage = async (event) => {
const data = event?.data;
if (!data || typeof data !== "object") return;
if (String(data.source || "") !== "eg-frontend") return;
if (String(data.type || "") === "handshake") {
if (!trustedOrigin) {
trustedOrigin = String(event.origin || "");
}
if (String(event.origin || "") !== trustedOrigin) {
sendResponse(
event.source,
event.origin,
data.id,
false,
null,
createViewerError("untrusted_origin", "Only the first handshake origin is allowed", {
trustedOrigin,
receivedOrigin: event.origin,
}),
);
return;
}
sendResponse(event.source, event.origin, data.id, true, {
accepted: true,
apiVersion: runtime?.manifest?.meta?.api_version ?? 1,
ready: !!runtime?.isReady,
transports: cloneJsonSafe(runtime?.manifest?.meta?.transports, ["js_api", "postMessage"]),
});
if (runtime?.isReady) {
emitRemoteEvent("ready", buildSceneInfo(runtime));
}
return;
}
if (String(data.type || "") !== "command") return;
if (!trustedOrigin) {
sendResponse(
event.source,
event.origin,
data.id,
false,
null,
createViewerError("handshake_required", "Send a handshake message before issuing commands"),
);
return;
}
if (String(event.origin || "") !== trustedOrigin) {
sendResponse(
event.source,
event.origin,
data.id,
false,
null,
createViewerError("untrusted_origin", "Command origin does not match the trusted handshake origin", {
trustedOrigin,
receivedOrigin: event.origin,
}),
);
return;
}
const command = String(data.command || "").trim();
const handler = commandHandlers.get(command);
if (!handler) {
sendResponse(
event.source,
event.origin,
data.id,
false,
null,
createViewerError("unknown_command", `Unknown command '${command}'`, {
command,
}),
);
return;
}
try {
const result = await handler(data.payload || {});
sendResponse(event.source, event.origin, data.id, true, result, null);
} catch (error) {
reportRuntimeError(runtime, error, { command });
sendResponse(event.source, event.origin, data.id, false, null, error);
}
};
window.addEventListener("message", onMessage);
return {
emitRemoteEvent,
dispose() {
window.removeEventListener("message", onMessage);
},
};
}
let activeViewerRuntime = null;
async function bootstrap() {
setStatus("Loading WebGL dependencies...");
let THREE;
let OrbitControls;
let GLTFLoader;
try {
THREE = await import(resolvePackUrl("vendor/three.module.min.js"));
({ OrbitControls } = await import(resolvePackUrl("vendor/OrbitControls.js")));
({ GLTFLoader } = await import(resolvePackUrl("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...");
let response;
try {
response = await fetch(resolvePackUrl("scene/scene_webgl.json"), { cache: "no-cache" });
} catch (err) {
const message = window.location.protocol === "file:"
? "Failed to load scene manifest. If you opened the page directly from the filesystem, please preview it through a local HTTP server or open a packaged export directory."
: "Failed to load scene manifest.";
throw createViewerError("manifest_fetch_failed", message, {
cause: String(err?.message || err),
url: resolvePackUrl("scene/scene_webgl.json"),
});
}
if (!response.ok) {
throw createViewerError("manifest_load_failed", `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 runtime = {
manifest: data,
scene,
camera,
controls,
renderer,
basis,
basisInv,
matrixConvention,
nodeOrder: [],
nodesById: new Map(),
nodesByPublicId: new Map(),
nodesByName: new Map(),
eventHub: createEventHub(),
readyDeferred: makeDeferred(),
animationControllers: [],
unsupportedScriptCount: 0,
isReady: false,
bridge: null,
};
activeViewerRuntime = runtime;
const api = createViewerApi(runtime, THREE);
runtime.bridge = createMessageBridge(runtime, api);
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 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();
}
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(String(node.id || ""), obj);
const entry = makeRuntimeEntry(node, obj);
registerRuntimeEntry(runtime, entry);
if (node.kind === "model") {
const modelUri = node.model?.uri;
if (modelUri) {
const pendingLoad = new Promise((resolve) => {
gltfLoader.load(
resolveAssetUrl(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);
entry.modelRoot = root;
entry.modelLoaded = true;
const controller = createAnimationController(
THREE,
root,
gltf,
node.animation || null,
buildNodeSelectorPayload(entry),
(eventName, payload) => emitRuntimeEvent(runtime, eventName, payload),
);
if (controller) {
entry.animationController = controller;
runtime.animationControllers.push(controller);
}
obj.add(root);
}
resolve();
},
undefined,
(err) => {
entry.modelLoaded = false;
entry.modelError = String(err || "model_load_failed");
console.warn(`Failed to load model ${modelUri}:`, err);
resolve();
},
);
});
pendingModelLoads.push(pendingLoad);
}
}
}
for (const entry of runtime.nodeOrder) {
const parent = entry.parentId ? nodeMap.get(entry.parentId) : null;
if (parent) {
parent.add(entry.obj);
} else {
scene.add(entry.obj);
}
if (entry.obj.isSpotLight && entry.obj.target) {
entry.obj.add(entry.obj.target);
}
}
await Promise.all(pendingModelLoads);
const skyboxState = await skyboxPromise;
const bloomSetup = await tryCreateBloomComposer(
THREE,
renderer,
scene,
camera,
renderProfile.bloom,
);
const composer = bloomSetup.composer;
runtime.composer = composer;
const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, nodeMap);
const scriptStates = scriptRuntime.runtimes;
runtime.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();
runtime.isReady = true;
const readyInfo = buildSceneInfo(runtime);
runtime.readyDeferred.resolve(readyInfo);
emitRuntimeEvent(runtime, "ready", readyInfo);
setStatus(
`Scene ready. Nodes: ${sceneNodes.length}, Animations: ${runtime.animationControllers.length}, Scripts: ${scriptStates.length}${runtime.unsupportedScriptCount > 0 ? ` (unsupported: ${runtime.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 controller of runtime.animationControllers) {
try {
controller.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);
if (activeViewerRuntime?.readyDeferred?.reject) {
activeViewerRuntime.readyDeferred.reject(err);
}
if (activeViewerRuntime) {
reportRuntimeError(activeViewerRuntime, err, { source: "bootstrap" });
}
setStatus(`Viewer bootstrap failed:\n${String(err?.message || err)}`, "error");
if (window.EGWebGLViewer && window.EGWebGLViewer.events && typeof window.EGWebGLViewer.events.on === "function") {
// no-op: keep API surface intact when bootstrap fails later
}
});