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; /* moved from top-left to bottom-left */ bottom:56px; 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; } | |
.axis-pair { margin:4px 0; } | |
.axis-pair .flex-row { align-items:center; } | |
.axis-pair span { width:14px; display:inline-block; font-size:11px; color:#aaa; } | |
</style> | |
<!-- Added import map to resolve bare specifier 'three' --> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/[email protected]/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">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> | |
</fieldset> | |
<fieldset id="collisionFieldset"> | |
<legend>Collision Sphere</legend> | |
<label><input id="collision_enabled" type="checkbox"> Enable Collision Sphere</label> | |
<div class="axis-pair">X | |
<div class="flex-row"> | |
<input id="collision_cx" type="number" value="0.1" step="0.01"> | |
<input id="collision_cx_slider" type="range" min="-1" max="1" step="0.01" value="0.1"> | |
</div> | |
</div> | |
<div class="axis-pair">Y | |
<div class="flex-row"> | |
<input id="collision_cy" type="number" value="0.0" step="0.01"> | |
<input id="collision_cy_slider" type="range" min="-1" max="1" step="0.01" value="0.0"> | |
</div> | |
</div> | |
<div class="axis-pair">Z | |
<div class="flex-row"> | |
<input id="collision_cz" type="number" value="0.35" step="0.01"> | |
<input id="collision_cz_slider" type="range" min="-1" max="1" step="0.01" value="0.35"> | |
</div> | |
</div> | |
<label>Collision Weight <input id="collision_weight" type="number" value="1.0" step="0.1"></label> | |
<label>Sphere Radius <input id="collision_radius" type="number" value="0.1" step="0.01" min="0.01" max="1.0"></label> | |
<label>Min Clearance | |
<div class="flex-row"> | |
<input id="collision_min_clearance" type="number" value="0.0" step="0.005" min="0" max="0.5"> | |
<input id="collision_min_clearance_slider" type="range" min="0" max="0.5" step="0.005" value="0.0"> | |
</div> | |
</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> | |
<fieldset> | |
<legend>Solver</legend> | |
<label>Steps <input id="num_steps" type="number" value="50" min="1" max="10000" step="10"></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/[email protected]/examples/jsm/controls/OrbitControls.js'; | |
import { GLTFLoader } from 'https://unpkg.com/[email protected]/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(); | |
let lastSolveIdAgent = 0; | |
let lastSolveIdPepper = 0; | |
// ----- Auto-solve ----- | |
let solving = false; | |
let solveDebounceTimer = null; | |
const SOLVE_DEBOUNCE_MS = 25; | |
let currentSolveController = null; | |
function scheduleSolve(immediate=false){ | |
if (solving){ if (currentSolveController) currentSolveController.abort(); } | |
if (immediate){ triggerSolve(true); return; } | |
if (solveDebounceTimer) clearTimeout(solveDebounceTimer); | |
solveDebounceTimer = setTimeout(()=> triggerSolve(false), SOLVE_DEBOUNCE_MS); | |
} | |
async function triggerSolve(){ | |
if (currentSolveController) currentSolveController.abort(); | |
currentSolveController = new AbortController(); | |
const controller = currentSolveController; | |
solving = true; | |
try { await doSolve(controller.signal); } | |
catch(e){ if (e.name !== 'AbortError') {} } | |
finally { if (controller === currentSolveController) solving = false; } | |
} | |
// 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 | |
let controlFrames = []; | |
let splineMode = false; | |
let splineU = 0; | |
let splineDuration = 2.0; | |
let initialCameraFramed = false; | |
function cleanupVisuals(){ | |
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; | |
} | |
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; const delta = -currentMin; | |
if (delta > 0 || Math.abs(delta) < 1e-3){ | |
modelGroup.position.y += delta; baseOffsetY = modelGroup.position.y; groundAligned = true; updateTargetSphere(); updateCollisionSphere(); | |
} | |
pendingAlign=false; | |
} | |
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'); | |
const numStepsInput = document.getElementById('num_steps'); | |
// Collision inputs | |
const collisionEnabledEl = document.getElementById('collision_enabled'); | |
const collisionWeightEl = document.getElementById('collision_weight'); | |
const collCx = document.getElementById('collision_cx'); | |
const collCy = document.getElementById('collision_cy'); | |
const collCz = document.getElementById('collision_cz'); | |
const collCxSlider = document.getElementById('collision_cx_slider'); | |
const collCySlider = document.getElementById('collision_cy_slider'); | |
const collCzSlider = document.getElementById('collision_cz_slider'); | |
const collRadiusEl = document.getElementById('collision_radius'); | |
const collMinClearEl = document.getElementById('collision_min_clearance'); | |
const collMinClearSlider = document.getElementById('collision_min_clearance_slider'); | |
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; | |
const modelGroup = new THREE.Group(); scene.add(modelGroup); | |
// Target sphere | |
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); | |
// Collision sphere (unit scaled later) | |
const collisionSphereGeom = new THREE.SphereGeometry(1, 32, 32); | |
const collisionSphereMat = new THREE.MeshStandardMaterial({color:0xff4444, emissive:0x660000, transparent:true, opacity:0.18, wireframe:false}); | |
const collisionSphere = new THREE.Mesh(collisionSphereGeom, collisionSphereMat); scene.add(collisionSphere); collisionSphere.visible = false; | |
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; | |
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; const tz = modelGroup?.position?.z ?? 0; | |
targetSphere.position.set(xRaw * sx + tx, yRaw * sy + ty, zRaw * sz + tz); | |
} | |
function updateCollisionSphere(){ | |
const cx = parseFloat(collCx.value) || 0; const cy = parseFloat(collCy.value) || 0; const cz = parseFloat(collCz.value) || 0; | |
const r = parseFloat(collRadiusEl.value) || 0.1; const mc = parseFloat(collMinClearEl.value) || 0.0; | |
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; const tz = modelGroup?.position?.z ?? 0; | |
collisionSphere.position.set(cx * sx + tx, cy * sy + ty, cz * sz + tz); | |
const uniform = (sx+sy+sz)/3; const effective = r + mc; // visualize base radius + clearance | |
collisionSphere.scale.set(effective*uniform, effective*uniform, effective*uniform); | |
collisionSphere.visible = collisionEnabledEl.checked; | |
} | |
['target_x','target_y','target_z'].forEach(id=>{ document.getElementById(id).addEventListener('input', updateTargetSphere); document.getElementById(id).addEventListener('change', updateTargetSphere); }); | |
updateTargetSphere(); updateCollisionSphere(); | |
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(){ | |
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); }); | |
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); setTimeout(()=>{ const cb=document.getElementById(id); if (cb) cb.addEventListener('change', ()=> scheduleSolve()); },0); }); | |
if (currentModel === 'agent'){ handFieldset.style.display=''; handShapeSel.innerHTML=""; configData.hand_shapes.forEach(s=>{ const o=document.createElement('option'); o.value=s; o.textContent=s; handShapeSel.appendChild(o); }); handPosSel.innerHTML=""; configData.hand_positions.forEach(s=>{ const o=document.createElement('option'); o.value=s; o.textContent=s; handPosSel.appendChild(o); }); } else { handFieldset.style.display='none'; } | |
if (configData && typeof configData.max_subpoints !== 'undefined') subpointsInput.max = configData.max_subpoints; | |
if (configData && typeof configData.default_num_steps !== 'undefined') numStepsInput.value = configData.default_num_steps; | |
if (configData && configData.collision_default_center){ const c=configData.collision_default_center; collCx.value=c[0]; collCy.value=c[1]; collCz.value=c[2]; collCxSlider.value=c[0]; collCySlider.value=c[1]; collCzSlider.value=c[2]; } | |
if (configData && typeof configData.collision_default_radius !== 'undefined'){ collRadiusEl.value = configData.collision_default_radius; } | |
if (configData && typeof configData.collision_default_min_clearance !== 'undefined'){ collMinClearEl.value = configData.collision_default_min_clearance; collMinClearSlider.value = configData.collision_default_min_clearance; } | |
updateTargetSphere(); updateCollisionSphere(); | |
} | |
subpointsInput.addEventListener('change', ()=>{ const v=parseInt(subpointsInput.value,10); if (v<=1){ document.getElementById('derivative_enabled').checked=false; } scheduleSolve(); }); | |
function applyModelSpecificGLTFPolicy(){ const urlInput=document.getElementById('gltf_url'); const showGltfChk=document.getElementById('show_gltf'); if (currentModel==='pepper'){ showGltfChk.disabled=true; if (gltfRoot) gltfRoot.visible=false; } else { if (!urlInput.value) urlInput.value="/files/smplx.glb"; showGltfChk.disabled=false; } } | |
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); gltfRoot.visible=document.getElementById('show_gltf').checked; if (animationFrames.length){ ensureActiveMeshBound(animationFrames[0]); applyFrame(animationFrames[Math.min(frameIndex, animationFrames.length-1)]); } fitCamera(gltfRoot); statusBar.textContent='GLTF loaded.'; }, undefined, err=>{ statusBar.textContent='GLTF error: '+err.message; log('GLTF error: '+err.message); }); } | |
document.getElementById('solve_btn').onclick = ()=> triggerSolve(true); | |
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(abortSignal){ | |
statusBar.textContent='Solving...'; log('Solve request started'); | |
const subpointsVal=parseInt(val('subpoints'),10); | |
const payload={ | |
model: currentModel, | |
target: [num('target_x'), num('target_y'), num('target_z')], | |
subpoints: subpointsVal, | |
num_steps: parseInt(numStepsInput.value,10) || 100, | |
distance_enabled: bool('distance_enabled'), | |
distance_weight: num('distance_weight'), | |
collision_enabled: collisionEnabledEl.checked, | |
collision_weight: parseFloat(collisionWeightEl.value)||1.0, | |
collision_center: [parseFloat(collCx.value)||0, parseFloat(collCy.value)||0, parseFloat(collCz.value)||0], | |
collision_radius: parseFloat(collRadiusEl.value)||0.1, | |
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: subpointsVal > 1 ? 'auto' : 'last', | |
collision_min_clearance: parseFloat(collMinClearEl.value)||0.0, | |
}; | |
try { const t0=performance.now(); const res=await fetch('/solve',{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload), signal:abortSignal}); if (!res.ok) throw new Error(res.status+' '+res.statusText); const json=await res.json(); if (json.status!=='ok') throw new Error(json.message || 'Solve failed'); 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 received=${json.result.frames})`); if (json.result.frames_data && json.result.frames_data.length){ animationFrames=json.result.frames_data; ensureActiveMeshBound(animationFrames[0]); controlFrames=[]; splineMode=false; playbackEnabled=false; if (animationFrames.length>1 && subpointsVal>1){ setupSplinePlayback(animationFrames); applyFrame(animationFrames[0]); } else { frameIndex=animationFrames.length-1; applyFrame(animationFrames[frameIndex]); } scheduleGroundAlign(); } | |
updateTargetSphere(); updateCollisionSphere(); showToast(`Solve OK • it=${json.result.iterations} • t=${json.result.solve_time.toFixed(2)}s • err=${json.result.objective.toExponential(2)}`,'success',4000); } | |
catch(e){ if (e.name==='AbortError'){ log('Solve aborted (superseded)'); return; } statusBar.textContent='Solve error: '+e.message; log('Solve error: '+e.message); showToast(`Solve ERROR: ${e.message}`,'error',6000); } | |
} | |
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); } | |
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; } | |
xSlider.addEventListener('input', ()=>{ syncNumToSlider('x'); updateTargetSphere(); }); ySlider.addEventListener('input', ()=>{ syncNumToSlider('y'); updateTargetSphere(); }); | |
xSlider.addEventListener('change', ()=>{ syncNumToSlider('x'); updateTargetSphere(); scheduleSolve(); }); ySlider.addEventListener('change', ()=>{ syncNumToSlider('y'); updateTargetSphere(); scheduleSolve(); }); | |
xNum.addEventListener('change', ()=>{ syncSliderToNum('x'); updateTargetSphere(); scheduleSolve(); }); yNum.addEventListener('change', ()=>{ syncSliderToNum('y'); updateTargetSphere(); scheduleSolve(); }); zNum.addEventListener('change', ()=>{ updateTargetSphere(); scheduleSolve(); }); | |
// Collision sliders | |
function syncCollSliderToNum(axis){ if (axis==='x') collCxSlider.value=collCx.value; else if (axis==='y') collCySlider.value=collCy.value; else if (axis==='z') collCzSlider.value=collCz.value; else if (axis==='mc') collMinClearSlider.value=collMinClearEl.value; } | |
function syncCollNumToSlider(axis){ if (axis==='x') collCx.value=collCxSlider.value; else if (axis==='y') collCy.value=collCySlider.value; else if (axis==='z') collCz.value=collCzSlider.value; else if (axis==='mc') collMinClearEl.value=collMinClearSlider.value; } | |
['x','y','z'].forEach(a=>{ const slider = a==='x'?collCxSlider: a==='y'?collCySlider:collCzSlider; slider.addEventListener('input', ()=>{ syncCollNumToSlider(a); updateCollisionSphere(); }); slider.addEventListener('change', ()=>{ syncCollNumToSlider(a); updateCollisionSphere(); scheduleSolve(); }); }); | |
[collCx, collCy, collCz].forEach((el,i)=> el.addEventListener('change', ()=>{ syncCollSliderToNum(i===0?'x':i===1?'y':'z'); updateCollisionSphere(); scheduleSolve(); })); | |
collRadiusEl.addEventListener('change', ()=>{ updateCollisionSphere(); scheduleSolve(); }); | |
collisionEnabledEl.addEventListener('change', ()=>{ updateCollisionSphere(); scheduleSolve(); }); | |
collMinClearSlider.addEventListener('input', ()=>{ syncCollNumToSlider('mc'); updateCollisionSphere(); }); | |
collMinClearSlider.addEventListener('change', ()=>{ syncCollNumToSlider('mc'); updateCollisionSphere(); scheduleSolve(); }); | |
collMinClearEl.addEventListener('change', ()=>{ syncCollSliderToNum('mc'); updateCollisionSphere(); scheduleSolve(); }); | |
const autoSolveSelectors=[ | |
'#distance_enabled','#distance_weight', | |
'#collision_enabled','#collision_weight', '#collision_radius', '#collision_cx', '#collision_cy', '#collision_cz', '#collision_min_clearance', | |
'#bone_zero_enabled','#bone_zero_weight', | |
'#derivative_enabled','#derivative_weight', | |
'#hand_shape','#hand_position', | |
'#end_effector','#wireframe','#show_gltf','#show_ikmesh', | |
'#play_fps','#num_steps' | |
]; | |
autoSolveSelectors.forEach(sel=>{ const el=document.querySelector(sel); if (el){ const evt=(el.type==='checkbox'||el.tagName==='SELECT')?'change':'input'; el.addEventListener(evt, ()=> scheduleSolve()); }}); | |
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; }); | |
function ensureActiveMeshBound(firstFrame){ if (!firstFrame) return; const verts=firstFrame.vertices; let bound=false; if (gltfPrimaryMesh && gltfPrimaryMesh.geometry?.attributes?.position && gltfPrimaryMesh.geometry.attributes.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(); fallbackMesh=null; } ikMesh=gltfPrimaryMesh; usingGLTFGeometry=true; if (gltfPrimaryMaterial) ikMesh.material=gltfPrimaryMaterial; bound=true; log('ensureActiveMeshBound: using GLTF primary mesh'); } | |
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'); } } | |
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(); updateCollisionSphere(); } | |
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(); } | |
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); playbackEnabled=true; } | |
function updateFramePlayback(){ if (!animationFrames.length) return; const idx=Math.min(Math.floor(frameIndex), animationFrames.length - 1); applyFrame(animationFrames[idx]); } | |
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(); initialCameraFramed=false; applyModelSpecificGLTFPolicy(); loadConfig().then(()=>{ if (currentModel==='agent'){ const url=document.getElementById('gltf_url').value; if (url) loadGLTF(url); } scheduleSolve(true); updateTargetSphere(); updateCollisionSphere(); }); }; }); | |
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; } 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; } } updateFramePlayback(); } if (pendingAlign) performGroundAlign(); if (!initialCameraFramed && modelGroup.children.length){ fitCamera(modelGroup); initialCameraFramed=true; } renderer.render(scene, camera); } | |
requestAnimationFrame(animate); | |
document.getElementById('reset_cam').addEventListener('click', ()=>{ fitCamera(modelGroup); }); | |
applyModelSpecificGLTFPolicy(); | |
loadConfig().then(()=>{ const url=document.getElementById('gltf_url').value; if (url) loadGLTF(url); scheduleGroundAlign(true); scheduleSolve(true); updateTargetSphere(); updateCollisionSphere(); }); | |
})(); | |
</script> | |
</body> | |
</html> | |