Files
PozzettiAndrea-ComfyUI-Geom…/javascript/viewer_fbx_debug.html
T
PozzettiAndrea 9ebf36b571 Rename viewer bundles .js -> .mjs so they stop leaking into the main realm
ComfyUI auto-imports every web/**/*.js into the shared main page; the four
big bundles (three, vtk-gltf, viewer-bundle-vtk, gsplat) only ever run
inside the viewer iframes but were getting pulled into the main document
too, where they wrote window.THREE / window.vtk / globalThis.* and could
collide with any other 3D pack. The **/*.js glob only matches .js, so
renaming to .mjs drops them from the auto-import while the iframes still
load them via classic <script src> (mime resolves to text/javascript).
window.* pollution from these bundles is gone from the main realm.
2026-08-13 11:41:01 +02:00

1937 lines
79 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>FBX Debug Viewer - Bone Roll & Animation</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #2a2a2a;
font-family: monospace;
width: 100%;
height: 100vh;
}
#viewerContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
text-align: center;
z-index: 10;
}
/* Bone Info Panel - Right Side */
#boneInfoPanel {
position: absolute;
top: 10px;
right: 10px;
width: 280px;
max-height: calc(100vh - 250px);
background: rgba(26, 26, 26, 0.95);
border: 1px solid #444;
border-radius: 5px;
padding: 10px;
color: white;
font-size: 11px;
overflow-y: auto;
z-index: 100;
display: none;
}
#boneInfoPanel.visible {
display: block;
}
#boneInfoPanel h3 {
margin: 0 0 10px 0;
padding-bottom: 5px;
border-bottom: 1px solid #444;
font-size: 13px;
color: #00ffff;
}
#boneInfoPanel .info-section {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #333;
}
#boneInfoPanel .info-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
#boneInfoPanel .info-label {
color: #888;
font-size: 10px;
margin-bottom: 2px;
}
#boneInfoPanel .info-value {
color: #fff;
font-family: monospace;
margin-bottom: 5px;
}
#boneInfoPanel .roll-highlight {
color: #ff6600;
font-weight: bold;
font-size: 14px;
}
#boneInfoPanel .axis-x { color: #ff4444; }
#boneInfoPanel .axis-y { color: #44ff44; }
#boneInfoPanel .axis-z { color: #4444ff; }
/* Controls Panel - Bottom */
#controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(26, 26, 26, 0.95);
border-top: 1px solid #444;
padding: 10px;
color: white;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 100;
max-height: 45%;
overflow-y: auto;
}
#controls button {
background: #4a4a4a;
color: white;
border: none;
padding: 8px 12px;
border-radius: 3px;
cursor: pointer;
font-family: monospace;
font-size: 12px;
white-space: nowrap;
}
#controls button:hover {
background: #5a5a5a;
}
#controls button:active {
background: #3a3a3a;
}
#controls button.active {
background: #007acc;
}
.control-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.control-section {
border-top: 1px solid #333;
padding-top: 8px;
margin-top: 4px;
}
.control-section-title {
color: #888;
font-size: 10px;
margin-bottom: 5px;
text-transform: uppercase;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
padding: 3px 0;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.slider-group {
display: flex;
align-items: center;
gap: 8px;
}
.slider-group label {
min-width: 70px;
font-size: 10px;
color: #aaa;
}
.slider-group input[type="range"] {
width: 100px;
cursor: pointer;
}
.slider-group .slider-value {
min-width: 40px;
font-size: 10px;
color: #fff;
}
#boneFilter {
background: #333;
border: 1px solid #555;
color: white;
padding: 5px 8px;
border-radius: 3px;
font-family: monospace;
font-size: 11px;
width: 150px;
}
#boneFilter::placeholder {
color: #666;
}
#status {
color: #888;
font-size: 11px;
margin-top: 5px;
}
/* Animation Controls */
#animationControls {
display: none;
border: 1px solid #555;
border-radius: 5px;
padding: 8px;
background: rgba(40, 40, 40, 0.9);
}
#animationControls.visible {
display: block;
}
#animationControls .anim-title {
color: #ffaa00;
font-weight: bold;
margin-bottom: 8px;
}
#animationSelect {
background: #333;
border: 1px solid #555;
color: white;
padding: 5px 8px;
border-radius: 3px;
font-family: monospace;
font-size: 11px;
min-width: 150px;
}
#timeline {
width: 100%;
cursor: pointer;
}
#timeDisplay {
min-width: 80px;
text-align: center;
font-family: monospace;
}
.playback-buttons {
display: flex;
gap: 5px;
}
.playback-buttons button {
padding: 5px 10px;
font-size: 14px;
}
</style>
</head>
<body>
<div id="viewerContainer">
<canvas id="canvas"></canvas>
<div id="loading">Ready to load rigged mesh</div>
</div>
<!-- Bone Info Panel - Right Side -->
<div id="boneInfoPanel">
<h3>Bone Info</h3>
<div class="info-section">
<div class="info-label">Name</div>
<div class="info-value" id="boneName">-</div>
<div class="info-label">Parent</div>
<div class="info-value" id="boneParent">-</div>
<div class="info-label">Index</div>
<div class="info-value" id="boneIndex">-</div>
</div>
<div class="info-section">
<div class="info-label">Roll Angle (Z-rotation)</div>
<div class="info-value roll-highlight" id="boneRoll">-</div>
</div>
<div class="info-section">
<div class="info-label">Local Position</div>
<div class="info-value">
<span class="axis-x">X:</span> <span id="localPosX">-</span>
<span class="axis-y">Y:</span> <span id="localPosY">-</span>
<span class="axis-z">Z:</span> <span id="localPosZ">-</span>
</div>
<div class="info-label">Local Rotation (Euler XYZ)</div>
<div class="info-value">
<span class="axis-x">X:</span> <span id="localRotX">-</span>
<span class="axis-y">Y:</span> <span id="localRotY">-</span>
<span class="axis-z">Z:</span> <span id="localRotZ">-</span>
</div>
<div class="info-label">Local Quaternion</div>
<div class="info-value" id="localQuat">-</div>
<div class="info-label">Local Scale</div>
<div class="info-value" id="localScale">-</div>
</div>
<div class="info-section">
<div class="info-label">World Position</div>
<div class="info-value">
<span class="axis-x">X:</span> <span id="worldPosX">-</span>
<span class="axis-y">Y:</span> <span id="worldPosY">-</span>
<span class="axis-z">Z:</span> <span id="worldPosZ">-</span>
</div>
<div class="info-label">World Rotation (Euler XYZ)</div>
<div class="info-value">
<span class="axis-x">X:</span> <span id="worldRotX">-</span>
<span class="axis-y">Y:</span> <span id="worldRotY">-</span>
<span class="axis-z">Z:</span> <span id="worldRotZ">-</span>
</div>
</div>
<div class="info-section">
<div class="info-label">Bone Length</div>
<div class="info-value" id="boneLength">-</div>
<div class="info-label">Children</div>
<div class="info-value" id="boneChildren">-</div>
</div>
</div>
<div id="controls">
<div><strong>FBX Debug Viewer</strong> - Bone Roll & Animation Debugging</div>
<!-- Animation Controls -->
<div id="animationControls">
<div class="anim-title">Animation Playback</div>
<div class="control-group">
<select id="animationSelect">
<option value="">No animations</option>
</select>
</div>
<div class="control-group" style="margin-top: 8px;">
<div class="playback-buttons">
<button id="playBtn" title="Play">&#9654;</button>
<button id="pauseBtn" title="Pause">&#10074;&#10074;</button>
<button id="stopBtn" title="Stop">&#9632;</button>
<button id="prevFrameBtn" title="Previous Frame">&#9664;</button>
<button id="nextFrameBtn" title="Next Frame">&#9654;</button>
</div>
<input type="range" id="timeline" min="0" max="100" value="0" step="0.1">
<span id="timeDisplay">0.00s / 0.00s</span>
</div>
<div class="control-group" style="margin-top: 5px;">
<div class="slider-group">
<label>Speed:</label>
<input type="range" id="speedSlider" min="0.1" max="3" value="1" step="0.1">
<span class="slider-value" id="speedValue">1.0x</span>
</div>
<label class="checkbox-label">
<input type="checkbox" id="loopAnimation" checked>
<span>Loop</span>
</label>
</div>
</div>
<!-- Visualization Controls -->
<div class="control-section">
<div class="control-section-title">Visualization</div>
<div class="control-group">
<label class="checkbox-label">
<input type="checkbox" id="showSkeleton" checked>
<span>Skeleton</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="showMesh" checked>
<span>Mesh</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="xraySkeleton" checked>
<span>X-Ray</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="showBoneAxes" checked>
<span>Bone Axes</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="showBoneNames">
<span>Bone Names</span>
</label>
</div>
</div>
<!-- Size Controls -->
<div class="control-section">
<div class="control-section-title">Size Controls</div>
<div class="control-group">
<div class="slider-group">
<label>Bone Size:</label>
<input type="range" id="boneSizeSlider" min="0.5" max="3" value="1" step="0.1">
<span class="slider-value" id="boneSizeValue">1.0x</span>
</div>
<div class="slider-group">
<label>Axis Size:</label>
<input type="range" id="axisSizeSlider" min="0.5" max="5" value="1" step="0.1">
<span class="slider-value" id="axisSizeValue">1.0x</span>
</div>
<div class="slider-group">
<label>Label Size:</label>
<input type="range" id="labelSizeSlider" min="0.5" max="3" value="1" step="0.1">
<span class="slider-value" id="labelSizeValue">1.0x</span>
</div>
</div>
</div>
<!-- Filter -->
<div class="control-section">
<div class="control-section-title">Bone Filter</div>
<div class="control-group">
<input type="text" id="boneFilter" placeholder="Filter bones by name...">
<span id="filteredCount">0/0 bones</span>
</div>
</div>
<!-- Scene Controls -->
<div class="control-section">
<div class="control-section-title">Scene</div>
<div class="control-group">
<label class="checkbox-label">
<input type="checkbox" id="showGrid" checked>
<span>Grid</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="showAxes" checked>
<span>World Axes</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="wireframeMode">
<span>Wireframe</span>
</label>
</div>
</div>
<!-- Action Buttons -->
<div class="control-group">
<button id="resetPose">Reset Pose</button>
<button id="resetCamera">Reset Camera</button>
<button id="takeScreenshot">Screenshot</button>
<button id="exportGLB">Export GLB</button>
<button id="exportFBX">Export FBX</button>
</div>
<div id="status">No mesh loaded</div>
</div>
<script>
// Dynamically determine the bundle path based on how this HTML is loaded
(function() {
var scripts = document.getElementsByTagName('script');
var currentScript = scripts[scripts.length - 1];
var baseUrl = '';
var docUrl = window.location.href;
if (docUrl.indexOf('/extensions/') !== -1) {
var match = docUrl.match(/\/extensions\/[^\/]+\//);
if (match) {
baseUrl = match[0];
}
}
var bundleScript = document.createElement('script');
bundleScript.src = baseUrl + 'js/viewer-bundle-three.mjs';
bundleScript.onload = function() {
console.log('[FBX Debug Viewer] Three.js bundle loaded from:', bundleScript.src);
initViewer();
};
bundleScript.onerror = function() {
console.error('[FBX Debug Viewer] Failed to load bundle from:', bundleScript.src);
document.getElementById('loading').textContent = 'Error: Failed to load 3D library';
document.getElementById('loading').style.color = '#ff6666';
};
document.head.appendChild(bundleScript);
})();
function initViewer() {
const THREE = window.THREE;
const OrbitControls = window.OrbitControls;
const FBXLoader = window.FBXLoader;
const TransformControls = window.TransformControls;
const GLTFExporter = window.GLTFExporter;
console.log('[FBX Debug Viewer] Initializing...');
const canvas = document.getElementById('canvas');
const loading = document.getElementById('loading');
const statusEl = document.getElementById('status');
const boneInfoPanel = document.getElementById('boneInfoPanel');
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a2a2a);
// Camera
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 2);
// Renderer
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
powerPreference: 'high-performance',
preserveDrawingBuffer: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// WebGL context loss handling
canvas.addEventListener('webglcontextlost', (event) => {
console.error('[FBX Debug Viewer] WebGL context lost!');
event.preventDefault();
loading.textContent = 'WebGL context lost. Attempting to restore...';
loading.style.display = 'block';
loading.style.color = 'orange';
}, false);
canvas.addEventListener('webglcontextrestored', () => {
console.log('[FBX Debug Viewer] WebGL context restored');
loading.textContent = 'WebGL context restored. Please reload the model.';
loading.style.color = 'green';
setTimeout(() => { loading.style.display = 'none'; }, 3000);
}, false);
// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.zoomSpeed = 3.0;
controls.minDistance = 0.1;
controls.zoomToCursor = true;
// Transform controls
const transformControls = new TransformControls(camera, canvas);
transformControls.setMode('rotate');
transformControls.setSpace('local');
transformControls.addEventListener('dragging-changed', (event) => {
controls.enabled = !event.value;
});
scene.add(transformControls);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(5, 10, 7);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-5, -5, -7);
scene.add(dirLight2);
// Grid and Axes
let grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(grid);
let axes = new THREE.AxesHelper(1);
scene.add(axes);
// State
let currentModel = null;
let skeletonHelper = null;
let bones = [];
let boneGizmos = [];
let boneLines = [];
let boneAxesHelpers = []; // RGB arrow groups for each bone
let boneLabels = []; // Sprite labels for bone names
let pivotIndicator = null;
let selectedBone = null;
let restPoseData = [];
let defaultCameraPosition = new THREE.Vector3(2, 2, 2);
let meshBounds = null;
let boneGizmoSize = 0.03;
let boneAxesSize = 0.05;
let boneLabelSize = 1.0;
let currentFBXFilename = null;
let parentOrigin = null;
let fbxExportApiPath = '/unirig/export_posed_fbx';
// Animation state
let mixer = null;
let animations = [];
let currentAction = null;
let clock = new THREE.Clock();
let isPlaying = false;
let playbackSpeed = 1.0;
// Filter state
let boneFilterText = '';
let visibleBoneIndices = new Set();
// Raycaster
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Helper function to validate bounding box
function isValidBounds(box) {
if (!box || box.isEmpty()) return false;
const size = box.getSize(new THREE.Vector3());
return isFinite(size.x) && isFinite(size.y) && isFinite(size.z) &&
size.x > 0 && size.y > 0 && size.z > 0;
}
// Convert radians to degrees
function radToDeg(rad) {
return (rad * 180 / Math.PI).toFixed(2);
}
// Format number for display
function formatNum(num, decimals = 4) {
return num.toFixed(decimals);
}
// ============ BONE AXES VISUALIZATION ============
function createBoneAxes(bone, size) {
const group = new THREE.Group();
group.userData.bone = bone;
const arrowLength = size;
const arrowHeadLength = size * 0.3;
const arrowHeadWidth = size * 0.15;
// X axis (red)
const xDir = new THREE.Vector3(1, 0, 0);
const xArrow = new THREE.ArrowHelper(xDir, new THREE.Vector3(), arrowLength, 0xff4444, arrowHeadLength, arrowHeadWidth);
xArrow.line.material.depthTest = !document.getElementById('xraySkeleton').checked;
xArrow.cone.material.depthTest = !document.getElementById('xraySkeleton').checked;
group.add(xArrow);
// Y axis (green)
const yDir = new THREE.Vector3(0, 1, 0);
const yArrow = new THREE.ArrowHelper(yDir, new THREE.Vector3(), arrowLength, 0x44ff44, arrowHeadLength, arrowHeadWidth);
yArrow.line.material.depthTest = !document.getElementById('xraySkeleton').checked;
yArrow.cone.material.depthTest = !document.getElementById('xraySkeleton').checked;
group.add(yArrow);
// Z axis (blue) - ROLL AXIS
const zDir = new THREE.Vector3(0, 0, 1);
const zArrow = new THREE.ArrowHelper(zDir, new THREE.Vector3(), arrowLength, 0x4444ff, arrowHeadLength, arrowHeadWidth);
zArrow.line.material.depthTest = !document.getElementById('xraySkeleton').checked;
zArrow.cone.material.depthTest = !document.getElementById('xraySkeleton').checked;
group.add(zArrow);
return group;
}
function createAllBoneAxes() {
// Remove existing
boneAxesHelpers.forEach(helper => {
scene.remove(helper);
helper.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
});
boneAxesHelpers = [];
if (bones.length === 0) return;
const size = boneAxesSize * (meshBounds ? meshBounds.getSize(new THREE.Vector3()).length() * 0.03 : 0.05);
bones.forEach((bone, index) => {
const axesGroup = createBoneAxes(bone, size);
axesGroup.userData.boneIndex = index;
scene.add(axesGroup);
boneAxesHelpers.push(axesGroup);
});
updateBoneAxes();
}
function updateBoneAxes() {
const showAxes = document.getElementById('showBoneAxes').checked;
const xray = document.getElementById('xraySkeleton').checked;
boneAxesHelpers.forEach((axesGroup, index) => {
const bone = axesGroup.userData.bone;
if (!bone) return;
// Update position and rotation from bone world transform
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
bone.getWorldPosition(worldPos);
bone.getWorldQuaternion(worldQuat);
axesGroup.position.copy(worldPos);
axesGroup.quaternion.copy(worldQuat);
// Visibility based on filter
const visible = showAxes && visibleBoneIndices.has(index);
axesGroup.visible = visible;
// Update depth test
axesGroup.traverse(child => {
if (child.material) {
child.material.depthTest = !xray;
}
});
});
}
// ============ BONE NAME LABELS ============
function createBoneLabel(bone, name, size) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const fontSize = Math.max(24, Math.floor(48 * size));
canvas.width = 256;
canvas.height = 64;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(0, 0, canvas.width, canvas.height);
context.font = `${fontSize}px monospace`;
context.fillStyle = '#ffffff';
context.textAlign = 'center';
context.textBaseline = 'middle';
// Truncate name if too long
let displayName = name;
if (displayName.length > 20) {
displayName = displayName.substring(0, 17) + '...';
}
context.fillText(displayName, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const material = new THREE.SpriteMaterial({
map: texture,
depthTest: !document.getElementById('xraySkeleton').checked,
transparent: true
});
const sprite = new THREE.Sprite(material);
sprite.userData.bone = bone;
sprite.scale.set(0.5 * size, 0.125 * size, 1);
return sprite;
}
function createAllBoneLabels() {
// Remove existing
boneLabels.forEach(label => {
scene.remove(label);
if (label.material.map) label.material.map.dispose();
label.material.dispose();
});
boneLabels = [];
if (bones.length === 0) return;
const baseSize = meshBounds ? meshBounds.getSize(new THREE.Vector3()).length() * 0.15 : 0.3;
bones.forEach((bone, index) => {
const label = createBoneLabel(bone, bone.name || `Bone_${index}`, baseSize * boneLabelSize);
label.userData.boneIndex = index;
scene.add(label);
boneLabels.push(label);
});
updateBoneLabels();
}
function updateBoneLabels() {
const showNames = document.getElementById('showBoneNames').checked;
const xray = document.getElementById('xraySkeleton').checked;
boneLabels.forEach((label, index) => {
const bone = label.userData.bone;
if (!bone) return;
const worldPos = new THREE.Vector3();
bone.getWorldPosition(worldPos);
// Offset label slightly above bone
const offset = boneGizmoSize * 2;
label.position.set(worldPos.x, worldPos.y + offset, worldPos.z);
// Visibility based on filter
const visible = showNames && visibleBoneIndices.has(index);
label.visible = visible;
// Update depth test
label.material.depthTest = !xray;
});
}
// ============ BONE INFO PANEL ============
function updateBoneInfoPanel(bone) {
if (!bone) {
boneInfoPanel.classList.remove('visible');
return;
}
boneInfoPanel.classList.add('visible');
// Basic info
document.getElementById('boneName').textContent = bone.name || 'Unnamed';
document.getElementById('boneParent').textContent = bone.parent?.name || (bone.parent?.isBone ? 'Unnamed Parent' : 'None (Root)');
const boneIndex = bones.indexOf(bone);
document.getElementById('boneIndex').textContent = boneIndex >= 0 ? boneIndex : '-';
// Local transforms
const euler = new THREE.Euler().setFromQuaternion(bone.quaternion, 'XYZ');
document.getElementById('localPosX').textContent = formatNum(bone.position.x);
document.getElementById('localPosY').textContent = formatNum(bone.position.y);
document.getElementById('localPosZ').textContent = formatNum(bone.position.z);
document.getElementById('localRotX').textContent = radToDeg(euler.x) + 'deg';
document.getElementById('localRotY').textContent = radToDeg(euler.y) + 'deg';
document.getElementById('localRotZ').textContent = radToDeg(euler.z) + 'deg';
document.getElementById('localQuat').textContent =
`(${formatNum(bone.quaternion.x, 3)}, ${formatNum(bone.quaternion.y, 3)}, ${formatNum(bone.quaternion.z, 3)}, ${formatNum(bone.quaternion.w, 3)})`;
document.getElementById('localScale').textContent =
`(${formatNum(bone.scale.x, 3)}, ${formatNum(bone.scale.y, 3)}, ${formatNum(bone.scale.z, 3)})`;
// Roll angle (Z-rotation in local space)
const rollDeg = radToDeg(euler.z);
document.getElementById('boneRoll').textContent = rollDeg + 'deg';
// World transforms
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
bone.getWorldPosition(worldPos);
bone.getWorldQuaternion(worldQuat);
const worldEuler = new THREE.Euler().setFromQuaternion(worldQuat, 'XYZ');
document.getElementById('worldPosX').textContent = formatNum(worldPos.x);
document.getElementById('worldPosY').textContent = formatNum(worldPos.y);
document.getElementById('worldPosZ').textContent = formatNum(worldPos.z);
document.getElementById('worldRotX').textContent = radToDeg(worldEuler.x) + 'deg';
document.getElementById('worldRotY').textContent = radToDeg(worldEuler.y) + 'deg';
document.getElementById('worldRotZ').textContent = radToDeg(worldEuler.z) + 'deg';
// Bone length (distance to first child)
let boneLength = 0;
const boneChildren = [];
bone.children.forEach(child => {
if (child.isBone) {
boneChildren.push(child.name || 'Unnamed');
if (boneLength === 0) {
boneLength = child.position.length();
}
}
});
document.getElementById('boneLength').textContent = boneLength > 0 ? formatNum(boneLength) : 'N/A (leaf)';
document.getElementById('boneChildren').textContent = boneChildren.length > 0 ? boneChildren.join(', ') : 'None (leaf)';
}
// ============ ANIMATION SYSTEM ============
function setupAnimationControls() {
const animSelect = document.getElementById('animationSelect');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const prevFrameBtn = document.getElementById('prevFrameBtn');
const nextFrameBtn = document.getElementById('nextFrameBtn');
const timeline = document.getElementById('timeline');
const timeDisplay = document.getElementById('timeDisplay');
const speedSlider = document.getElementById('speedSlider');
const speedValue = document.getElementById('speedValue');
const loopCheckbox = document.getElementById('loopAnimation');
// Animation selection
animSelect.addEventListener('change', (e) => {
const index = parseInt(e.target.value);
if (isNaN(index) || index < 0 || !animations[index]) {
if (currentAction) {
currentAction.stop();
currentAction = null;
}
return;
}
if (currentAction) {
currentAction.stop();
}
currentAction = mixer.clipAction(animations[index]);
currentAction.setLoop(loopCheckbox.checked ? THREE.LoopRepeat : THREE.LoopOnce);
currentAction.clampWhenFinished = true;
currentAction.play();
isPlaying = true;
timeline.max = animations[index].duration;
updateTimeDisplay();
});
playBtn.addEventListener('click', () => {
if (currentAction) {
currentAction.paused = false;
isPlaying = true;
}
});
pauseBtn.addEventListener('click', () => {
if (currentAction) {
currentAction.paused = true;
isPlaying = false;
}
});
stopBtn.addEventListener('click', () => {
if (currentAction) {
currentAction.stop();
currentAction.reset();
currentAction.play();
currentAction.paused = true;
isPlaying = false;
timeline.value = 0;
updateTimeDisplay();
}
});
prevFrameBtn.addEventListener('click', () => {
if (currentAction && animations.length > 0) {
const animIndex = parseInt(animSelect.value);
if (!isNaN(animIndex) && animations[animIndex]) {
const frameTime = 1 / 30; // Assume 30 fps
let newTime = Math.max(0, currentAction.time - frameTime);
currentAction.time = newTime;
mixer.update(0);
timeline.value = newTime;
updateTimeDisplay();
updateAllBoneVisuals();
}
}
});
nextFrameBtn.addEventListener('click', () => {
if (currentAction && animations.length > 0) {
const animIndex = parseInt(animSelect.value);
if (!isNaN(animIndex) && animations[animIndex]) {
const frameTime = 1 / 30;
const duration = animations[animIndex].duration;
let newTime = Math.min(duration, currentAction.time + frameTime);
currentAction.time = newTime;
mixer.update(0);
timeline.value = newTime;
updateTimeDisplay();
updateAllBoneVisuals();
}
}
});
timeline.addEventListener('input', (e) => {
if (currentAction) {
currentAction.time = parseFloat(e.target.value);
mixer.update(0);
updateTimeDisplay();
updateAllBoneVisuals();
}
});
speedSlider.addEventListener('input', (e) => {
playbackSpeed = parseFloat(e.target.value);
speedValue.textContent = playbackSpeed.toFixed(1) + 'x';
if (currentAction) {
currentAction.setEffectiveTimeScale(playbackSpeed);
}
});
loopCheckbox.addEventListener('change', (e) => {
if (currentAction) {
currentAction.setLoop(e.target.checked ? THREE.LoopRepeat : THREE.LoopOnce);
}
});
function updateTimeDisplay() {
const animIndex = parseInt(animSelect.value);
if (!isNaN(animIndex) && animations[animIndex] && currentAction) {
const current = currentAction.time.toFixed(2);
const total = animations[animIndex].duration.toFixed(2);
timeDisplay.textContent = `${current}s / ${total}s`;
timeline.value = currentAction.time;
} else {
timeDisplay.textContent = '0.00s / 0.00s';
}
}
return { updateTimeDisplay };
}
const animControls = setupAnimationControls();
function populateAnimationList() {
const animSelect = document.getElementById('animationSelect');
const animControlsDiv = document.getElementById('animationControls');
animSelect.innerHTML = '';
if (animations.length === 0) {
animSelect.innerHTML = '<option value="">No animations</option>';
animControlsDiv.classList.remove('visible');
return;
}
animControlsDiv.classList.add('visible');
animations.forEach((anim, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${anim.name || 'Animation ' + index} (${anim.duration.toFixed(2)}s)`;
animSelect.appendChild(option);
});
}
// ============ BONE FILTER ============
function applyBoneFilter() {
const filterText = document.getElementById('boneFilter').value.toLowerCase();
boneFilterText = filterText;
visibleBoneIndices.clear();
bones.forEach((bone, index) => {
const boneName = (bone.name || `Bone_${index}`).toLowerCase();
if (filterText === '' || boneName.includes(filterText)) {
visibleBoneIndices.add(index);
}
});
document.getElementById('filteredCount').textContent = `${visibleBoneIndices.size}/${bones.length} bones`;
updateBoneGizmoVisibility();
updateBoneAxes();
updateBoneLabels();
}
function updateBoneGizmoVisibility() {
const showSkeleton = document.getElementById('showSkeleton').checked;
boneGizmos.forEach((gizmo, index) => {
gizmo.visible = showSkeleton && visibleBoneIndices.has(index);
});
boneLines.forEach((line, index) => {
const boneIndex = line.userData.boneIndex;
line.visible = showSkeleton && visibleBoneIndices.has(boneIndex);
});
}
document.getElementById('boneFilter').addEventListener('input', applyBoneFilter);
// ============ SIZE SLIDER HANDLERS ============
document.getElementById('boneSizeSlider').addEventListener('input', (e) => {
const scale = parseFloat(e.target.value);
document.getElementById('boneSizeValue').textContent = scale.toFixed(1) + 'x';
boneGizmos.forEach(gizmo => {
gizmo.scale.setScalar(scale);
});
if (pivotIndicator) {
pivotIndicator.scale.setScalar(scale);
}
});
document.getElementById('axisSizeSlider').addEventListener('input', (e) => {
boneAxesSize = parseFloat(e.target.value);
document.getElementById('axisSizeValue').textContent = boneAxesSize.toFixed(1) + 'x';
createAllBoneAxes();
});
document.getElementById('labelSizeSlider').addEventListener('input', (e) => {
boneLabelSize = parseFloat(e.target.value);
document.getElementById('labelSizeValue').textContent = boneLabelSize.toFixed(1) + 'x';
createAllBoneLabels();
});
// ============ EXISTING FUNCTIONS (from viewer_fbx.html) ============
function createPivotIndicator() {
if (pivotIndicator) {
scene.remove(pivotIndicator);
disposeObject(pivotIndicator);
}
pivotIndicator = new THREE.Group();
const ringGeometry = new THREE.TorusGeometry(boneGizmoSize * 2, boneGizmoSize * 0.15, 8, 32);
const xRing = new THREE.Mesh(ringGeometry, new THREE.MeshBasicMaterial({
color: 0xff4444,
transparent: true,
opacity: 0.8,
depthTest: false
}));
xRing.rotation.y = Math.PI / 2;
pivotIndicator.add(xRing);
const yRing = new THREE.Mesh(ringGeometry, new THREE.MeshBasicMaterial({
color: 0x44ff44,
transparent: true,
opacity: 0.8,
depthTest: false
}));
yRing.rotation.x = Math.PI / 2;
pivotIndicator.add(yRing);
const zRing = new THREE.Mesh(ringGeometry, new THREE.MeshBasicMaterial({
color: 0x4444ff,
transparent: true,
opacity: 0.8,
depthTest: false
}));
pivotIndicator.add(zRing);
const centerGeometry = new THREE.SphereGeometry(boneGizmoSize * 0.4, 12, 12);
const centerSphere = new THREE.Mesh(centerGeometry, new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.9,
depthTest: false
}));
pivotIndicator.add(centerSphere);
pivotIndicator.visible = false;
scene.add(pivotIndicator);
}
function updatePivotIndicator() {
if (!pivotIndicator || !selectedBone) {
if (pivotIndicator) pivotIndicator.visible = false;
return;
}
const worldPos = new THREE.Vector3();
selectedBone.getWorldPosition(worldPos);
pivotIndicator.position.copy(worldPos);
pivotIndicator.visible = document.getElementById('showSkeleton').checked;
}
function createBoneLines() {
boneLines.forEach(line => {
scene.remove(line);
disposeObject(line);
});
boneLines = [];
if (bones.length === 0) return;
const lineMaterial = new THREE.LineBasicMaterial({
color: 0x00ffaa,
linewidth: 2,
depthTest: document.getElementById('xraySkeleton').checked ? false : true,
transparent: true,
opacity: 0.7
});
bones.forEach((bone, index) => {
if (bone.parent && bone.parent.isBone) {
const parentPos = new THREE.Vector3();
const childPos = new THREE.Vector3();
bone.parent.getWorldPosition(parentPos);
bone.getWorldPosition(childPos);
const points = [parentPos, childPos];
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, lineMaterial.clone());
line.userData.boneIndex = index;
line.userData.bone = bone;
scene.add(line);
boneLines.push(line);
}
});
}
function updateBoneLines() {
boneLines.forEach((line) => {
const bone = line.userData.bone;
if (bone && bone.parent && bone.parent.isBone) {
const parentPos = new THREE.Vector3();
const childPos = new THREE.Vector3();
bone.parent.getWorldPosition(parentPos);
bone.getWorldPosition(childPos);
const positions = line.geometry.attributes.position;
positions.setXYZ(0, parentPos.x, parentPos.y, parentPos.z);
positions.setXYZ(1, childPos.x, childPos.y, childPos.z);
positions.needsUpdate = true;
const xray = document.getElementById('xraySkeleton').checked;
line.material.depthTest = !xray;
}
});
}
function createBoneGizmos() {
boneGizmos.forEach(gizmo => scene.remove(gizmo));
boneGizmos = [];
if (meshBounds && isValidBounds(meshBounds)) {
const size = meshBounds.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
boneGizmoSize = maxDim * 0.02;
} else {
if (bones.length > 1) {
let totalDist = 0;
let count = 0;
const tempPos = new THREE.Vector3();
bones.forEach(bone => {
bone.getWorldPosition(tempPos);
if (isFinite(tempPos.x) && isFinite(tempPos.y) && isFinite(tempPos.z)) {
totalDist += tempPos.length();
count++;
}
});
if (count > 0) {
const avgDist = totalDist / count;
boneGizmoSize = Math.max(0.02, avgDist * 0.03);
}
}
}
bones.forEach((bone, index) => {
const geometry = new THREE.SphereGeometry(boneGizmoSize, 16, 16);
const material = new THREE.MeshBasicMaterial({
color: 0xffaa00,
depthTest: document.getElementById('xraySkeleton').checked ? false : true,
transparent: true,
opacity: 0.9
});
const sphere = new THREE.Mesh(geometry, material);
const worldPos = new THREE.Vector3();
bone.getWorldPosition(worldPos);
sphere.position.copy(worldPos);
sphere.userData.boneIndex = index;
sphere.userData.bone = bone;
scene.add(sphere);
boneGizmos.push(sphere);
});
// Initialize visible bone indices
visibleBoneIndices.clear();
bones.forEach((_, index) => visibleBoneIndices.add(index));
document.getElementById('filteredCount').textContent = `${bones.length}/${bones.length} bones`;
createPivotIndicator();
createBoneLines();
createAllBoneAxes();
createAllBoneLabels();
}
function updateBoneGizmos() {
boneGizmos.forEach((gizmo, index) => {
const bone = bones[index];
const worldPos = new THREE.Vector3();
bone.getWorldPosition(worldPos);
gizmo.position.copy(worldPos);
const xray = document.getElementById('xraySkeleton').checked;
gizmo.material.depthTest = !xray;
});
updateBoneLines();
updatePivotIndicator();
}
function updateAllBoneVisuals() {
updateBoneGizmos();
updateBoneAxes();
updateBoneLabels();
if (selectedBone) {
updateBoneInfoPanel(selectedBone);
}
}
function saveRestPose() {
restPoseData = bones.map(bone => ({
position: bone.position.clone(),
quaternion: bone.quaternion.clone(),
scale: bone.scale.clone()
}));
}
function resetToRestPose() {
if (restPoseData.length === 0) return;
// Stop any playing animation
if (currentAction) {
currentAction.stop();
currentAction = null;
isPlaying = false;
}
bones.forEach((bone, index) => {
if (restPoseData[index]) {
bone.position.copy(restPoseData[index].position);
bone.quaternion.copy(restPoseData[index].quaternion);
bone.scale.copy(restPoseData[index].scale);
}
});
if (selectedBone) {
transformControls.detach();
selectedBone = null;
boneInfoPanel.classList.remove('visible');
}
updateAllBoneVisuals();
statusEl.textContent = 'Pose reset';
}
function resetCamera() {
camera.position.copy(defaultCameraPosition);
camera.lookAt(0, 0, 0);
controls.target.set(0, 0, 0);
controls.update();
statusEl.textContent = 'Camera reset';
}
function disposeObject(obj) {
if (!obj) return;
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(mat => {
if (mat.map) mat.map.dispose();
if (mat.normalMap) mat.normalMap.dispose();
if (mat.roughnessMap) mat.roughnessMap.dispose();
if (mat.metalnessMap) mat.metalnessMap.dispose();
mat.dispose();
});
} else {
if (obj.material.map) obj.material.map.dispose();
if (obj.material.normalMap) obj.material.normalMap.dispose();
if (obj.material.roughnessMap) obj.material.roughnessMap.dispose();
if (obj.material.metalnessMap) obj.material.metalnessMap.dispose();
obj.material.dispose();
}
}
if (obj.children) {
obj.children.forEach(child => disposeObject(child));
}
}
function loadFBX(filepath) {
console.log('[FBX Debug Viewer] Loading:', filepath);
loading.textContent = 'Loading rigged mesh...';
loading.style.display = 'block';
loading.style.color = 'white';
// Cleanup previous
if (currentModel) {
scene.remove(currentModel);
disposeObject(currentModel);
currentModel = null;
}
if (skeletonHelper) {
scene.remove(skeletonHelper);
disposeObject(skeletonHelper);
skeletonHelper = null;
}
boneGizmos.forEach(gizmo => {
scene.remove(gizmo);
disposeObject(gizmo);
});
boneGizmos = [];
boneLines.forEach(line => {
scene.remove(line);
disposeObject(line);
});
boneLines = [];
boneAxesHelpers.forEach(helper => {
scene.remove(helper);
helper.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
});
boneAxesHelpers = [];
boneLabels.forEach(label => {
scene.remove(label);
if (label.material.map) label.material.map.dispose();
label.material.dispose();
});
boneLabels = [];
if (pivotIndicator) {
scene.remove(pivotIndicator);
disposeObject(pivotIndicator);
pivotIndicator = null;
}
bones = [];
restPoseData = [];
transformControls.detach();
selectedBone = null;
boneInfoPanel.classList.remove('visible');
// Reset animation state
if (mixer) {
mixer.stopAllAction();
mixer = null;
}
animations = [];
currentAction = null;
isPlaying = false;
const loader = new FBXLoader();
loader.load(
filepath,
(fbx) => {
console.log('[FBX Debug Viewer] FBX Loaded successfully');
currentModel = fbx;
const box = new THREE.Box3().setFromObject(currentModel);
let maxDim = 2;
if (isValidBounds(box)) {
const center = box.getCenter(new THREE.Vector3());
currentModel.position.sub(center);
meshBounds = box;
const size = box.getSize(new THREE.Vector3());
maxDim = Math.max(size.x, size.y, size.z);
} else {
meshBounds = null;
currentModel.traverse((child) => {
if (child.isMesh || child.isSkinnedMesh) {
let geomBox = null;
if (child.geometry) {
child.geometry.computeBoundingBox();
geomBox = child.geometry.boundingBox;
}
if (geomBox && isValidBounds(geomBox)) {
const geomSize = geomBox.getSize(new THREE.Vector3());
const geomMaxDim = Math.max(geomSize.x, geomSize.y, geomSize.z);
maxDim = geomMaxDim;
meshBounds = geomBox.clone();
const geomCenter = geomBox.getCenter(new THREE.Vector3());
currentModel.position.sub(geomCenter);
}
}
});
}
let foundSkeleton = false;
currentModel.traverse((child) => {
if (child.isSkinnedMesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: 0x88aacc,
metalness: 0.3,
roughness: 0.7,
skinning: true
});
} else if (Array.isArray(child.material)) {
child.material.forEach(mat => {
mat.skinning = true;
});
} else {
child.material.skinning = true;
}
if (child.skeleton) {
foundSkeleton = true;
skeletonHelper = new THREE.SkeletonHelper(child);
skeletonHelper.material = new THREE.LineBasicMaterial({
color: 0x00ff00,
linewidth: 3,
depthTest: document.getElementById('xraySkeleton').checked ? false : true
});
currentModel.add(skeletonHelper);
bones = child.skeleton.bones;
if (restPoseData.length === 0) {
saveRestPose();
}
createBoneGizmos();
}
}
});
// Handle animations
if (fbx.animations && fbx.animations.length > 0) {
animations = fbx.animations;
mixer = new THREE.AnimationMixer(fbx);
console.log('[FBX Debug Viewer] Found', animations.length, 'animations');
}
populateAnimationList();
scene.add(currentModel);
const distance = maxDim * 2.5;
scene.remove(grid);
scene.remove(axes);
const gridSize = Math.max(10, Math.ceil(maxDim * 1.5));
const gridDivisions = Math.max(10, Math.ceil(gridSize / 10));
grid = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x222222);
scene.add(grid);
const axesSize = maxDim * 0.3;
axes = new THREE.AxesHelper(axesSize);
scene.add(axes);
defaultCameraPosition.set(distance, distance, distance);
camera.position.copy(defaultCameraPosition);
camera.lookAt(0, 0, 0);
controls.target.set(0, 0, 0);
controls.update();
controls.maxDistance = maxDim * 10;
loading.style.display = 'none';
let statusText = foundSkeleton ? `Loaded: ${bones.length} bones` : 'Loaded (no skeleton found)';
if (animations.length > 0) {
statusText += `, ${animations.length} animation(s)`;
}
statusEl.textContent = statusText;
},
(progress) => {
if (progress.lengthComputable) {
const percent = (progress.loaded / progress.total * 100).toFixed(0);
loading.textContent = `Loading... ${percent}%`;
}
},
(error) => {
console.error('[FBX Debug Viewer] Error loading FBX:', error);
let errorMsg = 'Error loading FBX';
if (error?.message?.includes('404')) {
errorMsg = 'File not found (404)';
} else if (error?.message?.includes('Failed to fetch')) {
errorMsg = 'Network error - check URL';
}
loading.textContent = errorMsg + ' - check console';
loading.style.color = '#ff6666';
statusEl.textContent = 'Load failed: ' + errorMsg;
}
);
}
// Animation loop
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// Update animation mixer
if (mixer && isPlaying && currentAction && !currentAction.paused) {
mixer.update(delta * playbackSpeed);
updateAllBoneVisuals();
animControls.updateTimeDisplay();
}
controls.update();
renderer.render(scene, camera);
}
animate();
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
console.log('[FBX Debug Viewer] Cleaning up resources before unload');
if (currentModel) disposeObject(currentModel);
if (skeletonHelper) disposeObject(skeletonHelper);
boneGizmos.forEach(gizmo => disposeObject(gizmo));
boneLines.forEach(line => disposeObject(line));
boneAxesHelpers.forEach(helper => {
helper.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
});
boneLabels.forEach(label => {
if (label.material.map) label.material.map.dispose();
label.material.dispose();
});
if (pivotIndicator) disposeObject(pivotIndicator);
if (mixer) mixer.stopAllAction();
renderer.dispose();
controls.dispose();
transformControls.dispose();
});
// Mouse click for bone selection
canvas.addEventListener('click', (event) => {
if (bones.length === 0) return;
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(boneGizmos);
if (intersects.length > 0) {
const clickedGizmo = intersects[0].object;
const bone = clickedGizmo.userData.bone;
boneGizmos.forEach(g => {
g.material.color.setHex(0xffaa00);
g.material.opacity = 0.8;
});
clickedGizmo.material.color.setHex(0x00ffff);
clickedGizmo.material.opacity = 1.0;
transformControls.attach(bone);
selectedBone = bone;
updatePivotIndicator();
updateBoneInfoPanel(bone);
statusEl.textContent = `Selected: ${bone.name || 'Bone ' + clickedGizmo.userData.boneIndex}`;
} else {
if (selectedBone) {
transformControls.detach();
selectedBone = null;
boneGizmos.forEach(g => {
g.material.color.setHex(0xffaa00);
g.material.opacity = 0.8;
});
if (pivotIndicator) pivotIndicator.visible = false;
boneInfoPanel.classList.remove('visible');
statusEl.textContent = `${bones.length} bones`;
}
}
});
transformControls.addEventListener('change', () => {
updateAllBoneVisuals();
});
// UI Controls
document.getElementById('showSkeleton').addEventListener('change', (e) => {
if (skeletonHelper) {
skeletonHelper.visible = e.target.checked;
}
updateBoneGizmoVisibility();
if (pivotIndicator && selectedBone) {
pivotIndicator.visible = e.target.checked;
}
});
document.getElementById('showMesh').addEventListener('change', (e) => {
if (currentModel) {
currentModel.traverse((child) => {
if (child.isMesh) {
child.visible = e.target.checked;
}
});
}
});
document.getElementById('xraySkeleton').addEventListener('change', (e) => {
if (skeletonHelper) {
skeletonHelper.material.depthTest = !e.target.checked;
}
boneGizmos.forEach(gizmo => {
gizmo.material.depthTest = !e.target.checked;
});
updateBoneAxes();
updateBoneLabels();
});
document.getElementById('showBoneAxes').addEventListener('change', () => {
updateBoneAxes();
});
document.getElementById('showBoneNames').addEventListener('change', () => {
updateBoneLabels();
});
document.getElementById('resetPose').addEventListener('click', resetToRestPose);
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('showGrid').addEventListener('change', (e) => {
if (grid) grid.visible = e.target.checked;
});
document.getElementById('showAxes').addEventListener('change', (e) => {
if (axes) axes.visible = e.target.checked;
});
document.getElementById('wireframeMode').addEventListener('change', (e) => {
if (currentModel) {
currentModel.traverse((child) => {
if (child.isMesh) {
if (Array.isArray(child.material)) {
child.material.forEach(mat => mat.wireframe = e.target.checked);
} else {
child.material.wireframe = e.target.checked;
}
}
});
}
});
// Screenshot button
document.getElementById('takeScreenshot').addEventListener('click', async () => {
try {
statusEl.textContent = 'Taking screenshot...';
const dataURL = canvas.toDataURL('image/png');
const blob = await (await fetch(dataURL)).blob();
const filename = `fbx_debug_screenshot_${Date.now()}.png`;
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output');
formData.append('subfolder', '');
const uploadUrl = parentOrigin ? `${parentOrigin}/upload/image` : '/upload/image';
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (response.ok) {
statusEl.textContent = `Screenshot saved: ${filename}`;
} else {
statusEl.textContent = 'Screenshot failed';
}
} catch (error) {
statusEl.textContent = 'Screenshot error';
console.error('[FBX Debug Viewer] Screenshot error:', error);
}
});
// Export GLB button
document.getElementById('exportGLB').addEventListener('click', async () => {
try {
if (!currentModel) {
statusEl.textContent = 'No model to export';
return;
}
const defaultFilename = `fbx_debug_export_${Date.now()}`;
let filename = prompt('Enter filename for GLB export (without extension):', defaultFilename);
if (filename === null) {
statusEl.textContent = 'Export cancelled';
return;
}
filename = filename.trim();
if (filename === '') filename = defaultFilename;
if (!filename.toLowerCase().endsWith('.glb')) filename = filename + '.glb';
statusEl.textContent = 'Exporting GLB...';
const exportScene = new THREE.Scene();
let skinnedMesh = null;
currentModel.traverse((child) => {
if (child.isSkinnedMesh) skinnedMesh = child;
});
if (!skinnedMesh) {
statusEl.textContent = 'No mesh to export';
return;
}
const bakedGeometry = skinnedMesh.geometry.clone();
skinnedMesh.updateMatrixWorld(true);
const position = bakedGeometry.attributes.position;
const vertex = new THREE.Vector3();
const skinIndex = bakedGeometry.attributes.skinIndex;
const skinWeight = bakedGeometry.attributes.skinWeight;
const boneMatrices = skinnedMesh.skeleton.boneMatrices;
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const finalVertex = new THREE.Vector3();
for (let j = 0; j < 4; j++) {
const weight = skinWeight.getComponent(i, j);
if (weight > 0) {
const boneIndex = skinIndex.getComponent(i, j);
const boneMatrix = new THREE.Matrix4();
boneMatrix.fromArray(boneMatrices, boneIndex * 16);
const tempVertex = vertex.clone();
tempVertex.applyMatrix4(boneMatrix);
tempVertex.multiplyScalar(weight);
finalVertex.add(tempVertex);
}
}
position.setXYZ(i, finalVertex.x, finalVertex.y, finalVertex.z);
}
position.needsUpdate = true;
bakedGeometry.computeVertexNormals();
const oldMat = skinnedMesh.material;
const newMat = new THREE.MeshStandardMaterial({
name: oldMat.name,
map: oldMat.map,
metalness: 0.0,
roughness: 0.8,
transparent: oldMat.transparent,
opacity: oldMat.opacity,
side: oldMat.side
});
const bakedMesh = new THREE.Mesh(bakedGeometry, newMat);
bakedMesh.name = skinnedMesh.name;
exportScene.add(bakedMesh);
const exporter = new GLTFExporter();
exporter.parse(
exportScene,
async (gltf) => {
const blob = new Blob([gltf], { type: 'application/octet-stream' });
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output');
formData.append('subfolder', '');
const uploadUrl = parentOrigin ? `${parentOrigin}/upload/image` : '/upload/image';
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (response.ok) {
statusEl.textContent = `GLB saved: ${filename}`;
} else {
statusEl.textContent = 'GLB export failed';
}
},
(error) => {
console.error('[FBX Debug Viewer] GLB export error:', error);
statusEl.textContent = 'GLB export error';
},
{
binary: true,
embedImages: true,
maxTextureSize: 4096
}
);
} catch (error) {
statusEl.textContent = 'GLB export error';
console.error('[FBX Debug Viewer] GLB export error:', error);
}
});
// Export FBX button
document.getElementById('exportFBX').addEventListener('click', async () => {
try {
if (!currentModel) {
statusEl.textContent = 'No model to export';
return;
}
if (!currentFBXFilename) {
statusEl.textContent = 'FBX filename not available';
return;
}
const defaultFilename = `posed_debug_export_${Date.now()}`;
let filename = prompt('Enter filename for FBX export (without extension):', defaultFilename);
if (filename === null) {
statusEl.textContent = 'Export cancelled';
return;
}
filename = filename.trim();
if (filename === '') filename = defaultFilename;
if (!filename.toLowerCase().endsWith('.fbx')) filename = filename + '.fbx';
statusEl.textContent = 'Exporting FBX...';
const boneTransforms = {};
currentModel.updateMatrixWorld(true);
bones.forEach((bone, boneIndex) => {
if (!restPoseData[boneIndex]) return;
const rest = restPoseData[boneIndex];
const posDelta = new THREE.Vector3().subVectors(bone.position, rest.position);
const quatDelta = rest.quaternion.clone().invert().multiply(bone.quaternion.clone());
const scaleDelta = new THREE.Vector3(
bone.scale.x / rest.scale.x,
bone.scale.y / rest.scale.y,
bone.scale.z / rest.scale.z
);
boneTransforms[bone.name] = {
position: { x: posDelta.x, y: posDelta.y, z: posDelta.z },
quaternion: { x: quatDelta.x, y: quatDelta.y, z: quatDelta.z, w: quatDelta.w },
scale: { x: scaleDelta.x, y: scaleDelta.y, z: scaleDelta.z }
};
});
const exportUrl = parentOrigin ? `${parentOrigin}${fbxExportApiPath}` : fbxExportApiPath;
const response = await fetch(exportUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fbx_filename: currentFBXFilename,
bone_transforms: boneTransforms,
output_filename: filename
})
});
const result = await response.json();
if (response.ok && result.success) {
statusEl.textContent = `FBX saved: ${result.filename}`;
} else {
statusEl.textContent = `FBX export failed: ${result.error || 'Unknown error'}`;
}
} catch (error) {
statusEl.textContent = 'FBX export error';
console.error('[FBX Debug Viewer] FBX export error:', error);
}
});
// Listen for messages from parent window
window.addEventListener('message', (event) => {
console.log('[FBX Debug Viewer] Received postMessage:', event.data);
if (!parentOrigin && event.origin) {
parentOrigin = event.origin;
}
if (event.data.type === 'LOAD_FBX') {
const filepathStr = event.data.filepath;
const match = filepathStr.match(/filename=([^&]+)/);
if (match) {
currentFBXFilename = decodeURIComponent(match[1]);
}
if (event.data.fbxExportApiPath) {
fbxExportApiPath = event.data.fbxExportApiPath;
}
loadFBX(event.data.filepath);
}
});
// Notify parent that viewer is ready
console.log('[FBX Debug Viewer] Ready');
if (window.parent) {
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
}
}
</script>
</body>
</html>