File size: 38,330 Bytes
3f7d1b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
<!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/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">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/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();
  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>