const statusEl = document.getElementById("status"); const canvas = document.getElementById("scene-canvas"); function setStatus(message, level = "warn") { if (!statusEl) return; statusEl.textContent = message; statusEl.className = `status ${level}`; } function rowMajorToMatrix4(THREE, m) { const mat = new THREE.Matrix4(); mat.set( m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15], ); return mat; } function pandaRowMajorToMatrix4(THREE, m) { const mat = new THREE.Matrix4(); // Panda's matrix data uses row-vector convention (translation on last row). // Three.js expects column-vector convention (translation on last column). mat.set( m[0], m[4], m[8], m[12], m[1], m[5], m[9], m[13], m[2], m[6], m[10], m[14], m[3], m[7], m[11], m[15], ); return mat; } function convertNodeMatrix(THREE, sourceMatRowMajor, basis, basisInv, matrixConvention = "panda_row_vector_row_major") { const src = matrixConvention === "panda_row_vector_row_major" ? pandaRowMajorToMatrix4(THREE, sourceMatRowMajor) : rowMajorToMatrix4(THREE, sourceMatRowMajor); return basis.clone().multiply(src).multiply(basisInv); } function toColorArray(color, fallback = [1, 1, 1]) { if (!Array.isArray(color) || color.length < 3) return fallback; return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0]; } function applyMaterialOverride(THREE, root, override) { if (!override) return; root.traverse((obj) => { if (!obj.isMesh || !obj.material) return; const list = Array.isArray(obj.material) ? obj.material : [obj.material]; for (const mat of list) { if (mat.color && Array.isArray(override.base_color)) { const [r, g, b] = override.base_color; mat.color.setRGB(r ?? 1, g ?? 1, b ?? 1); } if (Object.prototype.hasOwnProperty.call(override, "roughness") && "roughness" in mat) { mat.roughness = Number(override.roughness); } if (Object.prototype.hasOwnProperty.call(override, "metallic") && "metalness" in mat) { mat.metalness = Number(override.metallic); } if (Object.prototype.hasOwnProperty.call(override, "opacity")) { const opacity = THREE.MathUtils.clamp(Number(override.opacity), 0, 1); const isTransparent = opacity < 0.999; mat.opacity = opacity; mat.transparent = isTransparent; // Prevent "see-through solid mesh" when source GLTF had transparent pipeline state. mat.depthWrite = !isTransparent; mat.depthTest = true; mat.blending = isTransparent ? THREE.NormalBlending : THREE.NoBlending; if (!isTransparent && "alphaTest" in mat) { mat.alphaTest = 0; } if (!isTransparent && "transmission" in mat) { mat.transmission = 0; } } mat.needsUpdate = true; } }); } function textureSlotByStage(stageName) { const key = String(stageName || "").toLowerCase(); if (key.includes("normal")) return "normalMap"; if (key.includes("rough")) return "roughnessMap"; if (key.includes("metal")) return "metalnessMap"; if (key.includes("emission") || key.includes("emissive")) return "emissiveMap"; if (key.includes("ao")) return "aoMap"; if (key.includes("alpha") || key.includes("opacity")) return "alphaMap"; return "map"; } function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) { if (!Array.isArray(textureOverrides) || textureOverrides.length === 0) return; const texBySlot = new Map(); for (const item of textureOverrides) { if (!item || !item.uri) continue; const slot = textureSlotByStage(item.stage); if (texBySlot.has(slot)) continue; try { const tex = textureLoader.load(item.uri); tex.flipY = false; texBySlot.set(slot, tex); } catch (err) { console.warn("Texture load failed:", item.uri, err); } } if (texBySlot.size === 0) return; root.traverse((obj) => { if (!obj.isMesh || !obj.material) return; const list = Array.isArray(obj.material) ? obj.material : [obj.material]; for (const mat of list) { for (const [slot, tex] of texBySlot.entries()) { if (slot in mat) { mat[slot] = tex; } } mat.needsUpdate = true; } }); } function 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(); } async function bootstrap() { setStatus("Loading WebGL dependencies..."); let THREE; let OrbitControls; let GLTFLoader; try { THREE = await import("../vendor/three.module.min.js"); ({ OrbitControls } = await import("../vendor/OrbitControls.js")); ({ GLTFLoader } = await import("../vendor/GLTFLoader.js")); } catch (err) { setStatus( [ "Failed to load local Three.js vendor files.", "Please replace vendor placeholders with official files:", "- vendor/three.module.min.js", "- vendor/OrbitControls.js", "- vendor/GLTFLoader.js", "", String(err), ].join("\n"), "error", ); throw err; } setStatus("Loading scene manifest..."); const response = await fetch("../scene/scene_webgl.json", { cache: "no-cache" }); if (!response.ok) { throw new Error(`Failed to load scene manifest: HTTP ${response.status}`); } const data = await response.json(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, }); renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x11151c); const cameraData = data.camera || {}; const camera = new THREE.PerspectiveCamera( Number(cameraData.fov_deg ?? 80), window.innerWidth / Math.max(1, window.innerHeight), Number(cameraData.near ?? 0.1), Number(cameraData.far ?? 10000), ); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.target.set(0, 0, 0); const basis = rowMajorToMatrix4( THREE, Array.isArray(data.coordinate?.basis_matrix) ? data.coordinate.basis_matrix : [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1], ); const basisInv = basis.clone().invert(); const matrixConvention = String(data.coordinate?.matrix_convention || "panda_row_vector_row_major"); if (Array.isArray(cameraData.matrix_local_row_major) && cameraData.matrix_local_row_major.length === 16) { const camMat = convertNodeMatrix( THREE, cameraData.matrix_local_row_major, basis, basisInv, matrixConvention, ); camera.matrix.copy(camMat); camera.matrix.decompose(camera.position, camera.quaternion, camera.scale); camera.matrixAutoUpdate = true; camera.updateMatrix(); } else { camera.position.set(0, -50, 20); camera.lookAt(0, 0, 0); } const env = data.environment || {}; 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); scene.add(dirLight); scene.add(dirLight.target); } const nodeMap = new Map(); const pendingModelLoads = []; const gltfLoader = new GLTFLoader(); const textureLoader = new THREE.TextureLoader(); for (const node of Array.isArray(data.nodes) ? data.nodes : []) { 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), ); } 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); 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 = true; } else { obj = new THREE.Group(); const modelUri = node.model?.uri; if (modelUri) { const p = new Promise((resolve) => { gltfLoader.load( modelUri, (gltf) => { const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null); if (root) { applyMaterialOverride(THREE, root, node.material_override || null); applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader); obj.add(root); } resolve(); }, undefined, (err) => { console.warn(`Failed to load model ${modelUri}:`, err); resolve(); }, ); }); pendingModelLoads.push(p); } } obj.name = node.name || node.id || "node"; if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) { const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention); obj.matrixAutoUpdate = false; obj.matrix.copy(converted); obj.matrix.decompose(obj.position, obj.quaternion, obj.scale); } nodeMap.set(node.id, obj); } for (const node of Array.isArray(data.nodes) ? data.nodes : []) { const obj = nodeMap.get(node.id); if (!obj) continue; const parent = node.parent_id ? nodeMap.get(node.parent_id) : null; if (parent) { parent.add(obj); } else { scene.add(obj); } if (obj.isSpotLight && obj.target) { obj.add(obj.target); } } await Promise.all(pendingModelLoads); const resize = () => { const w = window.innerWidth; const h = Math.max(1, window.innerHeight); camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }; window.addEventListener("resize", resize); resize(); setStatus( `Scene ready. Nodes: ${(data.nodes || []).length}.\nUse mouse to orbit, wheel to zoom.`, "ok", ); const clock = new THREE.Clock(); const tick = () => { requestAnimationFrame(tick); const dt = clock.getDelta(); if (dt >= 0) { controls.update(); renderer.render(scene, camera); } }; tick(); } bootstrap().catch((err) => { console.error(err); setStatus(`Viewer bootstrap failed:\n${String(err)}`, "error"); });