JAX-IK / static /index.html
hvoss-techfak's picture
reverted some changes
91d15eb
<!DOCTYPE html>
<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>