3649 lines
126 KiB
JavaScript
3649 lines
126 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 buildSupportedScriptCatalog() {
|
|
const descriptors = [
|
|
{
|
|
key: "moverscript",
|
|
name: "MoverScript",
|
|
aliases: ["mover"],
|
|
summary: "Move the node back and forth along one Panda axis.",
|
|
defaultParams: {
|
|
move_axis: "x",
|
|
move_distance: 5.0,
|
|
move_speed: 2.0,
|
|
current_direction: 1,
|
|
current_distance: 0.0,
|
|
is_moving: true,
|
|
},
|
|
},
|
|
{
|
|
key: "rotatorscript",
|
|
name: "RotatorScript",
|
|
aliases: ["rotator"],
|
|
summary: "Rotate the node continuously around the Y axis in Web view.",
|
|
defaultParams: {
|
|
rotation_speed_y: 30.0,
|
|
is_rotating: true,
|
|
},
|
|
},
|
|
{
|
|
key: "scalerscript",
|
|
name: "ScalerScript",
|
|
aliases: ["scaler"],
|
|
summary: "Animate the node scale with a sine wave.",
|
|
defaultParams: {
|
|
base_scale: 1.0,
|
|
scale_amplitude: 0.3,
|
|
scale_speed: 2.0,
|
|
uniform_scale: true,
|
|
time_accumulator: 0.0,
|
|
is_scaling: true,
|
|
},
|
|
},
|
|
{
|
|
key: "colorchangerscript",
|
|
name: "ColorChangerScript",
|
|
aliases: ["colorchanger"],
|
|
summary: "Animate mesh material color or opacity over time.",
|
|
defaultParams: {
|
|
color_speed: 1.0,
|
|
color_mode: "rainbow",
|
|
base_color: [1, 1, 1, 1],
|
|
intensity: 1.0,
|
|
time_accumulator: 0.0,
|
|
is_changing: true,
|
|
},
|
|
},
|
|
{
|
|
key: "bouncerscript",
|
|
name: "BouncerScript",
|
|
aliases: ["bouncer"],
|
|
summary: "Bounce the node vertically using a simple waveform.",
|
|
defaultParams: {
|
|
jump_height: 2.0,
|
|
jump_speed: 3.0,
|
|
bounce_type: "sine",
|
|
time_accumulator: 0.0,
|
|
is_bouncing: true,
|
|
bounce_direction: 1,
|
|
},
|
|
},
|
|
{
|
|
key: "followerscript",
|
|
name: "FollowerScript",
|
|
aliases: ["follower"],
|
|
summary: "Follow another exported node selected by id/publicId/name reference.",
|
|
defaultParams: {
|
|
follow_speed: 5.0,
|
|
follow_distance: 2.0,
|
|
is_following: true,
|
|
target_ref: null,
|
|
},
|
|
},
|
|
{
|
|
key: "comboanimatorscript",
|
|
name: "ComboAnimatorScript",
|
|
aliases: ["comboanimator"],
|
|
summary: "Rotate and bounce the node using a built-in Web combo animation.",
|
|
defaultParams: {
|
|
time: 0.0,
|
|
is_active: true,
|
|
},
|
|
},
|
|
];
|
|
|
|
const catalog = new Map();
|
|
for (const descriptor of descriptors) {
|
|
const aliases = Array.from(new Set([
|
|
descriptor.key,
|
|
descriptor.name,
|
|
...(Array.isArray(descriptor.aliases) ? descriptor.aliases : []),
|
|
].map((value) => normalizeScriptName(value)).filter((value) => !!value)));
|
|
const normalized = {
|
|
key: normalizeScriptName(descriptor.key || descriptor.name || ""),
|
|
name: String(descriptor.name || descriptor.key || ""),
|
|
aliases,
|
|
summary: String(descriptor.summary || ""),
|
|
defaultParams: cloneJsonSafe(descriptor.defaultParams, {}),
|
|
};
|
|
for (const alias of aliases) {
|
|
catalog.set(alias, normalized);
|
|
}
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
const WEB_SUPPORTED_SCRIPT_CATALOG = buildSupportedScriptCatalog();
|
|
|
|
function getWebSupportedScriptDescriptor(scriptName) {
|
|
return WEB_SUPPORTED_SCRIPT_CATALOG.get(normalizeScriptName(scriptName)) || null;
|
|
}
|
|
|
|
function listWebSupportedScripts() {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const descriptor of WEB_SUPPORTED_SCRIPT_CATALOG.values()) {
|
|
if (!descriptor?.key || seen.has(descriptor.key)) continue;
|
|
seen.add(descriptor.key);
|
|
out.push({
|
|
key: descriptor.key,
|
|
name: descriptor.name,
|
|
aliases: Array.isArray(descriptor.aliases) ? descriptor.aliases.slice() : [],
|
|
summary: descriptor.summary || "",
|
|
defaultParams: cloneJsonSafe(descriptor.defaultParams, {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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 || "",
|
|
params: cloneJsonSafe(params, {}),
|
|
...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, nodeNameLookup = buildNodeNameLookup(nodeMap)) {
|
|
const runtimes = [];
|
|
let unsupportedCount = 0;
|
|
|
|
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 ensureDeclaredScripts(entry) {
|
|
if (!entry?.nodeData || typeof entry.nodeData !== "object") {
|
|
throw createViewerError("invalid_node_data", "Node data is unavailable for script operations", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
});
|
|
}
|
|
if (!Array.isArray(entry.nodeData.scripts)) {
|
|
entry.nodeData.scripts = [];
|
|
}
|
|
return entry.nodeData.scripts;
|
|
}
|
|
|
|
function findDeclaredScriptIndex(entry, scriptNameOrKey) {
|
|
const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
|
|
const wantedKey = normalizeScriptName(scriptNameOrKey);
|
|
if (!wantedKey) return -1;
|
|
for (let i = 0; i < declared.length; i += 1) {
|
|
const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || "");
|
|
if (key === wantedKey) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function normalizeScriptConfigInput(scriptInput, fallbackName = "") {
|
|
if (typeof scriptInput === "string") {
|
|
const trimmed = String(scriptInput).trim();
|
|
if (!trimmed) {
|
|
throw createViewerError("invalid_script_config", "Script name must not be empty");
|
|
}
|
|
return { name: trimmed, enabled: true, params: {} };
|
|
}
|
|
|
|
if (!scriptInput || typeof scriptInput !== "object") {
|
|
throw createViewerError("invalid_script_config", "Script config must be a string or object");
|
|
}
|
|
|
|
const name = String(scriptInput.name || fallbackName || "").trim();
|
|
if (!name) {
|
|
throw createViewerError("invalid_script_config", "Script config must include a name");
|
|
}
|
|
|
|
const params = (scriptInput.params && typeof scriptInput.params === "object")
|
|
? cloneJsonSafe(scriptInput.params, {})
|
|
: {};
|
|
|
|
const out = {
|
|
name,
|
|
params,
|
|
};
|
|
|
|
if (Object.prototype.hasOwnProperty.call(scriptInput, "enabled")) {
|
|
out.enabled = toBoolean(scriptInput.enabled, true);
|
|
}
|
|
|
|
for (const key of ["file", "project_relative_path", "script_guid", "relative_path", "path"]) {
|
|
const value = String(scriptInput[key] || "").trim();
|
|
if (value) {
|
|
out[key] = value;
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function mergeScriptConfig(existingConfig, nextConfig) {
|
|
const existing = (existingConfig && typeof existingConfig === "object") ? existingConfig : {};
|
|
const next = normalizeScriptConfigInput(nextConfig, existing?.name || "");
|
|
return {
|
|
...cloneJsonSafe(existing, {}),
|
|
...next,
|
|
name: next.name || String(existing?.name || ""),
|
|
enabled: Object.prototype.hasOwnProperty.call(next, "enabled")
|
|
? !!next.enabled
|
|
: toBoolean(existing?.enabled, true),
|
|
params: {
|
|
...(existing?.params && typeof existing.params === "object" ? cloneJsonSafe(existing.params, {}) : {}),
|
|
...(next?.params && typeof next.params === "object" ? cloneJsonSafe(next.params, {}) : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function refreshUnsupportedScriptStats(runtime) {
|
|
let unsupportedCount = 0;
|
|
for (const entry of runtime?.nodeOrder || []) {
|
|
const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
|
|
entry.unsupportedScripts = [];
|
|
for (const scriptCfg of declared) {
|
|
if (getWebSupportedScriptDescriptor(scriptCfg?.name || scriptCfg?.key || "")) {
|
|
continue;
|
|
}
|
|
unsupportedCount += 1;
|
|
entry.unsupportedScripts.push(String(scriptCfg?.name || ""));
|
|
}
|
|
}
|
|
runtime.unsupportedScriptCount = unsupportedCount;
|
|
return unsupportedCount;
|
|
}
|
|
|
|
function removeScriptRuntime(runtime, entry, scriptNameOrKey) {
|
|
const wantedKey = normalizeScriptName(scriptNameOrKey);
|
|
if (!wantedKey) return 0;
|
|
let removed = 0;
|
|
|
|
if (Array.isArray(entry?.scriptStates)) {
|
|
for (let i = entry.scriptStates.length - 1; i >= 0; i -= 1) {
|
|
const state = entry.scriptStates[i];
|
|
const stateKey = normalizeScriptName(state?.name || state?.key || "");
|
|
if (stateKey === wantedKey) {
|
|
entry.scriptStates.splice(i, 1);
|
|
removed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(runtime?.scriptRuntimes)) {
|
|
for (let i = runtime.scriptRuntimes.length - 1; i >= 0; i -= 1) {
|
|
const state = runtime.scriptRuntimes[i];
|
|
const stateKey = normalizeScriptName(state?.name || state?.key || "");
|
|
const sameOwner = String(state?.ownerId || "") === String(entry?.id || "");
|
|
if (sameOwner && stateKey === wantedKey) {
|
|
runtime.scriptRuntimes.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function instantiateScriptRuntime(THREE, runtime, entry, scriptCfg) {
|
|
const state = createScriptState(
|
|
THREE,
|
|
scriptCfg,
|
|
entry,
|
|
runtime?.nodeMap || new Map(),
|
|
runtime?.nodeNameLookup || buildNodeNameLookup(runtime?.nodeMap || new Map()),
|
|
entry?.name || entry?.id || "",
|
|
);
|
|
if (!state) {
|
|
throw createViewerError("script_not_supported", "Script is not supported in the Web viewer", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
script: scriptCfg?.name || "",
|
|
supportedScripts: listWebSupportedScripts(),
|
|
});
|
|
}
|
|
entry.scriptStates.push(state);
|
|
if (Array.isArray(runtime?.scriptRuntimes)) {
|
|
runtime.scriptRuntimes.push(state);
|
|
}
|
|
refreshUnsupportedScriptStats(runtime);
|
|
return state;
|
|
}
|
|
|
|
function attachScriptToEntry(THREE, runtime, entry, scriptInput) {
|
|
const nextConfig = normalizeScriptConfigInput(scriptInput);
|
|
const descriptor = getWebSupportedScriptDescriptor(nextConfig.name);
|
|
if (!descriptor) {
|
|
throw createViewerError("script_not_supported", "Requested script cannot run in the Web viewer", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
script: nextConfig.name,
|
|
supportedScripts: listWebSupportedScripts(),
|
|
});
|
|
}
|
|
|
|
const declared = ensureDeclaredScripts(entry);
|
|
const existingIndex = findDeclaredScriptIndex(entry, nextConfig.name);
|
|
const mergedConfig = existingIndex >= 0
|
|
? mergeScriptConfig(declared[existingIndex], { ...nextConfig, name: descriptor.name })
|
|
: mergeScriptConfig({ name: descriptor.name, enabled: true, params: descriptor.defaultParams }, nextConfig);
|
|
|
|
if (existingIndex >= 0) {
|
|
declared.splice(existingIndex, 1, mergedConfig);
|
|
} else {
|
|
declared.push(mergedConfig);
|
|
}
|
|
|
|
removeScriptRuntime(runtime, entry, descriptor.key);
|
|
instantiateScriptRuntime(THREE, runtime, entry, mergedConfig);
|
|
return mergedConfig;
|
|
}
|
|
|
|
function detachScriptFromEntry(runtime, entry, scriptNameOrKey) {
|
|
const wantedKey = normalizeScriptName(scriptNameOrKey);
|
|
if (!wantedKey) {
|
|
throw createViewerError("invalid_script_config", "Script name must not be empty");
|
|
}
|
|
|
|
const declared = ensureDeclaredScripts(entry);
|
|
let removedDeclared = 0;
|
|
for (let i = declared.length - 1; i >= 0; i -= 1) {
|
|
const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || "");
|
|
if (key === wantedKey) {
|
|
declared.splice(i, 1);
|
|
removedDeclared += 1;
|
|
}
|
|
}
|
|
|
|
const removedRuntime = removeScriptRuntime(runtime, entry, wantedKey);
|
|
refreshUnsupportedScriptStats(runtime);
|
|
|
|
if (removedDeclared === 0 && removedRuntime === 0) {
|
|
throw createViewerError("script_not_found", "Requested script was not found on the node", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
script: scriptNameOrKey,
|
|
});
|
|
}
|
|
|
|
return removedDeclared + removedRuntime;
|
|
}
|
|
|
|
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);
|
|
const descriptor = getWebSupportedScriptDescriptor(name || key);
|
|
out.push({
|
|
name,
|
|
key,
|
|
enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true),
|
|
supported: !!runtimeState,
|
|
webSupported: !!descriptor,
|
|
params: cloneJsonSafe(scriptCfg?.params ?? runtimeState?.params ?? {}, {}),
|
|
file: String(scriptCfg?.file || ""),
|
|
projectRelativePath: String(scriptCfg?.project_relative_path || ""),
|
|
scriptGuid: String(scriptCfg?.script_guid || ""),
|
|
});
|
|
if (runtimeState) {
|
|
runtimeByKey.delete(key);
|
|
}
|
|
}
|
|
|
|
for (const runtimeState of runtimeByKey.values()) {
|
|
const runtimeName = String(runtimeState?.name || "");
|
|
const runtimeKey = normalizeScriptName(runtimeName || runtimeState?.key || "");
|
|
const descriptor = getWebSupportedScriptDescriptor(runtimeName || runtimeKey);
|
|
out.push({
|
|
name: runtimeName,
|
|
key: runtimeKey,
|
|
enabled: !!runtimeState.enabled,
|
|
supported: true,
|
|
webSupported: !!descriptor,
|
|
params: cloneJsonSafe(runtimeState?.params ?? {}, {}),
|
|
file: "",
|
|
projectRelativePath: "",
|
|
scriptGuid: "",
|
|
});
|
|
}
|
|
|
|
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,
|
|
webSupportedScripts: listWebSupportedScripts(),
|
|
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;
|
|
});
|
|
},
|
|
},
|
|
scripts: {
|
|
listSupported() {
|
|
return runApiAction(runtime, "scripts.listSupported", () => {
|
|
return listWebSupportedScripts();
|
|
});
|
|
},
|
|
attach(selector, script) {
|
|
return runApiAction(runtime, "scripts.attach", () => {
|
|
const entry = resolveRuntimeEntry(runtime, selector);
|
|
attachScriptToEntry(THREE, runtime, entry, script);
|
|
const result = buildNodeState(THREE, entry);
|
|
emitRuntimeEvent(runtime, "stateChanged", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
changeType: "script",
|
|
state: result,
|
|
});
|
|
return result;
|
|
});
|
|
},
|
|
detach(selector, scriptNameOrKey) {
|
|
return runApiAction(runtime, "scripts.detach", () => {
|
|
const entry = resolveRuntimeEntry(runtime, selector);
|
|
detachScriptFromEntry(runtime, entry, scriptNameOrKey);
|
|
const result = buildNodeState(THREE, entry);
|
|
emitRuntimeEvent(runtime, "stateChanged", {
|
|
node: buildNodeSelectorPayload(entry),
|
|
changeType: "script",
|
|
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)],
|
|
["scripts.listSupported", () => api.scripts.listSupported()],
|
|
["scripts.attach", (payload) => api.scripts.attach(payload?.selector, payload?.script ?? payload)],
|
|
["scripts.detach", (payload) => api.scripts.detach(payload?.selector, payload?.name ?? payload?.key ?? payload?.script ?? "")],
|
|
]);
|
|
|
|
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(),
|
|
nodeMap: new Map(),
|
|
nodeNameLookup: new Map(),
|
|
eventHub: createEventHub(),
|
|
readyDeferred: makeDeferred(),
|
|
animationControllers: [],
|
|
scriptRuntimes: [],
|
|
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);
|
|
runtime.nodeMap.set(String(node.id || ""), obj);
|
|
|
|
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;
|
|
|
|
runtime.nodeNameLookup = buildNodeNameLookup(runtime.nodeMap);
|
|
const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, runtime.nodeMap, runtime.nodeNameLookup);
|
|
const scriptStates = scriptRuntime.runtimes;
|
|
runtime.scriptRuntimes = scriptStates;
|
|
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 runtime.scriptRuntimes) {
|
|
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
|
|
}
|
|
});
|