387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
const statusEl = document.getElementById("status");
|
|
const canvas = document.getElementById("scene-canvas");
|
|
|
|
function setStatus(message, level = "warn") {
|
|
if (!statusEl) return;
|
|
statusEl.textContent = message;
|
|
statusEl.className = `status ${level}`;
|
|
}
|
|
|
|
function rowMajorToMatrix4(THREE, m) {
|
|
const mat = new THREE.Matrix4();
|
|
mat.set(
|
|
m[0], m[1], m[2], m[3],
|
|
m[4], m[5], m[6], m[7],
|
|
m[8], m[9], m[10], m[11],
|
|
m[12], m[13], m[14], m[15],
|
|
);
|
|
return mat;
|
|
}
|
|
|
|
function pandaRowMajorToMatrix4(THREE, m) {
|
|
const mat = new THREE.Matrix4();
|
|
// Panda's matrix data uses row-vector convention (translation on last row).
|
|
// Three.js expects column-vector convention (translation on last column).
|
|
mat.set(
|
|
m[0], m[4], m[8], m[12],
|
|
m[1], m[5], m[9], m[13],
|
|
m[2], m[6], m[10], m[14],
|
|
m[3], m[7], m[11], m[15],
|
|
);
|
|
return mat;
|
|
}
|
|
|
|
function convertNodeMatrix(THREE, sourceMatRowMajor, basis, basisInv, matrixConvention = "panda_row_vector_row_major") {
|
|
const src = matrixConvention === "panda_row_vector_row_major"
|
|
? pandaRowMajorToMatrix4(THREE, sourceMatRowMajor)
|
|
: rowMajorToMatrix4(THREE, sourceMatRowMajor);
|
|
return basis.clone().multiply(src).multiply(basisInv);
|
|
}
|
|
|
|
function toColorArray(color, fallback = [1, 1, 1]) {
|
|
if (!Array.isArray(color) || color.length < 3) return fallback;
|
|
return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
|
|
}
|
|
|
|
function applyMaterialOverride(THREE, root, override) {
|
|
if (!override) return;
|
|
|
|
root.traverse((obj) => {
|
|
if (!obj.isMesh || !obj.material) return;
|
|
|
|
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
for (const mat of list) {
|
|
if (mat.color && Array.isArray(override.base_color)) {
|
|
const [r, g, b] = override.base_color;
|
|
mat.color.setRGB(r ?? 1, g ?? 1, b ?? 1);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(override, "roughness") && "roughness" in mat) {
|
|
mat.roughness = Number(override.roughness);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(override, "metallic") && "metalness" in mat) {
|
|
mat.metalness = Number(override.metallic);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(override, "opacity")) {
|
|
const opacity = THREE.MathUtils.clamp(Number(override.opacity), 0, 1);
|
|
const isTransparent = opacity < 0.999;
|
|
mat.opacity = opacity;
|
|
mat.transparent = isTransparent;
|
|
// Prevent "see-through solid mesh" when source GLTF had transparent pipeline state.
|
|
mat.depthWrite = !isTransparent;
|
|
mat.depthTest = true;
|
|
mat.blending = isTransparent ? THREE.NormalBlending : THREE.NoBlending;
|
|
if (!isTransparent && "alphaTest" in mat) {
|
|
mat.alphaTest = 0;
|
|
}
|
|
if (!isTransparent && "transmission" in mat) {
|
|
mat.transmission = 0;
|
|
}
|
|
}
|
|
mat.needsUpdate = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function textureSlotByStage(stageName) {
|
|
const key = String(stageName || "").toLowerCase();
|
|
if (key.includes("normal")) return "normalMap";
|
|
if (key.includes("rough")) return "roughnessMap";
|
|
if (key.includes("metal")) return "metalnessMap";
|
|
if (key.includes("emission") || key.includes("emissive")) return "emissiveMap";
|
|
if (key.includes("ao")) return "aoMap";
|
|
if (key.includes("alpha") || key.includes("opacity")) return "alphaMap";
|
|
return "map";
|
|
}
|
|
|
|
function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) {
|
|
if (!Array.isArray(textureOverrides) || textureOverrides.length === 0) return;
|
|
|
|
const texBySlot = new Map();
|
|
for (const item of textureOverrides) {
|
|
if (!item || !item.uri) continue;
|
|
const slot = textureSlotByStage(item.stage);
|
|
if (texBySlot.has(slot)) continue;
|
|
|
|
try {
|
|
const tex = textureLoader.load(item.uri);
|
|
tex.flipY = false;
|
|
texBySlot.set(slot, tex);
|
|
} catch (err) {
|
|
console.warn("Texture load failed:", item.uri, err);
|
|
}
|
|
}
|
|
|
|
if (texBySlot.size === 0) return;
|
|
|
|
root.traverse((obj) => {
|
|
if (!obj.isMesh || !obj.material) return;
|
|
|
|
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
for (const mat of list) {
|
|
for (const [slot, tex] of texBySlot.entries()) {
|
|
if (slot in mat) {
|
|
mat[slot] = tex;
|
|
}
|
|
}
|
|
mat.needsUpdate = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function 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");
|
|
});
|