JAX-IK / static /index.html
hvoss-techfak's picture
disable collision for now until it is free of bugs
4ee4086
<!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; 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>