Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>JAX-IK Three.js IK Demo</title> | |
<style> | |
body { margin:0; font-family:Arial, sans-serif; background:#1e1e1e; color:#ddd; } | |
header { padding:10px 16px; background:#111; border-bottom:1px solid #333; } | |
h1 { margin:0; font-size:18px; } | |
#tabs { display:flex; background:#222; } | |
.tab-btn { padding:10px 16px; cursor:pointer; border:none; background:#222; color:#ccc; font-size:13px; } | |
.tab-btn.active { background:#333; color:#fff; } | |
#content { display:flex; height:calc(100vh - 82px); } | |
#viewerPane { flex: 1 1 auto; position:relative; } | |
#uiPane { width:360px; overflow-y:auto; background:#181818; border-left:1px solid #333; padding:10px 12px; box-sizing:border-box; } | |
fieldset { border:1px solid #444; margin:8px 0 14px 0; padding:8px 10px; } | |
legend { padding:0 6px; font-size:12px; color:#9ad; } | |
label { display:block; font-size:12px; margin:4px 0; } | |
input[type=number], select { width:100%; box-sizing:border-box; background:#222; border:1px solid #444; color:#ddd; padding:4px; } | |
input[type=text] { width:100%; box-sizing:border-box; background:#222; border:1px solid #444; color:#ddd; padding:4px; } | |
button.primary { background:#2d6be3; color:#fff; border:none; padding:8px 12px; cursor:pointer; font-size:12px; border-radius:3px; } | |
button.secondary { background:#444; color:#eee; border:none; padding:6px 10px; cursor:pointer; font-size:12px; border-radius:3px; margin-left:4px; } | |
.flex-row { display:flex; gap:6px; } | |
.inline { display:inline-block; } | |
#statusBar { font-size:11px; background:#111; padding:6px 10px; border-top:1px solid #333; position:fixed; bottom:0; left:0; right:0; color:#aaa; } | |
.bone-grid { columns:2 140px; column-gap:12px; } | |
.bone-grid label { break-inside:avoid; } | |
.viewer-canvas { position:absolute; top:0; left:0; width:100%; height:100%; } | |
#log { position:absolute; right:8px; top:8px; max-width:260px; max-height:50%; overflow:auto; font:11px monospace; background:rgba(0,0,0,0.55); padding:6px; border:1px solid #444; border-radius:4px; } | |
.badge { font-size:10px; background:#444; color:#ccc; padding:2px 5px; border-radius:3px; margin-left:4px; } | |
#toastContainer { position:fixed; top:10px; left:10px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; } | |
.toast { min-width:220px; max-width:300px; background:rgba(30,30,30,0.95); color:#eee; font:12px/1.4 Arial, sans-serif; | |
border:1px solid #444; border-radius:4px; padding:8px 10px; box-shadow:0 4px 12px rgba(0,0,0,0.4); | |
opacity:0; transform:translateY(-6px); transition:opacity .18s ease, transform .18s ease; pointer-events:auto; } | |
.toast.show { opacity:1; transform:translateY(0); } | |
.toast.success { border-color:#2e8b57; } | |
.toast.error { border-color:#b33939; } | |
.toast .toast-close { float:right; cursor:pointer; color:#888; margin-left:6px; } | |
.toast .toast-close:hover { color:#fff; } | |
</style> | |
<!-- Added import map to resolve bare specifier 'three' --> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/three@0.160.0/build/three.module.js" | |
} | |
} | |
</script> | |
</head> | |
<body> | |
<header> | |
<h1> | |
<a href="https://github.com/hvoss-techfak/TF-JAX-IK" target="_blank" rel="noopener noreferrer" style="color:#fff; text-decoration:none;"> | |
JAX IK Demo - Please visit our Github Page for more information | |
</a> | |
<span class="badge" id="modelBadge">Agent</span> | |
</h1> | |
</header> | |
<div id="tabs"> | |
<button class="tab-btn active" data-model="agent">Virtual Agent</button> | |
<button class="tab-btn" data-model="pepper" style="display:none;" aria-hidden="true">URDF Robot</button> | |
</div> | |
<div id="content"> | |
<div id="viewerPane"> | |
<div id="log"></div> | |
<!-- Canvas inserted by three.js --> | |
</div> | |
<div id="uiPane"> | |
<fieldset> | |
<legend>Target Position</legend> | |
<label>X | |
<div class="flex-row"> | |
<input id="target_x" type="number" value="0.0" step="0.01" data-auto-solve="change"> | |
<input id="target_x_slider" type="range" min="-1" max="1" step="0.01" value="0.0"> | |
</div> | |
</label> | |
<label>Y | |
<div class="flex-row"> | |
<input id="target_y" type="number" value="0.2" step="0.01" data-auto-solve="change"> | |
<input id="target_y_slider" type="range" min="-1" max="1" step="0.01" value="0.2"> | |
</div> | |
</label> | |
<label>Z <input id="target_z" type="number" value="0.35" step="0.01" data-auto-solve="change"></label> | |
<label>Trajectory Points | |
<!-- changed: removed '(static only)', removed disabled, max updated dynamically --> | |
<input id="subpoints" type="number" value="1" min="1" max="20" step="1" data-auto-solve="change"> | |
</label> | |
</fieldset> | |
<fieldset> | |
<legend>Primary Objectives</legend> | |
<label><input id="distance_enabled" type="checkbox" checked> Distance Objective</label> | |
<label>Distance Weight <input id="distance_weight" type="number" value="1.0" step="0.1"></label> | |
<label hidden style="display:none;"><input id="collision_enabled" type="checkbox"> Collision Avoidance</label> | |
<label hidden style="display:none;">Collision Weight <input id="collision_weight" type="number" value="1.0" step="0.1"></label> | |
</fieldset> | |
<fieldset> | |
<legend>Regularization</legend> | |
<label><input id="bone_zero_enabled" type="checkbox" checked> Bone Zero Rotation</label> | |
<label>Bone Zero Weight <input id="bone_zero_weight" type="number" value="0.1" step="0.01"></label> | |
<label><input id="derivative_enabled" type="checkbox" checked> Trajectory Smoothing</label> | |
<label>Derivative Weight <input id="derivative_weight" type="number" value="0.05" step="0.01"></label> | |
</fieldset> | |
<fieldset id="handFieldset"> | |
<legend>Hand (Agent Only)</legend> | |
<label>Hand Shape | |
<select id="hand_shape"></select> | |
</label> | |
<label>Hand Position | |
<select id="hand_position"></select> | |
</label> | |
</fieldset> | |
<fieldset> | |
<legend>Configuration</legend> | |
<label>End Effector | |
<select id="end_effector"></select> | |
</label> | |
<div style="margin-top:6px; font-size:12px;">Controlled Bones:</div> | |
<div id="bonesContainer" class="bone-grid"></div> | |
</fieldset> | |
<fieldset> | |
<legend>Model / Animation</legend> | |
<!-- CHANGED: added explicit style to ensure invisibility --> | |
<label hidden style="display:none;">GLTF URL <input id="gltf_url" type="text" value="/files/smplx.glb"></label> | |
<div class="flex-row" style="margin-top:6px;"> | |
<button id="reset_cam" class="secondary" type="button">Reset Camera</button> | |
</div> | |
<label style="margin-top:8px;"><input id="wireframe" type="checkbox"> Wireframe</label> | |
<label style="margin-top:4px;"><input id="show_gltf" type="checkbox" checked> Show GLTF Reference</label> | |
<label style="margin-top:4px;"><input id="show_ikmesh" type="checkbox" checked> Show IK Mesh</label> | |
<label>Playback FPS <input id="play_fps" type="number" value="24" min="1" max="120"></label> | |
</fieldset> | |
<div style="text-align:right; margin-top:10px;"> | |
<button id="solve_btn" class="primary" type="button">Solve IK</button> | |
</div> | |
</div> | |
</div> | |
<div id="statusBar">Ready.</div> | |
<div id="toastContainer"></div> | |
<script type="module"> | |
import * as THREE from 'three'; | |
import { OrbitControls } from 'https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js'; | |
import { GLTFLoader } from 'https://unpkg.com/three@0.160.0/examples/jsm/loaders/GLTFLoader.js'; | |
(function(){ | |
// ----- Logging ----- | |
const logEl = document.getElementById('log'); | |
function log(msg){ | |
const t = new Date().toISOString().split('T')[1].replace('Z',''); | |
logEl.textContent = `[${t}] ${msg}\n` + logEl.textContent.slice(0, 8000); | |
} | |
// ----- State ----- | |
let currentModel = 'agent'; | |
let configData = null; | |
let animationFrames = []; | |
let frameIndex = 0; | |
let ikMesh = null; | |
let usingGLTFGeometry = false; | |
let gltfRoot = null; | |
let gltfPrimaryMaterial = null; | |
let gltfPrimaryMesh = null; | |
let gltfPrimaryMaterialCaptured = false; | |
let fallbackMesh = null; | |
let lastTime = performance.now(); | |
// REMOVED: let lastFrameFetch = 0; | |
// REMOVED: const pollInterval = 1000; | |
// (ADDED) Solve ID tracking to avoid "lastSolveIdAgent / lastSolveIdPepper is not defined" errors | |
let lastSolveIdAgent = 0; | |
let lastSolveIdPepper = 0; | |
// ----- Auto-solve (moved early to avoid ReferenceError) ----- | |
let solving = false; | |
let solvePending = false; | |
let solveDebounceTimer = null; | |
const SOLVE_DEBOUNCE_MS = 40; | |
function scheduleSolve(immediate=false){ | |
if (solving){ solvePending = true; return; } | |
if (solveDebounceTimer) clearTimeout(solveDebounceTimer); | |
solveDebounceTimer = setTimeout(()=> { triggerSolve(); }, immediate ? 50 : SOLVE_DEBOUNCE_MS); | |
} | |
async function triggerSolve(){ | |
if (solving){ solvePending = true; return; } | |
solving = true; | |
try { await doSolve(); } | |
catch(e){ /* doSolve handles status/log */ } | |
finally { | |
solving = false; | |
if (solvePending){ | |
solvePending = false; | |
scheduleSolve(); | |
} | |
} | |
} | |
// Playback state | |
let playbackEnabled = false; | |
const allowLoopPlayback = false; | |
// Ground alignment state | |
let baseOffsetY = 0; | |
let groundAligned = false; | |
let pendingAlign = false; | |
let alignAttemptCount = 0; | |
const maxAlignAttempts = 120; | |
// --- Trajectory / spline state (ADDED) --- | |
let controlFrames = []; | |
let splineMode = false; | |
let splineU = 0; | |
let splineDuration = 2.0; // seconds for full path | |
// One-time initial camera framing flag (ADDED) | |
let initialCameraFramed = false; | |
function cleanupVisuals(){ | |
if (ikMesh && usingGLTFGeometry){ | |
// keep GLTF mesh for possible reuse after reconfigure? remove anyway for clean slate | |
ikMesh = null; | |
} | |
if (fallbackMesh){ | |
modelGroup.remove(fallbackMesh); | |
if (fallbackMesh.geometry) fallbackMesh.geometry.dispose(); | |
if (fallbackMesh.material) fallbackMesh.material.dispose(); | |
fallbackMesh = null; | |
} | |
if (gltfRoot){ | |
gltfRoot.traverse(o=>{ | |
if (o.isMesh){ | |
if (o.geometry) o.geometry.dispose(); | |
if (o.material){ | |
if (Array.isArray(o.material)) o.material.forEach(m=>m.dispose()); | |
else o.material.dispose(); | |
} | |
} | |
}); | |
modelGroup.remove(gltfRoot); | |
gltfRoot = null; | |
} | |
// Remove any residual children | |
while (modelGroup.children.length) modelGroup.remove(modelGroup.children[0]); | |
gltfPrimaryMesh = null; | |
gltfPrimaryMaterial = null; | |
gltfPrimaryMaterialCaptured = false; | |
ikMesh = null; | |
usingGLTFGeometry = false; | |
animationFrames = []; | |
frameIndex = 0; | |
baseOffsetY = 0; | |
groundAligned = false; | |
pendingAlign = false; | |
alignAttemptCount = 0; | |
log("Visuals cleaned."); | |
} | |
function scheduleGroundAlign(force=false){ | |
if (force){ | |
groundAligned = false; | |
alignAttemptCount = 0; | |
} | |
pendingAlign = true; | |
} | |
function performGroundAlign(){ | |
if (!pendingAlign) return; | |
if (!modelGroup.children.length){ | |
if (++alignAttemptCount > maxAlignAttempts) pendingAlign = false; | |
return; | |
} | |
const box = new THREE.Box3().setFromObject(modelGroup); | |
if (box.isEmpty() || !isFinite(box.min.y)){ | |
if (++alignAttemptCount > maxAlignAttempts) pendingAlign = false; | |
return; | |
} | |
const currentMin = box.min.y; | |
// Desired world shift so new min ~= 0 | |
const delta = -currentMin; | |
// Avoid large downward moves; only allow raising or tiny corrections | |
if (delta > 0 || Math.abs(delta) < 1e-3){ | |
modelGroup.position.y += delta; | |
baseOffsetY = modelGroup.position.y; | |
groundAligned = true; | |
updateTargetSphere(); // (NEW) keep target indicator vertically aligned | |
} | |
pendingAlign = false; | |
} | |
// (ADD) fitCamera helper (uses modelGroup if populated) | |
function fitCamera(obj){ | |
const targetObj = modelGroup.children.length ? modelGroup : obj; | |
if (!targetObj) return; | |
const box = new THREE.Box3().setFromObject(targetObj); | |
if (box.isEmpty()) return; | |
const size = box.getSize(new THREE.Vector3()); | |
const center = box.getCenter(new THREE.Vector3()); | |
controls.target.copy(center); | |
const maxDim = Math.max(size.x,size.y,size.z); | |
const dist = maxDim * 3; | |
const dir = new THREE.Vector3(0.0,0.5,1).normalize(); | |
camera.position.copy(center).addScaledVector(dir, dist); | |
camera.near = maxDim/100; | |
camera.far = maxDim*100; | |
camera.updateProjectionMatrix(); | |
controls.update(); | |
} | |
// ----- DOM refs ----- | |
const statusBar = document.getElementById('statusBar'); | |
const bonesContainer = document.getElementById('bonesContainer'); | |
const endEffectorSel = document.getElementById('end_effector'); | |
const handShapeSel = document.getElementById('hand_shape'); | |
const handPosSel = document.getElementById('hand_position'); | |
const handFieldset = document.getElementById('handFieldset'); | |
const modelBadge = document.getElementById('modelBadge'); | |
const subpointsInput = document.getElementById('subpoints'); // ADDED | |
// Inputs | |
function val(id){ return document.getElementById(id).value; } | |
function num(id){ return parseFloat(val(id)); } | |
function bool(id){ return document.getElementById(id).checked; } | |
// ----- Three.js scene ----- | |
const renderer = new THREE.WebGLRenderer({ antialias:true }); | |
renderer.setPixelRatio(window.devicePixelRatio); | |
renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight); | |
renderer.domElement.className = 'viewer-canvas'; | |
document.getElementById('viewerPane').appendChild(renderer.domElement); | |
const scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x222222); | |
const camera = new THREE.PerspectiveCamera(45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.01, 1000); | |
camera.position.set(0,1,3); | |
const controls = new OrbitControls(camera, renderer.domElement); | |
controls.target.set(0,0.8,0); | |
scene.add(new THREE.HemisphereLight(0xffffff,0x444444,1.0)); | |
const d = new THREE.DirectionalLight(0xffffff,0.8); | |
d.position.set(3,10,10); scene.add(d); | |
const ground = new THREE.Mesh( | |
new THREE.CircleGeometry(5,48), | |
new THREE.MeshStandardMaterial({color:0x303030, metalness:0.1, roughness:0.9}) | |
); | |
ground.rotation.x = -Math.PI/2; | |
ground.receiveShadow = true; | |
scene.add(ground); | |
ground.visible = false; // DISABLED ground plane | |
// (ADD) Group to apply vertical lift without altering raw vertex data | |
const modelGroup = new THREE.Group(); | |
scene.add(modelGroup); | |
// (ADDED) Target sphere visual | |
const targetSphereGeom = new THREE.SphereGeometry(0.01, 24, 24); | |
const targetSphereMat = new THREE.MeshStandardMaterial({color:0x00ff55, emissive:0x008833}); | |
const targetSphere = new THREE.Mesh(targetSphereGeom, targetSphereMat); | |
scene.add(targetSphere); | |
// (REPLACED) updateTargetSphere: now accounts for modelGroup translation (baseOffsetY) | |
// and uniform scaling (Pepper) on all axes so sphere aligns with the visual robot pose. | |
function updateTargetSphere(){ | |
const xRaw = parseFloat(document.getElementById('target_x').value) || 0; | |
const yRaw = parseFloat(document.getElementById('target_y').value) || 0; | |
const zRaw = parseFloat(document.getElementById('target_z').value) || 0; | |
// Model transform | |
const sx = modelGroup?.scale?.x ?? 1; | |
const sy = modelGroup?.scale?.y ?? 1; | |
const sz = modelGroup?.scale?.z ?? 1; | |
const tx = modelGroup?.position?.x ?? 0; | |
const ty = modelGroup?.position?.y ?? 0; // this already equals baseOffsetY after ground align | |
const tz = modelGroup?.position?.z ?? 0; | |
// Apply uniform / per-axis scaling then translation so sphere sits where the solver | |
// target maps in the displayed (scaled & lifted) robot coordinates. | |
targetSphere.position.set( | |
xRaw * sx + tx, | |
yRaw * sy + ty, | |
zRaw * sz + tz | |
); | |
} | |
// Attach live update listeners | |
['target_x','target_y','target_z'].forEach(id=>{ | |
document.getElementById(id).addEventListener('input', updateTargetSphere); | |
document.getElementById(id).addEventListener('change', updateTargetSphere); | |
}); | |
// Initial placement after DOM ready | |
updateTargetSphere(); | |
window.addEventListener('resize', () => { | |
renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight); | |
camera.aspect = renderer.domElement.clientWidth / renderer.domElement.clientHeight; | |
camera.updateProjectionMatrix(); | |
}); | |
function resetCamera(){ | |
camera.position.set(0,1,3); | |
controls.target.set(0,0.8,0); | |
controls.update(); | |
} | |
// ----- Config fetch ----- | |
async function loadConfig(){ | |
const res = await fetch(`/config?model=${currentModel}`); | |
configData = await res.json(); | |
populateUIFromConfig(); | |
log(`Config loaded for ${currentModel}`); | |
} | |
function populateUIFromConfig(){ | |
// End effector | |
endEffectorSel.innerHTML = ""; | |
configData.end_effector_choices.forEach(b=>{ | |
const opt = document.createElement('option'); | |
opt.value = b; opt.textContent = b; | |
if (b === configData.default_end_effector) opt.selected = true; | |
endEffectorSel.appendChild(opt); | |
}); | |
// Bones | |
bonesContainer.innerHTML = ""; | |
configData.selectable_bones.forEach(b=>{ | |
const id = `bone_${b}`; | |
const label = document.createElement('label'); | |
label.innerHTML = `<input type="checkbox" id="${id}" ${configData.default_controlled_bones.includes(b)?'checked':''}> ${b}`; | |
bonesContainer.appendChild(label); | |
// Add listener for auto-solve | |
setTimeout(()=>{ // ensure element in DOM | |
const cb = document.getElementById(id); | |
if (cb) cb.addEventListener('change', ()=> scheduleSolve()); | |
},0); | |
}); | |
// Hand controls (agent only) | |
if (currentModel === 'agent'){ | |
handFieldset.style.display = ''; | |
handShapeSel.innerHTML = ""; | |
configData.hand_shapes.forEach(s=>{ | |
const opt = document.createElement('option'); | |
opt.value = s; opt.textContent = s; | |
handShapeSel.appendChild(opt); | |
}); | |
handPosSel.innerHTML = ""; | |
configData.hand_positions.forEach(s=>{ | |
const opt = document.createElement('option'); | |
opt.value = s; opt.textContent = s; | |
handPosSel.appendChild(opt); | |
}); | |
} else { | |
handFieldset.style.display = 'none'; | |
} | |
// Set subpoints max if provided | |
if (configData && typeof configData.max_subpoints !== 'undefined'){ | |
subpointsInput.max = configData.max_subpoints; | |
} | |
updateTargetSphere(); | |
} | |
// Subpoints change listener (ensure derivative toggle & autosolve) - ADDED | |
subpointsInput.addEventListener('change', ()=>{ | |
const v = parseInt(subpointsInput.value,10); | |
if (v <= 1){ | |
document.getElementById('derivative_enabled').checked = false; | |
} | |
scheduleSolve(); | |
}); | |
// Disable GLTF controls on Pepper tab (avoid SMPLX loading there) | |
function applyModelSpecificGLTFPolicy(){ | |
const urlInput = document.getElementById('gltf_url'); | |
//const loadBtn = document.getElementById('load_gltf'); | |
const showGltfChk = document.getElementById('show_gltf'); | |
if (currentModel === 'pepper'){ | |
urlInput.value = ""; // prevent accidental SMPLX load | |
//loadBtn.disabled = true; | |
showGltfChk.disabled = true; | |
if (gltfRoot) { gltfRoot.visible = false; } | |
} else { | |
//if (!urlInput.value) urlInput.value = "/files/smplx.glb"; | |
//loadBtn.disabled = false; | |
showGltfChk.disabled = false; | |
} | |
} | |
// ----- GLTF Loader (reuse its primary mesh for IK if vertex counts match) ----- | |
const gltfLoader = new GLTFLoader(); | |
function loadGLTF(url){ | |
if (!url){ log("No GLTF URL (possibly Pepper) - skipping load."); return; } | |
const u = url.startsWith('http') ? url : url; | |
statusBar.textContent = `Loading GLTF: ${u}`; | |
log(`Loading GLTF ${u}`); | |
gltfLoader.load(u, gltf=>{ | |
if (gltfRoot) modelGroup.remove(gltfRoot); | |
gltfRoot = gltf.scene; | |
gltfPrimaryMaterial = null; | |
gltfPrimaryMesh = null; | |
gltfPrimaryMaterialCaptured = false; | |
gltfRoot.traverse(o=>{ | |
if (o.isMesh && o.geometry?.attributes?.position){ | |
if (!gltfPrimaryMesh || o.geometry.attributes.position.count > gltfPrimaryMesh.geometry.attributes.position.count){ | |
gltfPrimaryMesh = o; | |
} | |
} | |
}); | |
if (gltfPrimaryMesh){ | |
if (Array.isArray(gltfPrimaryMesh.material)){ | |
const mm = gltfPrimaryMesh.material.find(m=>m.map) || gltfPrimaryMesh.material[0]; | |
gltfPrimaryMaterial = mm.clone(); | |
} else { | |
gltfPrimaryMaterial = gltfPrimaryMesh.material.clone(); | |
} | |
gltfPrimaryMaterialCaptured = true; | |
log(`Captured GLTF primary mesh (verts=${gltfPrimaryMesh.geometry.attributes.position.count})`); | |
} else { | |
log("No primary mesh found in GLTF."); | |
} | |
modelGroup.add(gltfRoot); // (CHANGED) add to modelGroup instead of scene | |
gltfRoot.visible = document.getElementById('show_gltf').checked; | |
// Bind deformation if frames already exist | |
if (animationFrames.length){ | |
ensureActiveMeshBound(animationFrames[0]); | |
applyFrame(animationFrames[Math.min(frameIndex, animationFrames.length-1)]); | |
} | |
fitCamera(gltfRoot); | |
// adjustModelHeight(gltfRoot); // (ADD) ensure height set even without frames yet | |
statusBar.textContent = "GLTF loaded."; | |
}, undefined, err=>{ | |
statusBar.textContent = "GLTF error: " + err.message; | |
log("GLTF error: "+err.message); | |
}); | |
} | |
// ----- Animation Frames Fetch ----- | |
async function fetchFrames(force=false){ | |
const now = performance.now(); | |
if (!force && now - lastFrameFetch < pollInterval) return; | |
lastFrameFetch = now; | |
const url = `/animation?model=${currentModel==='pepper'?'pepper':'agent'}`; | |
try { | |
const res = await fetch(url); | |
if (!res.ok) throw new Error(res.status); | |
const data = await res.json(); | |
if (force || data.length !== animationFrames.length){ | |
animationFrames = data; | |
if (animationFrames.length){ | |
ensureActiveMeshBound(animationFrames[0]); | |
controlFrames = []; | |
splineMode = false; | |
playbackEnabled = false; | |
const requestedSub = payload.subpoints; | |
if (animationFrames.length > 1 && requestedSub > 1){ | |
setupSplinePlayback(animationFrames); | |
applyFrame(animationFrames[0]); | |
} else { | |
frameIndex = animationFrames.length - 1; | |
applyFrame(animationFrames[frameIndex]); | |
} | |
scheduleGroundAlign(); | |
lastFrameFetch = performance.now(); | |
} | |
} | |
} catch(e){ | |
log("Animation fetch error: "+e.message); | |
} | |
} | |
// Decide how to bind deformation on first frames arrival | |
function prepareDeformationBinding(){ | |
if (!animationFrames.length) return; | |
ensureActiveMeshBound(animationFrames[0]); | |
applyFrame(animationFrames[0]); | |
} | |
// Deprecate old createOrUpdateFallbackMesh usage by redirect (kept if referenced) | |
function createOrUpdateFallbackMesh(){ | |
if (!animationFrames.length) return; | |
ensureActiveMeshBound(animationFrames[0]); | |
} | |
// ----- Playback control (NEW) ----- | |
function updateFramePlayback(){ | |
if (!animationFrames.length) return; | |
const idx = Math.min(Math.floor(frameIndex), animationFrames.length - 1); // clamp | |
applyFrame(animationFrames[idx]); | |
} | |
// ----- Solve ----- | |
// REMOVE old binding to doSolve directly (replaced below) | |
// document.getElementById('solve_btn').onclick = () => doSolve(); | |
document.getElementById('solve_btn').onclick = () => triggerSolve(); | |
function collectBones(){ | |
const bones = []; | |
if (!configData) return bones; | |
configData.selectable_bones.forEach(b=>{ | |
const cb = document.getElementById(`bone_${b}`); | |
if (cb && cb.checked) bones.push(b); | |
}); | |
return bones; | |
} | |
async function doSolve(){ | |
statusBar.textContent = "Solving..."; | |
log("Solve request started"); | |
const tSubmit = performance.now(); | |
const payload = { | |
model: currentModel, | |
target: [num('target_x'), num('target_y'), num('target_z')], | |
subpoints: parseInt(val('subpoints'),10), | |
distance_enabled: bool('distance_enabled'), | |
distance_weight: num('distance_weight'), | |
collision_enabled: bool('collision_enabled'), | |
collision_weight: num('collision_weight'), | |
bone_zero_enabled: bool('bone_zero_enabled'), | |
bone_zero_weight: num('bone_zero_weight'), | |
derivative_enabled: bool('derivative_enabled'), | |
derivative_weight: num('derivative_weight'), | |
controlled_bones: collectBones(), | |
end_effector: endEffectorSel.value, | |
hand_shape: currentModel==='agent'? handShapeSel.value : "None", | |
hand_position: currentModel==='agent'? handPosSel.value : "None", | |
frames_mode: "auto" | |
}; | |
try { | |
await fetch("/configure", { | |
method:"POST", | |
headers:{'Content-Type':'application/json'}, | |
body: JSON.stringify({ | |
model: currentModel, | |
controlled_bones: payload.controlled_bones, | |
end_effector: payload.end_effector | |
}) | |
}); | |
const t0 = performance.now(); | |
const res = await fetch("/solve", { | |
method:"POST", | |
headers:{'Content-Type':'application/json'}, | |
body: JSON.stringify(payload) | |
}); | |
const json = await res.json(); | |
if (json.status !== 'ok') throw new Error(json.message || 'Solve failed'); | |
// (ADDED) Stale solve guard | |
const sid = json.result.solve_id || 0; | |
if (payload.model === 'agent'){ | |
if (sid <= lastSolveIdAgent){ | |
log(`Stale agent solve ignored (sid=${sid} <= ${lastSolveIdAgent})`); | |
return; | |
} | |
lastSolveIdAgent = sid; | |
} else { | |
if (sid <= lastSolveIdPepper){ | |
log(`Stale pepper solve ignored (sid=${sid} <= ${lastSolveIdPepper})`); | |
return; | |
} | |
lastSolveIdPepper = sid; | |
} | |
const server = json.result.solve_time; | |
const total = (performance.now()-t0)/1000; | |
statusBar.textContent = | |
`Solved: server=${server.toFixed(2)}s total=${total.toFixed(2)}s it=${json.result.iterations} obj=${json.result.objective.toFixed(6)} frames=${json.result.frames}`; | |
log("Solve completed (frames inline)"); | |
if (json.result.frames_data && json.result.frames_data.length){ | |
animationFrames = json.result.frames_data; | |
ensureActiveMeshBound(animationFrames[0]); | |
controlFrames = []; | |
splineMode = false; | |
playbackEnabled = false; | |
const requestedSub = payload.subpoints; | |
if (animationFrames.length > 1 && requestedSub > 1){ | |
setupSplinePlayback(animationFrames); | |
applyFrame(animationFrames[0]); | |
} else { | |
frameIndex = animationFrames.length - 1; | |
applyFrame(animationFrames[frameIndex]); | |
} | |
scheduleGroundAlign(); | |
} | |
updateTargetSphere(); | |
showToast( | |
`Solve OK • it=${json.result.iterations} • t=${json.result.solve_time.toFixed(2)}s • err=${json.result.objective.toExponential(2)}`, | |
'success', | |
5000 | |
); | |
} catch(e){ | |
statusBar.textContent = "Solve error: " + e.message; | |
log("Solve error: "+e.message); | |
showToast(`Solve ERROR: ${e.message}`, 'error', 6000); | |
} | |
} | |
// ----- Toast System (ADDED) ----- | |
const toastContainer = document.getElementById('toastContainer'); | |
function showToast(message, type='success', ttl=4000){ | |
const el = document.createElement('div'); | |
el.className = `toast ${type}`; | |
const close = document.createElement('span'); | |
close.className = 'toast-close'; | |
close.textContent = '✕'; | |
close.onclick = (e)=>{ e.stopPropagation(); remove(); }; | |
const content = document.createElement('div'); | |
content.textContent = message; | |
el.appendChild(close); | |
el.appendChild(content); | |
toastContainer.appendChild(el); | |
requestAnimationFrame(()=> el.classList.add('show')); | |
function remove(){ | |
el.classList.remove('show'); | |
setTimeout(()=> el.remove(), 180); | |
} | |
setTimeout(remove, ttl); | |
} | |
// ----- Target sliders (NEW) ----- | |
const xNum = document.getElementById('target_x'); | |
const yNum = document.getElementById('target_y'); | |
const zNum = document.getElementById('target_z'); | |
const xSlider = document.getElementById('target_x_slider'); | |
const ySlider = document.getElementById('target_y_slider'); | |
function syncSliderToNum(axis){ | |
if (axis==='x') xSlider.value = xNum.value; | |
else if (axis==='y') ySlider.value = yNum.value; | |
} | |
function syncNumToSlider(axis){ | |
if (axis==='x') xNum.value = xSlider.value; | |
else if (axis==='y') yNum.value = ySlider.value; | |
} | |
// Slider live update (no solve while dragging) | |
xSlider.addEventListener('input', ()=>{ syncNumToSlider('x'); updateTargetSphere(); }); | |
ySlider.addEventListener('input', ()=>{ syncNumToSlider('y'); updateTargetSphere(); }); | |
// Solve only after release (change event) | |
xSlider.addEventListener('change', ()=>{ syncNumToSlider('x'); updateTargetSphere(); scheduleSolve(); }); | |
ySlider.addEventListener('change', ()=>{ syncNumToSlider('y'); updateTargetSphere(); scheduleSolve(); }); | |
// Numeric fields: update slider & sphere; solve on change (debounced) | |
xNum.addEventListener('change', ()=>{ syncSliderToNum('x'); updateTargetSphere(); scheduleSolve(); }); | |
yNum.addEventListener('change', ()=>{ syncSliderToNum('y'); updateTargetSphere(); scheduleSolve(); }); | |
zNum.addEventListener('change', ()=>{ updateTargetSphere(); scheduleSolve(); }); | |
// Existing listeners for target sphere input (remove duplicate if present) | |
// ...existing code... | |
// ----- Hook other auto-solve controls (NEW) ----- | |
const autoSolveSelectors = [ | |
'#distance_enabled','#distance_weight', | |
'#collision_enabled','#collision_weight', | |
'#bone_zero_enabled','#bone_zero_weight', | |
'#derivative_enabled','#derivative_weight', | |
'#hand_shape','#hand_position', | |
'#end_effector','#wireframe','#show_gltf','#show_ikmesh', | |
'#play_fps' | |
]; | |
autoSolveSelectors.forEach(sel=>{ | |
const el = document.querySelector(sel); | |
if (el){ | |
const evt = (el.type === 'checkbox' || el.tagName === 'SELECT') ? 'change' : 'input'; | |
el.addEventListener(evt, ()=> scheduleSolve()); | |
} | |
}); | |
// Wireframe / visibility still immediate visual update (keep old logic if any) | |
document.getElementById('wireframe').addEventListener('change', ()=>{ | |
if (ikMesh && ikMesh.material){ | |
ikMesh.material.wireframe = document.getElementById('wireframe').checked; | |
ikMesh.material.needsUpdate = true; | |
} | |
if (gltfPrimaryMesh && gltfPrimaryMesh.material){ | |
if (Array.isArray(gltfPrimaryMesh.material)) gltfPrimaryMesh.material.forEach(m=>{m.wireframe=document.getElementById('wireframe').checked; m.needsUpdate=true;}); | |
else { gltfPrimaryMesh.material.wireframe = document.getElementById('wireframe').checked; gltfPrimaryMesh.material.needsUpdate=true; } | |
} | |
}); | |
document.getElementById('show_gltf').addEventListener('change', ()=>{ | |
if (gltfRoot) gltfRoot.visible = document.getElementById('show_gltf').checked; | |
}); | |
document.getElementById('show_ikmesh').addEventListener('change', ()=>{ | |
if (ikMesh) ikMesh.visible = document.getElementById('show_ikmesh').checked; | |
}); | |
// Update bones listener attachment inside populateUIFromConfig (MODIFIED) | |
function populateUIFromConfig(){ | |
// End effector | |
endEffectorSel.innerHTML = ""; | |
configData.end_effector_choices.forEach(b=>{ | |
const opt = document.createElement('option'); | |
opt.value = b; opt.textContent = b; | |
if (b === configData.default_end_effector) opt.selected = true; | |
endEffectorSel.appendChild(opt); | |
}); | |
// Bones | |
bonesContainer.innerHTML = ""; | |
configData.selectable_bones.forEach(b=>{ | |
const id = `bone_${b}`; | |
const label = document.createElement('label'); | |
label.innerHTML = `<input type="checkbox" id="${id}" ${configData.default_controlled_bones.includes(b)?'checked':''}> ${b}`; | |
bonesContainer.appendChild(label); | |
// Add listener for auto-solve | |
setTimeout(()=>{ // ensure element in DOM | |
const cb = document.getElementById(id); | |
if (cb) cb.addEventListener('change', ()=> scheduleSolve()); | |
},0); | |
}); | |
// Hand controls (agent only) | |
if (currentModel === 'agent'){ | |
handFieldset.style.display = ''; | |
handShapeSel.innerHTML = ""; | |
configData.hand_shapes.forEach(s=>{ | |
const opt = document.createElement('option'); | |
opt.value = s; opt.textContent = s; | |
handShapeSel.appendChild(opt); | |
}); | |
handPosSel.innerHTML = ""; | |
configData.hand_positions.forEach(s=>{ | |
const opt = document.createElement('option'); | |
opt.value = s; | |
opt.textContent = s; | |
handPosSel.appendChild(opt); | |
}); | |
} else { | |
handFieldset.style.display = 'none'; | |
} | |
// Set subpoints max if provided | |
if (configData && typeof configData.max_subpoints !== 'undefined'){ | |
subpointsInput.max = configData.max_subpoints; | |
} | |
updateTargetSphere(); | |
} | |
// Solve button untouched; user can still manually force | |
// document.getElementById('solve_btn').onclick = ()=> triggerSolve(); | |
// During tab switch, avoid duplicate immediate solve until config/frames loaded | |
// (MODIFY tab switch handler to schedule solve after frames) | |
document.querySelectorAll('.tab-btn').forEach(btn=>{ | |
btn.onclick = () => { | |
if (btn.classList.contains('active')) return; | |
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); | |
btn.classList.add('active'); | |
currentModel = btn.getAttribute('data-model'); | |
modelBadge.textContent = currentModel === 'pepper' ? "Pepper Robot" : "Agent"; | |
cleanupVisuals(); | |
applyModelSpecificGLTFPolicy(); | |
loadConfig().then(()=>{ | |
const url = document.getElementById('gltf_url').value; | |
if (url) loadGLTF(url); | |
fitCamera(modelGroup); | |
scheduleSolve(true); | |
updateTargetSphere(); | |
}); | |
}; | |
}); | |
// ===== Unified Mesh Binding (ADDED) ===== | |
function ensureActiveMeshBound(firstFrame){ | |
if (!firstFrame) return; | |
const verts = firstFrame.vertices; | |
let bound = false; | |
// Try GLTF primary mesh first | |
if (gltfPrimaryMesh && | |
gltfPrimaryMesh.geometry?.attributes?.position && | |
gltfPrimaryMesh.geometry.attributes.position.count === verts.length){ | |
// Remove fallback if exists | |
if (fallbackMesh){ | |
modelGroup.remove(fallbackMesh); | |
if (fallbackMesh.geometry) fallbackMesh.geometry.dispose(); | |
if (fallbackMesh.material && !Array.isArray(fallbackMesh.material)) fallbackMesh.material.dispose(); | |
fallbackMesh = null; | |
} | |
ikMesh = gltfPrimaryMesh; | |
usingGLTFGeometry = true; | |
if (gltfPrimaryMaterial) ikMesh.material = gltfPrimaryMaterial; | |
bound = true; | |
log("ensureActiveMeshBound: using GLTF primary mesh"); | |
} | |
// Fallback creation/update | |
if (!bound){ | |
if (!fallbackMesh || | |
!fallbackMesh.geometry?.getAttribute('position') || | |
fallbackMesh.geometry.getAttribute('position').count !== verts.length){ | |
if (fallbackMesh){ | |
modelGroup.remove(fallbackMesh); | |
if (fallbackMesh.geometry) fallbackMesh.geometry.dispose(); | |
if (fallbackMesh.material && !Array.isArray(fallbackMesh.material)) fallbackMesh.material.dispose(); | |
} | |
const geom = new THREE.BufferGeometry(); | |
const posArr = new Float32Array(verts.length * 3); | |
for (let i=0;i<verts.length;i++){ | |
const v = verts[i]; | |
posArr[i*3]=v[0]; posArr[i*3+1]=v[1]; posArr[i*3+2]=v[2]; | |
} | |
geom.setAttribute('position', new THREE.BufferAttribute(posArr,3)); | |
geom.getAttribute('position').setUsage(THREE.DynamicDrawUsage); | |
const faces = firstFrame.faces; | |
const idx = new Uint32Array(faces.length*3); | |
for (let i=0;i<faces.length;i++){ | |
const f = faces[i]; | |
idx[i*3]=f[0]; idx[i*3+1]=f[1]; idx[i*3+2]=f[2]; | |
} | |
geom.setIndex(new THREE.BufferAttribute(idx,1)); | |
geom.computeVertexNormals(); | |
const mat = gltfPrimaryMaterial ? gltfPrimaryMaterial.clone() | |
: new THREE.MeshStandardMaterial({color:0x6699ff, metalness:0.2, roughness:0.6}); | |
fallbackMesh = new THREE.Mesh(geom, mat); | |
fallbackMesh.visible = document.getElementById('show_ikmesh').checked; | |
modelGroup.add(fallbackMesh); | |
ikMesh = fallbackMesh; | |
usingGLTFGeometry = false; | |
log("ensureActiveMeshBound: created fallback mesh"); | |
} else { | |
ikMesh = fallbackMesh; | |
usingGLTFGeometry = false; | |
log("ensureActiveMeshBound: reused fallback mesh"); | |
} | |
} | |
// Pepper scaling (visual only) | |
if (currentModel === 'pepper' && ikMesh){ | |
const box = new THREE.Box3().setFromObject(modelGroup); | |
const h = box.max.y - box.min.y; | |
const targetH = 1.2; | |
if (h > 0 && (h < 0.6 || h > 2.0)){ | |
const s = targetH / h; | |
modelGroup.scale.set(s,s,s); | |
log(`Pepper scaled: origH=${h.toFixed(3)} scale=${s.toFixed(3)}`); | |
scheduleGroundAlign(true); | |
} | |
} | |
if (gltfRoot) gltfRoot.visible = document.getElementById('show_gltf').checked; | |
if (ikMesh) ikMesh.visible = document.getElementById('show_ikmesh').checked; | |
updateTargetSphere(); | |
} | |
// ===== Frame Application (ADDED) ===== | |
function applyFrame(frame){ | |
if (!ikMesh || !frame) return; | |
const posAttr = ikMesh.geometry.getAttribute('position'); | |
if (!posAttr || posAttr.count !== frame.vertices.length){ | |
log("applyFrame: vertex count mismatch"); | |
return; | |
} | |
const arr = posAttr.array; | |
const verts = frame.vertices; | |
for (let i=0;i<verts.length;i++){ | |
const v = verts[i]; | |
arr[i*3]=v[0]; arr[i*3+1]=v[1]; arr[i*3+2]=v[2]; | |
} | |
posAttr.needsUpdate = true; | |
if (!groundAligned) scheduleGroundAlign(); | |
} | |
// ----- B-spline helpers (ADDED) --- | |
function bsplineBasis(t){ | |
const t2=t*t, t3=t2*t; | |
return [ | |
(1 - 3*t + 3*t2 - t3)/6, | |
(4 - 6*t2 + 3*t3)/6, | |
(1 + 3*t + 3*t2 - 3*t3)/6, | |
t3/6 | |
]; | |
} | |
function getControlFrame(i){ | |
if (i < 0) return controlFrames[0]; | |
if (i >= controlFrames.length) return controlFrames[controlFrames.length-1]; | |
return controlFrames[i]; | |
} | |
function evalSplineVertices(u){ | |
if (!ikMesh || controlFrames.length < 2) return; | |
const n = controlFrames.length; | |
const seg = Math.min(Math.floor(u), n-2); | |
const t = u - seg; | |
const [w0,w1,w2,w3] = bsplineBasis(t); | |
const f0 = getControlFrame(seg-1); | |
const f1 = getControlFrame(seg); | |
const f2 = getControlFrame(seg+1); | |
const f3 = getControlFrame(seg+2); | |
const posAttr = ikMesh.geometry.getAttribute('position'); | |
if (!posAttr) return; | |
const arr = posAttr.array; | |
const v0=f0.vertices, v1=f1.vertices, v2=f2.vertices, v3=f3.vertices; | |
const count = posAttr.count; | |
for (let i=0;i<count;i++){ | |
const a0=v0[i], a1=v1[i], a2=v2[i], a3=v3[i]; | |
arr[i*3] = w0*a0[0]+w1*a1[0]+w2*a2[0]+w3*a3[0]; | |
arr[i*3+1] = w0*a0[1]+w1*a1[1]+w2*a2[1]+w3*a3[1]; | |
arr[i*3+2] = w0*a0[2]+w1*a1[2]+w2*a2[2]+w3*a3[2]; | |
} | |
posAttr.needsUpdate = true; | |
if (!groundAligned) scheduleGroundAlign(); | |
} | |
function setupSplinePlayback(frames){ | |
controlFrames = frames; | |
splineMode = true; | |
splineU = 0; | |
const segments = frames.length - 1; | |
splineDuration = Math.min(segments * 0.6, 8.0); // heuristic | |
playbackEnabled = true; | |
} | |
// ----- Render Loop ----- | |
function animate(now){ | |
requestAnimationFrame(animate); | |
const dt = (now - lastTime)/1000; | |
lastTime = now; | |
if (splineMode && playbackEnabled){ | |
const n = controlFrames.length; | |
if (n > 1){ | |
splineU += (dt / splineDuration) * (n - 1); | |
if (splineU >= (n - 1)){ | |
splineU = n - 1; | |
playbackEnabled = false; // stop at end | |
} | |
evalSplineVertices(splineU); | |
} | |
} else if (animationFrames.length > 1 && playbackEnabled){ | |
const fps = parseInt(document.getElementById('play_fps').value,10) || 24; | |
frameIndex += dt * fps; | |
if (frameIndex >= animationFrames.length){ | |
if (allowLoopPlayback){ | |
frameIndex = 0; | |
} else { | |
frameIndex = animationFrames.length - 1; | |
playbackEnabled = false; // stop at final frame | |
} | |
} | |
updateFramePlayback(); | |
} | |
// If not animating but we just solved / loaded, ensure final frame rendered | |
if (!playbackEnabled && animationFrames.length && frameIndex === animationFrames.length - 1){ | |
// Single application safeguard (light cost) | |
// applyFrame(animationFrames[frameIndex]); // already applied; can omit | |
} | |
if (pendingAlign) performGroundAlign(); | |
// (ADDED) One-time initial camera framing after first geometry appears | |
if (!initialCameraFramed && modelGroup.children.length){ | |
fitCamera(modelGroup); | |
initialCameraFramed = true; | |
} | |
renderer.render(scene, camera); | |
} | |
requestAnimationFrame(animate); | |
// ----- Init ----- | |
applyModelSpecificGLTFPolicy(); | |
loadConfig().then(()=>{ | |
const url = document.getElementById('gltf_url').value; | |
if (url) loadGLTF(url); | |
scheduleGroundAlign(true); | |
scheduleSolve(true); | |
updateTargetSphere(); | |
}); | |
})(); | |
</script> | |
</body> | |
</html> | |