hvoss-techfak commited on
Commit
6737b3a
·
1 Parent(s): 66f720d

re-added collision. Hardened app.py. Some more small changes.

Browse files
Files changed (2) hide show
  1. app.py +423 -188
  2. static/index.html +179 -790
app.py CHANGED
@@ -1,4 +1,4 @@
1
- import logging, faulthandler, sys, time, tempfile, os, requests, zipfile, io, gc, threading, psutil, json, configargparse
2
  faulthandler.enable()
3
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
4
  logger = logging.getLogger("jax_ik_server")
@@ -38,8 +38,10 @@ def download_and_setup_files():
38
  if not os.path.isdir(pepper_dir):
39
  logger.info("Downloading Pepper model...")
40
  try:
41
- r = requests.get("https://uni-bielefeld.sciebo.de/s/98Sy5s143XgNntb/download", stream=True); r.raise_for_status()
42
- with zipfile.ZipFile(io.BytesIO(r.content)) as z: z.extractall("files/")
 
 
43
  except Exception as e:
44
  logger.warning(f"Pepper download failed: {e}")
45
  # SMPLX
@@ -47,7 +49,8 @@ def download_and_setup_files():
47
  if not os.path.isfile(smplx_file):
48
  logger.info("Downloading SMPLX model...")
49
  try:
50
- r = requests.get("https://uni-bielefeld.sciebo.de/s/B5StwQdiR4DW5mc/download"); r.raise_for_status()
 
51
  open(smplx_file, "wb").write(r.content)
52
  except Exception as e:
53
  logger.warning(f"SMPLX download failed: {e}")
@@ -56,51 +59,189 @@ class IKServer:
56
  def __init__(self, args):
57
  self.args = args
58
  self.solve_lock = threading.Lock()
 
59
  self.process = psutil.Process(os.getpid())
60
 
61
- # Caches
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  self.solver_cache = {}
63
  self.urdf_solver_cache = {}
64
- self.max_cache_size = 5
65
  self.cache_access_order = []
66
  self.urdf_cache_access_order = []
 
67
 
68
  # Animation buffers
69
  self.animation_frames_agent = []
70
  self.animation_frames_urdf = []
71
 
72
- # FastAPI
73
  self.app = FastAPI()
74
  self.app.add_middleware(
75
  CORSMiddleware,
76
  allow_origins=["*"], allow_credentials=True,
77
- allow_methods=["*"], allow_headers=["*"]
78
  )
79
  os.makedirs("static", exist_ok=True)
80
  os.makedirs("files", exist_ok=True)
81
  self.app.mount("/static", StaticFiles(directory="static"), name="static")
82
  self.app.mount("/files", StaticFiles(directory="files"), name="files")
83
 
84
- # Init models (once)
85
  self._init_agent()
86
  self._setup_agent_objectives()
87
  self._init_urdf()
88
  self._setup_urdf_objectives()
89
 
90
- # Cleanup tracking
91
  self.last_cleanup_time = time.time()
92
  self.cleanup_interval = 30
93
 
94
- self.agent_solve_counter = 0 # added
95
- self.urdf_solve_counter = 0 # added
96
-
97
  self._register_routes()
98
  logger.info("Server ready.")
99
- # NEW warmup thread to JIT both solvers early
100
  if getattr(self.args, 'warmup', True):
101
  threading.Thread(target=self._warmup_all, daemon=True).start()
102
 
103
- # ---- Warmup helpers (NEW) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _warmup_agent(self):
105
  try:
106
  mand = [DistanceObjTraj(target_points=np.array([0.0,0.2,0.35]), bone_name=self.current_end_effector, use_head=True, weight=1.0)]
@@ -128,61 +269,9 @@ class IKServer:
128
  self._warmup_urdf()
129
  logger.info(f"Warmup finished in {time.time()-t0:.2f}s")
130
 
131
- # ---------- Cache ----------
132
- def _evict_lru(self, is_urdf=False):
133
- cache = self.urdf_solver_cache if is_urdf else self.solver_cache
134
- order = self.urdf_cache_access_order if is_urdf else self.cache_access_order
135
- if not order: return
136
- key = order.pop(0)
137
- cache.pop(key, None)
138
- gc.collect()
139
-
140
- def _cache_key(self, bones, num_steps): return tuple(sorted(bones)) + (int(num_steps),)
141
-
142
- def _get_solver(self, bones, is_urdf=False, num_steps=None):
143
- if num_steps is None:
144
- num_steps = self.args.num_steps
145
- key = self._cache_key(bones, num_steps)
146
- cache = self.urdf_solver_cache if is_urdf else self.solver_cache
147
- order = self.urdf_cache_access_order if is_urdf else self.cache_access_order
148
- if key in cache:
149
- if key in order: order.remove(key)
150
- order.append(key)
151
- return cache[key]
152
- if len(cache) >= self.max_cache_size:
153
- self._evict_lru(is_urdf)
154
- if is_urdf:
155
- solver = InverseKinematicsSolver(
156
- model_file=self.urdf_file,
157
- controlled_bones=bones,
158
- bounds=None,
159
- threshold=self.args.threshold,
160
- num_steps=int(num_steps),
161
- compute_sdf=False,
162
- )
163
- else:
164
- bounds = []
165
- for b in bones:
166
- if b in self.bounds_dict:
167
- lower, upper = self.bounds_dict[b]
168
- bounds.extend(list(zip(lower, upper)))
169
- else:
170
- bounds.extend([(-90, 90)] * 3)
171
- solver = InverseKinematicsSolver(
172
- model_file=self.args.gltf_file,
173
- controlled_bones=bones,
174
- bounds=bounds,
175
- threshold=self.args.threshold,
176
- num_steps=int(num_steps),
177
- compute_sdf=False,
178
- )
179
- cache[key] = solver
180
- order.append(key)
181
- return solver
182
-
183
  # ---------- Initialization ----------
184
  def _init_agent(self):
185
- self.current_num_steps = self.args.num_steps # NEW
186
  basic = InverseKinematicsSolver(
187
  model_file=self.args.gltf_file,
188
  controlled_bones=["left_collar"],
@@ -209,7 +298,7 @@ class IKServer:
209
  self.animation_frames_agent = self._frames_from_angles([self.initial_rotations], False)
210
 
211
  def _init_urdf(self):
212
- self.urdf_current_num_steps = self.args.num_steps # NEW
213
  self.urdf_file = "files/pepper_description-master/urdf/pepper.urdf"
214
  basic = InverseKinematicsSolver(
215
  model_file=self.urdf_file,
@@ -244,6 +333,10 @@ class IKServer:
244
  min_clearance=0.0,
245
  weight=1.0,
246
  )
 
 
 
 
247
 
248
  def _setup_urdf_objectives(self):
249
  self.urdf_distance_obj = DistanceObjTraj(
@@ -257,58 +350,34 @@ class IKServer:
257
  min_clearance=0.0,
258
  weight=1.0,
259
  )
 
 
 
260
 
261
- # ---------- Configuration ----------
262
- def configure_agent(self, bones, eff, num_steps=None):
263
- if num_steps is None:
264
- num_steps = self.current_num_steps
265
- if not bones: bones = self.default_controlled_bones
266
- if eff not in self.available_bones: eff = self.default_end_effector
267
- changed = (
268
- bones != self.current_controlled_bones or
269
- eff != self.current_end_effector or
270
- int(num_steps) != int(self.current_num_steps)
271
- )
272
- if bones != self.current_controlled_bones or int(num_steps) != int(self.current_num_steps):
273
- self.current_controlled_bones = bones
274
- self.current_num_steps = int(num_steps)
275
- self.solver = self._get_solver(bones, is_urdf=False, num_steps=self.current_num_steps)
276
- self.initial_rotations = np.zeros(len(self.solver.controlled_bones)*3, dtype=np.float32)
277
- self.best_angles = self.initial_rotations.copy()
278
- if eff != self.current_end_effector:
279
- self.current_end_effector = eff
280
- if changed:
281
- self._setup_agent_objectives()
282
- return {"controlled_bones": self.current_controlled_bones, "end_effector": self.current_end_effector, "num_steps": self.current_num_steps}
283
-
284
- def configure_urdf(self, bones, eff, num_steps=None):
285
- if num_steps is None:
286
- num_steps = self.urdf_current_num_steps
287
- if not bones: bones = self.urdf_default_controlled_bones
288
- if eff not in self.urdf_available_bones: eff = self.urdf_default_end_effector
289
- changed = (
290
- bones != self.urdf_current_controlled_bones or
291
- eff != self.urdf_current_end_effector or
292
- int(num_steps) != int(self.urdf_current_num_steps)
293
- )
294
- if bones != self.urdf_current_controlled_bones or int(num_steps) != int(self.urdf_current_num_steps):
295
- self.urdf_current_controlled_bones = bones
296
- self.urdf_current_num_steps = int(num_steps)
297
- self.urdf_solver = self._get_solver(bones, is_urdf=True, num_steps=self.urdf_current_num_steps)
298
- self.urdf_initial_rotations = np.zeros(len(self.urdf_solver.controlled_bones)*3, dtype=np.float32)
299
- self.urdf_best_angles = self.urdf_initial_rotations.copy()
300
- if eff != self.urdf_current_end_effector:
301
- self.urdf_current_end_effector = eff
302
- if changed:
303
- self._setup_urdf_objectives()
304
- return {"controlled_bones": self.urdf_current_controlled_bones, "end_effector": self.urdf_current_end_effector, "num_steps": self.urdf_current_num_steps}
305
-
306
- # ---------- Objectives build ----------
307
  def _build_agent_objectives(self, payload):
308
  tgt = np.array(payload.get("target",[0.0,0.2,0.35]))
309
  self.distance_obj.update_params({"bone_name": self.current_end_effector, "target_points": tgt, "weight": float(payload.get("distance_weight",1.0))})
310
- self.collision_obj.update_params({"weight": float(payload.get("collision_weight",1.0))})
311
- subpoints = int(payload.get("subpoints",1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  mandatory, optional = [], []
313
  if payload.get("distance_enabled", True): mandatory.append(self.distance_obj)
314
  if payload.get("collision_enabled", False): optional.append(self.collision_obj)
@@ -318,7 +387,7 @@ class IKServer:
318
  optional.append(CombinedDerivativeObj(max_order=3, weights=[float(payload.get("derivative_weight",0.05))]*3))
319
  elif not payload.get("bone_zero_enabled", True) and not payload.get("derivative_enabled", True):
320
  optional.append(BoneZeroRotationObj(weight=0.01))
321
- # Hand spec
322
  hand_shape = payload.get("hand_shape","None")
323
  hand_position = payload.get("hand_position","None")
324
  params = {
@@ -352,8 +421,24 @@ class IKServer:
352
  def _build_urdf_objectives(self, payload):
353
  tgt = np.array(payload.get("target",[0.3,0.3,0.35]))
354
  self.urdf_distance_obj.update_params({"bone_name": self.urdf_current_end_effector, "target_points": tgt, "weight": float(payload.get("distance_weight",1.0))})
355
- self.urdf_collision_obj.update_params({"weight": float(payload.get("collision_weight",1.0))})
356
- subpoints = int(payload.get("subpoints",1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  mandatory, optional = [], []
358
  if payload.get("distance_enabled", True): mandatory.append(self.urdf_distance_obj)
359
  if payload.get("collision_enabled", False): optional.append(self.urdf_collision_obj)
@@ -365,85 +450,212 @@ class IKServer:
365
  optional.append(BoneZeroRotationObj(weight=0.01))
366
  return mandatory, optional, subpoints
367
 
368
- # ---------- Solving ----------
369
  def _frames_from_angles(self, angles_seq, is_urdf):
370
  frames = []
371
  for ang in angles_seq:
372
- if is_urdf:
373
- verts = deform_mesh(ang, self.urdf_solver.fk_solver, self.urdf_mesh_data)
374
- faces = self.urdf_mesh_data["faces"]
375
- else:
376
- verts = deform_mesh(ang, self.solver.fk_solver, self.mesh_data)
377
- faces = self.mesh_data["faces"]
378
- frames.append({"vertices": verts.tolist(), "faces": faces.tolist()})
 
 
 
379
  return frames
380
 
381
- def solve_agent(self, payload, last_only=False):
382
- mand, opt, sub = self._build_agent_objectives(payload)
383
- start = time.time()
384
- best_angles, obj_val, steps = self.solver.solve(
385
- initial_rotations=self.initial_rotations,
386
- learning_rate=self.args.learning_rate,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  mandatory_objective_functions=tuple(mand),
388
  optional_objective_functions=tuple(opt),
389
- ik_points=sub,
390
  verbose=False,
391
  )
392
- self.best_angles = best_angles[-1].copy()
393
- self.initial_rotations = self.best_angles.copy()
394
- if last_only:
395
- self.animation_frames_agent = self._frames_from_angles([best_angles[-1]], False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  else:
397
- self.animation_frames_agent = self._frames_from_angles(best_angles, False)
 
 
 
398
  self.agent_solve_counter += 1
 
 
 
 
399
  return {
 
400
  "solve_time": time.time()-start,
401
  "iterations": steps,
402
  "objective": obj_val,
403
  "frames": len(self.animation_frames_agent),
404
  "solve_id": self.agent_solve_counter,
 
 
405
  }
406
 
407
  def solve_urdf(self, payload, last_only=False):
 
 
408
  mand, opt, sub = self._build_urdf_objectives(payload)
409
- start = time.time()
410
- best_angles, obj_val, steps = self.urdf_solver.solve(
411
- initial_rotations=self.urdf_initial_rotations,
412
- learning_rate=self.args.learning_rate,
413
- mandatory_objective_functions=tuple(mand),
414
- optional_objective_functions=tuple(opt),
415
- ik_points=sub,
416
- verbose=False,
417
- )
418
- self.urdf_best_angles = best_angles[-1].copy()
419
- self.urdf_initial_rotations = self.urdf_best_angles.copy()
420
- if last_only:
421
- self.animation_frames_urdf = self._frames_from_angles([best_angles[-1]], True)
 
 
 
 
 
 
 
 
 
 
 
 
422
  else:
423
- self.animation_frames_urdf = self._frames_from_angles(best_angles, True)
 
 
 
424
  self.urdf_solve_counter += 1
 
 
 
425
  return {
 
426
  "solve_time": time.time()-start,
427
  "iterations": steps,
428
  "objective": obj_val,
429
  "frames": len(self.animation_frames_urdf),
430
  "solve_id": self.urdf_solve_counter,
 
 
431
  }
432
 
433
  # ---------- Housekeeping ----------
434
  def _cleanup(self):
435
  now = time.time()
436
- if now - self.last_cleanup_time < self.cleanup_interval: return
437
- gc.collect()
 
 
 
 
 
 
 
 
 
438
  self.last_cleanup_time = now
439
 
440
  # ---------- API ----------
441
  def _register_routes(self):
442
  @self.app.get("/")
443
- def index(): return FileResponse("static/index.html")
 
444
 
445
  @self.app.get("/threejs_viewer")
446
- def legacy(): return FileResponse("static/index.html")
 
447
 
448
  @self.app.get("/animation")
449
  def animation(request: Request):
@@ -463,8 +675,11 @@ class IKServer:
463
  "end_effector_choices": self.urdf_available_bones,
464
  "hand_shapes": [],
465
  "hand_positions": [],
466
- "max_subpoints": 20, # added
467
- "default_num_steps": self.urdf_current_num_steps, # NEW
 
 
 
468
  }
469
  return {
470
  "model":"agent",
@@ -479,48 +694,50 @@ class IKServer:
479
  "Look 45° X Downwards","Look 45° X Upwards","Look X Inward","Look to Body",
480
  "Arm Down","Arm 45° Down","Arm Flat"
481
  ],
482
- "max_subpoints": 20, # added
483
- "default_num_steps": self.current_num_steps, # NEW
 
 
 
484
  }
485
 
486
  @self.app.post("/configure")
487
  async def configure(request: Request):
488
  payload = await request.json()
489
  model = payload.get("model","agent").lower()
490
- num_steps = int(payload.get("num_steps", self.args.num_steps))
491
- if model == "pepper":
492
- cfg = self.configure_urdf(payload.get("controlled_bones", []), payload.get("end_effector"), num_steps=num_steps)
493
- else:
494
- cfg = self.configure_agent(payload.get("controlled_bones", []), payload.get("end_effector"), num_steps=num_steps)
495
- return JSONResponse({"status":"ok","config":cfg})
 
 
 
496
 
497
  @self.app.post("/solve")
498
  async def solve(request: Request):
499
  payload = await request.json()
500
  model = payload.get("model","agent").lower()
501
  return_mode = payload.get("frames_mode", "auto")
502
- num_steps = int(payload.get("num_steps", self.args.num_steps)) # NEW
503
- subpoints = int(payload.get("subpoints",1))
504
  last_only = (return_mode != 'all' and subpoints == 1)
505
  self._cleanup()
506
  with self.solve_lock:
507
  try:
508
  if model == "pepper":
509
- self.configure_urdf(payload.get("controlled_bones", []),
510
- payload.get("end_effector", self.urdf_current_end_effector),
511
- num_steps=num_steps)
512
  result = self.solve_urdf(payload, last_only=last_only); result["model"]="pepper"; result["num_steps"] = num_steps
513
  if last_only:
514
- frames = list(self.animation_frames_urdf) # only one frame
515
  elif return_mode == "all" or (return_mode == "auto" and subpoints > 1):
516
  frames = list(self.animation_frames_urdf)
517
  else:
518
  frames = [self.animation_frames_urdf[-1]]
519
  result["frames_data"] = frames
520
  else:
521
- self.configure_agent(payload.get("controlled_bones", []),
522
- payload.get("end_effector", self.current_end_effector),
523
- num_steps=num_steps)
524
  result = self.solve_agent(payload, last_only=last_only); result["model"]="agent"; result["num_steps"] = num_steps
525
  if last_only:
526
  frames = list(self.animation_frames_agent)
@@ -529,35 +746,53 @@ class IKServer:
529
  else:
530
  frames = [self.animation_frames_agent[-1]]
531
  result["frames_data"] = frames
532
- return JSONResponse({"status":"ok","result":result})
 
 
533
  except Exception as e:
534
- logger.exception("Solve failed")
535
  return JSONResponse({"status":"error","message":str(e)}, status_code=500)
536
 
 
 
 
 
 
 
 
 
537
  @self.app.get("/health")
538
  def health():
 
539
  return {
540
  "status":"ok",
541
  "agent_frames": len(self.animation_frames_agent),
542
  "urdf_frames": len(self.animation_frames_urdf),
543
  "cache_agent": len(self.solver_cache),
544
  "cache_urdf": len(self.urdf_solver_cache),
 
 
 
 
 
 
545
  }
546
 
547
  @self.app.get("/favicon.ico")
548
- def favicon(): return Response(status_code=204)
 
549
 
550
  # ---------- Main ----------
551
  def main():
552
  parser = configargparse.ArgumentParser(description="Inverse Kinematics Solver - Three.js Web UI", default_config_files=["config.ini"])
553
- parser.add("--gltf_file", type=str, default="files/smplx.glb")
554
- parser.add("--hand", type=str, choices=["left","right"], default="left")
555
- parser.add("--threshold", type=float, default=0.0001)
556
- parser.add("--num_steps", type=int, default=100)
557
- parser.add("--learning_rate", type=float, default=0.2)
558
- parser.add("--subpoints", type=int, default=1)
559
- parser.add("--api_port", type=int, default=17861)
560
- parser.add("--warmup", action='store_true', default=True, help="Enable background JIT warmup")
561
  args = parser.parse_args()
562
 
563
  download_and_setup_files()
 
1
+ import logging, faulthandler, sys, time, os, requests, zipfile, io, gc, threading, psutil, json, configargparse
2
  faulthandler.enable()
3
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
4
  logger = logging.getLogger("jax_ik_server")
 
38
  if not os.path.isdir(pepper_dir):
39
  logger.info("Downloading Pepper model...")
40
  try:
41
+ r = requests.get("https://uni-bielefeld.sciebo.de/s/98Sy5s143XgNntb/download", stream=True)
42
+ r.raise_for_status()
43
+ with zipfile.ZipFile(io.BytesIO(r.content)) as z:
44
+ z.extractall("files/")
45
  except Exception as e:
46
  logger.warning(f"Pepper download failed: {e}")
47
  # SMPLX
 
49
  if not os.path.isfile(smplx_file):
50
  logger.info("Downloading SMPLX model...")
51
  try:
52
+ r = requests.get("https://uni-bielefeld.sciebo.de/s/B5StwQdiR4DW5mc/download")
53
+ r.raise_for_status()
54
  open(smplx_file, "wb").write(r.content)
55
  except Exception as e:
56
  logger.warning(f"SMPLX download failed: {e}")
 
59
  def __init__(self, args):
60
  self.args = args
61
  self.solve_lock = threading.Lock()
62
+ self.config_lock = threading.Lock()
63
  self.process = psutil.Process(os.getpid())
64
 
65
+ # Failure / resilience counters
66
+ self.agent_fail_count = 0
67
+ self.urdf_fail_count = 0
68
+ self.last_agent_error = None
69
+ self.last_urdf_error = None
70
+ self.agent_solve_counter = 0
71
+ self.urdf_solve_counter = 0
72
+ self.max_fail_retries = 2 # total attempts (initial + retries)
73
+ self.circuit_breaker_threshold = 5 # open after N consecutive failures
74
+ self.circuit_open_until = 0 # epoch time until which solves short-circuit
75
+ self.circuit_backoff_seconds = 5
76
+
77
+ # Numeric safety
78
+ self.max_num_steps_global = 20000
79
+ self.max_learning_rate = 5.0
80
+
81
+ # Caches for solver reuse (LRU)
82
  self.solver_cache = {}
83
  self.urdf_solver_cache = {}
 
84
  self.cache_access_order = []
85
  self.urdf_cache_access_order = []
86
+ self.max_cache_size = 5
87
 
88
  # Animation buffers
89
  self.animation_frames_agent = []
90
  self.animation_frames_urdf = []
91
 
92
+ # FastAPI app
93
  self.app = FastAPI()
94
  self.app.add_middleware(
95
  CORSMiddleware,
96
  allow_origins=["*"], allow_credentials=True,
97
+ allow_methods=["*"], allow_headers=["*"],
98
  )
99
  os.makedirs("static", exist_ok=True)
100
  os.makedirs("files", exist_ok=True)
101
  self.app.mount("/static", StaticFiles(directory="static"), name="static")
102
  self.app.mount("/files", StaticFiles(directory="files"), name="files")
103
 
104
+ # Initialize models
105
  self._init_agent()
106
  self._setup_agent_objectives()
107
  self._init_urdf()
108
  self._setup_urdf_objectives()
109
 
110
+ # Cleanup timing
111
  self.last_cleanup_time = time.time()
112
  self.cleanup_interval = 30
113
 
 
 
 
114
  self._register_routes()
115
  logger.info("Server ready.")
 
116
  if getattr(self.args, 'warmup', True):
117
  threading.Thread(target=self._warmup_all, daemon=True).start()
118
 
119
+ # ---------- Utility / Safety ----------
120
+ def _safe_int(self, v, default, lo=None, hi=None):
121
+ try:
122
+ v = int(v)
123
+ except Exception:
124
+ v = default
125
+ if lo is not None and v < lo: v = lo
126
+ if hi is not None and v > hi: v = hi
127
+ return v
128
+
129
+ def _safe_float(self, v, default, lo=None, hi=None):
130
+ try:
131
+ v = float(v)
132
+ if np.isnan(v) or np.isinf(v): raise ValueError
133
+ except Exception:
134
+ v = default
135
+ if lo is not None and v < lo: v = lo
136
+ if hi is not None and v > hi: v = hi
137
+ return v
138
+
139
+ def _sanitize_num_steps(self, steps):
140
+ return self._safe_int(steps, self.args.num_steps, 1, self.max_num_steps_global)
141
+
142
+ def _sanitize_bones(self, bones, is_urdf=False):
143
+ if not isinstance(bones, (list, tuple)):
144
+ return []
145
+ allowed = self.urdf_available_bones if is_urdf else self.available_bones
146
+ return [b for b in bones if b in allowed]
147
+
148
+ def _cache_key(self, bones, num_steps):
149
+ return tuple(sorted(bones)) + (int(num_steps),)
150
+
151
+ def _evict_lru(self, is_urdf=False):
152
+ cache = self.urdf_solver_cache if is_urdf else self.solver_cache
153
+ order = self.urdf_cache_access_order if is_urdf else self.cache_access_order
154
+ if not order:
155
+ return
156
+ k = order.pop(0)
157
+ cache.pop(k, None)
158
+ gc.collect()
159
+
160
+ def _invalidate_caches(self):
161
+ self.solver_cache.clear(); self.urdf_solver_cache.clear()
162
+ self.cache_access_order.clear(); self.urdf_cache_access_order.clear()
163
+ gc.collect()
164
+
165
+ def _create_solver(self, bones, is_urdf, num_steps):
166
+ if is_urdf:
167
+ return InverseKinematicsSolver(
168
+ model_file=self.urdf_file,
169
+ controlled_bones=bones,
170
+ bounds=None,
171
+ threshold=self.args.threshold,
172
+ num_steps=int(num_steps),
173
+ compute_sdf=False,
174
+ )
175
+ bounds = []
176
+ for b in bones:
177
+ if b in self.bounds_dict:
178
+ lower, upper = self.bounds_dict[b]
179
+ bounds.extend(list(zip(lower, upper)))
180
+ else:
181
+ bounds.extend([(-90, 90)] * 3)
182
+ return InverseKinematicsSolver(
183
+ model_file=self.args.gltf_file,
184
+ controlled_bones=bones,
185
+ bounds=bounds,
186
+ threshold=self.args.threshold,
187
+ num_steps=int(num_steps),
188
+ compute_sdf=False,
189
+ )
190
+
191
+ def _get_solver(self, bones, is_urdf=False, num_steps=None):
192
+ if num_steps is None:
193
+ num_steps = self.args.num_steps
194
+ num_steps = self._sanitize_num_steps(num_steps)
195
+ key = self._cache_key(bones, num_steps)
196
+ cache = self.urdf_solver_cache if is_urdf else self.solver_cache
197
+ order = self.urdf_cache_access_order if is_urdf else self.cache_access_order
198
+ if key in cache:
199
+ if key in order: order.remove(key)
200
+ order.append(key)
201
+ return cache[key]
202
+ if len(cache) >= self.max_cache_size:
203
+ self._evict_lru(is_urdf)
204
+ try:
205
+ solver = self._create_solver(bones, is_urdf, num_steps)
206
+ except Exception as e:
207
+ logger.warning(f"Primary solver creation failure (is_urdf={is_urdf}): {e}; using defaults")
208
+ bones = self.urdf_default_controlled_bones if is_urdf else self.default_controlled_bones
209
+ solver = self._create_solver(bones, is_urdf, num_steps)
210
+ cache[key] = solver
211
+ order.append(key)
212
+ return solver
213
+
214
+ def _rebuild_solver_safe(self, is_urdf=False, force_defaults=False):
215
+ try:
216
+ if is_urdf:
217
+ bones = self.urdf_default_controlled_bones if force_defaults else self.urdf_current_controlled_bones
218
+ bones = self._sanitize_bones(bones, True) or self.urdf_default_controlled_bones
219
+ self.urdf_solver = self._create_solver(bones, True, self.urdf_current_num_steps)
220
+ self.urdf_initial_rotations = np.zeros(len(self.urdf_solver.controlled_bones)*3, dtype=np.float32)
221
+ self.urdf_best_angles = self.urdf_initial_rotations.copy()
222
+ else:
223
+ bones = self.default_controlled_bones if force_defaults else self.current_controlled_bones
224
+ bones = self._sanitize_bones(bones, False) or self.default_controlled_bones
225
+ self.solver = self._create_solver(bones, False, self.current_num_steps)
226
+ self.initial_rotations = np.zeros(len(self.solver.controlled_bones)*3, dtype=np.float32)
227
+ self.best_angles = self.initial_rotations.copy()
228
+ return True
229
+ except Exception as e:
230
+ (self.last_urdf_error if is_urdf else setattr(self, 'last_agent_error', str(e)))
231
+ logger.exception("Solver rebuild failed")
232
+ return False
233
+
234
+ def _attempt_solver_recovery(self, is_urdf=False):
235
+ logger.warning("Attempting full solver recovery (invalidate caches + defaults)")
236
+ self._invalidate_caches()
237
+ if is_urdf:
238
+ self.urdf_current_controlled_bones = self.urdf_default_controlled_bones.copy()
239
+ return self._rebuild_solver_safe(True, force_defaults=True)
240
+ else:
241
+ self.current_controlled_bones = self.default_controlled_bones.copy()
242
+ return self._rebuild_solver_safe(False, force_defaults=True)
243
+
244
+ # ---------- Warmup ----------
245
  def _warmup_agent(self):
246
  try:
247
  mand = [DistanceObjTraj(target_points=np.array([0.0,0.2,0.35]), bone_name=self.current_end_effector, use_head=True, weight=1.0)]
 
269
  self._warmup_urdf()
270
  logger.info(f"Warmup finished in {time.time()-t0:.2f}s")
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  # ---------- Initialization ----------
273
  def _init_agent(self):
274
+ self.current_num_steps = self._sanitize_num_steps(self.args.num_steps)
275
  basic = InverseKinematicsSolver(
276
  model_file=self.args.gltf_file,
277
  controlled_bones=["left_collar"],
 
298
  self.animation_frames_agent = self._frames_from_angles([self.initial_rotations], False)
299
 
300
  def _init_urdf(self):
301
+ self.urdf_current_num_steps = self._sanitize_num_steps(self.args.num_steps)
302
  self.urdf_file = "files/pepper_description-master/urdf/pepper.urdf"
303
  basic = InverseKinematicsSolver(
304
  model_file=self.urdf_file,
 
333
  min_clearance=0.0,
334
  weight=1.0,
335
  )
336
+ # store defaults for config exposure
337
+ self.collision_default_center = [0.1,0.0,0.35]
338
+ self.collision_default_radius = 0.1
339
+ self.collision_default_min_clearance = 0.0
340
 
341
  def _setup_urdf_objectives(self):
342
  self.urdf_distance_obj = DistanceObjTraj(
 
350
  min_clearance=0.0,
351
  weight=1.0,
352
  )
353
+ self.urdf_collision_default_center = [0.2,0.0,0.35]
354
+ self.urdf_collision_default_radius = 0.1
355
+ self.urdf_collision_default_min_clearance = 0.0
356
 
357
+ # ---------- Build objectives per request ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  def _build_agent_objectives(self, payload):
359
  tgt = np.array(payload.get("target",[0.0,0.2,0.35]))
360
  self.distance_obj.update_params({"bone_name": self.current_end_effector, "target_points": tgt, "weight": float(payload.get("distance_weight",1.0))})
361
+ # collision updates (weight, center, radius, min_clearance)
362
+ if payload.get("collision_enabled", False):
363
+ coll_update = {"weight": float(payload.get("collision_weight",1.0))}
364
+ center = payload.get("collision_center")
365
+ if isinstance(center, (list, tuple)) and len(center)==3:
366
+ coll_update["center"] = center
367
+ radius = payload.get("collision_radius")
368
+ if radius is not None:
369
+ coll_update["radius"] = float(radius)
370
+ if "collision_min_clearance" in payload:
371
+ coll_update["min_clearance"] = float(payload.get("collision_min_clearance", 0.0))
372
+ self.collision_obj.update_params(coll_update)
373
+ else:
374
+ # still update weight / min_clearance so they can be changed next enable
375
+ mc = payload.get("collision_min_clearance")
376
+ upd = {"weight": float(payload.get("collision_weight",1.0))}
377
+ if mc is not None:
378
+ upd["min_clearance"] = float(mc)
379
+ self.collision_obj.update_params(upd)
380
+ subpoints = self._safe_int(payload.get("subpoints",1), 1, 1, 100)
381
  mandatory, optional = [], []
382
  if payload.get("distance_enabled", True): mandatory.append(self.distance_obj)
383
  if payload.get("collision_enabled", False): optional.append(self.collision_obj)
 
387
  optional.append(CombinedDerivativeObj(max_order=3, weights=[float(payload.get("derivative_weight",0.05))]*3))
388
  elif not payload.get("bone_zero_enabled", True) and not payload.get("derivative_enabled", True):
389
  optional.append(BoneZeroRotationObj(weight=0.01))
390
+ # Hand specification
391
  hand_shape = payload.get("hand_shape","None")
392
  hand_position = payload.get("hand_position","None")
393
  params = {
 
421
  def _build_urdf_objectives(self, payload):
422
  tgt = np.array(payload.get("target",[0.3,0.3,0.35]))
423
  self.urdf_distance_obj.update_params({"bone_name": self.urdf_current_end_effector, "target_points": tgt, "weight": float(payload.get("distance_weight",1.0))})
424
+ if payload.get("collision_enabled", False):
425
+ coll_update = {"weight": float(payload.get("collision_weight",1.0))}
426
+ center = payload.get("collision_center")
427
+ if isinstance(center, (list, tuple)) and len(center)==3:
428
+ coll_update["center"] = center
429
+ radius = payload.get("collision_radius")
430
+ if radius is not None:
431
+ coll_update["radius"] = float(radius)
432
+ if "collision_min_clearance" in payload:
433
+ coll_update["min_clearance"] = float(payload.get("collision_min_clearance", 0.0))
434
+ self.urdf_collision_obj.update_params(coll_update)
435
+ else:
436
+ mc = payload.get("collision_min_clearance")
437
+ upd = {"weight": float(payload.get("collision_weight",1.0))}
438
+ if mc is not None:
439
+ upd["min_clearance"] = float(mc)
440
+ self.urdf_collision_obj.update_params(upd)
441
+ subpoints = self._safe_int(payload.get("subpoints",1),1,1,100)
442
  mandatory, optional = [], []
443
  if payload.get("distance_enabled", True): mandatory.append(self.urdf_distance_obj)
444
  if payload.get("collision_enabled", False): optional.append(self.urdf_collision_obj)
 
450
  optional.append(BoneZeroRotationObj(weight=0.01))
451
  return mandatory, optional, subpoints
452
 
453
+ # ---------- Frames ----------
454
  def _frames_from_angles(self, angles_seq, is_urdf):
455
  frames = []
456
  for ang in angles_seq:
457
+ try:
458
+ if is_urdf:
459
+ verts = deform_mesh(ang, self.urdf_solver.fk_solver, self.urdf_mesh_data)
460
+ faces = self.urdf_mesh_data["faces"]
461
+ else:
462
+ verts = deform_mesh(ang, self.solver.fk_solver, self.mesh_data)
463
+ faces = self.mesh_data["faces"]
464
+ frames.append({"vertices": verts.tolist(), "faces": faces.tolist()})
465
+ except Exception as e:
466
+ logger.warning(f"Mesh deformation failed: {e}")
467
  return frames
468
 
469
+ # ---------- Configuration ----------
470
+ def configure_agent(self, bones, eff, num_steps=None):
471
+ with self.config_lock:
472
+ if num_steps is None:
473
+ num_steps = self.current_num_steps
474
+ num_steps = self._sanitize_num_steps(num_steps)
475
+ bones = self._sanitize_bones(bones, False) or self.default_controlled_bones
476
+ if eff not in getattr(self, 'available_bones', []):
477
+ eff = self.default_end_effector
478
+ changed = (bones != self.current_controlled_bones or eff != self.current_end_effector or int(num_steps) != int(self.current_num_steps))
479
+ if changed:
480
+ try:
481
+ self.current_controlled_bones = bones
482
+ self.current_end_effector = eff
483
+ self.current_num_steps = int(num_steps)
484
+ self.solver = self._get_solver(bones, False, self.current_num_steps)
485
+ self.initial_rotations = np.zeros(len(self.solver.controlled_bones)*3, dtype=np.float32)
486
+ self.best_angles = self.initial_rotations.copy()
487
+ self._setup_agent_objectives()
488
+ except Exception as e:
489
+ self.agent_fail_count += 1
490
+ self.last_agent_error = str(e)
491
+ logger.exception("configure_agent failed; attempting default recovery")
492
+ if not self._attempt_solver_recovery(False):
493
+ raise
494
+ return {"controlled_bones": self.current_controlled_bones, "end_effector": self.current_end_effector, "num_steps": self.current_num_steps}
495
+
496
+ def configure_urdf(self, bones, eff, num_steps=None):
497
+ with self.config_lock:
498
+ if num_steps is None:
499
+ num_steps = self.urdf_current_num_steps
500
+ num_steps = self._sanitize_num_steps(num_steps)
501
+ bones = self._sanitize_bones(bones, True) or self.urdf_default_controlled_bones
502
+ if eff not in getattr(self, 'urdf_available_bones', []):
503
+ eff = self.urdf_default_end_effector
504
+ changed = (bones != self.urdf_current_controlled_bones or eff != self.urdf_current_end_effector or int(num_steps) != int(self.urdf_current_num_steps))
505
+ if changed:
506
+ try:
507
+ self.urdf_current_controlled_bones = bones
508
+ self.urdf_current_end_effector = eff
509
+ self.urdf_current_num_steps = int(num_steps)
510
+ self.urdf_solver = self._get_solver(bones, True, self.urdf_current_num_steps)
511
+ self.urdf_initial_rotations = np.zeros(len(self.urdf_solver.controlled_bones)*3, dtype=np.float32)
512
+ self.urdf_best_angles = self.urdf_initial_rotations.copy()
513
+ self._setup_urdf_objectives()
514
+ except Exception as e:
515
+ self.urdf_fail_count += 1
516
+ self.last_urdf_error = str(e)
517
+ logger.exception("configure_urdf failed; attempting default recovery")
518
+ if not self._attempt_solver_recovery(True):
519
+ raise
520
+ return {"controlled_bones": self.urdf_current_controlled_bones, "end_effector": self.urdf_current_end_effector, "num_steps": self.urdf_current_num_steps}
521
+
522
+ # ---------- Solve (hardened) ----------
523
+ def _solve_core(self, solver, init, mand, opt, subpoints, lr):
524
+ return solver.solve(
525
+ initial_rotations=init,
526
+ learning_rate=lr,
527
  mandatory_objective_functions=tuple(mand),
528
  optional_objective_functions=tuple(opt),
529
+ ik_points=subpoints,
530
  verbose=False,
531
  )
532
+
533
+ def solve_agent(self, payload, last_only=False):
534
+ if time.time() < self.circuit_open_until:
535
+ return {"status":"circuit_open","error":"agent circuit open","frames":len(self.animation_frames_agent)}
536
+ mand, opt, sub = self._build_agent_objectives(payload)
537
+ lr = self._safe_float(payload.get("learning_rate", self.args.learning_rate), self.args.learning_rate, 1e-5, self.max_learning_rate)
538
+ attempts = 0
539
+ best_angles = None; obj_val = float('inf'); steps = 0; error_msg=None; recovered=False
540
+ start=time.time()
541
+ while attempts < self.max_fail_retries:
542
+ attempts += 1
543
+ try:
544
+ best_angles, obj_val, steps = self._solve_core(self.solver, self.initial_rotations, mand, opt, sub, lr)
545
+ break
546
+ except Exception as e:
547
+ self.agent_fail_count += 1
548
+ error_msg = str(e)
549
+ self.last_agent_error = error_msg
550
+ logger.warning(f"Agent solve attempt {attempts} failed: {e}")
551
+ if attempts < self.max_fail_retries:
552
+ # First retry: rebuild current; second: full recovery
553
+ if attempts == 1:
554
+ self._rebuild_solver_safe(False, force_defaults=False)
555
+ else:
556
+ recovered = self._attempt_solver_recovery(False)
557
+ else:
558
+ break
559
+ if best_angles is None:
560
+ # Provide at least one frame (initial)
561
+ frame_angles = [self.initial_rotations]
562
+ if recovered:
563
+ frame_angles = [self.initial_rotations]
564
  else:
565
+ self.best_angles = best_angles[-1].copy()
566
+ self.initial_rotations = self.best_angles.copy()
567
+ frame_angles = [best_angles[-1]] if last_only else best_angles
568
+ self.animation_frames_agent = self._frames_from_angles(frame_angles, False)
569
  self.agent_solve_counter += 1
570
+ # Circuit breaker update
571
+ if best_angles is None and self.agent_fail_count >= self.circuit_breaker_threshold:
572
+ self.circuit_open_until = time.time() + self.circuit_backoff_seconds
573
+ status = "ok" if best_angles is not None else ("recovered" if recovered else "failed")
574
  return {
575
+ "status":status,
576
  "solve_time": time.time()-start,
577
  "iterations": steps,
578
  "objective": obj_val,
579
  "frames": len(self.animation_frames_agent),
580
  "solve_id": self.agent_solve_counter,
581
+ "attempts": attempts,
582
+ "error": error_msg,
583
  }
584
 
585
  def solve_urdf(self, payload, last_only=False):
586
+ if time.time() < self.circuit_open_until:
587
+ return {"status":"circuit_open","error":"urdf circuit open","frames":len(self.animation_frames_urdf)}
588
  mand, opt, sub = self._build_urdf_objectives(payload)
589
+ lr = self._safe_float(payload.get("learning_rate", self.args.learning_rate), self.args.learning_rate, 1e-5, self.max_learning_rate)
590
+ attempts=0
591
+ best_angles=None; obj_val=float('inf'); steps=0; error_msg=None; recovered=False
592
+ start=time.time()
593
+ while attempts < self.max_fail_retries:
594
+ attempts += 1
595
+ try:
596
+ best_angles, obj_val, steps = self._solve_core(self.urdf_solver, self.urdf_initial_rotations, mand, opt, sub, lr)
597
+ break
598
+ except Exception as e:
599
+ self.urdf_fail_count += 1
600
+ error_msg = str(e)
601
+ self.last_urdf_error = error_msg
602
+ logger.warning(f"URDF solve attempt {attempts} failed: {e}")
603
+ if attempts < self.max_fail_retries:
604
+ if attempts == 1:
605
+ self._rebuild_solver_safe(True, force_defaults=False)
606
+ else:
607
+ recovered = self._attempt_solver_recovery(True)
608
+ else:
609
+ break
610
+ if best_angles is None:
611
+ frame_angles = [self.urdf_initial_rotations]
612
+ if recovered:
613
+ frame_angles = [self.urdf_initial_rotations]
614
  else:
615
+ self.urdf_best_angles = best_angles[-1].copy()
616
+ self.urdf_initial_rotations = self.urdf_best_angles.copy()
617
+ frame_angles = [best_angles[-1]] if last_only else best_angles
618
+ self.animation_frames_urdf = self._frames_from_angles(frame_angles, True)
619
  self.urdf_solve_counter += 1
620
+ if best_angles is None and self.urdf_fail_count >= self.circuit_breaker_threshold:
621
+ self.circuit_open_until = time.time() + self.circuit_backoff_seconds
622
+ status = "ok" if best_angles is not None else ("recovered" if recovered else "failed")
623
  return {
624
+ "status":status,
625
  "solve_time": time.time()-start,
626
  "iterations": steps,
627
  "objective": obj_val,
628
  "frames": len(self.animation_frames_urdf),
629
  "solve_id": self.urdf_solve_counter,
630
+ "attempts": attempts,
631
+ "error": error_msg,
632
  }
633
 
634
  # ---------- Housekeeping ----------
635
  def _cleanup(self):
636
  now = time.time()
637
+ if now - self.last_cleanup_time < self.cleanup_interval:
638
+ return
639
+ try:
640
+ gc.collect()
641
+ # Memory guard: if RSS > 2GB, clear caches
642
+ rss = self.process.memory_info().rss
643
+ if rss > 2 * 1024**3:
644
+ logger.warning("High memory usage detected; invalidating caches")
645
+ self._invalidate_caches()
646
+ except Exception:
647
+ pass
648
  self.last_cleanup_time = now
649
 
650
  # ---------- API ----------
651
  def _register_routes(self):
652
  @self.app.get("/")
653
+ def index():
654
+ return FileResponse("static/index.html")
655
 
656
  @self.app.get("/threejs_viewer")
657
+ def legacy():
658
+ return FileResponse("static/index.html")
659
 
660
  @self.app.get("/animation")
661
  def animation(request: Request):
 
675
  "end_effector_choices": self.urdf_available_bones,
676
  "hand_shapes": [],
677
  "hand_positions": [],
678
+ "max_subpoints": 20,
679
+ "default_num_steps": self.urdf_current_num_steps,
680
+ "collision_default_center": getattr(self, 'urdf_collision_default_center', [0.2,0.0,0.35]),
681
+ "collision_default_radius": getattr(self, 'urdf_collision_default_radius', 0.1),
682
+ "collision_default_min_clearance": getattr(self, 'urdf_collision_default_min_clearance', 0.0),
683
  }
684
  return {
685
  "model":"agent",
 
694
  "Look 45° X Downwards","Look 45° X Upwards","Look X Inward","Look to Body",
695
  "Arm Down","Arm 45° Down","Arm Flat"
696
  ],
697
+ "max_subpoints": 20,
698
+ "default_num_steps": self.current_num_steps,
699
+ "collision_default_center": getattr(self, 'collision_default_center', [0.1,0.0,0.35]),
700
+ "collision_default_radius": getattr(self, 'collision_default_radius', 0.1),
701
+ "collision_default_min_clearance": getattr(self, 'collision_default_min_clearance', 0.0),
702
  }
703
 
704
  @self.app.post("/configure")
705
  async def configure(request: Request):
706
  payload = await request.json()
707
  model = payload.get("model","agent").lower()
708
+ num_steps = self._sanitize_num_steps(payload.get("num_steps", self.args.num_steps))
709
+ try:
710
+ if model == "pepper":
711
+ cfg = self.configure_urdf(payload.get("controlled_bones", []), payload.get("end_effector"), num_steps=num_steps)
712
+ else:
713
+ cfg = self.configure_agent(payload.get("controlled_bones", []), payload.get("end_effector"), num_steps=num_steps)
714
+ return JSONResponse({"status":"ok","config":cfg})
715
+ except Exception as e:
716
+ return JSONResponse({"status":"error","message":str(e)}, status_code=500)
717
 
718
  @self.app.post("/solve")
719
  async def solve(request: Request):
720
  payload = await request.json()
721
  model = payload.get("model","agent").lower()
722
  return_mode = payload.get("frames_mode", "auto")
723
+ num_steps = self._sanitize_num_steps(payload.get("num_steps", self.args.num_steps))
724
+ subpoints = self._safe_int(payload.get("subpoints",1),1,1,100)
725
  last_only = (return_mode != 'all' and subpoints == 1)
726
  self._cleanup()
727
  with self.solve_lock:
728
  try:
729
  if model == "pepper":
730
+ self.configure_urdf(payload.get("controlled_bones", []), payload.get("end_effector", self.urdf_current_end_effector), num_steps=num_steps)
 
 
731
  result = self.solve_urdf(payload, last_only=last_only); result["model"]="pepper"; result["num_steps"] = num_steps
732
  if last_only:
733
+ frames = list(self.animation_frames_urdf)
734
  elif return_mode == "all" or (return_mode == "auto" and subpoints > 1):
735
  frames = list(self.animation_frames_urdf)
736
  else:
737
  frames = [self.animation_frames_urdf[-1]]
738
  result["frames_data"] = frames
739
  else:
740
+ self.configure_agent(payload.get("controlled_bones", []), payload.get("end_effector", self.current_end_effector), num_steps=num_steps)
 
 
741
  result = self.solve_agent(payload, last_only=last_only); result["model"]="agent"; result["num_steps"] = num_steps
742
  if last_only:
743
  frames = list(self.animation_frames_agent)
 
746
  else:
747
  frames = [self.animation_frames_agent[-1]]
748
  result["frames_data"] = frames
749
+ status = result.get("status","ok")
750
+ top_status = "ok" if status in ("ok","recovered") else status
751
+ return JSONResponse({"status":top_status,"result":result})
752
  except Exception as e:
753
+ logger.exception("Solve failed unrecoverably")
754
  return JSONResponse({"status":"error","message":str(e)}, status_code=500)
755
 
756
+ @self.app.post("/reset")
757
+ def reset():
758
+ with self.solve_lock:
759
+ self._invalidate_caches()
760
+ self._attempt_solver_recovery(False)
761
+ self._attempt_solver_recovery(True)
762
+ return {"status":"ok","message":"solvers & caches reset"}
763
+
764
  @self.app.get("/health")
765
  def health():
766
+ rss = self.process.memory_info().rss if self.process else 0
767
  return {
768
  "status":"ok",
769
  "agent_frames": len(self.animation_frames_agent),
770
  "urdf_frames": len(self.animation_frames_urdf),
771
  "cache_agent": len(self.solver_cache),
772
  "cache_urdf": len(self.urdf_solver_cache),
773
+ "agent_fail_count": self.agent_fail_count,
774
+ "urdf_fail_count": self.urdf_fail_count,
775
+ "last_agent_error": self.last_agent_error,
776
+ "last_urdf_error": self.last_urdf_error,
777
+ "circuit_open_for": max(0, int(self.circuit_open_until - time.time())),
778
+ "memory_rss_mb": round(rss/1024/1024,1),
779
  }
780
 
781
  @self.app.get("/favicon.ico")
782
+ def favicon():
783
+ return Response(status_code=204)
784
 
785
  # ---------- Main ----------
786
  def main():
787
  parser = configargparse.ArgumentParser(description="Inverse Kinematics Solver - Three.js Web UI", default_config_files=["config.ini"])
788
+ parser.add_argument("--gltf_file", type=str, default="files/smplx.glb")
789
+ parser.add_argument("--hand", type=str, choices=["left","right"], default="left")
790
+ parser.add_argument("--threshold", type=float, default=0.0001)
791
+ parser.add_argument("--num_steps", type=int, default=100)
792
+ parser.add_argument("--learning_rate", type=float, default=0.2)
793
+ parser.add_argument("--subpoints", type=int, default=1)
794
+ parser.add_argument("--api_port", type=int, default=17861)
795
+ parser.add_argument("--warmup", action='store_true', default=True, help="Enable background JIT warmup")
796
  args = parser.parse_args()
797
 
798
  download_and_setup_files()
static/index.html CHANGED
@@ -37,6 +37,9 @@
37
  .toast.error { border-color:#b33939; }
38
  .toast .toast-close { float:right; cursor:pointer; color:#888; margin-left:6px; }
39
  .toast .toast-close:hover { color:#fff; }
 
 
 
40
  </style>
41
  <!-- Added import map to resolve bare specifier 'three' -->
42
  <script type="importmap">
@@ -90,8 +93,36 @@
90
  <legend>Primary Objectives</legend>
91
  <label><input id="distance_enabled" type="checkbox" checked> Distance Objective</label>
92
  <label>Distance Weight <input id="distance_weight" type="number" value="1.0" step="0.1"></label>
93
- <label hidden style="display:none;"><input id="collision_enabled" type="checkbox"> Collision Avoidance</label>
94
- <label hidden style="display:none;">Collision Weight <input id="collision_weight" type="number" value="1.0" step="0.1"></label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  </fieldset>
96
  <fieldset>
97
  <legend>Regularization</legend>
@@ -166,46 +197,28 @@ import { GLTFLoader } from 'https://unpkg.com/[email protected]/examples/jsm/loaders
166
  let gltfPrimaryMaterialCaptured = false;
167
  let fallbackMesh = null;
168
  let lastTime = performance.now();
169
- // REMOVED: let lastFrameFetch = 0;
170
- // REMOVED: const pollInterval = 1000;
171
- // (ADDED) Solve ID tracking to avoid "lastSolveIdAgent / lastSolveIdPepper is not defined" errors
172
  let lastSolveIdAgent = 0;
173
  let lastSolveIdPepper = 0;
174
 
175
- // ----- Auto-solve (moved early to avoid ReferenceError) -----
176
  let solving = false;
177
- let solvePending = false;
178
  let solveDebounceTimer = null;
179
- const SOLVE_DEBOUNCE_MS = 25; // reduced from 40
180
- let currentSolveController = null; // NEW for cancellation
181
-
182
  function scheduleSolve(immediate=false){
183
- if (solving){
184
- // Abort in-flight solve and queue new immediate solve
185
- if (currentSolveController){ currentSolveController.abort(); }
186
- }
187
- if (immediate){
188
- // Fire instantly for first / critical interactions
189
- triggerSolve(true);
190
- return;
191
- }
192
  if (solveDebounceTimer) clearTimeout(solveDebounceTimer);
193
- solveDebounceTimer = setTimeout(()=> { triggerSolve(false); }, SOLVE_DEBOUNCE_MS);
194
  }
195
-
196
- async function triggerSolve(isImmediate=false){
197
- if (solving){
198
- // In-flight already (might have just been aborted). Let doSolve handle continuation.
199
- }
200
- if (currentSolveController){ currentSolveController.abort(); }
201
  currentSolveController = new AbortController();
202
  const controller = currentSolveController;
203
  solving = true;
204
  try { await doSolve(controller.signal); }
205
- catch(e){ if (e.name !== 'AbortError') {/* logged in doSolve */} }
206
- finally {
207
- if (controller === currentSolveController){ solving = false; }
208
- }
209
  }
210
 
211
  // Playback state
@@ -219,19 +232,14 @@ import { GLTFLoader } from 'https://unpkg.com/[email protected]/examples/jsm/loaders
219
  let alignAttemptCount = 0;
220
  const maxAlignAttempts = 120;
221
 
222
- // --- Trajectory / spline state (ADDED) ---
223
  let controlFrames = [];
224
  let splineMode = false;
225
  let splineU = 0;
226
- let splineDuration = 2.0; // seconds for full path
227
- // One-time initial camera framing flag (ADDED)
228
  let initialCameraFramed = false;
229
 
230
  function cleanupVisuals(){
231
- if (ikMesh && usingGLTFGeometry){
232
- // keep GLTF mesh for possible reuse after reconfigure? remove anyway for clean slate
233
- ikMesh = null;
234
- }
235
  if (fallbackMesh){
236
  modelGroup.remove(fallbackMesh);
237
  if (fallbackMesh.geometry) fallbackMesh.geometry.dispose();
@@ -239,84 +247,34 @@ import { GLTFLoader } from 'https://unpkg.com/[email protected]/examples/jsm/loaders
239
  fallbackMesh = null;
240
  }
241
  if (gltfRoot){
242
- gltfRoot.traverse(o=>{
243
- if (o.isMesh){
244
- if (o.geometry) o.geometry.dispose();
245
- if (o.material){
246
- if (Array.isArray(o.material)) o.material.forEach(m=>m.dispose());
247
- else o.material.dispose();
248
- }
249
- }
250
- });
251
- modelGroup.remove(gltfRoot);
252
- gltfRoot = null;
253
  }
254
- // Remove any residual children
255
  while (modelGroup.children.length) modelGroup.remove(modelGroup.children[0]);
256
-
257
- gltfPrimaryMesh = null;
258
- gltfPrimaryMaterial = null;
259
- gltfPrimaryMaterialCaptured = false;
260
- ikMesh = null;
261
- usingGLTFGeometry = false;
262
- animationFrames = [];
263
- frameIndex = 0;
264
- baseOffsetY = 0;
265
- groundAligned = false;
266
- pendingAlign = false;
267
- alignAttemptCount = 0;
268
- log("Visuals cleaned.");
269
- }
270
-
271
- function scheduleGroundAlign(force=false){
272
- if (force){
273
- groundAligned = false;
274
- alignAttemptCount = 0;
275
- }
276
- pendingAlign = true;
277
  }
278
-
279
  function performGroundAlign(){
280
  if (!pendingAlign) return;
281
- if (!modelGroup.children.length){
282
- if (++alignAttemptCount > maxAlignAttempts) pendingAlign = false;
283
- return;
284
- }
285
  const box = new THREE.Box3().setFromObject(modelGroup);
286
- if (box.isEmpty() || !isFinite(box.min.y)){
287
- if (++alignAttemptCount > maxAlignAttempts) pendingAlign = false;
288
- return;
289
- }
290
- const currentMin = box.min.y;
291
- // Desired world shift so new min ~= 0
292
- const delta = -currentMin;
293
- // Avoid large downward moves; only allow raising or tiny corrections
294
  if (delta > 0 || Math.abs(delta) < 1e-3){
295
- modelGroup.position.y += delta;
296
- baseOffsetY = modelGroup.position.y;
297
- groundAligned = true;
298
- updateTargetSphere(); // (NEW) keep target indicator vertically aligned
299
  }
300
- pendingAlign = false;
301
  }
302
-
303
- // (ADD) fitCamera helper (uses modelGroup if populated)
304
  function fitCamera(obj){
305
- const targetObj = modelGroup.children.length ? modelGroup : obj;
306
- if (!targetObj) return;
307
- const box = new THREE.Box3().setFromObject(targetObj);
308
- if (box.isEmpty()) return;
309
- const size = box.getSize(new THREE.Vector3());
310
- const center = box.getCenter(new THREE.Vector3());
311
- controls.target.copy(center);
312
- const maxDim = Math.max(size.x,size.y,size.z);
313
- const dist = maxDim * 3;
314
- const dir = new THREE.Vector3(0.0,0.5,1).normalize();
315
  camera.position.copy(center).addScaledVector(dir, dist);
316
- camera.near = maxDim/100;
317
- camera.far = maxDim*100;
318
- camera.updateProjectionMatrix();
319
- controls.update();
320
  }
321
 
322
  // ----- DOM refs -----
@@ -327,10 +285,21 @@ import { GLTFLoader } from 'https://unpkg.com/[email protected]/examples/jsm/loaders
327
  const handPosSel = document.getElementById('hand_position');
328
  const handFieldset = document.getElementById('handFieldset');
329
  const modelBadge = document.getElementById('modelBadge');
330
- const subpointsInput = document.getElementById('subpoints'); // ADDED
331
- const numStepsInput = document.getElementById('num_steps'); // NEW
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
- // Inputs
334
  function val(id){ return document.getElementById(id).value; }
335
  function num(id){ return parseFloat(val(id)); }
336
  function bool(id){ return document.getElementById(id).checked; }
@@ -341,743 +310,163 @@ import { GLTFLoader } from 'https://unpkg.com/[email protected]/examples/jsm/loaders
341
  renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight);
342
  renderer.domElement.className = 'viewer-canvas';
343
  document.getElementById('viewerPane').appendChild(renderer.domElement);
344
-
345
- const scene = new THREE.Scene();
346
- scene.background = new THREE.Color(0x222222);
347
-
348
  const camera = new THREE.PerspectiveCamera(45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.01, 1000);
349
  camera.position.set(0,1,3);
350
-
351
- const controls = new OrbitControls(camera, renderer.domElement);
352
- controls.target.set(0,0.8,0);
353
-
354
  scene.add(new THREE.HemisphereLight(0xffffff,0x444444,1.0));
355
- const d = new THREE.DirectionalLight(0xffffff,0.8);
356
- d.position.set(3,10,10); scene.add(d);
357
-
358
- const ground = new THREE.Mesh(
359
- new THREE.CircleGeometry(5,48),
360
- new THREE.MeshStandardMaterial({color:0x303030, metalness:0.1, roughness:0.9})
361
- );
362
- ground.rotation.x = -Math.PI/2;
363
- ground.receiveShadow = true;
364
- scene.add(ground);
365
- ground.visible = false; // DISABLED ground plane
366
 
367
- // (ADD) Group to apply vertical lift without altering raw vertex data
368
- const modelGroup = new THREE.Group();
369
- scene.add(modelGroup);
370
-
371
- // (ADDED) Target sphere visual
372
  const targetSphereGeom = new THREE.SphereGeometry(0.01, 24, 24);
373
  const targetSphereMat = new THREE.MeshStandardMaterial({color:0x00ff55, emissive:0x008833});
374
- const targetSphere = new THREE.Mesh(targetSphereGeom, targetSphereMat);
375
- scene.add(targetSphere);
 
 
 
376
 
377
- // (REPLACED) updateTargetSphere: now accounts for modelGroup translation (baseOffsetY)
378
- // and uniform scaling (Pepper) on all axes so sphere aligns with the visual robot pose.
379
  function updateTargetSphere(){
380
  const xRaw = parseFloat(document.getElementById('target_x').value) || 0;
381
  const yRaw = parseFloat(document.getElementById('target_y').value) || 0;
382
  const zRaw = parseFloat(document.getElementById('target_z').value) || 0;
383
- // Model transform
384
- const sx = modelGroup?.scale?.x ?? 1;
385
- const sy = modelGroup?.scale?.y ?? 1;
386
- const sz = modelGroup?.scale?.z ?? 1;
387
- const tx = modelGroup?.position?.x ?? 0;
388
- const ty = modelGroup?.position?.y ?? 0; // this already equals baseOffsetY after ground align
389
- const tz = modelGroup?.position?.z ?? 0;
390
- // Apply uniform / per-axis scaling then translation so sphere sits where the solver
391
- // target maps in the displayed (scaled & lifted) robot coordinates.
392
- targetSphere.position.set(
393
- xRaw * sx + tx,
394
- yRaw * sy + ty,
395
- zRaw * sz + tz
396
- );
397
  }
398
-
399
- // Attach live update listeners
400
- ['target_x','target_y','target_z'].forEach(id=>{
401
- document.getElementById(id).addEventListener('input', updateTargetSphere);
402
- document.getElementById(id).addEventListener('change', updateTargetSphere);
403
- });
404
-
405
- // Initial placement after DOM ready
406
- updateTargetSphere();
407
-
408
- window.addEventListener('resize', () => {
409
- renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight);
410
- camera.aspect = renderer.domElement.clientWidth / renderer.domElement.clientHeight;
411
- camera.updateProjectionMatrix();
412
- });
413
-
414
- function resetCamera(){
415
- camera.position.set(0,1,3);
416
- controls.target.set(0,0.8,0);
417
- controls.update();
418
  }
 
 
 
 
 
419
 
420
  // ----- Config fetch -----
421
- async function loadConfig(){
422
- const res = await fetch(`/config?model=${currentModel}`);
423
- configData = await res.json();
424
- populateUIFromConfig();
425
- log(`Config loaded for ${currentModel}`);
426
- }
427
 
428
  function populateUIFromConfig(){
429
- // End effector
430
- endEffectorSel.innerHTML = "";
431
- configData.end_effector_choices.forEach(b=>{
432
- const opt = document.createElement('option');
433
- opt.value = b; opt.textContent = b;
434
- if (b === configData.default_end_effector) opt.selected = true;
435
- endEffectorSel.appendChild(opt);
436
- });
437
- // Bones
438
- bonesContainer.innerHTML = "";
439
- configData.selectable_bones.forEach(b=>{
440
- const id = `bone_${b}`;
441
- const label = document.createElement('label');
442
- label.innerHTML = `<input type="checkbox" id="${id}" ${configData.default_controlled_bones.includes(b)?'checked':''}> ${b}`;
443
- bonesContainer.appendChild(label);
444
- // Add listener for auto-solve
445
- setTimeout(()=>{ // ensure element in DOM
446
- const cb = document.getElementById(id);
447
- if (cb) cb.addEventListener('change', ()=> scheduleSolve());
448
- },0);
449
- });
450
- // Hand controls (agent only)
451
- if (currentModel === 'agent'){
452
- handFieldset.style.display = '';
453
- handShapeSel.innerHTML = "";
454
- configData.hand_shapes.forEach(s=>{
455
- const opt = document.createElement('option');
456
- opt.value = s; opt.textContent = s;
457
- handShapeSel.appendChild(opt);
458
- });
459
- handPosSel.innerHTML = "";
460
- configData.hand_positions.forEach(s=>{
461
- const opt = document.createElement('option');
462
- opt.value = s; opt.textContent = s;
463
- handPosSel.appendChild(opt);
464
- });
465
- } else {
466
- handFieldset.style.display = 'none';
467
- }
468
- // Set subpoints max if provided
469
- if (configData && typeof configData.max_subpoints !== 'undefined'){
470
- subpointsInput.max = configData.max_subpoints;
471
- }
472
- if (configData && typeof configData.default_num_steps !== 'undefined'){
473
- numStepsInput.value = configData.default_num_steps;
474
- }
475
- updateTargetSphere();
476
  }
477
 
478
- // Subpoints change listener (ensure derivative toggle & autosolve) - ADDED
479
- subpointsInput.addEventListener('change', ()=>{
480
- const v = parseInt(subpointsInput.value,10);
481
- if (v <= 1){
482
- document.getElementById('derivative_enabled').checked = false;
483
- }
484
- scheduleSolve();
485
- });
486
 
487
- // Disable GLTF controls on Pepper tab (avoid SMPLX loading there)
488
- function applyModelSpecificGLTFPolicy(){
489
- const urlInput = document.getElementById('gltf_url');
490
- const showGltfChk = document.getElementById('show_gltf');
491
- if (currentModel === 'pepper'){
492
- // Do NOT permanently clear stored URL; just hide GLTF reference
493
- // (removed previous urlInput.value = "" to preserve texture reuse when switching back)
494
- showGltfChk.disabled = true;
495
- if (gltfRoot) { gltfRoot.visible = false; }
496
- } else {
497
- // Restore default GLTF path if empty so textures load properly after tab switching
498
- if (!urlInput.value) urlInput.value = "/files/smplx.glb";
499
- showGltfChk.disabled = false;
500
- }
501
- }
502
 
503
- // ----- GLTF Loader (reuse its primary mesh for IK if vertex counts match) -----
504
  const gltfLoader = new GLTFLoader();
505
- function loadGLTF(url){
506
- if (!url){ log("No GLTF URL (possibly Pepper) - skipping load."); return; }
507
- const u = url.startsWith('http') ? url : url;
508
- statusBar.textContent = `Loading GLTF: ${u}`;
509
- log(`Loading GLTF ${u}`);
510
- gltfLoader.load(u, gltf=>{
511
- if (gltfRoot) modelGroup.remove(gltfRoot);
512
- gltfRoot = gltf.scene;
513
- gltfPrimaryMaterial = null;
514
- gltfPrimaryMesh = null;
515
- gltfPrimaryMaterialCaptured = false;
516
-
517
- gltfRoot.traverse(o=>{
518
- if (o.isMesh && o.geometry?.attributes?.position){
519
- if (!gltfPrimaryMesh || o.geometry.attributes.position.count > gltfPrimaryMesh.geometry.attributes.position.count){
520
- gltfPrimaryMesh = o;
521
- }
522
- }
523
- });
524
-
525
- if (gltfPrimaryMesh){
526
- if (Array.isArray(gltfPrimaryMesh.material)){
527
- const mm = gltfPrimaryMesh.material.find(m=>m.map) || gltfPrimaryMesh.material[0];
528
- gltfPrimaryMaterial = mm.clone();
529
- } else {
530
- gltfPrimaryMaterial = gltfPrimaryMesh.material.clone();
531
- }
532
- gltfPrimaryMaterialCaptured = true;
533
- log(`Captured GLTF primary mesh (verts=${gltfPrimaryMesh.geometry.attributes.position.count})`);
534
- } else {
535
- log("No primary mesh found in GLTF.");
536
- }
537
-
538
- modelGroup.add(gltfRoot); // (CHANGED) add to modelGroup instead of scene
539
- gltfRoot.visible = document.getElementById('show_gltf').checked;
540
-
541
- // Bind deformation if frames already exist
542
- if (animationFrames.length){
543
- ensureActiveMeshBound(animationFrames[0]);
544
- applyFrame(animationFrames[Math.min(frameIndex, animationFrames.length-1)]);
545
- }
546
-
547
- fitCamera(gltfRoot);
548
- // adjustModelHeight(gltfRoot); // (ADD) ensure height set even without frames yet
549
- statusBar.textContent = "GLTF loaded.";
550
- }, undefined, err=>{
551
- statusBar.textContent = "GLTF error: " + err.message;
552
- log("GLTF error: "+err.message);
553
- });
554
- }
555
-
556
- // ----- Animation Frames Fetch -----
557
- async function fetchFrames(force=false){
558
- const now = performance.now();
559
- if (!force && now - lastFrameFetch < pollInterval) return;
560
- lastFrameFetch = now;
561
- const url = `/animation?model=${currentModel==='pepper'?'pepper':'agent'}`;
562
- try {
563
- const res = await fetch(url);
564
- if (!res.ok) throw new Error(res.status);
565
- const data = await res.json();
566
- if (force || data.length !== animationFrames.length){
567
- animationFrames = data;
568
- if (animationFrames.length){
569
- ensureActiveMeshBound(animationFrames[0]);
570
- controlFrames = [];
571
- splineMode = false;
572
- playbackEnabled = false;
573
- const requestedSub = payload.subpoints;
574
- if (animationFrames.length > 1 && requestedSub > 1){
575
- setupSplinePlayback(animationFrames);
576
- applyFrame(animationFrames[0]);
577
- } else {
578
- frameIndex = animationFrames.length - 1;
579
- applyFrame(animationFrames[frameIndex]);
580
- }
581
- scheduleGroundAlign();
582
- lastFrameFetch = performance.now();
583
- }
584
- }
585
- } catch(e){
586
- log("Animation fetch error: "+e.message);
587
- }
588
- }
589
 
590
- // Decide how to bind deformation on first frames arrival
591
- function prepareDeformationBinding(){
592
- if (!animationFrames.length) return;
593
- ensureActiveMeshBound(animationFrames[0]);
594
- applyFrame(animationFrames[0]);
595
- }
596
-
597
- // Deprecate old createOrUpdateFallbackMesh usage by redirect (kept if referenced)
598
- function createOrUpdateFallbackMesh(){
599
- if (!animationFrames.length) return;
600
- ensureActiveMeshBound(animationFrames[0]);
601
- }
602
-
603
- // ----- Playback control (NEW) -----
604
- function updateFramePlayback(){
605
- if (!animationFrames.length) return;
606
- const idx = Math.min(Math.floor(frameIndex), animationFrames.length - 1); // clamp
607
- applyFrame(animationFrames[idx]);
608
- }
609
-
610
- // ----- Solve -----
611
- // REMOVE old binding to doSolve directly (replaced below)
612
- // document.getElementById('solve_btn').onclick = () => doSolve();
613
- document.getElementById('solve_btn').onclick = () => triggerSolve(true);
614
-
615
- function collectBones(){
616
- const bones = [];
617
- if (!configData) return bones;
618
- configData.selectable_bones.forEach(b=>{
619
- const cb = document.getElementById(`bone_${b}`);
620
- if (cb && cb.checked) bones.push(b);
621
- });
622
- return bones;
623
- }
624
 
625
  async function doSolve(abortSignal){
626
- statusBar.textContent = "Solving...";
627
- log("Solve request started");
628
- const subpointsVal = parseInt(val('subpoints'),10);
629
- const payload = {
630
  model: currentModel,
631
  target: [num('target_x'), num('target_y'), num('target_z')],
632
  subpoints: subpointsVal,
633
  num_steps: parseInt(numStepsInput.value,10) || 100,
634
  distance_enabled: bool('distance_enabled'),
635
  distance_weight: num('distance_weight'),
636
- collision_enabled: bool('collision_enabled'),
637
- collision_weight: num('collision_weight'),
 
 
638
  bone_zero_enabled: bool('bone_zero_enabled'),
639
  bone_zero_weight: num('bone_zero_weight'),
640
  derivative_enabled: bool('derivative_enabled'),
641
  derivative_weight: num('derivative_weight'),
642
  controlled_bones: collectBones(),
643
  end_effector: endEffectorSel.value,
644
- hand_shape: currentModel==='agent'? handShapeSel.value : "None",
645
- hand_position: currentModel==='agent'? handPosSel.value : "None",
646
- // Request only last frame unless we actually need a trajectory
647
- frames_mode: subpointsVal > 1 ? 'auto' : 'last'
648
  };
649
- try {
650
- const t0 = performance.now();
651
- // Single request (backend configure occurs inside /solve)
652
- const res = await fetch("/solve", {
653
- method:"POST",
654
- headers:{'Content-Type':'application/json'},
655
- body: JSON.stringify(payload),
656
- signal: abortSignal
657
- });
658
- if (!res.ok) throw new Error(res.status+" "+res.statusText);
659
- const json = await res.json();
660
- if (json.status !== 'ok') throw new Error(json.message || 'Solve failed');
661
-
662
- // Stale solve guard
663
- const sid = json.result.solve_id || 0;
664
- if (payload.model === 'agent'){
665
- if (sid <= lastSolveIdAgent){ log(`Stale agent solve ignored (sid=${sid} <= ${lastSolveIdAgent})`); return; }
666
- lastSolveIdAgent = sid;
667
- } else {
668
- if (sid <= lastSolveIdPepper){ log(`Stale pepper solve ignored (sid=${sid} <= ${lastSolveIdPepper})`); return; }
669
- lastSolveIdPepper = sid;
670
- }
671
-
672
- const server = json.result.solve_time;
673
- const total = (performance.now()-t0)/1000;
674
- statusBar.textContent =
675
- `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}`;
676
- log(`Solve completed (frames received=${json.result.frames})`);
677
- if (json.result.frames_data && json.result.frames_data.length){
678
- animationFrames = json.result.frames_data;
679
- ensureActiveMeshBound(animationFrames[0]);
680
- controlFrames = [];
681
- splineMode = false;
682
- playbackEnabled = false;
683
- if (animationFrames.length > 1 && subpointsVal > 1){
684
- setupSplinePlayback(animationFrames);
685
- applyFrame(animationFrames[0]);
686
- } else {
687
- frameIndex = animationFrames.length - 1;
688
- applyFrame(animationFrames[frameIndex]);
689
- }
690
- scheduleGroundAlign();
691
- }
692
- updateTargetSphere();
693
- showToast(
694
- `Solve OK • it=${json.result.iterations} • t=${json.result.solve_time.toFixed(2)}s • err=${json.result.objective.toExponential(2)}`,
695
- 'success',
696
- 4000
697
- );
698
- } catch(e){
699
- if (e.name === 'AbortError'){ log('Solve aborted (superseded by new request)'); return; }
700
- statusBar.textContent = "Solve error: " + e.message;
701
- log("Solve error: "+e.message);
702
- showToast(`Solve ERROR: ${e.message}`, 'error', 6000);
703
- }
704
- }
705
-
706
- // ----- Toast System (ADDED) -----
707
- const toastContainer = document.getElementById('toastContainer');
708
- function showToast(message, type='success', ttl=4000){
709
- const el = document.createElement('div');
710
- el.className = `toast ${type}`;
711
- const close = document.createElement('span');
712
- close.className = 'toast-close';
713
- close.textContent = '✕';
714
- close.onclick = (e)=>{ e.stopPropagation(); remove(); };
715
- const content = document.createElement('div');
716
- content.textContent = message;
717
- el.appendChild(close);
718
- el.appendChild(content);
719
- toastContainer.appendChild(el);
720
- requestAnimationFrame(()=> el.classList.add('show'));
721
- function remove(){
722
- el.classList.remove('show');
723
- setTimeout(()=> el.remove(), 180);
724
- }
725
- setTimeout(remove, ttl);
726
- }
727
-
728
- // ----- Target sliders (NEW) -----
729
- const xNum = document.getElementById('target_x');
730
- const yNum = document.getElementById('target_y');
731
- const zNum = document.getElementById('target_z');
732
- const xSlider = document.getElementById('target_x_slider');
733
- const ySlider = document.getElementById('target_y_slider');
734
-
735
- function syncSliderToNum(axis){
736
- if (axis==='x') xSlider.value = xNum.value;
737
- else if (axis==='y') ySlider.value = yNum.value;
738
- }
739
- function syncNumToSlider(axis){
740
- if (axis==='x') xNum.value = xSlider.value;
741
- else if (axis==='y') yNum.value = ySlider.value;
742
  }
743
 
744
- // Slider live update (no solve while dragging)
745
- xSlider.addEventListener('input', ()=>{ syncNumToSlider('x'); updateTargetSphere(); });
746
- ySlider.addEventListener('input', ()=>{ syncNumToSlider('y'); updateTargetSphere(); });
747
-
748
- // Solve only after release (change event)
749
- xSlider.addEventListener('change', ()=>{ syncNumToSlider('x'); updateTargetSphere(); scheduleSolve(); });
750
- ySlider.addEventListener('change', ()=>{ syncNumToSlider('y'); updateTargetSphere(); scheduleSolve(); });
751
-
752
- // Numeric fields: update slider & sphere; solve on change (debounced)
753
- xNum.addEventListener('change', ()=>{ syncSliderToNum('x'); updateTargetSphere(); scheduleSolve(); });
754
- yNum.addEventListener('change', ()=>{ syncSliderToNum('y'); updateTargetSphere(); scheduleSolve(); });
755
- zNum.addEventListener('change', ()=>{ updateTargetSphere(); scheduleSolve(); });
756
-
757
- // Existing listeners for target sphere input (remove duplicate if present)
758
- // ...existing code...
759
-
760
- // ----- Hook other auto-solve controls (NEW) -----
761
- const autoSolveSelectors = [
 
 
 
 
762
  '#distance_enabled','#distance_weight',
763
- '#collision_enabled','#collision_weight',
764
  '#bone_zero_enabled','#bone_zero_weight',
765
  '#derivative_enabled','#derivative_weight',
766
  '#hand_shape','#hand_position',
767
  '#end_effector','#wireframe','#show_gltf','#show_ikmesh',
768
- '#play_fps','#num_steps' // ADDED num_steps
769
  ];
770
- autoSolveSelectors.forEach(sel=>{
771
- const el = document.querySelector(sel);
772
- if (el){
773
- const evt = (el.type === 'checkbox' || el.tagName === 'SELECT') ? 'change' : 'input';
774
- el.addEventListener(evt, ()=> scheduleSolve());
775
- }
776
- });
777
-
778
- // Wireframe / visibility still immediate visual update (keep old logic if any)
779
- document.getElementById('wireframe').addEventListener('change', ()=>{
780
- if (ikMesh && ikMesh.material){
781
- ikMesh.material.wireframe = document.getElementById('wireframe').checked;
782
- ikMesh.material.needsUpdate = true;
783
- }
784
- if (gltfPrimaryMesh && gltfPrimaryMesh.material){
785
- if (Array.isArray(gltfPrimaryMesh.material)) gltfPrimaryMesh.material.forEach(m=>{m.wireframe=document.getElementById('wireframe').checked; m.needsUpdate=true;});
786
- else { gltfPrimaryMesh.material.wireframe = document.getElementById('wireframe').checked; gltfPrimaryMesh.material.needsUpdate=true; }
787
- }
788
- });
789
 
790
- document.getElementById('show_gltf').addEventListener('change', ()=>{
791
- if (gltfRoot) gltfRoot.visible = document.getElementById('show_gltf').checked;
792
- });
793
- document.getElementById('show_ikmesh').addEventListener('change', ()=>{
794
- if (ikMesh) ikMesh.visible = document.getElementById('show_ikmesh').checked;
795
- });
796
 
797
- // Update bones listener attachment inside populateUIFromConfig (MODIFIED)
798
- function populateUIFromConfig(){
799
- // End effector
800
- endEffectorSel.innerHTML = "";
801
- configData.end_effector_choices.forEach(b=>{
802
- const opt = document.createElement('option');
803
- opt.value = b; opt.textContent = b;
804
- if (b === configData.default_end_effector) opt.selected = true;
805
- endEffectorSel.appendChild(opt);
806
- });
807
- // Bones
808
- bonesContainer.innerHTML = "";
809
- configData.selectable_bones.forEach(b=>{
810
- const id = `bone_${b}`;
811
- const label = document.createElement('label');
812
- label.innerHTML = `<input type="checkbox" id="${id}" ${configData.default_controlled_bones.includes(b)?'checked':''}> ${b}`;
813
- bonesContainer.appendChild(label);
814
- // Add listener for auto-solve
815
- setTimeout(()=>{ // ensure element in DOM
816
- const cb = document.getElementById(id);
817
- if (cb) cb.addEventListener('change', ()=> scheduleSolve());
818
- },0);
819
- });
820
- // Hand controls (agent only)
821
- if (currentModel === 'agent'){
822
- handFieldset.style.display = '';
823
- handShapeSel.innerHTML = "";
824
- configData.hand_shapes.forEach(s=>{
825
- const opt = document.createElement('option');
826
- opt.value = s; opt.textContent = s;
827
- handShapeSel.appendChild(opt);
828
- });
829
- handPosSel.innerHTML = "";
830
- configData.hand_positions.forEach(s=>{
831
- const opt = document.createElement('option');
832
- opt.value = s;
833
- opt.textContent = s;
834
- handPosSel.appendChild(opt);
835
- });
836
- } else {
837
- handFieldset.style.display = 'none';
838
- }
839
- // Set subpoints max if provided
840
- if (configData && typeof configData.max_subpoints !== 'undefined'){
841
- subpointsInput.max = configData.max_subpoints;
842
- }
843
- if (configData && typeof configData.default_num_steps !== 'undefined'){
844
- numStepsInput.value = configData.default_num_steps;
845
- }
846
- updateTargetSphere();
847
- }
848
 
849
- // Solve button untouched; user can still manually force
850
- // document.getElementById('solve_btn').onclick = ()=> triggerSolve();
 
 
 
 
851
 
852
- // During tab switch, avoid duplicate immediate solve until config/frames loaded
853
- // (MODIFY tab switch handler to schedule solve after frames)
854
- document.querySelectorAll('.tab-btn').forEach(btn=>{
855
- btn.onclick = () => {
856
- if (btn.classList.contains('active')) return;
857
- document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
858
- btn.classList.add('active');
859
- currentModel = btn.getAttribute('data-model');
860
- modelBadge.textContent = currentModel === 'pepper' ? "Pepper Robot" : "Agent";
861
- cleanupVisuals();
862
- // Ensure camera will refit once new geometry (fallback mesh or GLTF) is created
863
- initialCameraFramed = false; // ADDED
864
- applyModelSpecificGLTFPolicy();
865
- loadConfig().then(()=>{
866
- if (currentModel === 'agent') {
867
- const url = document.getElementById('gltf_url').value;
868
- if (url) loadGLTF(url);
869
- }
870
- scheduleSolve(true); // immediate first solve on tab switch
871
- updateTargetSphere();
872
- });
873
- };
874
- });
875
 
876
- // ===== Unified Mesh Binding (ADDED) =====
877
- function ensureActiveMeshBound(firstFrame){
878
- if (!firstFrame) return;
879
- const verts = firstFrame.vertices;
880
- let bound = false;
881
-
882
- // Try GLTF primary mesh first
883
- if (gltfPrimaryMesh &&
884
- gltfPrimaryMesh.geometry?.attributes?.position &&
885
- gltfPrimaryMesh.geometry.attributes.position.count === verts.length){
886
- // Remove fallback if exists
887
- if (fallbackMesh){
888
- modelGroup.remove(fallbackMesh);
889
- if (fallbackMesh.geometry) fallbackMesh.geometry.dispose();
890
- if (fallbackMesh.material && !Array.isArray(fallbackMesh.material)) fallbackMesh.material.dispose();
891
- fallbackMesh = null;
892
- }
893
- ikMesh = gltfPrimaryMesh;
894
- usingGLTFGeometry = true;
895
- if (gltfPrimaryMaterial) ikMesh.material = gltfPrimaryMaterial;
896
- bound = true;
897
- log("ensureActiveMeshBound: using GLTF primary mesh");
898
- }
899
-
900
- // Fallback creation/update
901
- if (!bound){
902
- if (!fallbackMesh ||
903
- !fallbackMesh.geometry?.getAttribute('position') ||
904
- fallbackMesh.geometry.getAttribute('position').count !== verts.length){
905
- if (fallbackMesh){
906
- modelGroup.remove(fallbackMesh);
907
- if (fallbackMesh.geometry) fallbackMesh.geometry.dispose();
908
- if (fallbackMesh.material && !Array.isArray(fallbackMesh.material)) fallbackMesh.material.dispose();
909
- }
910
- const geom = new THREE.BufferGeometry();
911
- const posArr = new Float32Array(verts.length * 3);
912
- for (let i=0;i<verts.length;i++){
913
- const v = verts[i];
914
- posArr[i*3]=v[0]; posArr[i*3+1]=v[1]; posArr[i*3+2]=v[2];
915
- }
916
- geom.setAttribute('position', new THREE.BufferAttribute(posArr,3));
917
- geom.getAttribute('position').setUsage(THREE.DynamicDrawUsage);
918
- const faces = firstFrame.faces;
919
- const idx = new Uint32Array(faces.length*3);
920
- for (let i=0;i<faces.length;i++){
921
- const f = faces[i];
922
- idx[i*3]=f[0]; idx[i*3+1]=f[1]; idx[i*3+2]=f[2];
923
- }
924
- geom.setIndex(new THREE.BufferAttribute(idx,1));
925
- geom.computeVertexNormals();
926
- const mat = gltfPrimaryMaterial ? gltfPrimaryMaterial.clone()
927
- : new THREE.MeshStandardMaterial({color:0x6699ff, metalness:0.2, roughness:0.6});
928
- fallbackMesh = new THREE.Mesh(geom, mat);
929
- fallbackMesh.visible = document.getElementById('show_ikmesh').checked;
930
- modelGroup.add(fallbackMesh);
931
- ikMesh = fallbackMesh;
932
- usingGLTFGeometry = false;
933
- log("ensureActiveMeshBound: created fallback mesh");
934
- } else {
935
- ikMesh = fallbackMesh;
936
- usingGLTFGeometry = false;
937
- log("ensureActiveMeshBound: reused fallback mesh");
938
- }
939
- }
940
-
941
- // Pepper scaling (visual only)
942
- if (currentModel === 'pepper' && ikMesh){
943
- const box = new THREE.Box3().setFromObject(modelGroup);
944
- const h = box.max.y - box.min.y;
945
- const targetH = 1.2;
946
- if (h > 0 && (h < 0.6 || h > 2.0)){
947
- const s = targetH / h;
948
- modelGroup.scale.set(s,s,s);
949
- log(`Pepper scaled: origH=${h.toFixed(3)} scale=${s.toFixed(3)}`);
950
- scheduleGroundAlign(true);
951
- }
952
- }
953
-
954
- if (gltfRoot) gltfRoot.visible = document.getElementById('show_gltf').checked;
955
- if (ikMesh) ikMesh.visible = document.getElementById('show_ikmesh').checked;
956
-
957
- updateTargetSphere();
958
- }
959
-
960
- // ===== Frame Application (ADDED) =====
961
- function applyFrame(frame){
962
- if (!ikMesh || !frame) return;
963
- const posAttr = ikMesh.geometry.getAttribute('position');
964
- if (!posAttr || posAttr.count !== frame.vertices.length){
965
- log("applyFrame: vertex count mismatch");
966
- return;
967
- }
968
- const arr = posAttr.array;
969
- const verts = frame.vertices;
970
- for (let i=0;i<verts.length;i++){
971
- const v = verts[i];
972
- arr[i*3]=v[0]; arr[i*3+1]=v[1]; arr[i*3+2]=v[2];
973
- }
974
- posAttr.needsUpdate = true;
975
- if (!groundAligned) scheduleGroundAlign();
976
- }
977
-
978
- // ----- B-spline helpers (ADDED) ---
979
- function bsplineBasis(t){
980
- const t2=t*t, t3=t2*t;
981
- return [
982
- (1 - 3*t + 3*t2 - t3)/6,
983
- (4 - 6*t2 + 3*t3)/6,
984
- (1 + 3*t + 3*t2 - 3*t3)/6,
985
- t3/6
986
- ];
987
- }
988
- function getControlFrame(i){
989
- if (i < 0) return controlFrames[0];
990
- if (i >= controlFrames.length) return controlFrames[controlFrames.length-1];
991
- return controlFrames[i];
992
- }
993
- function evalSplineVertices(u){
994
- if (!ikMesh || controlFrames.length < 2) return;
995
- const n = controlFrames.length;
996
- const seg = Math.min(Math.floor(u), n-2);
997
- const t = u - seg;
998
- const [w0,w1,w2,w3] = bsplineBasis(t);
999
- const f0 = getControlFrame(seg-1);
1000
- const f1 = getControlFrame(seg);
1001
- const f2 = getControlFrame(seg+1);
1002
- const f3 = getControlFrame(seg+2);
1003
- const posAttr = ikMesh.geometry.getAttribute('position');
1004
- if (!posAttr) return;
1005
- const arr = posAttr.array;
1006
- const v0=f0.vertices, v1=f1.vertices, v2=f2.vertices, v3=f3.vertices;
1007
- const count = posAttr.count;
1008
- for (let i=0;i<count;i++){
1009
- const a0=v0[i], a1=v1[i], a2=v2[i], a3=v3[i];
1010
- arr[i*3] = w0*a0[0]+w1*a1[0]+w2*a2[0]+w3*a3[0];
1011
- arr[i*3+1] = w0*a0[1]+w1*a1[1]+w2*a2[1]+w3*a3[1];
1012
- arr[i*3+2] = w0*a0[2]+w1*a1[2]+w2*a2[2]+w3*a3[2];
1013
- }
1014
- posAttr.needsUpdate = true;
1015
- if (!groundAligned) scheduleGroundAlign();
1016
- }
1017
- function setupSplinePlayback(frames){
1018
- controlFrames = frames;
1019
- splineMode = true;
1020
- splineU = 0;
1021
- const segments = frames.length - 1;
1022
- splineDuration = Math.min(segments * 0.6, 8.0); // heuristic
1023
- playbackEnabled = true;
1024
- }
1025
-
1026
- // ----- Render Loop -----
1027
- function animate(now){
1028
- requestAnimationFrame(animate);
1029
- const dt = (now - lastTime)/1000;
1030
- lastTime = now;
1031
- if (splineMode && playbackEnabled){
1032
- const n = controlFrames.length;
1033
- if (n > 1){
1034
- splineU += (dt / splineDuration) * (n - 1);
1035
- if (splineU >= (n - 1)){
1036
- splineU = n - 1;
1037
- playbackEnabled = false; // stop at end
1038
- }
1039
- evalSplineVertices(splineU);
1040
- }
1041
- } else if (animationFrames.length > 1 && playbackEnabled){
1042
- const fps = parseInt(document.getElementById('play_fps').value,10) || 24;
1043
- frameIndex += dt * fps;
1044
- if (frameIndex >= animationFrames.length){
1045
- if (allowLoopPlayback){
1046
- frameIndex = 0;
1047
- } else {
1048
- frameIndex = animationFrames.length - 1;
1049
- playbackEnabled = false; // stop at final frame
1050
- }
1051
- }
1052
- updateFramePlayback();
1053
- }
1054
- // If not animating but we just solved / loaded, ensure final frame rendered
1055
- if (!playbackEnabled && animationFrames.length && frameIndex === animationFrames.length - 1){
1056
- // Single application safeguard (light cost)
1057
- // applyFrame(animationFrames[frameIndex]); // already applied; can omit
1058
- }
1059
- if (pendingAlign) performGroundAlign();
1060
-
1061
- // (ADDED) One-time initial camera framing after first geometry appears
1062
- if (!initialCameraFramed && modelGroup.children.length){
1063
- fitCamera(modelGroup);
1064
- initialCameraFramed = true;
1065
- }
1066
-
1067
- renderer.render(scene, camera);
1068
- }
1069
  requestAnimationFrame(animate);
1070
 
1071
- // ----- Init -----
1072
- applyModelSpecificGLTFPolicy();
1073
- loadConfig().then(()=>{
1074
- const url = document.getElementById('gltf_url').value;
1075
- if (url) loadGLTF(url);
1076
- scheduleGroundAlign(true);
1077
- scheduleSolve(true);
1078
- updateTargetSphere();
1079
- });
1080
 
 
 
1081
  })();
1082
  </script>
1083
  </body>
 
37
  .toast.error { border-color:#b33939; }
38
  .toast .toast-close { float:right; cursor:pointer; color:#888; margin-left:6px; }
39
  .toast .toast-close:hover { color:#fff; }
40
+ .axis-pair { margin:4px 0; }
41
+ .axis-pair .flex-row { align-items:center; }
42
+ .axis-pair span { width:14px; display:inline-block; font-size:11px; color:#aaa; }
43
  </style>
44
  <!-- Added import map to resolve bare specifier 'three' -->
45
  <script type="importmap">
 
93
  <legend>Primary Objectives</legend>
94
  <label><input id="distance_enabled" type="checkbox" checked> Distance Objective</label>
95
  <label>Distance Weight <input id="distance_weight" type="number" value="1.0" step="0.1"></label>
96
+ </fieldset>
97
+ <fieldset id="collisionFieldset">
98
+ <legend>Collision Sphere</legend>
99
+ <label><input id="collision_enabled" type="checkbox"> Enable Collision Sphere</label>
100
+ <div class="axis-pair">X
101
+ <div class="flex-row">
102
+ <input id="collision_cx" type="number" value="0.1" step="0.01">
103
+ <input id="collision_cx_slider" type="range" min="-1" max="1" step="0.01" value="0.1">
104
+ </div>
105
+ </div>
106
+ <div class="axis-pair">Y
107
+ <div class="flex-row">
108
+ <input id="collision_cy" type="number" value="0.0" step="0.01">
109
+ <input id="collision_cy_slider" type="range" min="-1" max="1" step="0.01" value="0.0">
110
+ </div>
111
+ </div>
112
+ <div class="axis-pair">Z
113
+ <div class="flex-row">
114
+ <input id="collision_cz" type="number" value="0.35" step="0.01">
115
+ <input id="collision_cz_slider" type="range" min="-1" max="1" step="0.01" value="0.35">
116
+ </div>
117
+ </div>
118
+ <label>Collision Weight <input id="collision_weight" type="number" value="1.0" step="0.1"></label>
119
+ <label>Sphere Radius <input id="collision_radius" type="number" value="0.1" step="0.01" min="0.01" max="1.0"></label>
120
+ <label>Min Clearance
121
+ <div class="flex-row">
122
+ <input id="collision_min_clearance" type="number" value="0.0" step="0.005" min="0" max="0.5">
123
+ <input id="collision_min_clearance_slider" type="range" min="0" max="0.5" step="0.005" value="0.0">
124
+ </div>
125
+ </label>
126
  </fieldset>
127
  <fieldset>
128
  <legend>Regularization</legend>
 
197
  let gltfPrimaryMaterialCaptured = false;
198
  let fallbackMesh = null;
199
  let lastTime = performance.now();
 
 
 
200
  let lastSolveIdAgent = 0;
201
  let lastSolveIdPepper = 0;
202
 
203
+ // ----- Auto-solve -----
204
  let solving = false;
 
205
  let solveDebounceTimer = null;
206
+ const SOLVE_DEBOUNCE_MS = 25;
207
+ let currentSolveController = null;
 
208
  function scheduleSolve(immediate=false){
209
+ if (solving){ if (currentSolveController) currentSolveController.abort(); }
210
+ if (immediate){ triggerSolve(true); return; }
 
 
 
 
 
 
 
211
  if (solveDebounceTimer) clearTimeout(solveDebounceTimer);
212
+ solveDebounceTimer = setTimeout(()=> triggerSolve(false), SOLVE_DEBOUNCE_MS);
213
  }
214
+ async function triggerSolve(){
215
+ if (currentSolveController) currentSolveController.abort();
 
 
 
 
216
  currentSolveController = new AbortController();
217
  const controller = currentSolveController;
218
  solving = true;
219
  try { await doSolve(controller.signal); }
220
+ catch(e){ if (e.name !== 'AbortError') {} }
221
+ finally { if (controller === currentSolveController) solving = false; }
 
 
222
  }
223
 
224
  // Playback state
 
232
  let alignAttemptCount = 0;
233
  const maxAlignAttempts = 120;
234
 
235
+ // Trajectory / spline state
236
  let controlFrames = [];
237
  let splineMode = false;
238
  let splineU = 0;
239
+ let splineDuration = 2.0;
 
240
  let initialCameraFramed = false;
241
 
242
  function cleanupVisuals(){
 
 
 
 
243
  if (fallbackMesh){
244
  modelGroup.remove(fallbackMesh);
245
  if (fallbackMesh.geometry) fallbackMesh.geometry.dispose();
 
247
  fallbackMesh = null;
248
  }
249
  if (gltfRoot){
250
+ 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(); } } });
251
+ modelGroup.remove(gltfRoot); gltfRoot = null;
 
 
 
 
 
 
 
 
 
252
  }
 
253
  while (modelGroup.children.length) modelGroup.remove(modelGroup.children[0]);
254
+ gltfPrimaryMesh = null; gltfPrimaryMaterial = null; gltfPrimaryMaterialCaptured = false; ikMesh = null; usingGLTFGeometry = false;
255
+ animationFrames = []; frameIndex = 0; baseOffsetY = 0; groundAligned = false; pendingAlign = false; alignAttemptCount = 0;
256
+ log('Visuals cleaned.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
+ function scheduleGroundAlign(force=false){ if (force){ groundAligned=false; alignAttemptCount=0; } pendingAlign=true; }
259
  function performGroundAlign(){
260
  if (!pendingAlign) return;
261
+ if (!modelGroup.children.length){ if (++alignAttemptCount > maxAlignAttempts) pendingAlign=false; return; }
 
 
 
262
  const box = new THREE.Box3().setFromObject(modelGroup);
263
+ if (box.isEmpty() || !isFinite(box.min.y)){ if (++alignAttemptCount > maxAlignAttempts) pendingAlign=false; return; }
264
+ const currentMin = box.min.y; const delta = -currentMin;
 
 
 
 
 
 
265
  if (delta > 0 || Math.abs(delta) < 1e-3){
266
+ modelGroup.position.y += delta; baseOffsetY = modelGroup.position.y; groundAligned = true; updateTargetSphere(); updateCollisionSphere();
 
 
 
267
  }
268
+ pendingAlign=false;
269
  }
 
 
270
  function fitCamera(obj){
271
+ const targetObj = modelGroup.children.length ? modelGroup : obj; if (!targetObj) return;
272
+ const box = new THREE.Box3().setFromObject(targetObj); if (box.isEmpty()) return;
273
+ const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
274
+ controls.target.copy(center); const maxDim = Math.max(size.x,size.y,size.z);
275
+ const dist = maxDim * 3; const dir = new THREE.Vector3(0.0,0.5,1).normalize();
 
 
 
 
 
276
  camera.position.copy(center).addScaledVector(dir, dist);
277
+ camera.near = maxDim/100; camera.far = maxDim*100; camera.updateProjectionMatrix(); controls.update();
 
 
 
278
  }
279
 
280
  // ----- DOM refs -----
 
285
  const handPosSel = document.getElementById('hand_position');
286
  const handFieldset = document.getElementById('handFieldset');
287
  const modelBadge = document.getElementById('modelBadge');
288
+ const subpointsInput = document.getElementById('subpoints');
289
+ const numStepsInput = document.getElementById('num_steps');
290
+ // Collision inputs
291
+ const collisionEnabledEl = document.getElementById('collision_enabled');
292
+ const collisionWeightEl = document.getElementById('collision_weight');
293
+ const collCx = document.getElementById('collision_cx');
294
+ const collCy = document.getElementById('collision_cy');
295
+ const collCz = document.getElementById('collision_cz');
296
+ const collCxSlider = document.getElementById('collision_cx_slider');
297
+ const collCySlider = document.getElementById('collision_cy_slider');
298
+ const collCzSlider = document.getElementById('collision_cz_slider');
299
+ const collRadiusEl = document.getElementById('collision_radius');
300
+ const collMinClearEl = document.getElementById('collision_min_clearance');
301
+ const collMinClearSlider = document.getElementById('collision_min_clearance_slider');
302
 
 
303
  function val(id){ return document.getElementById(id).value; }
304
  function num(id){ return parseFloat(val(id)); }
305
  function bool(id){ return document.getElementById(id).checked; }
 
310
  renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight);
311
  renderer.domElement.className = 'viewer-canvas';
312
  document.getElementById('viewerPane').appendChild(renderer.domElement);
313
+ const scene = new THREE.Scene(); scene.background = new THREE.Color(0x222222);
 
 
 
314
  const camera = new THREE.PerspectiveCamera(45, renderer.domElement.clientWidth / renderer.domElement.clientHeight, 0.01, 1000);
315
  camera.position.set(0,1,3);
316
+ const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0,0.8,0);
 
 
 
317
  scene.add(new THREE.HemisphereLight(0xffffff,0x444444,1.0));
318
+ const d = new THREE.DirectionalLight(0xffffff,0.8); d.position.set(3,10,10); scene.add(d);
319
+ const ground = new THREE.Mesh(new THREE.CircleGeometry(5,48), new THREE.MeshStandardMaterial({color:0x303030, metalness:0.1, roughness:0.9}));
320
+ ground.rotation.x = -Math.PI/2; ground.receiveShadow = true; scene.add(ground); ground.visible = false;
321
+ const modelGroup = new THREE.Group(); scene.add(modelGroup);
 
 
 
 
 
 
 
322
 
323
+ // Target sphere
 
 
 
 
324
  const targetSphereGeom = new THREE.SphereGeometry(0.01, 24, 24);
325
  const targetSphereMat = new THREE.MeshStandardMaterial({color:0x00ff55, emissive:0x008833});
326
+ const targetSphere = new THREE.Mesh(targetSphereGeom, targetSphereMat); scene.add(targetSphere);
327
+ // Collision sphere (unit scaled later)
328
+ const collisionSphereGeom = new THREE.SphereGeometry(1, 32, 32);
329
+ const collisionSphereMat = new THREE.MeshStandardMaterial({color:0xff4444, emissive:0x660000, transparent:true, opacity:0.18, wireframe:false});
330
+ const collisionSphere = new THREE.Mesh(collisionSphereGeom, collisionSphereMat); scene.add(collisionSphere); collisionSphere.visible = false;
331
 
 
 
332
  function updateTargetSphere(){
333
  const xRaw = parseFloat(document.getElementById('target_x').value) || 0;
334
  const yRaw = parseFloat(document.getElementById('target_y').value) || 0;
335
  const zRaw = parseFloat(document.getElementById('target_z').value) || 0;
336
+ const sx = modelGroup?.scale?.x ?? 1; const sy = modelGroup?.scale?.y ?? 1; const sz = modelGroup?.scale?.z ?? 1;
337
+ const tx = modelGroup?.position?.x ?? 0; const ty = modelGroup?.position?.y ?? 0; const tz = modelGroup?.position?.z ?? 0;
338
+ targetSphere.position.set(xRaw * sx + tx, yRaw * sy + ty, zRaw * sz + tz);
 
 
 
 
 
 
 
 
 
 
 
339
  }
340
+ function updateCollisionSphere(){
341
+ const cx = parseFloat(collCx.value) || 0; const cy = parseFloat(collCy.value) || 0; const cz = parseFloat(collCz.value) || 0;
342
+ const r = parseFloat(collRadiusEl.value) || 0.1; const mc = parseFloat(collMinClearEl.value) || 0.0;
343
+ 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;
344
+ collisionSphere.position.set(cx * sx + tx, cy * sy + ty, cz * sz + tz);
345
+ const uniform = (sx+sy+sz)/3; const effective = r + mc; // visualize base radius + clearance
346
+ collisionSphere.scale.set(effective*uniform, effective*uniform, effective*uniform);
347
+ collisionSphere.visible = collisionEnabledEl.checked;
 
 
 
 
 
 
 
 
 
 
 
 
348
  }
349
+ ['target_x','target_y','target_z'].forEach(id=>{ document.getElementById(id).addEventListener('input', updateTargetSphere); document.getElementById(id).addEventListener('change', updateTargetSphere); });
350
+ updateTargetSphere(); updateCollisionSphere();
351
+
352
+ window.addEventListener('resize', () => { renderer.setSize(document.getElementById('viewerPane').clientWidth, document.getElementById('viewerPane').clientHeight); camera.aspect = renderer.domElement.clientWidth / renderer.domElement.clientHeight; camera.updateProjectionMatrix(); });
353
+ function resetCamera(){ camera.position.set(0,1,3); controls.target.set(0,0.8,0); controls.update(); }
354
 
355
  // ----- Config fetch -----
356
+ async function loadConfig(){ const res = await fetch(`/config?model=${currentModel}`); configData = await res.json(); populateUIFromConfig(); log(`Config loaded for ${currentModel}`); }
 
 
 
 
 
357
 
358
  function populateUIFromConfig(){
359
+ 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); });
360
+ 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); });
361
+ 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'; }
362
+ if (configData && typeof configData.max_subpoints !== 'undefined') subpointsInput.max = configData.max_subpoints;
363
+ if (configData && typeof configData.default_num_steps !== 'undefined') numStepsInput.value = configData.default_num_steps;
364
+ 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]; }
365
+ if (configData && typeof configData.collision_default_radius !== 'undefined'){ collRadiusEl.value = configData.collision_default_radius; }
366
+ if (configData && typeof configData.collision_default_min_clearance !== 'undefined'){ collMinClearEl.value = configData.collision_default_min_clearance; collMinClearSlider.value = configData.collision_default_min_clearance; }
367
+ updateTargetSphere(); updateCollisionSphere();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  }
369
 
370
+ subpointsInput.addEventListener('change', ()=>{ const v=parseInt(subpointsInput.value,10); if (v<=1){ document.getElementById('derivative_enabled').checked=false; } scheduleSolve(); });
 
 
 
 
 
 
 
371
 
372
+ 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; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
 
374
  const gltfLoader = new GLTFLoader();
375
+ 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.'); }
376
+ 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); }); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
+ document.getElementById('solve_btn').onclick = ()=> triggerSolve(true);
379
+ 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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
381
  async function doSolve(abortSignal){
382
+ statusBar.textContent='Solving...'; log('Solve request started');
383
+ const subpointsVal=parseInt(val('subpoints'),10);
384
+ const payload={
 
385
  model: currentModel,
386
  target: [num('target_x'), num('target_y'), num('target_z')],
387
  subpoints: subpointsVal,
388
  num_steps: parseInt(numStepsInput.value,10) || 100,
389
  distance_enabled: bool('distance_enabled'),
390
  distance_weight: num('distance_weight'),
391
+ collision_enabled: collisionEnabledEl.checked,
392
+ collision_weight: parseFloat(collisionWeightEl.value)||1.0,
393
+ collision_center: [parseFloat(collCx.value)||0, parseFloat(collCy.value)||0, parseFloat(collCz.value)||0],
394
+ collision_radius: parseFloat(collRadiusEl.value)||0.1,
395
  bone_zero_enabled: bool('bone_zero_enabled'),
396
  bone_zero_weight: num('bone_zero_weight'),
397
  derivative_enabled: bool('derivative_enabled'),
398
  derivative_weight: num('derivative_weight'),
399
  controlled_bones: collectBones(),
400
  end_effector: endEffectorSel.value,
401
+ hand_shape: currentModel==='agent'? handShapeSel.value : 'None',
402
+ hand_position: currentModel==='agent'? handPosSel.value : 'None',
403
+ frames_mode: subpointsVal > 1 ? 'auto' : 'last',
404
+ collision_min_clearance: parseFloat(collMinClearEl.value)||0.0,
405
  };
406
+ 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; }
407
+ 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(); }
408
+ 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); }
409
+ 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); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  }
411
 
412
+ const toastContainer=document.getElementById('toastContainer');
413
+ 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); }
414
+
415
+ 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');
416
+ function syncSliderToNum(axis){ if (axis==='x') xSlider.value=xNum.value; else if (axis==='y') ySlider.value=yNum.value; }
417
+ function syncNumToSlider(axis){ if (axis==='x') xNum.value=xSlider.value; else if (axis==='y') yNum.value=ySlider.value; }
418
+ xSlider.addEventListener('input', ()=>{ syncNumToSlider('x'); updateTargetSphere(); }); ySlider.addEventListener('input', ()=>{ syncNumToSlider('y'); updateTargetSphere(); });
419
+ xSlider.addEventListener('change', ()=>{ syncNumToSlider('x'); updateTargetSphere(); scheduleSolve(); }); ySlider.addEventListener('change', ()=>{ syncNumToSlider('y'); updateTargetSphere(); scheduleSolve(); });
420
+ xNum.addEventListener('change', ()=>{ syncSliderToNum('x'); updateTargetSphere(); scheduleSolve(); }); yNum.addEventListener('change', ()=>{ syncSliderToNum('y'); updateTargetSphere(); scheduleSolve(); }); zNum.addEventListener('change', ()=>{ updateTargetSphere(); scheduleSolve(); });
421
+
422
+ // Collision sliders
423
+ 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; }
424
+ 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; }
425
+ ['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(); }); });
426
+ [collCx, collCy, collCz].forEach((el,i)=> el.addEventListener('change', ()=>{ syncCollSliderToNum(i===0?'x':i===1?'y':'z'); updateCollisionSphere(); scheduleSolve(); }));
427
+ collRadiusEl.addEventListener('change', ()=>{ updateCollisionSphere(); scheduleSolve(); });
428
+ collisionEnabledEl.addEventListener('change', ()=>{ updateCollisionSphere(); scheduleSolve(); });
429
+ collMinClearSlider.addEventListener('input', ()=>{ syncCollNumToSlider('mc'); updateCollisionSphere(); });
430
+ collMinClearSlider.addEventListener('change', ()=>{ syncCollNumToSlider('mc'); updateCollisionSphere(); scheduleSolve(); });
431
+ collMinClearEl.addEventListener('change', ()=>{ syncCollSliderToNum('mc'); updateCollisionSphere(); scheduleSolve(); });
432
+
433
+ const autoSolveSelectors=[
434
  '#distance_enabled','#distance_weight',
435
+ '#collision_enabled','#collision_weight', '#collision_radius', '#collision_cx', '#collision_cy', '#collision_cz', '#collision_min_clearance',
436
  '#bone_zero_enabled','#bone_zero_weight',
437
  '#derivative_enabled','#derivative_weight',
438
  '#hand_shape','#hand_position',
439
  '#end_effector','#wireframe','#show_gltf','#show_ikmesh',
440
+ '#play_fps','#num_steps'
441
  ];
442
+ autoSolveSelectors.forEach(sel=>{ const el=document.querySelector(sel); if (el){ const evt=(el.type==='checkbox'||el.tagName==='SELECT')?'change':'input'; el.addEventListener(evt, ()=> scheduleSolve()); }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
+ 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; } } });
445
+ document.getElementById('show_gltf').addEventListener('change', ()=>{ if (gltfRoot) gltfRoot.visible=document.getElementById('show_gltf').checked; });
446
+ document.getElementById('show_ikmesh').addEventListener('change', ()=>{ if (ikMesh) ikMesh.visible=document.getElementById('show_ikmesh').checked; });
 
 
 
447
 
448
+ 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'); }
449
+ 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(); }
450
+ 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'); } }
451
+ 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); } }
452
+ if (gltfRoot) gltfRoot.visible=document.getElementById('show_gltf').checked; if (ikMesh) ikMesh.visible=document.getElementById('show_ikmesh').checked; updateTargetSphere(); updateCollisionSphere(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ 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(); }
455
+ 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]; }
456
+ function getControlFrame(i){ if (i<0) return controlFrames[0]; if (i>=controlFrames.length) return controlFrames[controlFrames.length-1]; return controlFrames[i]; }
457
+ 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(); }
458
+ function setupSplinePlayback(frames){ controlFrames=frames; splineMode=true; splineU=0; const segments=frames.length - 1; splineDuration=Math.min(segments * 0.6, 8.0); playbackEnabled=true; }
459
+ function updateFramePlayback(){ if (!animationFrames.length) return; const idx=Math.min(Math.floor(frameIndex), animationFrames.length - 1); applyFrame(animationFrames[idx]); }
460
 
461
+ 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(); }); }; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
+ 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); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  requestAnimationFrame(animate);
465
 
466
+ document.getElementById('reset_cam').addEventListener('click', ()=>{ fitCamera(modelGroup); });
 
 
 
 
 
 
 
 
467
 
468
+ applyModelSpecificGLTFPolicy();
469
+ loadConfig().then(()=>{ const url=document.getElementById('gltf_url').value; if (url) loadGLTF(url); scheduleGroundAlign(true); scheduleSolve(true); updateTargetSphere(); updateCollisionSphere(); });
470
  })();
471
  </script>
472
  </body>