Add direct-manipulation UI: drag on the 3D head to drive the controls

#1
by linoyts HF Staff - opened
Files changed (2) hide show
  1. README.md +24 -0
  2. app.py +731 -44
README.md CHANGED
@@ -52,6 +52,30 @@ no camera reset and no flicker.
52
  The model is pure NumPy (a few matrix multiplies, ~40 ms per evaluation), so the
53
  Space runs on `cpu-basic` with no GPU required.
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  ## Credits
56
 
57
  Model and reference demo: [google/GNM](https://github.com/google/GNM),
 
52
  The model is pure NumPy (a few matrix multiplies, ~40 ms per evaluation), so the
53
  Space runs on `cpu-basic` with no GPU required.
54
 
55
+ ## Direct manipulation
56
+
57
+ You can also **grab the 3D head directly** instead of only moving sliders — hover
58
+ any region to see what it does (the head is colour-coded by control region):
59
+
60
+ - **Eye** — drag ↕ to open / close that eye, ↔ to glance (aim the eyes).
61
+ - **Mouth** — drag the centre / chin **down** to open the jaw; drag a **lip
62
+ corner outward** to smile / stretch.
63
+ - **Tongue** — drag down to stick it out.
64
+ - **Skull / forehead** — drag to turn & nod the head; **gaze** aims when you grab
65
+ an eyeball.
66
+ - **Ear** — drag in / out (drives *Head shape 8*); **Cheek** — drag to stretch
67
+ the face (drives *Head shape 7*).
68
+ - **Alt + drag** free-sculpts the surface under the cursor with a soft falloff
69
+ brush (nose, brow, jawline). These are geometric offsets on top of the model —
70
+ a **↺ Reset shaping** button clears them.
71
+ - **Shift + drag** repositions the head, **right-drag** (or drag empty space)
72
+ orbits the camera, **scroll** zooms.
73
+
74
+ Each region drives the very same slider(s) — hovering one glows the matching
75
+ control and its tab — so the sliders and the head stay in sync. Every gesture
76
+ runs through the same reactive pipeline as the sliders; slider-driving drags are
77
+ throttled and run off the queue so the mesh tracks the cursor in real time.
78
+
79
  ## Credits
80
 
81
  Model and reference demo: [google/GNM](https://github.com/google/GNM),
app.py CHANGED
@@ -140,6 +140,122 @@ NUM_TRANSLATION = 3
140
  TOTAL_SLIDERS = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE + NUM_TRANSLATION
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  # ---------------------------------------------------------------------------
144
  # Parameter assembly + mesh generation.
145
  # ---------------------------------------------------------------------------
@@ -210,6 +326,58 @@ def positions_json(*slider_values) -> str:
210
  return json.dumps(flat, separators=(",", ":"))
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  # ---------------------------------------------------------------------------
214
  # Categorical / keyword expression presets.
215
  #
@@ -323,7 +491,20 @@ VIEWER_TEMPLATE = (
323
  '<div id="gnm-wrap" style="width:100%;height:560px;border-radius:8px;'
324
  'overflow:hidden;background:#f0f0f0;position:relative;">'
325
  '<canvas id="gnm-canvas" style="width:100%;height:100%;display:block;">'
326
- "</canvas></div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  )
328
 
329
  # js_on_load runs exactly once on mount. It builds a persistent Three.js scene,
@@ -336,10 +517,102 @@ VIEWER_JS_ON_LOAD = """
336
  (function () {
337
  const FACES = __FACES__;
338
  const COLORS = __COLORS__;
 
 
339
  // GNM head geometry (meters): Y-up / +Z-forward, centered ~ (0, 0.24, 0).
340
  const TARGET = [0, 0.24, 0.02];
341
 
342
- let renderer, scene, camera, geometry, mesh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  // Spherical camera around TARGET. Front-facing default: on +Z axis.
344
  let camState = { radius: 0.75, theta: 0.0, phi: Math.PI / 2 };
345
 
@@ -354,29 +627,113 @@ VIEWER_JS_ON_LOAD = """
354
  camera.lookAt(tx, ty, tz);
355
  }
356
 
357
- function setPositions(flat) {
358
- if (!geometry) return;
 
 
359
  let attr = geometry.getAttribute("position");
360
- if (!attr || attr.array.length !== flat.length) {
361
- attr = new THREE.BufferAttribute(new Float32Array(flat.length), 3);
362
  geometry.setAttribute("position", attr);
363
  }
364
- attr.array.set(flat);
 
 
 
 
 
365
  attr.needsUpdate = true;
366
  geometry.computeVertexNormals();
367
  geometry.computeBoundingSphere();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  }
369
 
370
  function build() {
371
  const wrap = element.querySelector("#gnm-wrap");
372
  const canvas = element.querySelector("#gnm-canvas");
 
 
 
 
 
 
 
 
 
373
  if (!wrap || !canvas || !window.THREE) return false;
374
  const W = wrap.clientWidth || 600, H = wrap.clientHeight || 560;
375
 
376
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
377
  renderer.setPixelRatio(window.devicePixelRatio || 1);
378
  renderer.setSize(W, H, false);
379
- // Tone down exposure so the mesh isn't blown out / overexposed.
380
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
381
  renderer.toneMappingExposure = 0.85;
382
 
@@ -386,18 +743,17 @@ VIEWER_JS_ON_LOAD = """
386
  camera = new THREE.PerspectiveCamera(35, W / H, 0.01, 100);
387
  applyCamera();
388
 
389
- // Reduced light intensities so the mesh isn't overexposed / blown out.
390
  scene.add(new THREE.AmbientLight(0xffffff, 0.40));
391
- const key = new THREE.DirectionalLight(0xffffff, 0.55);
392
- key.position.set(0.5, 1.0, 1.5); scene.add(key);
393
  const fill = new THREE.DirectionalLight(0xffffff, 0.20);
394
  fill.position.set(-0.8, 0.2, 0.5); scene.add(fill);
395
 
396
  geometry = new THREE.BufferGeometry();
397
  geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(FACES), 1));
398
- geometry.setAttribute(
399
- "color", new THREE.BufferAttribute(new Float32Array(COLORS), 3)
400
- );
401
  const mat = new THREE.MeshStandardMaterial({
402
  vertexColors: true, roughness: 0.75, metalness: 0.0,
403
  side: THREE.DoubleSide,
@@ -405,21 +761,208 @@ VIEWER_JS_ON_LOAD = """
405
  mesh = new THREE.Mesh(geometry, mat);
406
  scene.add(mesh);
407
 
408
- // --- Minimal orbit controls (rotate/zoom); never auto-resets. ---------
409
- let dragging = false, lastX = 0, lastY = 0;
410
- canvas.addEventListener("mousedown", (e) => {
411
- dragging = true; lastX = e.clientX; lastY = e.clientY;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  });
413
- window.addEventListener("mouseup", () => { dragging = false; });
414
- window.addEventListener("mousemove", (e) => {
415
- if (!dragging) return;
416
- const dx = e.clientX - lastX, dy = e.clientY - lastY;
417
- lastX = e.clientX; lastY = e.clientY;
418
- camState.theta -= dx * 0.01;
419
- camState.phi = Math.max(0.05, Math.min(Math.PI - 0.05,
420
- camState.phi - dy * 0.01));
421
- applyCamera();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  });
 
 
 
 
 
 
 
 
 
423
  canvas.addEventListener("wheel", (e) => {
424
  e.preventDefault();
425
  camState.radius = Math.max(0.2, Math.min(3.0,
@@ -427,8 +970,22 @@ VIEWER_JS_ON_LOAD = """
427
  applyCamera();
428
  }, { passive: false });
429
 
 
 
 
 
 
 
 
430
  function animate() {
431
  requestAnimationFrame(animate);
 
 
 
 
 
 
 
432
  renderer.render(scene, camera);
433
  }
434
  animate();
@@ -450,8 +1007,6 @@ VIEWER_JS_ON_LOAD = """
450
  if (flat && flat.length) setPositions(flat);
451
  }
452
 
453
- // Wait for the global THREE (head script) then build once, then draw the
454
- // initial value. watch() handles every subsequent update in place.
455
  (function ready() {
456
  if (!window.THREE || !build()) { setTimeout(ready, 40); return; }
457
  updateFromValue();
@@ -464,9 +1019,40 @@ VIEWER_JS_ON_LOAD = (
464
  VIEWER_JS_ON_LOAD
465
  .replace("__FACES__", _FACES_JSON)
466
  .replace("__COLORS__", _COLORS_JSON)
 
 
 
467
  )
468
 
469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  # ---------------------------------------------------------------------------
471
  # UI
472
  # ---------------------------------------------------------------------------
@@ -474,6 +1060,31 @@ CSS = """
474
  #col-container { max-width: 1200px; margin: 0 auto; }
475
  .dark .gradio-container { color: var(--body-text-color); }
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  /* Compact grouped sections. Each feature/parameter group (Head Shape, Left
478
  Eye, Mouth, Neck, Gaze, Translation, ...) is rendered as its own bordered
479
  section with a header row (group label + per-group Reset) and its sliders
@@ -664,9 +1275,9 @@ CSS = """
664
  """
665
 
666
  INTRO = """
667
- # 🧬 GNM — Generative aNthropometric Model (Head)
668
 
669
- **GNM Head** is a state-of-the-art parametric 3D statistical model of the human head from [google/GNM](https://github.com/google/GNM), with disentangled control over **identity**, **expression**, **pose** and **translation**. Move any slider to see it re-generate the 3D mesh live. Based on the [notebook demo](https://github.com/google/GNM/blob/main/gnm/shape/README.md#demo).
670
  """
671
 
672
 
@@ -702,6 +1313,7 @@ VSLIDER_TEMPLATE = (
702
  VSLIDER_JS_ON_LOAD = """
703
  (function () {
704
  const MIN = __MIN__, MAX = __MAX__, STEP = __STEP__;
 
705
  const root = element.querySelector(".vslider-root");
706
  const track = element.querySelector(".vslider-track");
707
  const fill = element.querySelector(".vslider-fill");
@@ -788,6 +1400,36 @@ VSLIDER_JS_ON_LOAD = """
788
  if (!track.clientHeight) { setTimeout(ready, 30); return; }
789
  paint(currentValue());
790
  })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  })();
792
  """
793
 
@@ -797,11 +1439,14 @@ def _js_num(x):
797
  return repr(float(x))
798
 
799
 
800
- def _vslider(label, minimum, maximum, value=0.0, step=None):
801
  """Create a custom vertical slider (drop-in replacement for gr.Slider).
802
 
803
  Returns a gr.HTML component whose value is the numeric slider value. The
804
  per-instance label / min / max / step are baked into its template and JS.
 
 
 
805
  """
806
  step_val = step if step is not None else 0.0
807
  tmpl = (
@@ -815,6 +1460,7 @@ def _vslider(label, minimum, maximum, value=0.0, step=None):
815
  .replace("__MIN__", _js_num(minimum))
816
  .replace("__MAX__", _js_num(maximum))
817
  .replace("__STEP__", _js_num(step_val))
 
818
  )
819
  return gr.HTML(
820
  value=float(value),
@@ -866,9 +1512,9 @@ with gr.Blocks(title="GNM Head") as demo:
866
  with gr.Column(elem_id="col-container"):
867
  gr.Markdown(INTRO)
868
 
869
- with gr.Row():
870
  # --- Controls -------------------------------------------------
871
- with gr.Column(scale=3):
872
  identity_sliders = []
873
  expression_sliders = []
874
  pose_sliders = []
@@ -888,6 +1534,7 @@ with gr.Blocks(title="GNM Head") as demo:
888
  _slider(
889
  f"Head shape {k + 1}",
890
  -3.0, 3.0, 0.0, 0.05,
 
891
  )
892
  )
893
  return sliders
@@ -951,6 +1598,8 @@ with gr.Blocks(title="GNM Head") as demo:
951
  )
952
 
953
  def _make_expr_builder(group_name, group_idx):
 
 
954
  def _build_expr():
955
  sliders = []
956
  for j in range(len(group_idx)):
@@ -962,7 +1611,10 @@ with gr.Blocks(title="GNM Head") as demo:
962
  else f"{group_name} {j + 1}"
963
  )
964
  sliders.append(
965
- _slider(label, -3.0, 3.0, 0.0, 0.05)
 
 
 
966
  )
967
  return sliders
968
  return _build_expr
@@ -1018,12 +1670,17 @@ with gr.Blocks(title="GNM Head") as demo:
1018
  "vergence control)."
1019
  )
1020
 
1021
- def _make_pose_builder(limits):
1022
  def _build_pose():
1023
  sliders = []
1024
- for name, limit in limits.items():
 
 
1025
  sliders.append(
1026
- _slider(name, -limit, limit, 0, 1)
 
 
 
1027
  )
1028
  return sliders
1029
  return _build_pose
@@ -1038,7 +1695,9 @@ with gr.Blocks(title="GNM Head") as demo:
1038
  with gr.Column(min_width=0):
1039
  pose_sliders += _group_section(
1040
  joint.capitalize(),
1041
- _make_pose_builder(limits),
 
 
1042
  )
1043
 
1044
  for joint, limits in POSE_LIMITS.items():
@@ -1046,7 +1705,9 @@ with gr.Blocks(title="GNM Head") as demo:
1046
  continue
1047
  pose_sliders += _group_section(
1048
  joint.capitalize(),
1049
- _make_pose_builder(limits),
 
 
1050
  )
1051
 
1052
  with gr.Tab("Translation"):
@@ -1054,13 +1715,16 @@ with gr.Blocks(title="GNM Head") as demo:
1054
 
1055
  def _build_translation():
1056
  sliders = []
1057
- for d in [
1058
  "X (left/right)",
1059
  "Y (up/down)",
1060
  "Z (forward/back)",
1061
- ]:
1062
  sliders.append(
1063
- _slider(d, -0.2, 0.2, 0.0, 0.01)
 
 
 
1064
  )
1065
  return sliders
1066
 
@@ -1072,7 +1736,7 @@ with gr.Blocks(title="GNM Head") as demo:
1072
  # The custom Three.js viewer IS the output component. Its value is
1073
  # the flat vertex-position array; `watch('value')` in the viewer's
1074
  # js_on_load morphs the persistent mesh in place on every update.
1075
- with gr.Column(scale=2):
1076
  viewer = gr.HTML(
1077
  value="",
1078
  html_template=VIEWER_TEMPLATE,
@@ -1081,6 +1745,14 @@ with gr.Blocks(title="GNM Head") as demo:
1081
  label="GNM head mesh",
1082
  elem_id="gnm-viewer",
1083
  )
 
 
 
 
 
 
 
 
1084
 
1085
  all_sliders = (
1086
  identity_sliders
@@ -1100,8 +1772,23 @@ with gr.Blocks(title="GNM Head") as demo:
1100
  inputs=all_sliders,
1101
  outputs=viewer,
1102
  show_progress="hidden",
 
1103
  )
1104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  # Per-group Reset: each group has its own ↺ Reset button that resets
1106
  # ONLY that group's sliders back to their defaults, then recomputes the
1107
  # mesh from the full (partially-reset) slider vector. There is no
 
140
  TOTAL_SLIDERS = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE + NUM_TRANSLATION
141
 
142
 
143
+ # ---------------------------------------------------------------------------
144
+ # Direct-manipulation zones.
145
+ #
146
+ # The viewer lets you click-and-drag *on the head itself* to drive the sliders:
147
+ # grab the jaw and pull down to open the mouth, grab an eyeball to aim the
148
+ # gaze, grab the head shell to turn it, etc. To do that, every mesh vertex is
149
+ # tagged with an interaction "zone", and the viewer maps a drag over a zone to
150
+ # the matching slider change. The tags are derived from GNM's own semantic
151
+ # vertex groups (chin_region, left_orbital_region, left_eye, ...), so the
152
+ # mapping tracks the real anatomy of the model.
153
+ # ---------------------------------------------------------------------------
154
+ (
155
+ ZONE_HEAD, # 0 - skull / forehead / nose: rotate the head
156
+ ZONE_LEFT_EYE, # 1 - left lid+brow skin: open/close left eye
157
+ ZONE_RIGHT_EYE, # 2 - right lid+brow skin: open/close right eye
158
+ ZONE_LEFT_GAZE, # 3 - left eyeball: aim gaze
159
+ ZONE_RIGHT_GAZE, # 4 - right eyeball: aim gaze
160
+ ZONE_MOUTH_OPEN, # 5 - centre lips + chin: drag to open / close the jaw
161
+ ZONE_TONGUE, # 6 - tongue / teeth: push the tongue out
162
+ ZONE_MOUTH_SMILE, # 7 - lip corners: drag outward to smile / stretch
163
+ ZONE_EAR, # 8 - ears: drag to pull them in / out (free sculpt)
164
+ ZONE_CHEEK, # 9 - cheeks: drag to stretch the face (free sculpt)
165
+ ) = range(10)
166
+
167
+ _zones = np.zeros(NUM_VERTICES, dtype=np.int16) # default: ZONE_HEAD
168
+
169
+ # Neutral vertex positions, for splitting the mouth into centre (open) vs
170
+ # corner (smile) sub-zones by x-coordinate.
171
+ _NEUTRAL = np.asarray(GNM(None, None, None, None))
172
+
173
+
174
+ def _tag_zone(regions, zone):
175
+ """Tag every vertex of the named GNM vertex groups with `zone`."""
176
+ for r in regions:
177
+ if r in GNM.vertex_group_names:
178
+ _zones[np.asarray(GNM.vertex_group_indices(r))] = zone
179
+
180
+
181
+ # Applied low-priority first; later tags win where regions overlap. Eyeballs
182
+ # (gaze) are tagged last so grabbing the eye always aims it rather than blinks.
183
+ _tag_zone(
184
+ ["upper_lip", "lower_lip", "mouth_sock",
185
+ "upper_lip_region", "lower_lip_region", "chin_region"],
186
+ ZONE_MOUTH_OPEN,
187
+ )
188
+ _tag_zone(["tongue", "gums", "teeth"], ZONE_TONGUE)
189
+ _tag_zone(
190
+ ["left_brow_region", "left_orbital_region", "left_infraorbital_region"],
191
+ ZONE_LEFT_EYE,
192
+ )
193
+ _tag_zone(
194
+ ["right_brow_region", "right_orbital_region", "right_infraorbital_region"],
195
+ ZONE_RIGHT_EYE,
196
+ )
197
+ _tag_zone(["left_eye"], ZONE_LEFT_GAZE)
198
+ _tag_zone(["right_eye"], ZONE_RIGHT_GAZE)
199
+ # Ears and cheeks are free-sculpt handles (drag to reshape geometrically).
200
+ _tag_zone(["ears"], ZONE_EAR)
201
+ _tag_zone(
202
+ ["left_cheek_region", "right_cheek_region",
203
+ "left_zygomatic_region", "right_zygomatic_region"],
204
+ ZONE_CHEEK,
205
+ )
206
+
207
+ # Split the mouth: lip vertices near the corners (|x| beyond ~half the mouth
208
+ # half-width) become the SMILE zone; the central lips + chin stay OPEN. This
209
+ # gives two separately-highlighted mouth areas that each do one thing.
210
+ _lip_groups = ["upper_lip", "lower_lip", "upper_lip_region", "lower_lip_region"]
211
+ _lip_idx = np.unique(np.concatenate(
212
+ [np.asarray(GNM.vertex_group_indices(g)) for g in _lip_groups
213
+ if g in GNM.vertex_group_names]
214
+ ))
215
+ _corner_x = 0.6 * np.abs(_NEUTRAL[_lip_idx, 0]).max() # corner threshold
216
+ _corner_verts = _lip_idx[np.abs(_NEUTRAL[_lip_idx, 0]) >= _corner_x]
217
+ _zones[_corner_verts] = ZONE_MOUTH_SMILE
218
+
219
+ ZONES_JSON = json.dumps(_zones.tolist(), separators=(",", ":"))
220
+
221
+ # Start offset of each slider group within its category's flat slider list
222
+ # (the assembled slider lists are in this canonical order).
223
+ _EXPR_SLOT_START = {}
224
+ _c = 0
225
+ for _name, _g in EXPRESSION_GROUPS.items():
226
+ _EXPR_SLOT_START[_name] = _c
227
+ _c += len(_g)
228
+ _POSE_SLOT_START = {}
229
+ _c = 0
230
+ for _joint, _lim in POSE_LIMITS.items():
231
+ _POSE_SLOT_START[_joint] = _c
232
+ _c += len(_lim)
233
+
234
+ # Which specific slider each drag gesture drives, addressed by the registry
235
+ # key baked into that slider (see `_vslider`). Measured directions on this
236
+ # model (see the calibration in the module docstring / presets):
237
+ # mouth slot 0 (lower_face_region_000): +raises/closes, -drops jaw open
238
+ # left/right eye slot 0: + raises brow / widens, - lowers / closes
239
+ # tongue slot +1 (tongue_000): + pushes the tongue forward/out
240
+ # head pose: pitch = nod, yaw = turn; gaze pose: pitch/yaw aim the eyes
241
+ VIEWER_CFG = {
242
+ "leftEyeKey": f"ex_{_EXPR_SLOT_START['Left eye']}",
243
+ "rightEyeKey": f"ex_{_EXPR_SLOT_START['Right eye']}",
244
+ "mouthKey": f"ex_{_EXPR_SLOT_START['Mouth']}",
245
+ # lower_face_region_001: negative widens + lifts the mouth corners (smile /
246
+ # stretch), positive puckers. Horizontal mouth drags map to this.
247
+ "smileKey": f"ex_{_EXPR_SLOT_START['Mouth'] + 1}",
248
+ "tongueKey": f"ex_{_EXPR_SLOT_START['Tongue'] + 1}",
249
+ "headPitchKey": f"pose_{_POSE_SLOT_START['head'] + 0}",
250
+ "headYawKey": f"pose_{_POSE_SLOT_START['head'] + 1}",
251
+ "gazePitchKey": f"pose_{_POSE_SLOT_START['gaze'] + 0}",
252
+ "gazeYawKey": f"pose_{_POSE_SLOT_START['gaze'] + 1}",
253
+ "txKey": "tr_0",
254
+ "tyKey": "tr_1",
255
+ }
256
+ VIEWER_CFG_JSON = json.dumps(VIEWER_CFG, separators=(",", ":"))
257
+
258
+
259
  # ---------------------------------------------------------------------------
260
  # Parameter assembly + mesh generation.
261
  # ---------------------------------------------------------------------------
 
326
  return json.dumps(flat, separators=(",", ":"))
327
 
328
 
329
+ # ---------------------------------------------------------------------------
330
+ # Sculpt (free-pull) — drag a point on the mesh to reshape the head.
331
+ #
332
+ # The bind-pose is linear in identity: vertex_v = template_v + Σ_k id_k · B[k,v]
333
+ # (B = vertex_identity_basis). So the Jacobian of the grabbed vertex w.r.t. the
334
+ # 10 exposed head-shape sliders is a constant (3, 10) matrix. Given a world-
335
+ # space drag `d` at vertex `vi`, we solve the (under-determined) least-squares
336
+ # `A·Δ ≈ d` for the smallest slider change Δ that moves that point toward the
337
+ # drag, add it to the sliders the drag started from, and clip to range. Because
338
+ # the basis is rich, the min-norm solution stays reasonably local (pulling an
339
+ # ear pokes the ear; pulling a cheek widens the face).
340
+ # ---------------------------------------------------------------------------
341
+ _ID_JAC = np.asarray(GNM.vertex_identity_basis)[IDENTITY_INDICES].astype(np.float64) # (10, V, 3)
342
+
343
+ # Gain for the sculpt gradient step (see sculpt_pull). Chosen so a moderate
344
+ # Alt-drag produces a clear but un-maxed reshape.
345
+ _SCULPT_GAIN = 6000.0
346
+
347
+
348
+ def sculpt_pull(payload, *slider_values):
349
+ """Reshape the head by nudging the shape sliders toward a drag.
350
+
351
+ `payload` is a JSON string ``{"vi": int, "d": [dx,dy,dz], "base": [10]}``
352
+ set by the viewer during an Alt-drag: `vi` is the grabbed vertex, `d` the
353
+ cumulative world drag, `base` the identity slider values at drag start.
354
+
355
+ The 10 exposed identity modes are *global* head-shape PCA directions, so a
356
+ single point can't be pulled to an arbitrary place (that inverse is
357
+ ill-conditioned). Instead we take a bounded gradient step: move each shape
358
+ slider proportionally to how strongly it pushes the grabbed vertex along
359
+ the drag (``Δ_k = gain · B[k, vi] · d``). Well-covered regions (cheeks,
360
+ brow, jaw) respond strongly; areas the basis barely controls (nose tip)
361
+ move a little — it never blows up. Returns the new identity slider values.
362
+ """
363
+ cur = [float(v) for v in slider_values[:NUM_IDENTITY]]
364
+ if not payload:
365
+ return cur
366
+ try:
367
+ p = json.loads(payload)
368
+ vi = int(p["vi"])
369
+ d = np.asarray(p["d"], dtype=np.float64).reshape(3)
370
+ base = np.asarray(p.get("base", cur), dtype=np.float64).reshape(-1)[:NUM_IDENTITY]
371
+ except Exception:
372
+ return cur
373
+ if not (0 <= vi < _ID_JAC.shape[1]):
374
+ return cur
375
+ step = _SCULPT_GAIN * (_ID_JAC[:, vi, :] @ d) # (10,) gradient of vertex·drag
376
+ new = np.clip(base + step, -3.0, 3.0)
377
+ new = np.round(new / 0.05) * 0.05 # snap to the slider step
378
+ return [float(x) for x in new]
379
+
380
+
381
  # ---------------------------------------------------------------------------
382
  # Categorical / keyword expression presets.
383
  #
 
491
  '<div id="gnm-wrap" style="width:100%;height:560px;border-radius:8px;'
492
  'overflow:hidden;background:#f0f0f0;position:relative;">'
493
  '<canvas id="gnm-canvas" style="width:100%;height:100%;display:block;">'
494
+ "</canvas>"
495
+ # Hover hint: shows what dragging the region under the cursor will do.
496
+ '<div id="gnm-hint" style="position:absolute;top:10px;left:10px;'
497
+ "padding:4px 10px;border-radius:6px;background:rgba(0,0,0,0.62);"
498
+ "color:#fff;font-size:13px;font-weight:600;letter-spacing:.2px;"
499
+ 'pointer-events:none;opacity:0;transition:opacity .12s;z-index:5;">'
500
+ "</div>"
501
+ # Reset-shaping button: appears once you free-sculpt (ear/cheek/Alt-drag),
502
+ # since those geometric tweaks have no slider to reset. Clears the sculpt.
503
+ '<button id="gnm-clear-sculpt" style="position:absolute;top:10px;'
504
+ "right:10px;display:none;padding:4px 10px;border-radius:6px;border:none;"
505
+ "background:rgba(0,0,0,0.62);color:#fff;font-size:12px;font-weight:600;"
506
+ 'cursor:pointer;z-index:6;">↺ Reset shaping</button>'
507
+ "</div>"
508
  )
509
 
510
  # js_on_load runs exactly once on mount. It builds a persistent Three.js scene,
 
517
  (function () {
518
  const FACES = __FACES__;
519
  const COLORS = __COLORS__;
520
+ const ZONES = __ZONES__; // per-vertex interaction zone id
521
+ const CFG = __CFG__; // gesture -> slider-registry-key map
522
  // GNM head geometry (meters): Y-up / +Z-forward, centered ~ (0, 0.24, 0).
523
  const TARGET = [0, 0.24, 0.02];
524
 
525
+ // Zone ids (mirror the Python ZONE_* constants).
526
+ const Z_HEAD=0, Z_LEYE=1, Z_REYE=2, Z_LGAZE=3, Z_RGAZE=4, Z_MOUTH=5,
527
+ Z_TONGUE=6, Z_SMILE=7, Z_EAR=8, Z_CHEEK=9;
528
+ // Zones whose plain drag free-sculpts the geometry (none now — ears & cheeks
529
+ // drive head-shape sliders instead; Alt+drag still free-sculpts anywhere).
530
+ const SCULPT_ZONES = {};
531
+
532
+ // Drag sensitivities (slider-units per pixel of drag).
533
+ const HEAD_G = 0.35; // degrees / px (head rotation)
534
+ const GAZE_G = 0.18; // degrees / px (eye gaze)
535
+ const EXPR_G = 0.022; // blendshape units / px (eyes, mouth, tongue)
536
+ const TRANS_G = 0.0016; // meters / px (shift-drag translate)
537
+ const ID_G = 0.02; // head-shape units / px (ear / cheek -> identity)
538
+ const SCULPT_G = 1.0; // sculpt drag is sent as real-world meters; the
539
+ // response gain lives server-side in _SCULPT_GAIN
540
+
541
+ // What a drag over each zone does: a friendly label + the slider(s) it
542
+ // drives. axis 'x' = horizontal drag, 'y' = vertical drag (up is positive
543
+ // because screen-y grows downward and we negate it). sign flips direction.
544
+ function zoneSpec(z) {
545
+ switch (z) {
546
+ case Z_HEAD: return { label: "↺ Rotate head · drag to turn / nod",
547
+ t: [ {key: CFG.headYawKey, axis:'x', gain: HEAD_G, sign: +1},
548
+ {key: CFG.headPitchKey, axis:'y', gain: HEAD_G, sign: -1} ] };
549
+ // The eyeball sits slightly behind the lids, so from the front a click
550
+ // lands on the lid/brow. That eye zone therefore does both: drag ↕ to
551
+ // open/close *this* eye, drag ↔ to glance (gaze yaw, both eyes). Orbit
552
+ // round to grab the eyeball itself (Z_*GAZE) for full up/down aiming.
553
+ case Z_LEYE: return { label: "👁 Left eye · ↕ open / close · ↔ glance",
554
+ t: [ {key: CFG.leftEyeKey, axis:'y', gain: EXPR_G, sign: +1},
555
+ {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1} ] };
556
+ case Z_REYE: return { label: "👁 Right eye · ↕ open / close · ↔ glance",
557
+ t: [ {key: CFG.rightEyeKey, axis:'y', gain: EXPR_G, sign: +1},
558
+ {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1} ] };
559
+ case Z_LGAZE:
560
+ case Z_RGAZE: return { label: "👀 Gaze · drag to aim the eyes",
561
+ t: [ {key: CFG.gazeYawKey, axis:'x', gain: GAZE_G, sign: +1},
562
+ {key: CFG.gazePitchKey, axis:'y', gain: GAZE_G, sign: -1} ] };
563
+ // Centre of the mouth / chin: vertical drag only — pull the jaw down to
564
+ // open, push up to close.
565
+ case Z_MOUTH: return { label: "🗣 Jaw · drag down to open the mouth",
566
+ t: [ {key: CFG.mouthKey, axis:'y', gain: EXPR_G, sign: +1} ] };
567
+ // Lip corners: horizontal drag only — pull a corner outward to smile /
568
+ // stretch, push inward to pucker (outwardX resolves the side).
569
+ case Z_SMILE: return { label: "😀 Mouth corner · pull out to smile / stretch",
570
+ t: [ {key: CFG.smileKey, axis:'x', gain: 0.02, outwardX: true} ] };
571
+ case Z_TONGUE:return { label: "👅 Tongue · drag down to stick it out",
572
+ t: [ {key: CFG.tongueKey, axis:'y', gain: EXPR_G, sign: -1} ] };
573
+ // Ears & cheeks drive head-shape sliders (Head shape 8 / 7). outwardXPos:
574
+ // pulling the ear out raises id_7 (ears out); outwardX: pulling the cheek
575
+ // out lowers id_6 (fuller face). Both resolve their side from the grab.
576
+ case Z_EAR: return { label: "👂 Ear · drag in / out (Head shape 8)",
577
+ t: [ {key: "id_7", axis:'x', gain: ID_G, outwardXPos: true} ] };
578
+ case Z_CHEEK: return { label: "🫦 Cheek · drag to stretch the face (Head shape 7)",
579
+ t: [ {key: "id_6", axis:'x', gain: ID_G, outwardX: true} ] };
580
+ }
581
+ return { label: "", t: [] };
582
+ }
583
+
584
+ // Highlight tints per zone (RGB 0..1). Head is not tinted (it's most of the
585
+ // mesh) — only the feature zones light up on hover.
586
+ const HL = {
587
+ 1:[1.0,0.55,0.05], 2:[1.0,0.55,0.05], 3:[0.10,0.60,1.0],
588
+ 4:[0.10,0.60,1.0], 5:[1.0,0.32,0.34], 6:[0.92,0.20,0.62],
589
+ 7:[0.20,0.80,0.45], // smile corners — distinct green from the red jaw
590
+ 8:[0.65,0.45,1.0], // ears — purple
591
+ 9:[1.0,0.75,0.15], // cheeks — amber
592
+ };
593
+
594
+ let renderer, scene, camera, geometry, mesh, colorAttr, clearBtn;
595
+ // Persistent free-sculpt offsets (world space), added on top of the GNM
596
+ // mesh every frame so a pulled ear / stretched cheek survives slider morphs.
597
+ let sculptOffsets = null, lastFlat = null;
598
+
599
+ // Show the "Reset shaping" button only when some sculpt offset is non-zero
600
+ // (those geometric tweaks aren't tied to any slider, so this is their reset).
601
+ function hasSculpt() {
602
+ if (!sculptOffsets) return false;
603
+ for (let i = 0; i < sculptOffsets.length; i++) if (sculptOffsets[i] !== 0) return true;
604
+ return false;
605
+ }
606
+ function updateClearBtn() {
607
+ if (clearBtn) clearBtn.style.display = hasSculpt() ? "block" : "none";
608
+ }
609
+ const BASE_COLORS = new Float32Array(COLORS); // pristine per-vertex colors
610
+ // Vertices grouped by zone, for fast hover recolor.
611
+ const zoneVerts = {};
612
+ for (let i = 0; i < ZONES.length; i++) {
613
+ (zoneVerts[ZONES[i]] || (zoneVerts[ZONES[i]] = [])).push(i);
614
+ }
615
+
616
  // Spherical camera around TARGET. Front-facing default: on +Z axis.
617
  let camState = { radius: 0.75, theta: 0.0, phi: Math.PI / 2 };
618
 
 
627
  camera.lookAt(tx, ty, tz);
628
  }
629
 
630
+ // Write lastFlat (+ any sculpt offsets) into the position buffer.
631
+ function applyBuffer() {
632
+ if (!geometry || !lastFlat) return;
633
+ const n = lastFlat.length;
634
  let attr = geometry.getAttribute("position");
635
+ if (!attr || attr.array.length !== n) {
636
+ attr = new THREE.BufferAttribute(new Float32Array(n), 3);
637
  geometry.setAttribute("position", attr);
638
  }
639
+ const a = attr.array;
640
+ if (sculptOffsets && sculptOffsets.length === n) {
641
+ for (let i = 0; i < n; i++) a[i] = lastFlat[i] + sculptOffsets[i];
642
+ } else {
643
+ a.set(lastFlat);
644
+ }
645
  attr.needsUpdate = true;
646
  geometry.computeVertexNormals();
647
  geometry.computeBoundingSphere();
648
+ geometry.computeBoundingBox();
649
+ }
650
+
651
+ function setPositions(flat) {
652
+ lastFlat = flat;
653
+ if (!sculptOffsets && flat.length) sculptOffsets = new Float32Array(flat.length);
654
+ applyBuffer();
655
+ }
656
+
657
+ // ---- Slider registry access (set by each vertical slider on mount). -----
658
+ function reg(k) {
659
+ return (window.__GNM && window.__GNM.reg) ? window.__GNM.reg[k] : null;
660
+ }
661
+ function getV(k) { const r = reg(k); return r ? parseFloat(r.get()) : 0; }
662
+ function setV(k, v) { const r = reg(k); if (r) r.set(v); }
663
+
664
+ // ---- Always-on region colour map + hover highlight ----------------------
665
+ // Every mapped zone is permanently tinted toward its HL colour at FAINT
666
+ // strength (the "control map"); the hovered zone is boosted to BRIGHT.
667
+ const FAINT = 0.32, BRIGHT = 0.78;
668
+ let hlZone = -1;
669
+
670
+ // Tint a zone's vertices toward its map colour by `strength` (0 = base).
671
+ function tintZone(z, strength) {
672
+ const vs = zoneVerts[z], c = HL[z]; if (!vs || !c) return;
673
+ const a = colorAttr.array, s = strength, b = 1 - strength;
674
+ for (let n = 0; n < vs.length; n++) {
675
+ const i = vs[n] * 3;
676
+ a[i] = b * BASE_COLORS[i] + s * c[0];
677
+ a[i+1] = b * BASE_COLORS[i+1] + s * c[1];
678
+ a[i+2] = b * BASE_COLORS[i+2] + s * c[2];
679
+ }
680
+ colorAttr.needsUpdate = true;
681
+ }
682
+
683
+ // Paint the whole control map at FAINT strength (call once the colour
684
+ // attribute exists).
685
+ function initColorMap() {
686
+ for (const zk in HL) tintZone(Number(zk), FAINT);
687
+ }
688
+
689
+ function setHighlight(z) {
690
+ if (z === hlZone) return;
691
+ if (hlZone >= 0 && HL[hlZone]) tintZone(hlZone, FAINT); // back to map
692
+ hlZone = (z >= 0 && HL[z]) ? z : -1;
693
+ if (hlZone >= 0) tintZone(hlZone, BRIGHT);
694
+ }
695
+
696
+ // ---- Slider glow: light up the control(s) a hovered region drives -------
697
+ const TAB_OF = { id: 0, ex: 1, pose: 2, tr: 3 };
698
+ let glowing = [];
699
+ function clearGlow() {
700
+ for (const el of glowing) el.classList.remove("gnm-ctl-glow");
701
+ glowing = [];
702
+ }
703
+ function glowZone(z) {
704
+ clearGlow();
705
+ const sp = zoneSpec(z);
706
+ const tabBtns = document.querySelectorAll('button[role="tab"]');
707
+ const seenTabs = {};
708
+ for (const t of (sp.t || [])) {
709
+ const r = reg(t.key);
710
+ if (r && r.el) { r.el.classList.add("gnm-ctl-glow"); glowing.push(r.el); }
711
+ const ti = TAB_OF[String(t.key).split("_")[0]];
712
+ if (ti != null && tabBtns[ti] && !seenTabs[ti]) {
713
+ seenTabs[ti] = 1;
714
+ tabBtns[ti].classList.add("gnm-ctl-glow"); glowing.push(tabBtns[ti]);
715
+ }
716
+ }
717
  }
718
 
719
  function build() {
720
  const wrap = element.querySelector("#gnm-wrap");
721
  const canvas = element.querySelector("#gnm-canvas");
722
+ const hint = element.querySelector("#gnm-hint");
723
+ clearBtn = element.querySelector("#gnm-clear-sculpt");
724
+ if (clearBtn) {
725
+ clearBtn.addEventListener("click", () => {
726
+ if (sculptOffsets) sculptOffsets.fill(0);
727
+ applyBuffer();
728
+ updateClearBtn();
729
+ });
730
+ }
731
  if (!wrap || !canvas || !window.THREE) return false;
732
  const W = wrap.clientWidth || 600, H = wrap.clientHeight || 560;
733
 
734
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
735
  renderer.setPixelRatio(window.devicePixelRatio || 1);
736
  renderer.setSize(W, H, false);
 
737
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
738
  renderer.toneMappingExposure = 0.85;
739
 
 
743
  camera = new THREE.PerspectiveCamera(35, W / H, 0.01, 100);
744
  applyCamera();
745
 
 
746
  scene.add(new THREE.AmbientLight(0xffffff, 0.40));
747
+ const keyL = new THREE.DirectionalLight(0xffffff, 0.55);
748
+ keyL.position.set(0.5, 1.0, 1.5); scene.add(keyL);
749
  const fill = new THREE.DirectionalLight(0xffffff, 0.20);
750
  fill.position.set(-0.8, 0.2, 0.5); scene.add(fill);
751
 
752
  geometry = new THREE.BufferGeometry();
753
  geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(FACES), 1));
754
+ colorAttr = new THREE.BufferAttribute(new Float32Array(COLORS), 3);
755
+ geometry.setAttribute("color", colorAttr);
756
+ initColorMap(); // paint the always-on region map
757
  const mat = new THREE.MeshStandardMaterial({
758
  vertexColors: true, roughness: 0.75, metalness: 0.0,
759
  side: THREE.DoubleSide,
 
761
  mesh = new THREE.Mesh(geometry, mat);
762
  scene.add(mesh);
763
 
764
+ // ---- Raycasting: screen point -> mesh hit -> interaction zone. --------
765
+ const raycaster = new THREE.Raycaster();
766
+ const ndc = new THREE.Vector2();
767
+ function pick(clientX, clientY) {
768
+ const rect = canvas.getBoundingClientRect();
769
+ ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
770
+ ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
771
+ raycaster.setFromCamera(ndc, camera);
772
+ const hits = raycaster.intersectObject(mesh, false);
773
+ if (!hits.length) return null;
774
+ const f = hits[0].face;
775
+ // Prefer a feature zone if any of the face's 3 verts carries one.
776
+ let z = ZONES[f.a];
777
+ if (z === Z_HEAD && ZONES[f.b] !== Z_HEAD) z = ZONES[f.b];
778
+ if (z === Z_HEAD && ZONES[f.c] !== Z_HEAD) z = ZONES[f.c];
779
+ // Nearest of the face's 3 vertices to the hit point (for sculpting).
780
+ const P = hits[0].point, pos = geometry.getAttribute("position");
781
+ let vi = f.a, best = Infinity;
782
+ for (const vv of [f.a, f.b, f.c]) {
783
+ const dx = pos.getX(vv) - P.x, dy = pos.getY(vv) - P.y,
784
+ dz = pos.getZ(vv) - P.z, dd = dx * dx + dy * dy + dz * dz;
785
+ if (dd < best) { best = dd; vi = vv; }
786
+ }
787
+ return { zone: z, point: P, vi: vi };
788
+ }
789
+
790
+ function showHint(text) {
791
+ if (!hint) return;
792
+ hint.textContent = text || "";
793
+ hint.style.opacity = text ? "1" : "0";
794
+ }
795
+
796
+ // ---- Interaction state machine ----------------------------------------
797
+ // mode: null | 'orbit' | 'manip' | 'trans' | 'sculpt'
798
+ let mode = null, spec = null, startX = 0, startY = 0, startVals = null;
799
+ let orbitLastX = 0, orbitLastY = 0, pendingApply = null;
800
+ let brush = null; // { list:[{i,w}], base: Float32Array } for a sculpt drag
801
+
802
+ // Meters of world space per screen pixel at the head, for the current
803
+ // camera — turns a sculpt drag (pixels) into a world displacement so the
804
+ // grabbed point tracks the cursor.
805
+ function metersPerPixel() {
806
+ const h = canvas.clientHeight || 560;
807
+ const fov = (camera.fov || 35) * Math.PI / 180;
808
+ return (2 * camState.radius * Math.tan(fov / 2)) / h;
809
+ }
810
+
811
+ // Sculpt brush: free-form pull of the surface under the cursor. All
812
+ // vertices within SCULPT_R of the grabbed point move with the drag,
813
+ // weighted by a smooth falloff, so pulling a cheek stretches the face and
814
+ // pulling an ear pushes it in/out. Offsets persist across mesh morphs.
815
+ const SCULPT_R = 0.05; // brush radius (meters)
816
+
817
+ function beginSculpt(hit, e) {
818
+ const pos = geometry.getAttribute("position");
819
+ const gx = pos.getX(hit.vi), gy = pos.getY(hit.vi), gz = pos.getZ(hit.vi);
820
+ const list = [];
821
+ for (let v = 0; v < ZONES.length; v++) {
822
+ const dx = pos.getX(v) - gx, dy = pos.getY(v) - gy, dz = pos.getZ(v) - gz;
823
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
824
+ if (dist < SCULPT_R) {
825
+ const t = 1 - dist / SCULPT_R;
826
+ list.push({ i: v, w: t * t * (3 - 2 * t) }); // smoothstep falloff
827
+ }
828
+ }
829
+ // Snapshot the current offsets so the drag is absolute (drag back undoes).
830
+ const base = new Float32Array(list.length * 3);
831
+ for (let n = 0; n < list.length; n++) {
832
+ const j = list[n].i * 3;
833
+ base[n * 3] = sculptOffsets[j];
834
+ base[n * 3 + 1] = sculptOffsets[j + 1];
835
+ base[n * 3 + 2] = sculptOffsets[j + 2];
836
+ }
837
+ brush = { list, base };
838
+ startX = e.clientX; startY = e.clientY;
839
+ mode = 'sculpt';
840
+ // Keep the zone highlight lit while sculpting a feature (ear/cheek); use
841
+ // its label if it has one, else a generic sculpt hint.
842
+ const sp = zoneSpec(hit.zone);
843
+ if (SCULPT_ZONES[hit.zone]) setHighlight(hit.zone); else setHighlight(-1);
844
+ showHint(sp.label || "🫳 Sculpt · drag to stretch / pull the surface");
845
+ }
846
+
847
+ function beginManip(z, hit, e) {
848
+ const base = zoneSpec(z);
849
+ // Resolve any `outwardX` target to a concrete sign from the grab point:
850
+ // grabbing the +x side and dragging +x (or -x side dragging -x) both
851
+ // count as pulling "outward". World +x maps to screen-right in the
852
+ // front view, so screen dx and world x share a direction here.
853
+ const gx = (hit && hit.point) ? hit.point.x : 0;
854
+ spec = {
855
+ label: base.label,
856
+ t: base.t.map((t) => {
857
+ if (t.outwardX) return { ...t, sign: (gx >= 0 ? -1 : 1) }; // outward -> negative
858
+ if (t.outwardXPos) return { ...t, sign: (gx >= 0 ? 1 : -1) }; // outward -> positive
859
+ return t;
860
+ }),
861
+ };
862
+ startX = e.clientX; startY = e.clientY; startVals = {};
863
+ for (const t of spec.t) startVals[t.key] = getV(t.key);
864
+ mode = 'manip';
865
+ setHighlight(z);
866
+ glowZone(z);
867
+ showHint(spec.label);
868
+ }
869
+
870
+ canvas.addEventListener("contextmenu", (e) => e.preventDefault());
871
+
872
+ canvas.addEventListener("pointerdown", (e) => {
873
+ try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
874
+ const rightBtn = (e.button === 2);
875
+ if (e.shiftKey && !rightBtn) { // Shift+drag = translate
876
+ mode = 'trans';
877
+ spec = { t: [ {key: CFG.txKey, axis:'x', gain: TRANS_G, sign: +1},
878
+ {key: CFG.tyKey, axis:'y', gain: TRANS_G, sign: +1} ] };
879
+ startX = e.clientX; startY = e.clientY; startVals = {};
880
+ for (const t of spec.t) startVals[t.key] = getV(t.key);
881
+ showHint("✚ Move head · drag to reposition");
882
+ e.preventDefault(); return;
883
+ }
884
+ const hit = rightBtn ? null : pick(e.clientX, e.clientY);
885
+ if (hit && (e.altKey || e.metaKey || SCULPT_ZONES[hit.zone])) {
886
+ beginSculpt(hit, e); // ear/cheek, or Alt+drag
887
+ } else if (hit) { // grab the head itself
888
+ beginManip(hit.zone, hit, e);
889
+ } else { // empty space / RMB = orbit
890
+ mode = 'orbit'; orbitLastX = e.clientX; orbitLastY = e.clientY;
891
+ }
892
+ e.preventDefault();
893
  });
894
+
895
+ window.addEventListener("pointermove", (e) => {
896
+ if (mode === 'orbit') {
897
+ const dx = e.clientX - orbitLastX, dy = e.clientY - orbitLastY;
898
+ orbitLastX = e.clientX; orbitLastY = e.clientY;
899
+ camState.theta -= dx * 0.01;
900
+ camState.phi = Math.max(0.05, Math.min(Math.PI - 0.05,
901
+ camState.phi - dy * 0.01));
902
+ applyCamera();
903
+ return;
904
+ }
905
+ if (mode === 'manip' || mode === 'trans') {
906
+ const dx = e.clientX - startX, dy = e.clientY - startY;
907
+ // Coalesce to one apply per animation frame (caps server round-trips).
908
+ pendingApply = () => {
909
+ for (const t of spec.t) {
910
+ const raw = (t.axis === 'x') ? dx : -dy;
911
+ setV(t.key, startVals[t.key] + raw * t.gain * t.sign);
912
+ }
913
+ };
914
+ e.preventDefault();
915
+ return;
916
+ }
917
+ if (mode === 'sculpt') {
918
+ const dx = e.clientX - startX, dy = e.clientY - startY;
919
+ pendingApply = () => {
920
+ if (!brush || !sculptOffsets) return;
921
+ // Screen drag -> world pull in the camera plane (right = +x screen,
922
+ // up = -y screen), so the grabbed point follows the cursor.
923
+ camera.updateMatrixWorld();
924
+ const m = camera.matrixWorld.elements;
925
+ const k = metersPerPixel() * SCULPT_G;
926
+ const wx = (m[0] * dx - m[4] * dy) * k;
927
+ const wy = (m[1] * dx - m[5] * dy) * k;
928
+ const wz = (m[2] * dx - m[6] * dy) * k;
929
+ const list = brush.list, base = brush.base;
930
+ for (let n = 0; n < list.length; n++) {
931
+ const j = list[n].i * 3, w = list[n].w;
932
+ sculptOffsets[j] = base[n * 3] + w * wx;
933
+ sculptOffsets[j + 1] = base[n * 3 + 1] + w * wy;
934
+ sculptOffsets[j + 2] = base[n * 3 + 2] + w * wz;
935
+ }
936
+ applyBuffer();
937
+ updateClearBtn();
938
+ };
939
+ e.preventDefault();
940
+ return;
941
+ }
942
+ // Hovering (no button held): brighten the zone under the cursor and
943
+ // glow the control(s) it drives.
944
+ if (e.buttons === 0) {
945
+ const hit = pick(e.clientX, e.clientY);
946
+ if (hit) {
947
+ showHint(zoneSpec(hit.zone).label);
948
+ setHighlight(hit.zone);
949
+ glowZone(hit.zone);
950
+ canvas.style.cursor = "grab";
951
+ } else {
952
+ showHint(""); setHighlight(-1); clearGlow();
953
+ canvas.style.cursor = "default";
954
+ }
955
+ }
956
  });
957
+
958
+ function endDrag() {
959
+ if (pendingApply) { pendingApply(); pendingApply = null; }
960
+ mode = null; spec = null; startVals = null; brush = null;
961
+ setHighlight(-1); showHint(""); clearGlow();
962
+ }
963
+ window.addEventListener("pointerup", endDrag);
964
+ window.addEventListener("pointercancel", endDrag);
965
+
966
  canvas.addEventListener("wheel", (e) => {
967
  e.preventDefault();
968
  camState.radius = Math.max(0.2, Math.min(3.0,
 
970
  applyCamera();
971
  }, { passive: false });
972
 
973
+ // Slider-driving drags round-trip to the server per update; firing every
974
+ // frame backs up the queue and the mesh lags behind the cursor. Throttle
975
+ // them to send only the *latest* value at a sustainable rate (no backlog =
976
+ // real-time feel). Client-side sculpt has no round-trip, so it stays at
977
+ // full framerate.
978
+ let lastSlideT = 0;
979
+ const SLIDE_MS = 40;
980
  function animate() {
981
  requestAnimationFrame(animate);
982
+ if (pendingApply) {
983
+ const throttled = (mode === 'manip' || mode === 'trans');
984
+ if (!throttled || (performance.now() - lastSlideT) >= SLIDE_MS) {
985
+ pendingApply(); pendingApply = null;
986
+ if (throttled) lastSlideT = performance.now();
987
+ }
988
+ }
989
  renderer.render(scene, camera);
990
  }
991
  animate();
 
1007
  if (flat && flat.length) setPositions(flat);
1008
  }
1009
 
 
 
1010
  (function ready() {
1011
  if (!window.THREE || !build()) { setTimeout(ready, 40); return; }
1012
  updateFromValue();
 
1019
  VIEWER_JS_ON_LOAD
1020
  .replace("__FACES__", _FACES_JSON)
1021
  .replace("__COLORS__", _COLORS_JSON)
1022
+ .replace("__ZONES__", ZONES_JSON)
1023
+ .replace("__CFG__", VIEWER_CFG_JSON)
1024
+ .replace("__NUM_IDENTITY__", str(NUM_IDENTITY))
1025
  )
1026
 
1027
 
1028
+ # Hidden "sculpt bus": a zero-size custom component whose value carries the
1029
+ # free-pull payload. The viewer writes to it via window.__GNM.sculptSet during
1030
+ # an Alt-drag, which fires its .change() -> sculpt_pull -> identity sliders.
1031
+ SCULPT_BUS_JS = """
1032
+ (function () {
1033
+ window.__GNM = window.__GNM || {};
1034
+ window.__GNM.sculptSet = function (v) { props.value = v; };
1035
+
1036
+ // Gradio only mounts the *active* tab's contents, so sliders in the other
1037
+ // tabs never register on the __GNM bus and the 3D head can't drive them.
1038
+ // Mount every tab once (click through them), then return to the first — the
1039
+ // rendered panels stay in the DOM (hidden), so all sliders stay registered.
1040
+ (function mountTabs(tries) {
1041
+ const tabs = Array.from(document.querySelectorAll('button[role="tab"]'));
1042
+ if (tabs.length < 2) {
1043
+ if ((tries || 0) < 60) setTimeout(function () { mountTabs((tries || 0) + 1); }, 100);
1044
+ return;
1045
+ }
1046
+ let i = 0;
1047
+ (function step() {
1048
+ if (i < tabs.length) { tabs[i].click(); i++; setTimeout(step, 120); }
1049
+ else { tabs[0].click(); }
1050
+ })();
1051
+ })();
1052
+ })();
1053
+ """
1054
+
1055
+
1056
  # ---------------------------------------------------------------------------
1057
  # UI
1058
  # ---------------------------------------------------------------------------
 
1060
  #col-container { max-width: 1200px; margin: 0 auto; }
1061
  .dark .gradio-container { color: var(--body-text-color); }
1062
 
1063
+ /* Glow applied (from the 3D viewer) to the slider(s) / tab a hovered head
1064
+ region drives, so the region <-> control correspondence is visible. */
1065
+ .gnm-ctl-glow {
1066
+ outline: 2px solid var(--color-accent, #ff7c00) !important;
1067
+ outline-offset: 1px;
1068
+ border-radius: 6px;
1069
+ box-shadow: 0 0 10px 1px var(--color-accent, #ff7c00);
1070
+ transition: box-shadow .1s, outline-color .1s;
1071
+ }
1072
+
1073
+ /* All slider groups are always expanded (they must stay mounted so the 3D
1074
+ viewer can drive every one of them). To keep the page short and the head
1075
+ always visible beside them, the controls column scrolls *internally* and
1076
+ the viewer stays pinned next to it. */
1077
+ #gnm-main-row { align-items: flex-start !important; }
1078
+ #gnm-controls-col {
1079
+ max-height: calc(100vh - 24px);
1080
+ overflow-y: auto;
1081
+ padding-right: 6px;
1082
+ }
1083
+ #gnm-viewer-col {
1084
+ position: sticky;
1085
+ top: 8px;
1086
+ }
1087
+
1088
  /* Compact grouped sections. Each feature/parameter group (Head Shape, Left
1089
  Eye, Mouth, Neck, Gaze, Translation, ...) is rendered as its own bordered
1090
  section with a header row (group label + per-group Reset) and its sliders
 
1275
  """
1276
 
1277
  INTRO = """
1278
+ # 🧬 GNM — Direct-Manipulation Head
1279
 
1280
+ **GNM Head** is a state-of-the-art parametric 3D statistical model of the human head from [google/GNM](https://github.com/google/GNM), with disentangled control over **identity**, **expression**, **pose** and **translation**. Move the sliders, or **grab the 3D head directly** — hover any part to see what it does. Based on the [notebook demo](https://github.com/google/GNM/blob/main/gnm/shape/README.md#demo).
1281
  """
1282
 
1283
 
 
1313
  VSLIDER_JS_ON_LOAD = """
1314
  (function () {
1315
  const MIN = __MIN__, MAX = __MAX__, STEP = __STEP__;
1316
+ const KEY = "__KEY__";
1317
  const root = element.querySelector(".vslider-root");
1318
  const track = element.querySelector(".vslider-track");
1319
  const fill = element.querySelector(".vslider-fill");
 
1400
  if (!track.clientHeight) { setTimeout(ready, 30); return; }
1401
  paint(currentValue());
1402
  })();
1403
+
1404
+ // Repaint when the track gains size — e.g. when this slider's tab becomes
1405
+ // visible after having been driven while hidden (its handle would otherwise
1406
+ // be stuck at the wrong position). Guarded so a zero-size (hidden) pass is
1407
+ // ignored.
1408
+ if (window.ResizeObserver) {
1409
+ new ResizeObserver(() => {
1410
+ if (track.clientHeight) paint(currentValue());
1411
+ }).observe(track);
1412
+ }
1413
+
1414
+ // Expose this slider on a global registry so the 3D viewer can drive it
1415
+ // when the user drags directly on the head. `set(v)` snaps/paints the value
1416
+ // and writes props.value, which fires the same .change() a manual drag does
1417
+ // (so the mesh recomputes through the exact same reactive path).
1418
+ if (KEY) {
1419
+ window.__GNM = window.__GNM || {};
1420
+ window.__GNM.reg = window.__GNM.reg || {};
1421
+ window.__GNM.reg[KEY] = {
1422
+ set(v) {
1423
+ const nv = clampSnap(v);
1424
+ paint(nv);
1425
+ if (nv !== parseFloat(props.value)) props.value = nv;
1426
+ return nv;
1427
+ },
1428
+ get() { return currentValue(); },
1429
+ min: MIN, max: MAX, step: STEP,
1430
+ el: root, // so the viewer can glow this slider when its region is hovered
1431
+ };
1432
+ }
1433
  })();
1434
  """
1435
 
 
1439
  return repr(float(x))
1440
 
1441
 
1442
+ def _vslider(label, minimum, maximum, value=0.0, step=None, key=None):
1443
  """Create a custom vertical slider (drop-in replacement for gr.Slider).
1444
 
1445
  Returns a gr.HTML component whose value is the numeric slider value. The
1446
  per-instance label / min / max / step are baked into its template and JS.
1447
+ If `key` is given, the slider registers itself under that key in the
1448
+ global `window.__GNM.reg` so the 3D viewer can drive it via direct
1449
+ manipulation on the head.
1450
  """
1451
  step_val = step if step is not None else 0.0
1452
  tmpl = (
 
1460
  .replace("__MIN__", _js_num(minimum))
1461
  .replace("__MAX__", _js_num(maximum))
1462
  .replace("__STEP__", _js_num(step_val))
1463
+ .replace("__KEY__", str(key) if key is not None else "")
1464
  )
1465
  return gr.HTML(
1466
  value=float(value),
 
1512
  with gr.Column(elem_id="col-container"):
1513
  gr.Markdown(INTRO)
1514
 
1515
+ with gr.Row(elem_id="gnm-main-row"):
1516
  # --- Controls -------------------------------------------------
1517
+ with gr.Column(scale=3, elem_id="gnm-controls-col"):
1518
  identity_sliders = []
1519
  expression_sliders = []
1520
  pose_sliders = []
 
1534
  _slider(
1535
  f"Head shape {k + 1}",
1536
  -3.0, 3.0, 0.0, 0.05,
1537
+ key=f"id_{k}",
1538
  )
1539
  )
1540
  return sliders
 
1598
  )
1599
 
1600
  def _make_expr_builder(group_name, group_idx):
1601
+ start = _EXPR_SLOT_START[group_name]
1602
+
1603
  def _build_expr():
1604
  sliders = []
1605
  for j in range(len(group_idx)):
 
1611
  else f"{group_name} {j + 1}"
1612
  )
1613
  sliders.append(
1614
+ _slider(
1615
+ label, -3.0, 3.0, 0.0, 0.05,
1616
+ key=f"ex_{start + j}",
1617
+ )
1618
  )
1619
  return sliders
1620
  return _build_expr
 
1670
  "vergence control)."
1671
  )
1672
 
1673
+ def _make_pose_builder(limits, start):
1674
  def _build_pose():
1675
  sliders = []
1676
+ for i, (name, limit) in enumerate(
1677
+ limits.items()
1678
+ ):
1679
  sliders.append(
1680
+ _slider(
1681
+ name, -limit, limit, 0, 1,
1682
+ key=f"pose_{start + i}",
1683
+ )
1684
  )
1685
  return sliders
1686
  return _build_pose
 
1695
  with gr.Column(min_width=0):
1696
  pose_sliders += _group_section(
1697
  joint.capitalize(),
1698
+ _make_pose_builder(
1699
+ limits, _POSE_SLOT_START[joint]
1700
+ ),
1701
  )
1702
 
1703
  for joint, limits in POSE_LIMITS.items():
 
1705
  continue
1706
  pose_sliders += _group_section(
1707
  joint.capitalize(),
1708
+ _make_pose_builder(
1709
+ limits, _POSE_SLOT_START[joint]
1710
+ ),
1711
  )
1712
 
1713
  with gr.Tab("Translation"):
 
1715
 
1716
  def _build_translation():
1717
  sliders = []
1718
+ for i, d in enumerate([
1719
  "X (left/right)",
1720
  "Y (up/down)",
1721
  "Z (forward/back)",
1722
+ ]):
1723
  sliders.append(
1724
+ _slider(
1725
+ d, -0.2, 0.2, 0.0, 0.01,
1726
+ key=f"tr_{i}",
1727
+ )
1728
  )
1729
  return sliders
1730
 
 
1736
  # The custom Three.js viewer IS the output component. Its value is
1737
  # the flat vertex-position array; `watch('value')` in the viewer's
1738
  # js_on_load morphs the persistent mesh in place on every update.
1739
+ with gr.Column(scale=2, elem_id="gnm-viewer-col"):
1740
  viewer = gr.HTML(
1741
  value="",
1742
  html_template=VIEWER_TEMPLATE,
 
1745
  label="GNM head mesh",
1746
  elem_id="gnm-viewer",
1747
  )
1748
+ # Hidden channel for the Alt-drag sculpt payload.
1749
+ sculpt_bus = gr.HTML(
1750
+ value="",
1751
+ html_template='<div style="display:none"></div>',
1752
+ js_on_load=SCULPT_BUS_JS,
1753
+ container=False,
1754
+ elem_id="gnm-sculpt-bus",
1755
+ )
1756
 
1757
  all_sliders = (
1758
  identity_sliders
 
1772
  inputs=all_sliders,
1773
  outputs=viewer,
1774
  show_progress="hidden",
1775
+ queue=False, # run off the queue -> lower latency for live drags
1776
  )
1777
 
1778
+ # Sculpt (Alt-drag): the viewer writes a free-pull payload into the
1779
+ # hidden sculpt bus; here we solve the identity sliders that move the
1780
+ # grabbed vertex toward the drag, set them (which repaints the Head
1781
+ # Shape sliders), then re-render the mesh.
1782
+ sculpt_bus.change(
1783
+ fn=sculpt_pull,
1784
+ inputs=[sculpt_bus] + all_sliders,
1785
+ outputs=identity_sliders,
1786
+ show_progress="hidden",
1787
+ ).then(
1788
+ fn=positions_json, inputs=all_sliders, outputs=viewer,
1789
+ show_progress="hidden",
1790
+ )
1791
+
1792
  # Per-group Reset: each group has its own ↺ Reset button that resets
1793
  # ONLY that group's sliders back to their defaults, then recomputes the
1794
  # mesh from the full (partially-reset) slider vector. There is no