Files
tinyusdz/web/js/animation.js
2025-12-17 06:31:52 +09:00

2835 lines
90 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { TinyUSDZLoader } from 'tinyusdz/TinyUSDZLoader.js';
import { TinyUSDZLoaderUtils } from 'tinyusdz/TinyUSDZLoaderUtils.js';
// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
// Camera
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
10000
);
camera.position.set(50, 50, 50);
camera.lookAt(0, 0, 0);
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// Orbit controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// Lighting
const ambientLight = new THREE.AmbientLight(0x404040, 1);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(50, 100, 50);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.left = -100;
directionalLight.shadow.camera.right = 100;
directionalLight.shadow.camera.top = 100;
directionalLight.shadow.camera.bottom = -100;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.bias = -0.0001; // Reduce shadow acne
scene.add(directionalLight);
// Ground plane
const groundGeometry = new THREE.PlaneGeometry(200, 200);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x333333,
roughness: 0.8,
metalness: 0.2
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Grid helper
let gridHelper = new THREE.GridHelper(200, 20, 0x666666, 0x444444);
scene.add(gridHelper);
// Axis helper at center
const axisHelper = new THREE.AxesHelper(50); // Size 50
axisHelper.name = 'AxisHelper';
scene.add(axisHelper);
// Virtual root object for USD scene (name = "/")
const usdSceneRoot = new THREE.Group();
usdSceneRoot.name = "/";
scene.add(usdSceneRoot);
// Store reference to the actual USD content node (child of usdSceneRoot)
// This is needed for creating the animation mixer on the correct root
let usdContentNode = null;
// Store USD animations from the file
let usdAnimations = [];
// Store the current file's upAxis (Y or Z)
let currentFileUpAxis = "Y";
// Store the current scene metadata
let currentSceneMetadata = {
upAxis: "Y",
metersPerUnit: 1.0,
framesPerSecond: 24.0,
timeCodesPerSecond: 24.0,
startTimeCode: null,
endTimeCode: null,
autoPlay: true,
comment: "",
copyright: ""
};
// Store currently selected object for transform display
let selectedObject = null;
// ===========================================
// Environment Map and Material Settings
// ===========================================
// PMREM generator for environment maps
let pmremGenerator = null;
// Current environment map
let envMap = null;
// Texture cache for material conversion
let textureCache = new Map();
// TinyUSDZ loader and scene references for cleanup
let currentLoader = null;
let currentUSDScene = null;
let usdDomeLightData = null; // Store DomeLight data from USD file
// Environment map presets
const ENV_PRESETS = {
'usd_dome': 'usd', // Special marker for USD DomeLight (if available)
'goegap_1k': 'assets/textures/goegap_1k.hdr',
'env_sunsky_sunset': 'assets/textures/env_sunsky_sunset.hdr',
'studio': null, // Will use synthetic studio lighting
'constant_color': 'constant' // Special marker for constant color environment
};
// Material and environment settings
const materialSettings = {
materialType: 'auto', // 'auto', 'openpbr', 'usdpreviewsurface'
envMapPreset: 'goegap_1k',
envMapIntensity: 1.0,
envConstantColor: '#ffffff', // Color for constant color environment
envColorspace: 'sRGB', // 'sRGB' (convert to linear) or 'linear' (no conversion)
showEnvBackground: false,
exposure: 1.0,
toneMapping: 'aces'
};
// Store bounding box helpers for each object
const objectBBoxHelpers = new Map(); // uuid -> BoxHelper
// Raycaster for object selection
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Highlight selected object
let selectionHelper = null;
// ===========================================
// USD Animation Extraction Functions
// ===========================================
/**
* Convert USD animation data to Three.js AnimationClip
* Supports both channel/sampler and track-based animation structures
* @param {Object} usdLoader - TinyUSDZ loader instance
* @param {THREE.Object3D} sceneRoot - Three.js scene containing the loaded geometry
* @returns {Array<THREE.AnimationClip>} Array of Three.js AnimationClips
*/
function convertUSDAnimationsToThreeJS(usdLoader, sceneRoot) {
const animationClips = [];
// Get number of animations
const numAnimations = usdLoader.numAnimations();
console.log(`Found ${numAnimations} animations in USD file`);
// Get summary of all animations
const animationInfos = usdLoader.getAllAnimationInfos();
console.log('Animation summaries:', animationInfos);
// Build node index map for faster lookup
const nodeIndexMap = new Map();
let nodeIndex = 0;
sceneRoot.traverse((obj) => {
nodeIndexMap.set(nodeIndex, obj);
//console.log(`Node index ${nodeIndex}: name="${obj.name}", type=${obj.type}, uuid=${obj.uuid}`);
nodeIndex++;
});
console.log(`Built node index map with ${nodeIndexMap.size} nodes`);
// Convert each animation to Three.js format
for (let i = 0; i < numAnimations; i++) {
const usdAnimation = usdLoader.getAnimation(i);
console.log(`Processing animation ${i}: ${usdAnimation.name}`);
// Check if this is a track-based animation (legacy format)
if (usdAnimation.tracks && usdAnimation.tracks.length > 0) {
console.log(`Animation ${i} uses track-based format with ${usdAnimation.tracks.length} tracks`);
// Process track-based animation
const keyframeTracks = [];
// Find the target object - for track animations, usually the first child after scene root
let targetObject = sceneRoot;
// Try to find the animated object by name from the animation
// Animation name format: "object_name_xform" or "object_name"
// Remove "_xform" suffix if present, then try exact match
if (usdAnimation.name) {
let searchName = usdAnimation.name;
// Remove common suffixes
searchName = searchName.replace(/_xform$/, '');
searchName = searchName.replace(/_anim$/, '');
console.log(`Searching for target object with name: "${searchName}" (from animation "${usdAnimation.name}")`);
// First try exact match
let found = false;
sceneRoot.traverse((obj) => {
if (obj.name === searchName) {
targetObject = obj;
found = true;
console.log(` Found exact match: "${obj.name}"`);
}
});
// If no exact match, try matching without the mesh suffix
if (!found) {
sceneRoot.traverse((obj) => {
if (obj.name && obj.name.startsWith(searchName)) {
targetObject = obj;
found = true;
console.log(` Found prefix match: "${obj.name}"`);
}
});
}
}
// If we can't find it by name, use the first mesh or group
if (targetObject === sceneRoot) {
console.warn(`Could not find target object for animation "${usdAnimation.name}", using first mesh/group`);
sceneRoot.traverse((obj) => {
if ((obj.isMesh || obj.isGroup) && obj !== sceneRoot) {
targetObject = obj;
return; // Stop traversal once we find the first mesh/group
}
});
}
const targetName = targetObject.name || 'AnimatedObject';
const targetUUID = targetObject.uuid;
console.log(`Target object for track-based animation: "${targetName}" (UUID: ${targetUUID})`);
// Process each track
for (const track of usdAnimation.tracks) {
if (!track.times || !track.values) {
console.warn('Track missing times or values');
continue;
}
// Convert times and values to arrays
const times = Array.isArray(track.times) ? track.times : Array.from(track.times);
const values = Array.isArray(track.values) ? track.values : Array.from(track.values);
const interpolation = getUSDInterpolationMode(track.interpolation);
console.log(`Processing track: ${track.path}, ${times.length} keyframes`);
// Create appropriate Three.js KeyframeTrack based on path
let keyframeTrack;
// Use UUID-based targeting for reliability (same as channel-based animations)
// Format: "<uuid>.<property>" (PropertyBinding checks uuid === nodeName)
switch (track.path) {
case 'translation':
case 'Translation':
keyframeTrack = new THREE.VectorKeyframeTrack(
`${targetUUID}.position`,
times,
values,
interpolation
);
console.log(` Created translation track: ${targetUUID}.position (${targetName})`);
break;
case 'rotation':
case 'Rotation':
// Rotation is stored as quaternions (x, y, z, w)
keyframeTrack = new THREE.QuaternionKeyframeTrack(
`${targetUUID}.quaternion`,
times,
values,
interpolation
);
console.log(` Created rotation track: ${targetUUID}.quaternion (${targetName})`);
break;
case 'scale':
case 'Scale':
keyframeTrack = new THREE.VectorKeyframeTrack(
`${targetUUID}.scale`,
times,
values,
interpolation
);
console.log(` Created scale track: ${targetUUID}.scale (${targetName})`);
break;
default:
console.warn(`Unknown track path: ${track.path}`);
continue;
}
if (keyframeTrack) {
keyframeTracks.push(keyframeTrack);
}
}
// Create Three.js AnimationClip from tracks
if (keyframeTracks.length > 0) {
const clip = new THREE.AnimationClip(
usdAnimation.name || `Animation_${i}`,
usdAnimation.duration || -1, // -1 will auto-calculate from tracks
keyframeTracks
);
animationClips.push(clip);
console.log(`Created clip: ${clip.name}, duration: ${clip.duration}s, tracks: ${clip.tracks.length}`);
}
continue; // Skip to next animation
}
// Handle channel-based animation (newer format)
if (!usdAnimation.channels || !usdAnimation.samplers) {
console.warn(`Animation ${i} missing channels/samplers and tracks`);
continue;
}
// Filter for node animations only (skip skeletal animations)
const nodeChannels = usdAnimation.channels.filter(channel => {
const targetType = channel.target_type || 'SceneNode'; // Default to SceneNode for backward compat
return targetType === 'SceneNode';
});
if (nodeChannels.length === 0) {
console.log(`Animation ${i} has no SceneNode channels (skipping skeletal-only animation)`);
continue;
}
console.log(`Animation ${i}: ${nodeChannels.length} node channels (${usdAnimation.channels.length - nodeChannels.length} skeletal channels skipped)`);
// Create Three.js KeyframeTracks from USD animation channels
const keyframeTracks = [];
for (const channel of nodeChannels) {
// Get sampler data
const sampler = usdAnimation.samplers[channel.sampler];
if (!sampler || !sampler.times || !sampler.values) {
console.warn(`Invalid sampler for channel`);
continue;
}
// Find the Three.js object for this channel
const targetObject = nodeIndexMap.get(channel.target_node);
if (!targetObject) {
console.warn(`Could not find object at node index: ${channel.target_node}`);
console.warn(`Available node indices: ${Array.from(nodeIndexMap.keys()).join(', ')}`);
continue;
}
// Convert times and values to arrays
const times = Array.isArray(sampler.times) ? sampler.times : Array.from(sampler.times);
const values = Array.isArray(sampler.values) ? sampler.values : Array.from(sampler.values);
// Create appropriate Three.js KeyframeTrack based on path
let keyframeTrack;
// Use UUID for reliable hierarchical animation targeting
// Three.js AnimationMixer supports both name-based and UUID-based targeting
const targetUUID = targetObject.uuid;
const targetName = targetObject.name || `node_${channel.target_node}`;
const interpolation = getUSDInterpolationMode(sampler.interpolation);
console.log(`Channel: target_node=${channel.target_node}, path=${channel.path}, target_name="${targetName}", uuid=${targetUUID}, keyframes=${times.length}`);
// Three.js AnimationMixer can target objects by UUID or by name
// Using UUID is more reliable for hierarchical animations
// Format: "<uuid>.<property>" (PropertyBinding checks uuid === nodeName)
switch (channel.path) {
case 'Translation':
keyframeTrack = new THREE.VectorKeyframeTrack(
`${targetUUID}.position`,
times,
values,
interpolation
);
break;
case 'Rotation':
// Rotation is stored as quaternions (x, y, z, w)
keyframeTrack = new THREE.QuaternionKeyframeTrack(
`${targetUUID}.quaternion`,
times,
values,
interpolation
);
break;
case 'Scale':
keyframeTrack = new THREE.VectorKeyframeTrack(
`${targetUUID}.scale`,
times,
values,
interpolation
);
break;
case 'Weights':
// For morph targets
keyframeTrack = new THREE.NumberKeyframeTrack(
`.uuid[${targetUUID}].morphTargetInfluences`,
times,
values,
interpolation
);
break;
default:
console.warn(`Unknown animation path: ${channel.path}`);
continue;
}
if (keyframeTrack) {
keyframeTracks.push(keyframeTrack);
// Debug: Log first few keyframe values
console.log(` Track "${keyframeTrack.name}": ${keyframeTrack.times.length} keyframes`);
if (keyframeTrack.times.length > 0 && channel.path === 'Scale') {
// Log scale values in detail for debugging
const numSamples = Math.min(3, keyframeTrack.times.length);
const currentScale = [targetObject.scale.x, targetObject.scale.y, targetObject.scale.z];
console.log(` Scale animation for "${targetName}" (current scale=[${currentScale[0].toFixed(4)}, ${currentScale[1].toFixed(4)}, ${currentScale[2].toFixed(4)}]):`);
for (let s = 0; s < numSamples; s++) {
const t = keyframeTrack.times[s];
const vIdx = s * 3;
const scale = [
keyframeTrack.values[vIdx],
keyframeTrack.values[vIdx + 1],
keyframeTrack.values[vIdx + 2]
];
console.log(` t=${t.toFixed(3)}s: scale=[${scale[0].toFixed(4)}, ${scale[1].toFixed(4)}, ${scale[2].toFixed(4)}]`);
}
// Check if animation scale differs significantly from current scale
const firstScale = [keyframeTrack.values[0], keyframeTrack.values[1], keyframeTrack.values[2]];
const scaleDiff = Math.abs(firstScale[0] - currentScale[0]) + Math.abs(firstScale[1] - currentScale[1]) + Math.abs(firstScale[2] - currentScale[2]);
if (scaleDiff > 0.01) {
console.warn(` ⚠️ Animation scale mismatch! Object's current scale differs from animation's first keyframe by ${scaleDiff.toFixed(4)}`);
console.warn(` This may indicate the animation doesn't include the base transform from USD.`);
}
}
if (keyframeTrack.times.length > 0) {
console.log(` First keyframe: time=${keyframeTrack.times[0]}, values=[${keyframeTrack.values.slice(0, 4).join(', ')}...]`);
if (keyframeTrack.times.length > 1) {
const lastIdx = keyframeTrack.times.length - 1;
const valuesPerKey = keyframeTrack.values.length / keyframeTrack.times.length;
const lastValueStart = lastIdx * valuesPerKey;
console.log(` Last keyframe: time=${keyframeTrack.times[lastIdx]}, values=[${keyframeTrack.values.slice(lastValueStart, lastValueStart + 4).join(', ')}...]`);
}
}
}
}
// Create Three.js AnimationClip
if (keyframeTracks.length > 0) {
const clip = new THREE.AnimationClip(
usdAnimation.name || `Animation_${i}`,
usdAnimation.duration || -1, // -1 will auto-calculate from tracks
keyframeTracks
);
animationClips.push(clip);
console.log(`Created clip: ${clip.name}, duration: ${clip.duration}s, tracks: ${clip.tracks.length}`);
console.log(`Track names in clip:`, clip.tracks.map(t => t.name));
}
}
return animationClips;
}
/**
* Convert USD interpolation mode to Three.js InterpolateMode
* @param {string} interpolation - USD interpolation mode (Linear, Step, CubicSpline)
* @returns {number} Three.js InterpolateMode constant
*/
function getUSDInterpolationMode(interpolation) {
switch (interpolation) {
case 'Step':
case 'STEP':
return THREE.InterpolateDiscrete;
case 'CubicSpline':
case 'CUBICSPLINE':
return THREE.InterpolateSmooth;
case 'Linear':
case 'LINEAR':
default:
return THREE.InterpolateLinear;
}
}
// ===========================================
// Environment Map Functions
// ===========================================
/**
* Initialize PMREM generator and renderer settings for PBR
*/
function initializePBRRenderer() {
// Create PMREM generator for environment maps
pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
// Set up tone mapping
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = materialSettings.exposure;
renderer.outputColorSpace = THREE.SRGBColorSpace;
console.log('PBR renderer initialized with ACES tone mapping');
}
// ===========================================
// Colorspace Conversion Utilities
// ===========================================
/**
* Convert sRGB component to linear
* @param {number} c - sRGB component value [0, 1]
* @returns {number} Linear component value [0, 1]
*/
function sRGBComponentToLinear(c) {
if (c <= 0.04045) {
return c / 12.92;
} else {
return Math.pow((c + 0.055) / 1.055, 2.4);
}
}
/**
* Parse hex color and convert to RGB [0, 1] with optional linear conversion
* @param {string} hexColor - Hex color string (e.g., '#ff8800')
* @param {boolean} toLinear - If true, convert from sRGB to linear
* @returns {object} {r, g, b} values in [0, 1]
*/
function parseHexColor(hexColor, toLinear = false) {
// Remove # if present
const hex = hexColor.replace('#', '');
// Parse RGB components
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
if (toLinear) {
return {
r: sRGBComponentToLinear(r),
g: sRGBComponentToLinear(g),
b: sRGBComponentToLinear(b)
};
}
return { r, g, b };
}
/**
* Convert RGB [0, 1] to hex color string for canvas
* @param {number} r - Red [0, 1]
* @param {number} g - Green [0, 1]
* @param {number} b - Blue [0, 1]
* @returns {string} Hex color string
*/
function rgbToHex(r, g, b) {
const toHex = (c) => {
const clamped = Math.max(0, Math.min(1, c));
const val = Math.round(clamped * 255);
return val.toString(16).padStart(2, '0');
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// ===========================================
// Environment Loading
// ===========================================
/**
* Load environment map from preset
* @param {string} preset - Environment preset name
*/
async function loadEnvironment(preset) {
materialSettings.envMapPreset = preset;
const path = ENV_PRESETS[preset];
if (!path) {
// Studio lighting - create synthetic environment
envMap = createStudioEnvironment();
applyEnvironment();
console.log('Using synthetic studio environment');
return;
}
if (path === 'usd') {
// USD DomeLight - use stored DomeLight data
if (usdDomeLightData) {
console.log('Using USD DomeLight environment');
// Environment map already loaded by loadDomeLightFromUSD
} else {
console.warn('USD DomeLight selected but no DomeLight data available');
envMap = createStudioEnvironment();
applyEnvironment();
}
return;
}
if (path === 'constant') {
// Constant color environment
envMap = createConstantColorEnvironment(materialSettings.envConstantColor, materialSettings.envColorspace);
applyEnvironment();
console.log('Using constant color environment');
return;
}
console.log(`Loading environment: ${preset}...`);
try {
const hdrLoader = new HDRLoader();
const texture = await hdrLoader.loadAsync(path);
envMap = pmremGenerator.fromEquirectangular(texture).texture;
texture.dispose();
applyEnvironment();
console.log(`Environment loaded: ${preset}`);
} catch (error) {
console.error('Failed to load environment:', error);
// Fall back to synthetic
envMap = createStudioEnvironment();
applyEnvironment();
}
}
/**
* Create a synthetic studio environment
* @returns {THREE.Texture} Environment texture
*/
function createStudioEnvironment() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// Create gradient (light from top)
const gradient = ctx.createLinearGradient(0, 0, 0, 256);
gradient.addColorStop(0, '#ffffff');
gradient.addColorStop(0.5, '#cccccc');
gradient.addColorStop(1, '#666666');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 256, 256);
const texture = new THREE.CanvasTexture(canvas);
texture.mapping = THREE.EquirectangularReflectionMapping;
return pmremGenerator.fromEquirectangular(texture).texture;
}
/**
* Create a constant color environment
* @param {string} color - Hex color string
* @param {string} colorspace - 'sRGB' or 'linear'
* @returns {THREE.Texture} Environment texture
*/
function createConstantColorEnvironment(color, colorspace = 'sRGB') {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// Parse and potentially convert color based on colorspace
let fillColor = color;
if (colorspace === 'sRGB') {
// Convert sRGB to linear for proper PBR workflow
const rgb = parseHexColor(color, true); // true = convert to linear
fillColor = rgbToHex(rgb.r, rgb.g, rgb.b);
}
// else: linear colorspace - use color as-is (no conversion)
// Fill with solid color
ctx.fillStyle = fillColor;
ctx.fillRect(0, 0, 256, 256);
const texture = new THREE.CanvasTexture(canvas);
texture.mapping = THREE.EquirectangularReflectionMapping;
// Set colorspace based on setting
if (colorspace === 'sRGB') {
texture.colorSpace = THREE.LinearSRGBColorSpace;
} else {
texture.colorSpace = THREE.LinearSRGBColorSpace; // Already linear
}
return pmremGenerator.fromEquirectangular(texture).texture;
}
/**
* Apply the current environment map to the scene
*/
function applyEnvironment() {
scene.environment = envMap;
updateEnvBackground();
updateEnvIntensity();
// Update envMap reference on all existing materials
usdSceneRoot.traverse((child) => {
if (child.isMesh && child.material) {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach(mat => {
mat.envMap = envMap;
mat.needsUpdate = true; // Flag material for shader recompilation
});
}
});
}
/**
* Update environment background visibility
*/
function updateEnvBackground() {
if (materialSettings.showEnvBackground && envMap) {
scene.background = envMap;
} else {
scene.background = new THREE.Color(0x1a1a1a);
}
}
/**
* Update constant color environment when color changes
*/
function updateConstantColorEnvironment() {
// Only update if constant color environment is selected
if (materialSettings.envMapPreset === 'constant_color') {
envMap = createConstantColorEnvironment(materialSettings.envConstantColor, materialSettings.envColorspace);
applyEnvironment();
}
}
/**
* Update environment map intensity on all materials
*/
function updateEnvIntensity() {
usdSceneRoot.traverse((child) => {
if (child.isMesh && child.material) {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach(mat => {
if (mat.envMapIntensity !== undefined) {
mat.envMapIntensity = materialSettings.envMapIntensity;
}
});
}
});
}
/**
* Update tone mapping type
* @param {string} value - Tone mapping type
*/
function updateToneMapping(value) {
const mappings = {
'none': THREE.NoToneMapping,
'linear': THREE.LinearToneMapping,
'reinhard': THREE.ReinhardToneMapping,
'cineon': THREE.CineonToneMapping,
'aces': THREE.ACESFilmicToneMapping,
'agx': THREE.AgXToneMapping,
'neutral': THREE.NeutralToneMapping
};
renderer.toneMapping = mappings[value] || THREE.ACESFilmicToneMapping;
}
// ===========================================
// DomeLight Environment Map Loading
// ===========================================
/**
* Load DomeLight from USD and apply to scene
* Uses TinyUSDZLoaderUtils.loadDomeLightFromUSD for the heavy lifting
* @param {Object} usdScene - USD scene object from TinyUSDZLoader
* @returns {Promise<Object|null>} DomeLight data or null if not found
*/
async function loadDomeLightFromUSD(usdScene) {
const result = await TinyUSDZLoaderUtils.loadDomeLightFromUSD(usdScene, pmremGenerator);
if (result) {
// Apply result to app state
envMap = result.texture;
materialSettings.envMapIntensity = result.intensity;
materialSettings.envMapPreset = 'usd_dome';
if (result.colorHex) {
materialSettings.envConstantColor = result.colorHex;
}
applyEnvironment();
// Store DomeLight data for reference
usdDomeLightData = {
name: result.name,
textureFile: result.textureFile,
envmapTextureId: result.envmapTextureId,
intensity: result.intensity,
color: result.color,
exposure: result.exposure,
envMap: envMap
};
return usdDomeLightData;
}
return null;
}
/**
* Reload all materials with current settings
*/
async function reloadMaterials() {
if (!usdContentNode) return;
console.log(`Reloading materials with type: ${materialSettings.materialType}`);
// Get the current USD scene loader
const loader = new TinyUSDZLoader();
await loader.init({ useZstdCompressedWasm: false, useMemory64: false });
// Clear texture cache for fresh reload
textureCache.clear();
// Traverse and update materials on all meshes
usdContentNode.traverse(async (child) => {
if (child.isMesh && child.userData.materialData) {
try {
const newMaterial = await TinyUSDZLoaderUtils.convertMaterial(
child.userData.materialData,
child.userData.usdScene,
{
preferredMaterialType: materialSettings.materialType,
envMap: envMap,
envMapIntensity: materialSettings.envMapIntensity,
textureCache: textureCache
}
);
// Preserve shadow settings
if (child.material) {
child.material.dispose();
}
child.material = newMaterial;
child.material.needsUpdate = true;
// Apply double-sided if enabled
if (animationParams.doubleSided) {
child.material.side = THREE.DoubleSide;
}
} catch (e) {
console.warn(`Failed to reload material for ${child.name}:`, e);
}
}
});
console.log('Materials reloaded');
}
// Load USD model asynchronously
async function loadUSDModel() {
// Initialize PBR renderer if not already done
if (!pmremGenerator) {
initializePBRRenderer();
// Load default environment
await loadEnvironment(materialSettings.envMapPreset);
}
const loader = new TinyUSDZLoader();
// Initialize the loader (wait for WASM module to load)
// Use memory64: false for browser compatibility
// Use useZstdCompressedWasm: false since compressed WASM is not available
await loader.init({ useZstdCompressedWasm: false, useMemory64: true });
currentLoader = loader; // Store reference for cleanup
// USD FILES
const usd_filename = "./assets/suzanne-xform.usdc";
// Load USD scene
const usd_scene = await loader.loadAsync(usd_filename);
currentUSDScene = usd_scene; // Store reference for cleanup
// Get the default root node from USD
const usdRootNode = usd_scene.getDefaultRootNode();
// Get scene metadata from the USD file
const sceneMetadata = usd_scene.getSceneMetadata ? usd_scene.getSceneMetadata() : {};
const fileUpAxis = sceneMetadata.upAxis || "Y";
currentFileUpAxis = fileUpAxis; // Store globally for toggle function
// Store metadata globally
currentSceneMetadata = {
upAxis: fileUpAxis,
metersPerUnit: sceneMetadata.metersPerUnit || 1.0,
framesPerSecond: sceneMetadata.framesPerSecond || 24.0,
timeCodesPerSecond: sceneMetadata.timeCodesPerSecond || 24.0,
startTimeCode: sceneMetadata.startTimeCode,
endTimeCode: sceneMetadata.endTimeCode,
autoPlay: sceneMetadata.autoPlay !== undefined ? sceneMetadata.autoPlay : true,
comment: sceneMetadata.comment || "",
copyright: sceneMetadata.copyright || ""
};
console.log('=== USD Scene Metadata ===');
console.log(`upAxis: "${currentSceneMetadata.upAxis}"`);
console.log(`metersPerUnit: ${currentSceneMetadata.metersPerUnit}`);
console.log(`framesPerSecond: ${currentSceneMetadata.framesPerSecond}`);
console.log(`timeCodesPerSecond: ${currentSceneMetadata.timeCodesPerSecond}`);
if (currentSceneMetadata.startTimeCode !== null && currentSceneMetadata.startTimeCode !== undefined) {
console.log(`startTimeCode: ${currentSceneMetadata.startTimeCode}`);
}
if (currentSceneMetadata.endTimeCode !== null && currentSceneMetadata.endTimeCode !== undefined) {
console.log(`endTimeCode: ${currentSceneMetadata.endTimeCode}`);
}
console.log(`autoPlay: ${currentSceneMetadata.autoPlay}`);
if (currentSceneMetadata.comment) {
console.log(`comment: "${currentSceneMetadata.comment}"`);
}
if (currentSceneMetadata.copyright) {
console.log(`copyright: "${currentSceneMetadata.copyright}"`);
}
console.log('========================');
// Update metadata UI
updateMetadataUI();
// Try to load DomeLight environment from USD
try {
const domeLightData = await loadDomeLightFromUSD(usd_scene);
if (domeLightData) {
console.log('Loaded DomeLight from USD:', domeLightData);
if (envPresetController) {
envPresetController.updateDisplay();
}
}
} catch (error) {
console.warn('Error checking for DomeLight:', error);
}
// Create default material with environment map
const defaultMtl = new THREE.MeshPhysicalMaterial({
color: 0x888888,
roughness: 0.5,
metalness: 0.0,
envMap: envMap,
envMapIntensity: materialSettings.envMapIntensity
});
// Clear texture cache for fresh load
textureCache.clear();
const options = {
overrideMaterial: false,
envMap: envMap,
envMapIntensity: materialSettings.envMapIntensity,
preferredMaterialType: materialSettings.materialType,
textureCache: textureCache,
storeMaterialData: true
};
// Build Three.js node from USD with MaterialX/OpenPBR support
const threeNode = await TinyUSDZLoaderUtils.buildThreeNode(usdRootNode, defaultMtl, usd_scene, options);
// Store USD scene reference for material reloading
threeNode.traverse((child) => {
if (child.isMesh) {
child.userData.usdScene = usd_scene;
}
});
// Store reference to USD content node for mixer creation
usdContentNode = threeNode;
// Clear existing USD scene
while (usdSceneRoot.children.length > 0) {
usdSceneRoot.remove(usdSceneRoot.children[0]);
}
// Add loaded USD scene to usdSceneRoot
usdSceneRoot.add(threeNode);
// Apply Z-up to Y-up conversion if enabled AND the file is actually Z-up
if (animationParams.applyUpAxisConversion && fileUpAxis === "Z") {
usdSceneRoot.rotation.x = -Math.PI / 2;
console.log(`[loadUSDModel] Applied Z-up to Y-up conversion (file upAxis="${fileUpAxis}"): rotation.x =`, usdSceneRoot.rotation.x);
} else if (animationParams.applyUpAxisConversion && fileUpAxis !== "Y") {
console.warn(`[loadUSDModel] File upAxis is "${fileUpAxis}" (not Y or Z), no rotation applied`);
} else {
console.log(`[loadUSDModel] No upAxis conversion needed (file upAxis="${fileUpAxis}", conversion ${animationParams.applyUpAxisConversion ? 'enabled' : 'disabled'})`);
}
// Debug: Log scene hierarchy transforms
console.log('=== Scene Hierarchy After UpAxis Conversion ===');
console.log('usdSceneRoot:', {
rotation: { x: usdSceneRoot.rotation.x, y: usdSceneRoot.rotation.y, z: usdSceneRoot.rotation.z },
position: { x: usdSceneRoot.position.x, y: usdSceneRoot.position.y, z: usdSceneRoot.position.z },
scale: { x: usdSceneRoot.scale.x, y: usdSceneRoot.scale.y, z: usdSceneRoot.scale.z }
});
if (threeNode) {
console.log('threeNode (usdContentNode):', {
rotation: { x: threeNode.rotation.x, y: threeNode.rotation.y, z: threeNode.rotation.z },
position: { x: threeNode.position.x, y: threeNode.position.y, z: threeNode.position.z },
scale: { x: threeNode.scale.x, y: threeNode.scale.y, z: threeNode.scale.z }
});
}
console.log('==============================================');
// Apply scene scale and update shadow frustum based on model bounds
animationParams.applySceneScale();
// Traverse and enable shadows for all meshes
usdSceneRoot.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// Extract USD animations if available
try {
const animationInfos = usd_scene.getAllAnimationInfos();
// IMPORTANT: Pass threeNode (the USD root) for correct node index mapping
// The node indices in USD animations reference nodes within the USD scene hierarchy
usdAnimations = convertUSDAnimationsToThreeJS(usd_scene, threeNode);
if (usdAnimations.length > 0) {
console.log(`Extracted ${usdAnimations.length} animations from USD file`);
// Animation parameters updated automatically via playAllUSDAnimations()
// Log animation details
usdAnimations.forEach((clip, index) => {
const info = animationInfos[index];
let typeStr = '';
if (info) {
const types = [];
if (info.has_skeletal_animation) types.push('skeletal');
if (info.has_node_animation) types.push('node');
if (types.length > 0) typeStr = ` [${types.join('+')}]`;
}
console.log(`Animation ${index}: ${clip.name}, duration: ${clip.duration}s, tracks: ${clip.tracks.length}${typeStr}`);
});
// Set time range from metadata or first USD animation
let timeRangeSource = "animation";
let beginTime = 0;
let endTime = 0;
// Prefer metadata startTimeCode/endTimeCode if available
if (currentSceneMetadata.startTimeCode !== null && currentSceneMetadata.startTimeCode !== undefined &&
currentSceneMetadata.endTimeCode !== null && currentSceneMetadata.endTimeCode !== undefined) {
beginTime = currentSceneMetadata.startTimeCode;
endTime = currentSceneMetadata.endTimeCode;
timeRangeSource = "metadata";
} else {
// Fallback to first animation clip duration
const firstClip = usdAnimations[0];
if (firstClip && firstClip.duration > 0) {
beginTime = 0;
endTime = firstClip.duration;
}
}
if (endTime > beginTime) {
animationParams.beginTime = beginTime;
animationParams.endTime = endTime;
animationParams.duration = endTime - beginTime;
animationParams.time = beginTime; // Reset time to beginning
console.log(`Set time range from ${timeRangeSource}: ${beginTime}s - ${endTime}s`);
// Update GUI controllers if they exist
updateTimeRangeGUIControllers(endTime);
}
// Set playback speed (FPS) from framesPerSecond metadata
const fps = currentSceneMetadata.framesPerSecond || 24.0;
animationParams.speed = fps;
console.log(`Set animation speed (FPS) from metadata: ${fps}`);
// Play all USD animations automatically
playAllUSDAnimations();
} else {
// No USD animations found
console.log('No USD animations found in this USD file');
// Still build scene graph UI for static scenes
buildSceneGraphUI();
}
} catch (error) {
console.log('No animations found in USD file or animation extraction not supported:', error);
}
}
// Debug: Dump scene hierarchy
function dumpSceneHierarchy(root, prefix = '', level = 0) {
const indent = ' '.repeat(level);
console.log(`${prefix}${indent}"${root.name || 'unnamed'}" [${root.type}] uuid=${root.uuid}`);
if (root.children && root.children.length > 0) {
root.children.forEach((child, index) => {
const isLast = index === root.children.length - 1;
const childPrefix = isLast ? '└─ ' : '├─ ';
dumpSceneHierarchy(child, childPrefix, level + 1);
});
}
}
// Toggle bounding box for an object
function toggleBoundingBox(obj, show) {
if (show) {
// Create bbox helper if it doesn't exist
if (!objectBBoxHelpers.has(obj.uuid)) {
const bbox = new THREE.Box3();
// Compute bounding box from the object and its children
bbox.setFromObject(obj);
// Create a BoxHelper or Box3Helper
const helper = new THREE.Box3Helper(bbox, 0x00ff00); // Green color
helper.name = `bbox_${obj.name || obj.uuid}`;
// Store the helper
objectBBoxHelpers.set(obj.uuid, helper);
// Add to scene
scene.add(helper);
console.log(`BBox created for "${obj.name}":`, {
min: bbox.min,
max: bbox.max,
size: bbox.getSize(new THREE.Vector3())
});
} else {
// Make it visible
const helper = objectBBoxHelpers.get(obj.uuid);
helper.visible = true;
}
} else {
// Hide the bbox helper
if (objectBBoxHelpers.has(obj.uuid)) {
const helper = objectBBoxHelpers.get(obj.uuid);
helper.visible = false;
}
}
}
// Update bounding box helper for an object (call this during animation)
function updateBoundingBox(obj) {
if (objectBBoxHelpers.has(obj.uuid)) {
const helper = objectBBoxHelpers.get(obj.uuid);
if (helper.visible) {
// Recompute the bounding box
const bbox = new THREE.Box3();
bbox.setFromObject(obj);
// Update the helper
helper.box = bbox;
}
}
}
// Build scene graph tree UI with animation controls
function buildSceneGraphUI() {
if (!window.sceneGraphFolder) return;
// Clear existing controls
window.sceneGraphFolder.controllers.forEach(c => c.destroy());
window.sceneGraphFolder.folders.forEach(f => f.destroy());
// Recursively add objects to the tree
function addObjectToUI(obj, parentFolder) {
const objectName = obj.name || `${obj.type}_${obj.uuid.slice(0, 8)}`;
const hasChildren = obj.children && obj.children.length > 0;
const isAnimated = objectAnimationActions.has(obj.uuid);
if (hasChildren) {
// Create a folder for objects with children
const folder = parentFolder.addFolder(objectName + (isAnimated ? ' 🎬' : ''));
// Add select button to show transform info
const selectControl = {
select: function() {
selectObject(obj);
}
};
folder.add(selectControl, 'select').name('👁️ Select');
// Add animation toggle if this object is animated
if (isAnimated) {
const animControl = {
enabled: true,
toggleAnimation: function() {
const animData = objectAnimationActions.get(obj.uuid);
if (animData) {
animData.enabled = this.enabled;
if (this.enabled) {
// Re-enable by setting weight to 1
animData.action.setEffectiveWeight(1.0);
} else {
// Disable by setting weight to 0
animData.action.setEffectiveWeight(0.0);
}
console.log(`${objectName} animation: ${this.enabled ? 'enabled' : 'disabled'}`);
}
}
};
folder.add(animControl, 'enabled')
.name('🎬 Animate')
.onChange(() => animControl.toggleAnimation());
}
// Add bounding box toggle
const bboxControl = {
showBBox: false,
toggleBBox: function() {
toggleBoundingBox(obj, this.showBBox);
}
};
folder.add(bboxControl, 'showBBox')
.name('📦 BBox')
.onChange(() => bboxControl.toggleBBox());
// Recursively add children
obj.children.forEach(child => {
addObjectToUI(child, folder);
});
} else {
// Leaf node - add select button
const selectControl = {
select: function() {
selectObject(obj);
}
};
parentFolder.add(selectControl, 'select').name(`👁️ ${objectName}`);
// Add animation toggle if this object is animated
if (isAnimated) {
const animControl = {
enabled: true,
label: objectName + ' 🎬',
toggleAnimation: function() {
const animData = objectAnimationActions.get(obj.uuid);
if (animData) {
animData.enabled = this.enabled;
if (this.enabled) {
animData.action.setEffectiveWeight(1.0);
} else {
animData.action.setEffectiveWeight(0.0);
}
console.log(`${objectName} animation: ${this.enabled ? 'enabled' : 'disabled'}`);
}
}
};
parentFolder.add(animControl, 'enabled')
.name(animControl.label)
.onChange(() => animControl.toggleAnimation());
}
// Add bounding box toggle for leaf nodes too
const bboxControl = {
showBBox: false,
label: '📦 BBox',
toggleBBox: function() {
toggleBoundingBox(obj, this.showBBox);
}
};
parentFolder.add(bboxControl, 'showBBox')
.name(bboxControl.label)
.onChange(() => bboxControl.toggleBBox());
}
}
// Start building from usdSceneRoot
if (usdSceneRoot && usdSceneRoot.children.length > 0) {
// Add the USD scene root and its children
addObjectToUI(usdSceneRoot, window.sceneGraphFolder);
window.sceneGraphFolder.show();
console.log('Scene graph UI built');
}
}
// Select an object and display its transform info
function selectObject(obj) {
selectedObject = obj;
// Remove previous selection helper
if (selectionHelper) {
scene.remove(selectionHelper);
if (selectionHelper.geometry) selectionHelper.geometry.dispose();
if (selectionHelper.material) selectionHelper.material.dispose();
selectionHelper = null;
}
// Create selection helper (wireframe box)
if (obj.isMesh || obj.isGroup) {
const bbox = new THREE.Box3().setFromObject(obj);
selectionHelper = new THREE.Box3Helper(bbox, 0xffff00); // Yellow color
selectionHelper.name = 'SelectionHelper';
scene.add(selectionHelper);
}
// Update transform info UI
updateTransformInfoUI(obj);
console.log('Selected object:', obj.name, obj);
}
// Update transform info UI
function updateTransformInfoUI(obj) {
if (!window.transformInfoFolder) return;
// Clear existing controls
window.transformInfoFolder.controllers.forEach(c => c.destroy());
if (!obj) {
window.transformInfoFolder.hide();
return;
}
// Create display object
const transformInfo = {
name: obj.name || 'unnamed',
type: obj.type,
posX: obj.position.x.toFixed(4),
posY: obj.position.y.toFixed(4),
posZ: obj.position.z.toFixed(4),
rotX: (obj.rotation.x * 180 / Math.PI).toFixed(2) + '°',
rotY: (obj.rotation.y * 180 / Math.PI).toFixed(2) + '°',
rotZ: (obj.rotation.z * 180 / Math.PI).toFixed(2) + '°',
scaleX: obj.scale.x.toFixed(4),
scaleY: obj.scale.y.toFixed(4),
scaleZ: obj.scale.z.toFixed(4),
};
// Add USD metadata if available
if (obj.userData['primMeta.absPath']) {
transformInfo.usdPath = obj.userData['primMeta.absPath'];
}
if (obj.userData['primMeta.displayName']) {
transformInfo.displayName = obj.userData['primMeta.displayName'];
}
// Add read-only controllers
window.transformInfoFolder.add(transformInfo, 'name').name('Name').disable().listen();
window.transformInfoFolder.add(transformInfo, 'type').name('Type').disable().listen();
if (transformInfo.usdPath) {
window.transformInfoFolder.add(transformInfo, 'usdPath').name('USD Path').disable().listen();
}
if (transformInfo.displayName) {
window.transformInfoFolder.add(transformInfo, 'displayName').name('Display Name').disable().listen();
}
window.transformInfoFolder.add(transformInfo, 'posX').name('Position X').disable().listen();
window.transformInfoFolder.add(transformInfo, 'posY').name('Position Y').disable().listen();
window.transformInfoFolder.add(transformInfo, 'posZ').name('Position Z').disable().listen();
window.transformInfoFolder.add(transformInfo, 'rotX').name('Rotation X').disable().listen();
window.transformInfoFolder.add(transformInfo, 'rotY').name('Rotation Y').disable().listen();
window.transformInfoFolder.add(transformInfo, 'rotZ').name('Rotation Z').disable().listen();
window.transformInfoFolder.add(transformInfo, 'scaleX').name('Scale X').disable().listen();
window.transformInfoFolder.add(transformInfo, 'scaleY').name('Scale Y').disable().listen();
window.transformInfoFolder.add(transformInfo, 'scaleZ').name('Scale Z').disable().listen();
window.transformInfoFolder.show();
console.log('Transform info updated for:', obj.name);
}
// Store per-object animation actions for individual control
const objectAnimationActions = new Map(); // uuid -> { action, enabled }
// Play all USD animations (all channels applied together)
function playAllUSDAnimations() {
if (usdAnimations.length === 0) return;
// Ensure mixer exists - create on usdContentNode (the actual USD root) for correct UUID resolution
// The mixer MUST be created on the same root that was used for animation extraction
if (!mixer && usdContentNode) {
mixer = new THREE.AnimationMixer(usdContentNode);
console.log('Created AnimationMixer on usdContentNode for hierarchical animation support');
if (mixer.root) {
console.log('Mixer root object:', mixer.root.name, 'UUID:', mixer.root.uuid);
}
// Debug: Dump scene hierarchy
console.log('=== Scene Hierarchy ===');
dumpSceneHierarchy(usdContentNode);
console.log('=======================');
// Debug: List all objects that the mixer can potentially target by name
if (mixer.root) {
console.log('=== Objects visible to mixer (by name) ===');
mixer.root.traverse((obj) => {
if (obj.name) {
console.log(` "${obj.name}" (${obj.type}, uuid: ${obj.uuid.slice(0, 8)})`);
}
});
console.log('==========================================');
}
}
// Stop all current animations
objectAnimationActions.forEach(({action}) => action.stop());
objectAnimationActions.clear();
// All USD animation clips contain channels for different objects
// We need to play all clips together
console.log(`Playing ${usdAnimations.length} USD animation clip(s) with all channels`);
usdAnimations.forEach((clip, clipIndex) => {
if (mixer && clip) {
// Validate that all tracks can find their targets before creating the action
let allTracksValid = true;
const invalidTracks = [];
clip.tracks.forEach(track => {
// Track name format: "<uuid>.<property>"
const parts = track.name.split('.');
if (parts.length >= 2) {
const uuid = parts[0];
let found = false;
// Check in usdContentNode which is the mixer root
if (usdContentNode) {
usdContentNode.traverse(obj => {
if (obj.uuid === uuid) {
found = true;
}
});
}
if (!found) {
allTracksValid = false;
invalidTracks.push({ track: track.name, uuid: uuid });
}
}
});
if (!allTracksValid) {
console.warn(`⚠️ Clip ${clipIndex} "${clip.name}" has ${invalidTracks.length} track(s) with invalid UUIDs:`);
invalidTracks.forEach(({track, uuid}) => {
console.warn(` - Track "${track}" references UUID ${uuid.slice(0, 8)} which doesn't exist in scene`);
});
console.warn(` Skipping this clip to avoid errors.`);
return; // Skip this clip
}
const action = mixer.clipAction(clip);
action.loop = THREE.LoopRepeat;
action.play();
console.log(` Clip ${clipIndex}: ${clip.name}, ${clip.tracks.length} tracks`);
// Debug: Log track targets to verify hierarchy
console.log(' Animation tracks:', clip.tracks.map(t => t.name));
// Group tracks by target object for per-object control
clip.tracks.forEach(track => {
// Track name format: "<uuid>.<property>"
const parts = track.name.split('.');
if (parts.length >= 2) {
const uuid = parts[0];
let found = false;
// Traverse usdContentNode which is the mixer root
if (usdContentNode) {
usdContentNode.traverse(obj => {
if (obj.uuid === uuid) {
console.log(` ✓ Found target for track "${track.name}": "${obj.name}" (${obj.type})`);
found = true;
// Store action reference for this object
if (!objectAnimationActions.has(uuid)) {
objectAnimationActions.set(uuid, {
action: action,
enabled: true,
objectName: obj.name,
object: obj
});
}
}
});
}
if (!found) {
console.warn(` ✗ Target not found for track "${track.name}"`);
}
}
});
}
});
// Store the first action as the main animation action for time control
if (usdAnimations.length > 0 && mixer) {
animationAction = mixer.clipAction(usdAnimations[0]);
}
// Build scene graph UI with per-object animation controls
buildSceneGraphUI();
}
// Animation mixer and actions
let mixer = null;
let animationAction = null;
// Debug: Track object transforms during animation
let debugAnimationTracking = false;
let debugFrameCounter = 0;
const DEBUG_LOG_INTERVAL = 60; // Log every 60 frames (about 1 second at 60fps)
function debugLogObjectTransforms() {
if (!debugAnimationTracking || !usdSceneRoot) return;
debugFrameCounter++;
if (debugFrameCounter % DEBUG_LOG_INTERVAL !== 0) return;
console.log('=== Animation Transform Debug ===');
console.log(`Time: ${animationParams.time.toFixed(3)}s`);
usdSceneRoot.traverse((obj) => {
// Log transforms of named objects or objects with animation
if (obj.name && obj.name !== '' && obj !== usdSceneRoot) {
const pos = obj.position;
const rot = obj.rotation;
const scale = obj.scale;
console.log(` "${obj.name}" (${obj.type}):`, {
position: `[${pos.x.toFixed(3)}, ${pos.y.toFixed(3)}, ${pos.z.toFixed(3)}]`,
rotation: `[${rot.x.toFixed(3)}, ${rot.y.toFixed(3)}, ${rot.z.toFixed(3)}]`,
scale: `[${scale.x.toFixed(3)}, ${scale.y.toFixed(3)}, ${scale.z.toFixed(3)}]`,
uuid: obj.uuid
});
}
});
console.log('================================');
}
// Animation parameters
const animationParams = {
isPlaying: true,
playPause: function() {
this.isPlaying = !this.isPlaying;
// Pause/unpause all animation actions
if (mixer) {
// Collect unique actions
const uniqueActions = new Set();
objectAnimationActions.forEach(({action, enabled}) => {
if (action && enabled) {
uniqueActions.add(action);
}
});
// Set paused state on all unique actions
uniqueActions.forEach(action => {
action.paused = !this.isPlaying;
});
}
// Also update the main action if it exists (fallback)
if (animationAction) {
animationAction.paused = !this.isPlaying;
}
},
reset: function() {
animationParams.time = animationParams.beginTime;
animationParams.speed = 24.0;
// Reset all animation actions
if (mixer) {
// Collect unique actions
const uniqueActions = new Set();
objectAnimationActions.forEach(({action, enabled}) => {
if (action && enabled) {
uniqueActions.add(action);
}
});
// Set time on all unique actions
uniqueActions.forEach(action => {
action.time = animationParams.beginTime;
});
}
// Also reset the main action if it exists (fallback)
if (animationAction) {
animationAction.time = animationParams.beginTime;
}
},
time: 0,
beginTime: 0,
endTime: 10,
duration: 10, // timecodes
speed: 24.0, // FPS (frames per second)
// Rendering options
shadowsEnabled: true,
toggleShadows: function() {
renderer.shadowMap.enabled = this.shadowsEnabled;
directionalLight.castShadow = this.shadowsEnabled;
ground.receiveShadow = this.shadowsEnabled;
// Update all loaded USD objects
usdSceneRoot.traverse((child) => {
if (child.isMesh) {
child.castShadow = this.shadowsEnabled;
child.receiveShadow = this.shadowsEnabled;
}
});
},
// Up axis conversion (Z-up to Y-up)
applyUpAxisConversion: true,
toggleUpAxisConversion: function() {
if (this.applyUpAxisConversion && currentFileUpAxis === "Z") {
// Apply Z-up to Y-up conversion (-90 degrees around X axis)
usdSceneRoot.rotation.x = -Math.PI / 2;
console.log(`[toggleUpAxisConversion] Applied Z-up to Y-up rotation (file upAxis="${currentFileUpAxis}"): usdSceneRoot.rotation.x =`, usdSceneRoot.rotation.x);
} else {
// Reset rotation (either disabled or file is already Y-up)
usdSceneRoot.rotation.x = 0;
if (this.applyUpAxisConversion && currentFileUpAxis !== "Z") {
console.log(`[toggleUpAxisConversion] No rotation needed (file upAxis="${currentFileUpAxis}"): usdSceneRoot.rotation.x =`, usdSceneRoot.rotation.x);
} else {
console.log(`[toggleUpAxisConversion] Reset rotation (conversion disabled): usdSceneRoot.rotation.x =`, usdSceneRoot.rotation.x);
}
}
},
// Double-sided rendering
doubleSided: false,
toggleDoubleSided: function() {
// Update all loaded USD objects
usdSceneRoot.traverse((child) => {
if (child.isMesh && child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(mat => {
mat.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
mat.needsUpdate = true;
});
} else {
child.material.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
child.material.needsUpdate = true;
}
// Also update original material if stored
if (child.userData.originalMaterial) {
if (Array.isArray(child.userData.originalMaterial)) {
child.userData.originalMaterial.forEach(mat => {
mat.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
});
} else {
child.userData.originalMaterial.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
}
}
}
});
// Update ground plane
if (ground.material) {
ground.material.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
ground.material.needsUpdate = true;
if (ground.userData.originalMaterial) {
ground.userData.originalMaterial.side = this.doubleSided ? THREE.DoubleSide : THREE.FrontSide;
}
}
},
// Normal visualization
showNormals: false,
toggleNormalVisualization: function() {
// Update all loaded USD objects
usdSceneRoot.traverse((child) => {
if (child.isMesh && child.material) {
// Store original materials if switching to normal view
if (this.showNormals && !child.userData.originalMaterial) {
child.userData.originalMaterial = child.material;
// Create normal material
const normalMat = new THREE.MeshNormalMaterial({
side: this.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
flatShading: false
});
child.material = normalMat;
}
// Restore original materials if switching back
else if (!this.showNormals && child.userData.originalMaterial) {
child.material = child.userData.originalMaterial;
child.userData.originalMaterial = null;
}
}
});
// Update ground plane
if (ground.material) {
if (this.showNormals && !ground.userData.originalMaterial) {
ground.userData.originalMaterial = ground.material;
ground.material = new THREE.MeshNormalMaterial({
side: this.doubleSided ? THREE.DoubleSide : THREE.FrontSide
});
} else if (!this.showNormals && ground.userData.originalMaterial) {
ground.material = ground.userData.originalMaterial;
ground.userData.originalMaterial = null;
}
}
},
// Scene scaling
sceneScale: 1.0,
applyMetersPerUnit: true, // Apply metersPerUnit scaling from USD metadata
applySceneScale: function() {
// Calculate effective scale: user scale * metersPerUnit (if enabled)
let effectiveScale = this.sceneScale;
if (this.applyMetersPerUnit && currentSceneMetadata.metersPerUnit) {
effectiveScale *= currentSceneMetadata.metersPerUnit;
console.log(`Applying metersPerUnit: ${currentSceneMetadata.metersPerUnit} (effective scale: ${effectiveScale})`);
}
usdSceneRoot.scale.set(effectiveScale, effectiveScale, effectiveScale);
// Calculate shadow camera frustum based on actual scene bounds
// This ensures shadows work correctly regardless of model size
if (usdContentNode) {
// Compute bounding box of the USD content
const bbox = new THREE.Box3().setFromObject(usdContentNode);
// Apply current scale to the bounding box
const scaledMin = bbox.min.clone().multiplyScalar(effectiveScale);
const scaledMax = bbox.max.clone().multiplyScalar(effectiveScale);
// Add padding (20% extra) to ensure shadows aren't clipped
const padding = 1.2;
const size = scaledMax.clone().sub(scaledMin).multiplyScalar(padding / 2);
// Set frustum to cover the entire scaled scene
const maxSize = Math.max(size.x, size.y, size.z);
directionalLight.shadow.camera.left = -maxSize;
directionalLight.shadow.camera.right = maxSize;
directionalLight.shadow.camera.top = maxSize;
directionalLight.shadow.camera.bottom = -maxSize;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = maxSize * 4; // Far enough to cover tall objects
// Update the shadow camera projection matrix
directionalLight.shadow.camera.updateProjectionMatrix();
console.log(`Shadow camera frustum updated for scale ${effectiveScale}:`, {
bbox: { min: scaledMin, max: scaledMax },
frustumSize: maxSize,
far: maxSize * 4
});
} else {
// Fallback if usdContentNode not yet loaded
const baseFrustumSize = 100;
const frustumSize = baseFrustumSize * effectiveScale;
directionalLight.shadow.camera.left = -frustumSize;
directionalLight.shadow.camera.right = frustumSize;
directionalLight.shadow.camera.top = frustumSize;
directionalLight.shadow.camera.bottom = -frustumSize;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500 * effectiveScale;
directionalLight.shadow.camera.updateProjectionMatrix();
console.log(`Shadow camera frustum updated (fallback) for scale ${effectiveScale}: [-${frustumSize}, ${frustumSize}]`);
}
},
setScalePreset_0_1: function() {
this.sceneScale = 0.1;
this.applySceneScale();
},
setScalePreset_1_0: function() {
this.sceneScale = 1.0;
this.applySceneScale();
},
setScalePreset_10_0: function() {
this.sceneScale = 10.0;
this.applySceneScale();
},
// Debug animation tracking
debugAnimationLog: false,
toggleDebugAnimationLog: function() {
debugAnimationTracking = this.debugAnimationLog;
if (this.debugAnimationLog) {
console.log('Animation debug logging enabled');
debugFrameCounter = 0; // Reset counter to log immediately
} else {
console.log('Animation debug logging disabled');
}
},
// Show all helpers toggle
showHelpers: true,
toggleAllHelpers: function() {
// Update individual toggles to match
this.showAxisHelper = this.showHelpers;
this.showGroundPlane = this.showHelpers;
this.showGrid = this.showHelpers;
// Apply changes
axisHelper.visible = this.showAxisHelper;
ground.visible = this.showGroundPlane;
gridHelper.visible = this.showGrid;
console.log(`All helpers ${this.showHelpers ? 'shown' : 'hidden'}`);
},
// Ground plane Y position
groundPlaneY: 0.0,
showGroundPlane: true,
showGrid: true,
applyGroundPlaneY: function() {
ground.position.y = this.groundPlaneY;
gridHelper.position.y = this.groundPlaneY;
console.log(`Ground plane Y position set to: ${this.groundPlaneY}`);
},
toggleGroundPlane: function() {
ground.visible = this.showGroundPlane;
// Update master toggle if needed
this.updateShowHelpersMasterToggle();
},
toggleGrid: function() {
gridHelper.visible = this.showGrid;
// Update master toggle if needed
this.updateShowHelpersMasterToggle();
},
updateShowHelpersMasterToggle: function() {
// Update master toggle to reflect if all helpers are shown
this.showHelpers = this.showAxisHelper && this.showGroundPlane && this.showGrid;
},
fitGroundToScene: function() {
// Calculate scene bounding box
const bbox = new THREE.Box3();
if (usdContentNode && usdContentNode.children.length > 0) {
bbox.setFromObject(usdContentNode);
} else if (usdSceneRoot && usdSceneRoot.children.length > 0) {
bbox.setFromObject(usdSceneRoot);
} else {
console.warn('No scene content to fit ground to');
return;
}
if (bbox.isEmpty()) {
console.warn('Scene bounding box is empty');
return;
}
// Set ground plane to the minimum Y of the bounding box
this.groundPlaneY = bbox.min.y;
this.applyGroundPlaneY();
console.log(`Ground plane fitted to scene bottom: Y = ${this.groundPlaneY.toFixed(4)}`);
},
// Fit to scene
fitToScene: function() {
fitToScene();
},
// Update functions
updateDuration: function() {
this.duration = this.endTime - this.beginTime;
}
};
// GUI setup
const gui = new GUI();
gui.title('Animation Controls');
// Store references to GUI controllers for dynamic updates
let timelineController = null;
let beginTimeController = null;
let endTimeController = null;
let envPresetController = null;
// Playback controls
const playbackFolder = gui.addFolder('Playback');
playbackFolder.add(animationParams, 'playPause').name('Play / Pause');
playbackFolder.add(animationParams, 'reset').name('Reset');
playbackFolder.add(animationParams, 'speed', 0.1, 100, 0.1).name('Speed (FPS)').listen();
timelineController = playbackFolder.add(animationParams, 'time', 0, 30, 0.01)
.name('Timeline')
.listen()
.onChange((value) => {
// When user manually scrubs the timeline, update all animation actions
if (mixer) {
// Collect unique actions (multiple objects might share the same action)
const uniqueActions = new Set();
objectAnimationActions.forEach(({action, enabled}) => {
if (action && enabled) {
uniqueActions.add(action);
}
});
console.log(`Timeline scrub to ${value.toFixed(3)}s - Updating ${uniqueActions.size} unique action(s) for ${objectAnimationActions.size} object(s)`);
// Debug: Show which objects and actions are being updated
const actionToObjects = new Map();
objectAnimationActions.forEach(({action, enabled, objectName}) => {
if (action && enabled) {
if (!actionToObjects.has(action)) {
actionToObjects.set(action, []);
}
actionToObjects.get(action).push(objectName);
}
});
actionToObjects.forEach((objects, action) => {
console.log(` Action for [${objects.join(', ')}]: clip="${action.getClip().name}"`);
});
// To properly scrub all actions to a specific time:
// AnimationMixer.update(0) may not evaluate, so we use a different approach:
// 1. Set mixer's internal time to 0
// 2. Set each action's time to target
// 3. Force evaluation with a tiny non-zero delta
// 4. Restore paused state
const wasPaused = !animationParams.isPlaying;
// Stop and reset mixer's time
mixer.timeScale = 1.0;
mixer.time = 0;
// Configure all actions for the target time
uniqueActions.forEach(action => {
// Don't reset - just set time directly and ensure it's playing
action.paused = false;
action.enabled = true;
action.time = value;
action.weight = 1.0;
console.log(` Set action time to ${value.toFixed(3)}s, clip="${action.getClip().name}"`);
});
// Also update the main action if it exists
if (animationAction && !uniqueActions.has(animationAction)) {
animationAction.paused = false;
animationAction.enabled = true;
animationAction.time = value;
animationAction.weight = 1.0;
}
// Force mixer to evaluate by calling update
// Using a small non-zero value to trigger evaluation
const deltaForEval = 0.0001;
mixer.update(deltaForEval);
// Compensate for the small delta we added
uniqueActions.forEach(action => {
action.time = value; // Reset to exact target time
});
if (animationAction) {
animationAction.time = value;
}
// Restore paused state if needed
if (wasPaused) {
uniqueActions.forEach(action => {
action.paused = true;
});
if (animationAction) {
animationAction.paused = true;
}
}
console.log(`Timeline scrub completed. Mixer time: ${mixer.time.toFixed(3)}s`);
}
});
// Time range controls (nested inside Playback folder)
beginTimeController = playbackFolder.add(animationParams, 'beginTime', 0, 29, 0.1)
.name('Begin TimeCode')
.onChange(() => {
if (animationParams.beginTime >= animationParams.endTime) {
animationParams.beginTime = animationParams.endTime - 0.1;
}
animationParams.updateDuration();
});
endTimeController = playbackFolder.add(animationParams, 'endTime', 0.1, 30, 0.1)
.name('End TimeCode')
.onChange(() => {
if (animationParams.endTime <= animationParams.beginTime) {
animationParams.endTime = animationParams.beginTime + 0.1;
}
animationParams.updateDuration();
});
playbackFolder.add(animationParams, 'duration', 0.1, 30, 0.1)
.name('Duration')
.listen()
.disable();
playbackFolder.open();
// Rendering controls
const renderingFolder = gui.addFolder('Rendering');
renderingFolder.add(animationParams, 'shadowsEnabled')
.name('Shadows')
.onChange(() => animationParams.toggleShadows());
renderingFolder.add(animationParams, 'applyUpAxisConversion')
.name('Z-up to Y-up')
.onChange(() => animationParams.toggleUpAxisConversion());
renderingFolder.add(animationParams, 'doubleSided')
.name('Double-Sided')
.onChange(() => animationParams.toggleDoubleSided());
renderingFolder.add(animationParams, 'showNormals')
.name('Show Normals')
.onChange(() => animationParams.toggleNormalVisualization());
// Add master helpers toggle
renderingFolder.add(animationParams, 'showHelpers')
.name('🔧 Show Helpers (All)')
.onChange(() => animationParams.toggleAllHelpers());
// Add axis helper toggle
animationParams.showAxisHelper = true;
animationParams.toggleAxisHelper = function() {
axisHelper.visible = this.showAxisHelper;
// Update master toggle if needed
this.updateShowHelpersMasterToggle();
};
renderingFolder.add(animationParams, 'showAxisHelper')
.name('Show Axis')
.onChange(() => animationParams.toggleAxisHelper());
// Ground plane controls
const groundFolder = renderingFolder.addFolder('Ground Plane');
groundFolder.add(animationParams, 'showGroundPlane')
.name('Show Ground')
.onChange(() => animationParams.toggleGroundPlane());
groundFolder.add(animationParams, 'showGrid')
.name('Show Grid')
.onChange(() => animationParams.toggleGrid());
groundFolder.add(animationParams, 'groundPlaneY', -1000, 1000, 0.01)
.name('Y Position')
.onChange(() => animationParams.applyGroundPlaneY())
.listen();
groundFolder.add(animationParams, 'fitGroundToScene')
.name('Fit to Scene Bottom');
groundFolder.open();
renderingFolder.add(animationParams, 'fitToScene')
.name('Fit to Scene');
// Scene scaling controls
const scaleFolder = renderingFolder.addFolder('Scene Scale');
scaleFolder.add(animationParams, 'sceneScale', 0.01, 100, 0.01)
.name('Scale')
.onChange(() => animationParams.applySceneScale())
.listen();
scaleFolder.add(animationParams, 'applyMetersPerUnit')
.name('Apply metersPerUnit')
.onChange(() => animationParams.applySceneScale());
scaleFolder.add(animationParams, 'setScalePreset_0_1').name('Scale: 1/10 (0.1x)');
scaleFolder.add(animationParams, 'setScalePreset_1_0').name('Scale: 1/1 (1.0x)');
scaleFolder.add(animationParams, 'setScalePreset_10_0').name('Scale: 10/1 (10x)');
scaleFolder.open();
renderingFolder.open();
// ===========================================
// Material & Environment GUI
// ===========================================
const materialFolder = gui.addFolder('Material & Environment');
// Material type selector
materialFolder.add(materialSettings, 'materialType', ['auto', 'openpbr', 'usdpreviewsurface'])
.name('Material Type')
.onChange(() => reloadMaterials());
// Environment preset selector
envPresetController = materialFolder.add(materialSettings, 'envMapPreset', Object.keys(ENV_PRESETS))
.name('Environment')
.onChange((value) => loadEnvironment(value));
// Constant color environment color picker
materialFolder.addColor(materialSettings, 'envConstantColor')
.name('Env Color')
.onChange(() => updateConstantColorEnvironment());
// Environment colorspace workflow
materialFolder.add(materialSettings, 'envColorspace', ['sRGB', 'linear'])
.name('Env Colorspace')
.onChange(() => updateConstantColorEnvironment());
// Environment intensity
materialFolder.add(materialSettings, 'envMapIntensity', 0, 3, 0.1)
.name('Env Intensity')
.onChange(() => updateEnvIntensity());
// Show environment as background
materialFolder.add(materialSettings, 'showEnvBackground')
.name('Show Env Background')
.onChange(() => updateEnvBackground());
// Exposure control
materialFolder.add(materialSettings, 'exposure', 0, 3, 0.1)
.name('Exposure')
.onChange((value) => {
renderer.toneMappingExposure = value;
});
// Tone mapping selector
materialFolder.add(materialSettings, 'toneMapping', ['none', 'linear', 'reinhard', 'cineon', 'aces', 'agx', 'neutral'])
.name('Tone Mapping')
.onChange((value) => updateToneMapping(value));
// Reload materials button
materialFolder.add({ reload: () => reloadMaterials() }, 'reload')
.name('Reload Materials');
materialFolder.open();
// Scene Metadata - will be populated dynamically
const metadataFolder = gui.addFolder('Scene Metadata');
window.metadataFolder = metadataFolder;
metadataFolder.hide(); // Hide until scene is loaded
// Transform Info - will be populated when object is selected
const transformInfoFolder = gui.addFolder('Transform Info');
window.transformInfoFolder = transformInfoFolder;
transformInfoFolder.hide(); // Hide until object is selected
// Scene Graph Tree - will be populated dynamically
const sceneGraphFolder = gui.addFolder('Scene Graph');
window.sceneGraphFolder = sceneGraphFolder;
sceneGraphFolder.hide(); // Hide until scene is loaded
// Function to update time range GUI controllers when animation is loaded
function updateTimeRangeGUIControllers(maxDuration) {
const newMax = Math.max(maxDuration, 30); // Ensure minimum of 30s for usability
// Update timeline controller
if (timelineController) {
timelineController.max(newMax);
timelineController.updateDisplay();
}
// Update begin time controller
if (beginTimeController) {
beginTimeController.max(newMax - 0.1);
beginTimeController.updateDisplay();
}
// Update end time controller
if (endTimeController) {
endTimeController.max(newMax);
endTimeController.updateDisplay();
}
console.log(`Updated GUI time range to 0-${newMax}s`);
}
// Function to update scene metadata UI
function updateMetadataUI() {
if (!window.metadataFolder) return;
// Clear existing controls
window.metadataFolder.controllers.forEach(c => c.destroy());
// Create read-only display object
const metadataDisplay = {
upAxis: currentSceneMetadata.upAxis,
metersPerUnit: currentSceneMetadata.metersPerUnit,
framesPerSecond: currentSceneMetadata.framesPerSecond,
timeCodesPerSecond: currentSceneMetadata.timeCodesPerSecond,
startTimeCode: currentSceneMetadata.startTimeCode !== null ? currentSceneMetadata.startTimeCode.toFixed(2) : "N/A",
endTimeCode: currentSceneMetadata.endTimeCode !== null ? currentSceneMetadata.endTimeCode.toFixed(2) : "N/A",
autoPlay: currentSceneMetadata.autoPlay,
comment: currentSceneMetadata.comment || "N/A",
copyright: currentSceneMetadata.copyright || "N/A"
};
// Add read-only controllers
window.metadataFolder.add(metadataDisplay, 'upAxis').name('Up Axis').disable().listen();
window.metadataFolder.add(metadataDisplay, 'metersPerUnit').name('Meters Per Unit').disable().listen();
window.metadataFolder.add(metadataDisplay, 'framesPerSecond').name('FPS').disable().listen();
window.metadataFolder.add(metadataDisplay, 'timeCodesPerSecond').name('Timecodes/sec').disable().listen();
window.metadataFolder.add(metadataDisplay, 'startTimeCode').name('Start TimeCode').disable().listen();
window.metadataFolder.add(metadataDisplay, 'endTimeCode').name('End TimeCode').disable().listen();
window.metadataFolder.add(metadataDisplay, 'autoPlay').name('Auto Play').disable().listen();
if (currentSceneMetadata.comment) {
window.metadataFolder.add(metadataDisplay, 'comment').name('Comment').disable().listen();
}
if (currentSceneMetadata.copyright) {
window.metadataFolder.add(metadataDisplay, 'copyright').name('Copyright').disable().listen();
}
window.metadataFolder.show();
console.log('Scene metadata UI updated');
}
// Fit camera, grid, and shadows to scene bounds
function fitToScene() {
// Compute bounding box of USD scene content
const bbox = new THREE.Box3();
if (usdContentNode && usdContentNode.children.length > 0) {
bbox.setFromObject(usdContentNode);
} else if (usdSceneRoot && usdSceneRoot.children.length > 0) {
bbox.setFromObject(usdSceneRoot);
} else {
console.warn('No scene content to fit to');
return;
}
// Check if bounding box is valid
if (bbox.isEmpty()) {
console.warn('Scene bounding box is empty');
return;
}
// Get bounding box dimensions
const size = new THREE.Vector3();
const center = new THREE.Vector3();
bbox.getSize(size);
bbox.getCenter(center);
console.log('Scene bounds:', {
min: bbox.min,
max: bbox.max,
size: size,
center: center
});
// Calculate the maximum dimension
const maxDim = Math.max(size.x, size.y, size.z);
// Calculate camera distance to fit the scene
// Use field of view to determine appropriate distance
const fov = camera.fov * (Math.PI / 180); // Convert to radians
const cameraDistance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.7; // 0.7 for some padding
// Position camera at a nice viewing angle (similar to current position ratio)
const cameraOffset = new THREE.Vector3(1, 1, 1).normalize().multiplyScalar(cameraDistance);
const newCameraPos = center.clone().add(cameraOffset);
// Update camera position
camera.position.copy(newCameraPos);
// Update OrbitControls target to look at scene center
controls.target.copy(center);
controls.update();
console.log('Camera fitted:', {
position: newCameraPos,
target: center,
distance: cameraDistance
});
// Update grid size to match scene bounds (with some padding)
const gridSize = Math.ceil(maxDim * 2.5); // 2.5x padding for context
const gridDivisions = 20;
// Remove old grid
scene.remove(gridHelper);
// Create new grid at the bottom of the scene (bbox minimum Y)
gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x666666, 0x444444);
gridHelper.position.y = bbox.min.y;
scene.add(gridHelper);
console.log('Grid updated:', {
size: gridSize,
divisions: gridDivisions,
divisionSize: gridSize / gridDivisions,
groundY: bbox.min.y
});
// Update ground plane to match grid
ground.geometry.dispose();
ground.geometry = new THREE.PlaneGeometry(gridSize, gridSize);
ground.position.y = bbox.min.y;
// Update ground plane Y parameter in UI
animationParams.groundPlaneY = bbox.min.y;
// Update shadow frustum to cover the scene (with padding)
const shadowSize = maxDim * 1.5; // 1.5x padding for shadows
directionalLight.shadow.camera.left = -shadowSize;
directionalLight.shadow.camera.right = shadowSize;
directionalLight.shadow.camera.top = shadowSize;
directionalLight.shadow.camera.bottom = -shadowSize;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = cameraDistance * 3;
directionalLight.shadow.camera.updateProjectionMatrix();
// Position directional light relative to scene
const lightDistance = cameraDistance * 0.8;
directionalLight.position.set(
center.x + lightDistance * 0.5,
center.y + lightDistance,
center.z + lightDistance * 0.5
);
console.log('Shadows updated:', {
frustumSize: shadowSize,
lightPosition: directionalLight.position,
far: cameraDistance * 3
});
console.log('✓ Fit to scene complete');
}
// Info folder
const infoFolder = gui.addFolder('Info');
const info = {
fps: 0,
objects: scene.children.length
};
infoFolder.add(info, 'fps').name('FPS').listen().disable();
infoFolder.add(info, 'objects').name('Objects').listen().disable();
infoFolder.add(animationParams, 'debugAnimationLog')
.name('Debug Animation Log')
.onChange(() => animationParams.toggleDebugAnimationLog());
infoFolder.open();
// Window resize handler
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Mouse click handler for object selection
window.addEventListener('click', onMouseClick, false);
function onMouseClick(event) {
// Ignore clicks on GUI
const guiElement = document.querySelector('.lil-gui');
if (guiElement && guiElement.contains(event.target)) {
return;
}
// Calculate mouse position in normalized device coordinates (-1 to +1)
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// Update the picking ray with the camera and mouse position
raycaster.setFromCamera(mouse, camera);
// Calculate objects intersecting the picking ray
// Only check USD scene objects, not helpers/grid/ground
const intersectables = [];
if (usdSceneRoot) {
usdSceneRoot.traverse((obj) => {
if (obj.isMesh) {
intersectables.push(obj);
}
});
}
const intersects = raycaster.intersectObjects(intersectables, false);
if (intersects.length > 0) {
// Select the first intersected object
const selectedObj = intersects[0].object;
selectObject(selectedObj);
console.log('Clicked object:', selectedObj.name);
} else {
// Deselect if clicking on empty space
if (selectedObject) {
selectedObject = null;
if (selectionHelper) {
scene.remove(selectionHelper);
if (selectionHelper.geometry) selectionHelper.geometry.dispose();
if (selectionHelper.material) selectionHelper.material.dispose();
selectionHelper = null;
}
updateTransformInfoUI(null);
console.log('Deselected object');
}
}
}
// Function to load a USD file from ArrayBuffer
async function loadUSDFromArrayBuffer(arrayBuffer, filename) {
// Initialize PBR renderer if not already done
if (!pmremGenerator) {
initializePBRRenderer();
// Load default environment
await loadEnvironment(materialSettings.envMapPreset);
}
// Clear existing USD scene
while (usdSceneRoot.children.length > 0) {
const child = usdSceneRoot.children[0];
// Dispose geometries and materials
child.traverse((obj) => {
if (obj.isMesh) {
obj.geometry?.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
}
});
usdSceneRoot.remove(child);
}
// Clear selection
selectedObject = null;
if (selectionHelper) {
scene.remove(selectionHelper);
if (selectionHelper.geometry) selectionHelper.geometry.dispose();
if (selectionHelper.material) selectionHelper.material.dispose();
selectionHelper = null;
}
updateTransformInfoUI(null);
// Clear bounding box helpers
objectBBoxHelpers.forEach((helper) => {
scene.remove(helper);
helper.geometry.dispose();
if (helper.material) {
helper.material.dispose();
}
});
objectBBoxHelpers.clear();
// Stop animation playback
animationParams.isPlaying = false;
// Stop and clear animation mixer
if (mixer) {
mixer.stopAllAction();
mixer = null;
}
animationAction = null;
// Reset animations
usdAnimations = [];
// Dispose textures in cache
textureCache.forEach((texture) => {
if (texture && texture.dispose) {
texture.dispose();
}
});
textureCache.clear();
// Clean up WASM memory from previous load
if (currentUSDScene) {
try {
// Try to delete the USD scene if it has a delete method
if (typeof currentUSDScene.delete === 'function') {
currentUSDScene.delete();
console.log('USD scene deleted');
}
} catch (e) {
console.warn('Could not delete USD scene:', e);
}
currentUSDScene = null;
}
// Clean up previous loader
if (currentLoader) {
try {
// Try to access native loader for memory cleanup
if (currentLoader.native_ && typeof currentLoader.native_.reset === 'function') {
currentLoader.native_.reset();
console.log('WASM memory reset via native loader');
} else if (currentLoader.native_ && typeof currentLoader.native_.clearAssets === 'function') {
currentLoader.native_.clearAssets();
console.log('WASM assets cleared via native loader');
}
} catch (e) {
console.warn('Could not reset WASM memory:', e);
}
currentLoader = null;
}
// Clear USD DomeLight data
usdDomeLightData = null;
const loader = new TinyUSDZLoader();
await loader.init({ useZstdCompressedWasm: false, useMemory64: false });
currentLoader = loader; // Store reference for cleanup
// Create a Blob URL from the ArrayBuffer
// This allows the loader to load the file as if it were a normal URL
const blob = new Blob([arrayBuffer]);
const blobUrl = URL.createObjectURL(blob);
console.log(`Loading USD from file: ${filename} (${(arrayBuffer.byteLength / 1024).toFixed(2)} KB)`);
// Load USD scene from Blob URL
const usd_scene = await loader.loadAsync(blobUrl);
currentUSDScene = usd_scene; // Store reference for cleanup
// Clean up the Blob URL after loading
URL.revokeObjectURL(blobUrl);
// Get the default root node from USD
const usdRootNode = usd_scene.getDefaultRootNode();
// Get scene metadata from the USD file
const sceneMetadata = usd_scene.getSceneMetadata ? usd_scene.getSceneMetadata() : {};
const fileUpAxis = sceneMetadata.upAxis || "Y";
currentFileUpAxis = fileUpAxis; // Store globally for toggle function
// Store metadata globally
currentSceneMetadata = {
upAxis: fileUpAxis,
metersPerUnit: sceneMetadata.metersPerUnit || 1.0,
framesPerSecond: sceneMetadata.framesPerSecond || 24.0,
timeCodesPerSecond: sceneMetadata.timeCodesPerSecond || 24.0,
startTimeCode: sceneMetadata.startTimeCode,
endTimeCode: sceneMetadata.endTimeCode,
autoPlay: sceneMetadata.autoPlay !== undefined ? sceneMetadata.autoPlay : true,
comment: sceneMetadata.comment || "",
copyright: sceneMetadata.copyright || ""
};
console.log('=== USD Scene Metadata ===');
console.log(`upAxis: "${currentSceneMetadata.upAxis}"`);
console.log(`metersPerUnit: ${currentSceneMetadata.metersPerUnit}`);
console.log(`framesPerSecond: ${currentSceneMetadata.framesPerSecond}`);
console.log(`timeCodesPerSecond: ${currentSceneMetadata.timeCodesPerSecond}`);
if (currentSceneMetadata.startTimeCode !== null && currentSceneMetadata.startTimeCode !== undefined) {
console.log(`startTimeCode: ${currentSceneMetadata.startTimeCode}`);
}
if (currentSceneMetadata.endTimeCode !== null && currentSceneMetadata.endTimeCode !== undefined) {
console.log(`endTimeCode: ${currentSceneMetadata.endTimeCode}`);
}
console.log(`autoPlay: ${currentSceneMetadata.autoPlay}`);
if (currentSceneMetadata.comment) {
console.log(`comment: "${currentSceneMetadata.comment}"`);
}
if (currentSceneMetadata.copyright) {
console.log(`copyright: "${currentSceneMetadata.copyright}"`);
}
console.log('========================');
// Update metadata UI
updateMetadataUI();
// Try to load DomeLight environment from USD
try {
const domeLightData = await loadDomeLightFromUSD(usd_scene);
if (domeLightData) {
console.log('Loaded DomeLight from USD:', domeLightData);
if (envPresetController) {
envPresetController.updateDisplay();
}
}
} catch (error) {
console.warn('Error checking for DomeLight:', error);
}
// Create default material with environment map
const defaultMtl = new THREE.MeshPhysicalMaterial({
color: 0x888888,
roughness: 0.5,
metalness: 0.0,
envMap: envMap,
envMapIntensity: materialSettings.envMapIntensity
});
// Clear texture cache for fresh load
textureCache.clear();
const options = {
overrideMaterial: false,
envMap: envMap,
envMapIntensity: materialSettings.envMapIntensity,
preferredMaterialType: materialSettings.materialType,
textureCache: textureCache,
storeMaterialData: true
};
// Build Three.js node from USD with MaterialX/OpenPBR support
const threeNode = await TinyUSDZLoaderUtils.buildThreeNode(usdRootNode, defaultMtl, usd_scene, options);
// Store USD scene reference for material reloading
threeNode.traverse((child) => {
if (child.isMesh) {
child.userData.usdScene = usd_scene;
}
});
// Store reference to USD content node for mixer creation
usdContentNode = threeNode;
// Add loaded USD scene to usdSceneRoot
usdSceneRoot.add(threeNode);
// Debug: Log initial transforms of all objects
console.log('=== Initial Object Transforms ===');
threeNode.traverse((obj) => {
if (obj.name && obj.name !== '') {
console.log(`Object "${obj.name}": position=[${obj.position.x.toFixed(3)}, ${obj.position.y.toFixed(3)}, ${obj.position.z.toFixed(3)}], scale=[${obj.scale.x.toFixed(3)}, ${obj.scale.y.toFixed(3)}, ${obj.scale.z.toFixed(3)}]`);
}
});
console.log('=================================');
// Apply Z-up to Y-up conversion if enabled AND the file is actually Z-up
if (animationParams.applyUpAxisConversion && fileUpAxis === "Z") {
usdSceneRoot.rotation.x = -Math.PI / 2;
console.log(`[loadUSDFromArrayBuffer] Applied Z-up to Y-up conversion (file upAxis="${fileUpAxis}"): rotation.x =`, usdSceneRoot.rotation.x);
} else if (animationParams.applyUpAxisConversion && fileUpAxis !== "Y") {
console.warn(`[loadUSDFromArrayBuffer] File upAxis is "${fileUpAxis}" (not Y or Z), no rotation applied`);
} else {
console.log(`[loadUSDFromArrayBuffer] No upAxis conversion needed (file upAxis="${fileUpAxis}", conversion ${animationParams.applyUpAxisConversion ? 'enabled' : 'disabled'})`);
}
// Apply scene scale and update shadow frustum based on model bounds
animationParams.applySceneScale();
// Traverse and enable shadows for all meshes
usdSceneRoot.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// Extract USD animations if available
try {
const animationInfos = usd_scene.getAllAnimationInfos();
// IMPORTANT: Pass threeNode (the USD root) for correct node index mapping
// The node indices in USD animations reference nodes within the USD scene hierarchy
usdAnimations = convertUSDAnimationsToThreeJS(usd_scene, threeNode);
if (usdAnimations.length > 0) {
console.log(`Extracted ${usdAnimations.length} animations from USD file`);
// Animation parameters updated automatically via playAllUSDAnimations()
// Log animation details
usdAnimations.forEach((clip, index) => {
const info = animationInfos[index];
let typeStr = '';
if (info) {
const types = [];
if (info.has_skeletal_animation) types.push('skeletal');
if (info.has_node_animation) types.push('node');
if (types.length > 0) typeStr = ` [${types.join('+')}]`;
}
console.log(`Animation ${index}: ${clip.name}, duration: ${clip.duration}s, tracks: ${clip.tracks.length}${typeStr}`);
});
// Set time range from metadata or first USD animation
let timeRangeSource = "animation";
let beginTime = 0;
let endTime = 0;
// Prefer metadata startTimeCode/endTimeCode if available
if (currentSceneMetadata.startTimeCode !== null && currentSceneMetadata.startTimeCode !== undefined &&
currentSceneMetadata.endTimeCode !== null && currentSceneMetadata.endTimeCode !== undefined) {
beginTime = currentSceneMetadata.startTimeCode;
endTime = currentSceneMetadata.endTimeCode;
timeRangeSource = "metadata";
} else {
// Fallback to first animation clip duration
const firstClip = usdAnimations[0];
if (firstClip && firstClip.duration > 0) {
beginTime = 0;
endTime = firstClip.duration;
}
}
if (endTime > beginTime) {
animationParams.beginTime = beginTime;
animationParams.endTime = endTime;
animationParams.duration = endTime - beginTime;
animationParams.time = beginTime; // Reset time to beginning
console.log(`Set time range from ${timeRangeSource}: ${beginTime}s - ${endTime}s`);
// Update GUI controllers if they exist
updateTimeRangeGUIControllers(endTime);
}
// Set playback speed (FPS) from framesPerSecond metadata
const fps = currentSceneMetadata.framesPerSecond || 24.0;
animationParams.speed = fps;
console.log(`Set animation speed (FPS) from metadata: ${fps}`);
// Play all USD animations automatically
playAllUSDAnimations();
} else {
// No USD animations found
console.log('No USD animations found in USD file');
// Still build scene graph UI for static scenes
buildSceneGraphUI();
}
} catch (error) {
console.log('No animations found in USD file or animation extraction not supported:', error);
// Still build scene graph UI for static scenes
buildSceneGraphUI();
}
}
// Listen for file upload events
window.addEventListener('loadUSDFile', async (event) => {
const file = event.detail.file;
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
await loadUSDFromArrayBuffer(arrayBuffer, file.name);
console.log('USD file loaded successfully:', file.name);
// Hide loading indicator
if (window.hideLoadingIndicator) {
window.hideLoadingIndicator();
}
} catch (error) {
console.error('Failed to load USD file:', error);
alert('Failed to load USD file: ' + error.message);
// Hide loading indicator on error too
if (window.hideLoadingIndicator) {
window.hideLoadingIndicator();
}
}
});
// Listen for default model reload
window.addEventListener('loadDefaultModel', async () => {
try {
await loadUSDModel();
console.log('Default model reloaded');
} catch (error) {
console.error('Failed to reload default model:', error);
}
});
// Load USD model
loadUSDModel().catch((error) => {
console.error('Failed to load USD model:', error);
alert('Failed to load USD file: ' + error.message);
});
// FPS calculation
let lastTime = performance.now();
let frames = 0;
let fpsUpdateTime = 0;
// Animation loop
function animate() {
requestAnimationFrame(animate);
const currentTime = performance.now();
const deltaTime = (currentTime - lastTime) / 1000; // Convert to seconds
lastTime = currentTime;
// Update FPS
frames++;
fpsUpdateTime += deltaTime;
if (fpsUpdateTime >= 0.5) {
info.fps = Math.round(frames / fpsUpdateTime);
frames = 0;
fpsUpdateTime = 0;
}
// Update animation time with begin/end range
if (animationParams.isPlaying) {
animationParams.time += deltaTime * animationParams.speed;
// Loop within begin/end range
if (animationParams.time > animationParams.endTime) {
animationParams.time = animationParams.beginTime;
// Sync all animation actions to the new time
if (mixer) {
// Collect unique actions
const uniqueActions = new Set();
objectAnimationActions.forEach(({action, enabled}) => {
if (action && enabled) {
uniqueActions.add(action);
}
});
// Set time on all unique actions
uniqueActions.forEach(action => {
action.time = animationParams.beginTime;
});
}
// Also update the main action if it exists (fallback)
if (animationAction) {
animationAction.time = animationParams.beginTime;
}
}
if (animationParams.time < animationParams.beginTime) {
animationParams.time = animationParams.beginTime;
}
}
// Update the mixer for KeyframeTrack animations
if (mixer && animationAction && animationParams.isPlaying) {
mixer.update(deltaTime * animationParams.speed);
}
// Debug: Log object transforms periodically
debugLogObjectTransforms();
// Update bounding boxes for objects that are being displayed
objectBBoxHelpers.forEach((helper, uuid) => {
if (helper.visible) {
// Find the object and update its bbox
usdSceneRoot.traverse(obj => {
if (obj.uuid === uuid) {
updateBoundingBox(obj);
}
});
}
});
// Update selection helper to follow selected object
if (selectionHelper && selectedObject) {
const bbox = new THREE.Box3().setFromObject(selectedObject);
selectionHelper.box = bbox;
}
// Update controls
controls.update();
// Render
renderer.render(scene, camera);
}
// Start animation
animate();