Spaces:
Running
Running
Add direct-manipulation UI: drag on the 3D head to drive the controls
#1
by linoyts HF Staff - opened
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 358 |
-
|
|
|
|
|
|
|
| 359 |
let attr = geometry.getAttribute("position");
|
| 360 |
-
if (!attr || attr.array.length !==
|
| 361 |
-
attr = new THREE.BufferAttribute(new Float32Array(
|
| 362 |
geometry.setAttribute("position", attr);
|
| 363 |
}
|
| 364 |
-
attr.array
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 392 |
-
|
| 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 |
-
|
| 399 |
-
|
| 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 |
-
// ---
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
});
|
| 413 |
-
|
| 414 |
-
window.addEventListener("
|
| 415 |
-
if (
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 —
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 1025 |
sliders.append(
|
| 1026 |
-
_slider(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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
|