mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
2835 lines
90 KiB
JavaScript
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();
|