feat:加上手动调节与表情回放

This commit is contained in:
Edom
2026-08-20 00:17:43 +08:00
parent 851f2b8c6c
commit e880350f62
31 changed files with 3577 additions and 104 deletions
+3
View File
@@ -14,6 +14,9 @@ replica/grok-blob-idle.svg
/tmp/
snap-out/
# Upstream reference checkout for editor/geometry comparison
bible-strong-avatar-lab/
# Never commit unpacked app dumps, unpack scripts, or third-party marks
extracted/
extract-asar.js
+4
View File
@@ -42,6 +42,10 @@
setPaused: (v) => bot.setPaused(v),
setMode: (m) => bot.setMode(m),
setFollowPointer: (v) => bot.setFollowPointer(v),
setPose: (v) => bot.setPose(v),
setManualOffset: (v) => bot.setManualOffset(v),
setEyeTune: (v) => bot.setEyeTune(v),
setManualHold: (v) => bot.setManualHold(v),
setEmphasis: (v) => bot.setEmphasis(v),
spin: (n) => bot.spinOnce(n),
bounce: () => bot.bounceOnce(),
+316 -45
View File
@@ -30,6 +30,8 @@
notifying: { x: 7, y: -3 }, playful: { x: 10, y: -4 }, happy: { x: -6, y: -4 },
laughing: { x: 4, y: -4 }, listening: { x: 5, y: -2 }, scared: { x: 12, y: 2 },
};
// Still-frame eyes. Shy playlist starts at idle 0; 24 is the look-down face.
const HOLD_EYE = { shy: 24 };
class GrokCharacter {
constructor(svg, opts = {}) {
@@ -46,13 +48,22 @@
this.eyeTopology = opts.eyeTopology ?? this.loginWrap;
this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null);
this.pose = { ...(this.loginWrap ? POSE : { turn: 0, tilt: 0, roll: 0, scale: 1 }), ...opts.pose };
this.poseRest = { turn: this.pose.turn, tilt: this.pose.tilt, roll: this.pose.roll };
this.poseHome = opts.poseHome || (this.loginWrap ? POSE_HOME : { turn: 0, tilt: 0, roll: 0 });
this.manualHold = false;
this.manualTx = 0;
this.manualTy = 0;
this.manualSpin = 0;
this.eyeTune = null;
this.uniformEyes = opts.uniformEyes ?? (this.loginWrap ? UNIFORM_EYES : false);
this.eyeScaleProp = opts.eyeScale ?? (this.loginWrap ? shapeEyeScale(this.shapeName) : 1);
this.emphasis = !!opts.emphasis;
this.followPointer = !!opts.followPointer;
this.gazeTarget = opts.gazeTarget || null;
this.frozen = opts.paused === "hold-pose";
this.holdAt = 900;
this.clockHold = 0;
this.frozenAt = 0;
this.driven = !!opts.driven;
this.paused = !!opts.paused;
this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
@@ -84,6 +95,10 @@
this.eyeTo = 0;
this.eyeStiffness = 7;
this.eyeIdx = 0;
this.eyeHoldMs = 0;
this.eyeRemain = 0;
this.playlistHold = false;
this.eyePinned = false;
this._fromPolys = null;
this.t0 = performance.now();
@@ -134,6 +149,19 @@
this.setColor(this.colorId, this.scheme);
this._applyPoseScale();
this.setState(this.state, { resetEyes: true });
this.eyePinned = Number.isFinite(opts.eyeIndex) && !!(this.geo.eyes && this.geo.eyes[opts.eyeIndex]);
if (this.eyePinned) {
const id = opts.eyeIndex;
this.eyeFrom = id;
this.eyeTo = id;
this.eyeMorph.x = 1;
this.eyeMorph.t = 1;
this.eyeMorph.v = 0;
this._fromPolys = null;
const list = this.playlists[this.state] || [];
const slot = list.indexOf(id);
this.eyeIdx = slot >= 0 ? slot : 0;
}
if (!this.driven) this._bindPointer();
if (this.frozen) {
this.holdFrame();
@@ -188,6 +216,10 @@
this.holdFrame();
return;
}
if (!v && this.frozen && this.frozenAt) {
this.clockHold += performance.now() - this.frozenAt;
}
this.frozenAt = 0;
this.frozen = false;
this.paused = !!v;
if (!this.paused) this._armTick();
@@ -195,12 +227,16 @@
_armTick() {
if (this.frozen || this.driven || this._raf) return;
this.last = performance.now();
this._raf = requestAnimationFrame((t) => this._tick(t));
this.last = this._clock();
this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold));
}
_wake() {
if (this.frozen || this.driven) return;
if (this.driven) return;
if (this.frozen) {
this._paint(this.last || this.t0);
return;
}
if (this.paused) this._armTick();
}
@@ -220,6 +256,111 @@
}
}
setPose(next) {
if (!next) return;
if (next.turn != null) this.pose.turn = next.turn;
if (next.tilt != null) this.pose.tilt = next.tilt;
if (next.roll != null) this.pose.roll = next.roll;
this._wake();
}
setManualOffset(next) {
if (!next) return;
if (next.tx != null) this.manualTx = next.tx;
if (next.ty != null) this.manualTy = next.ty;
if (next.spin != null) this.manualSpin = next.spin;
this._wake();
}
setEyeTune(tune) {
this.eyeTune = tune || null;
this._wake();
}
_clearFx() {
this.trick = null;
this.spinTurn = null;
this.gazeOrbit = null;
this.gazeSpin = 0;
this.hopAt = -1;
this.wildWide = false;
this.ovKind = null;
this.ovPrev = null;
this.ovTarget = null;
this.ovOn = false;
this.ovRest = false;
this.ovSpin = 0;
snapSpring(this.overlay, 0);
snapSpring(this.overlayMix, 1);
snapSpring(this.notify, 0);
snapSpring(this.humDots, 0);
this.particles?.clear();
this.fx?.resetInk();
this.fx?.resetRecv();
this.fx?.hideAll();
if (this.group) this.group.style.opacity = "1";
}
_snapBodyRest() {
snapSpring(this.spin, 0);
snapSpring(this.tx, 0);
snapSpring(this.ty, 0);
snapSpring(this.squash, 1);
snapSpring(this.eyeScale, 1);
this.extras = { ...this.extras, turn: null, Kr: 0, yi: 0, ki: 0, Yr: 0, Zr: 0, wi: 0, hop: 0 };
}
flushPerformance() {
this._clearFx();
this._snapBodyRest();
this._wake();
}
setManualHold(on) {
const next = !!on;
if (next === this.manualHold) {
this._wake();
return;
}
this.manualHold = next;
if (next) {
this.trickAt = Infinity;
this.gazeUntil = Infinity;
snapSpring(this.gazeX, 0);
snapSpring(this.gazeY, 0);
this.pointer.x = 0;
this.pointer.y = 0;
this.pointer.tx = 0;
this.pointer.ty = 0;
this.ctx.wantPn = null;
this.ctx.wantBurst = null;
this.ctx.tyKick = 0;
this.ctx.spinKick = 0;
this.ctx.forceSleepEye = false;
this.ctx.wakeEye = null;
this.setFollowPointer(false);
this._clearFx();
this._snapBodyRest();
} else {
this.manualTx = 0;
this.manualTy = 0;
this.manualSpin = 0;
this.eyeTune = null;
this.ctx = this._freshCtx(this._clock());
this.pose.turn = this.poseRest.turn;
this.pose.tilt = this.poseRest.tilt;
this.pose.roll = this.poseRest.roll;
this.gazeUntil = this._clock();
this.blinkUntil = this._clock() + rand(1500, 7000);
this.trickAt = this._clock() + rand(4000, 8000);
}
this._wake();
}
parts() {
return { svg: this.svg, body: this.body, eyes: this.eyeEls, group: this.group };
}
setGazeTarget(pt) {
this.gazeTarget = pt;
}
@@ -277,7 +418,47 @@
}
_clock() {
return this.driven ? this.last : performance.now();
if (this.driven) return this.last;
return performance.now() - this.clockHold;
}
// settle snaps t = x (stop); pause keeps the current targets.
freezeNow({ settle = false } = {}) {
const already = this.frozen;
this.frozen = true;
this.paused = true;
if (!already) this.frozenAt = performance.now();
this.last = this._clock();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = 0;
}
for (const s of [
this.spin,
this.tx,
this.ty,
this.squash,
this.blink,
this.eyeScale,
this.gazeX,
this.gazeY,
this.eyeMorph,
this.overlay,
this.overlayMix,
this.notify,
this.humDots,
this.shapeSpring,
this.overlayTurn,
]) {
if (settle) s.t = s.x;
s.v = 0;
}
this.ctx.wantPn = null;
this.ctx.wantBurst = null;
this.ctx.tyKick = 0;
this.ctx.spinKick = 0;
this._paint(this.last);
return this.snapshot();
}
setState(name, { resetEyes = false } = {}) {
@@ -302,7 +483,11 @@
} else if (name !== "sleeping" && name !== "waking") {
this._morphEyes(list[0], name === "excited" ? 10 : 8);
}
this.eyeUntil = this.stateAt + rand(...EYE_HOLD_MS[name]);
const hold = rand(...EYE_HOLD_MS[name]);
this.eyeHoldMs = hold;
this.eyeRemain = hold;
this.eyeUntil = this.stateAt + hold;
this.playlistHold = false;
const blink = BLINK_MS[name];
this.blinkUntil = blink ? this.stateAt + rand(1500, 7000) : Infinity;
this.gazeUntil = this.stateAt + rand(500, 1400);
@@ -351,20 +536,69 @@
};
}
seekEye(index, { snap = false } = {}) {
const list = this.playlists[this.state];
if (!list?.length) return;
const i = ((index % list.length) + list.length) % list.length;
this.eyeIdx = i;
if (snap) {
this.eyeFrom = list[i];
this.eyeTo = list[i];
this.eyeMorph.x = 1;
this.eyeMorph.t = 1;
this.eyeMorph.v = 0;
this._fromPolys = null;
} else {
this._morphEyes(list[i], this.state === "searching" || this.state === "excited" ? 10 : 6);
}
const hold = rand(...(EYE_HOLD_MS[this.state] || [2000, 3600]));
this.eyeHoldMs = hold;
this.eyeRemain = hold;
this.eyeUntil = this._clock() + hold;
this._wake();
}
setPlaylistHold(on) {
const next = !!on;
if (next === this.playlistHold) return;
if (next) this.eyeRemain = Math.max(0, this.eyeUntil - this._clock());
else {
this.eyeUntil = this._clock() + (this.eyeRemain || 0);
this._wake();
}
this.playlistHold = next;
}
playback() {
const remain = this.playlistHold
? (this.eyeRemain || 0)
: Math.max(0, this.eyeUntil - this._clock());
return {
state: this.state,
eyeIdx: this.eyeIdx || 0,
holdMs: this.eyeHoldMs || 0,
remainMs: remain,
held: !!this.playlistHold,
frozen: !!this.frozen,
};
}
spinOnce(turns = 1) {
if (this.manualHold) return;
this._wake();
this._pn(turns);
}
bounceOnce() {
if (this.manualHold) return;
this._wake();
this._hop(performance.now());
}
burstOnce() {
if (!this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3);
if (!this.manualHold && !this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3);
}
orbitGaze(ms = 1500) {
if (this.reduceMotion || this.frozen) return;
if (this.reduceMotion || this.frozen || this.manualHold) return;
this.gazeOrbit = { at: performance.now(), dur: Math.max(ms, 1), from: Math.PI * 2 };
this._armTick();
}
@@ -372,6 +606,7 @@
holdFrame(at = 900) {
this.frozen = true;
this.paused = true;
this.holdAt = at;
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = 0;
@@ -409,6 +644,11 @@
const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 };
snapSpring(this.gazeX, gz.x);
snapSpring(this.gazeY, gz.y);
const holdEye = this.eyePinned ? null : HOLD_EYE[this.state];
if (holdEye != null && this.geo.eyes[holdEye]) {
this.eyeFrom = holdEye;
this.eyeTo = holdEye;
}
this.eyeMorph.x = 1;
this.eyeMorph.v = 0;
this.eyeMorph.t = 1;
@@ -514,6 +754,19 @@
this.clipPath.setAttribute("d", geo.shapes[this.shapeName].path);
}
_poseRing(restRing, R, spinAmt, morphing) {
// Sphere limb is rotation-invariant. Only solids-of-revolution change
// silhouette with yaw (same path as the spin trick). Tilt-squash was
// flattening the ball into a coin.
const yaw = this.manualHold ? ((this.pose.turn - this.poseRest.turn) * Math.PI) / 180 : 0;
const spinning = spinAmt != null;
const yawAmt = (spinAmt ?? 0) + yaw;
if (morphing || Math.abs(yawAmt) < 1e-5) return { ring: restRing, turned: false };
const turnAt = FX.turnAtOf(this.shapeName, this.geo.shapes[this.shapeName].path, R, this.geo.solids);
if (!turnAt) return { ring: restRing, turned: false };
return { ring: turnAt(yawAmt), turned: true };
}
_morphEyes(index, stiffness = 7) {
if (index === this.eyeTo && this.eyeMorph.t === 1) return;
const t = clamp(this.eyeMorph.x, 0, 1);
@@ -544,7 +797,7 @@
}
_cycleShapeTrick() {
if (this.reduceMotion || this.paused || this.driven) return;
if (this.reduceMotion || this.paused || this.driven || this.manualHold) return;
this.trickCycle = (this.trickCycle + 1) % 5;
this.wildWide = false;
if (this.trickCycle === 0) this._pn(1);
@@ -654,16 +907,21 @@
prev: this.poseOut,
});
this.poseOut = pose;
this.spin.t = pose.spin;
this.tx.t = pose.tx;
this.ty.t = pose.ty;
this.squash.t = pose.squash;
this.eyeScale.t = pose.eyeBoost;
if (this.ctx.tyKick) {
this.spin.t = this.manualHold ? 0 : pose.spin;
this.tx.t = this.manualHold ? 0 : pose.tx;
this.ty.t = this.manualHold ? 0 : pose.ty;
this.squash.t = this.manualHold ? 1 : pose.squash;
this.eyeScale.t = this.manualHold ? 1 : pose.eyeBoost;
if (this.manualHold) {
this.blink.t = 1;
this.gazeX.t = 0;
this.gazeY.t = 0;
}
if (!this.manualHold && this.ctx.tyKick) {
this.ty.v += this.ctx.tyKick;
this.ctx.tyKick = 0;
}
if (this.ctx.spinKick) {
if (!this.manualHold && this.ctx.spinKick) {
this.spin.v += this.ctx.spinKick;
this.ctx.spinKick = 0;
}
@@ -684,24 +942,33 @@
EY.queueBlink(this.blinkQueue, now);
this.ctx.wantBlink = false;
}
if (this.ctx.wantPn) {
if (!this.manualHold && this.ctx.wantPn) {
this._pn(...this.ctx.wantPn);
this.ctx.wantPn = null;
}
if (this.ctx.wantBurst) {
if (!this.manualHold && this.ctx.wantBurst) {
this.particles.burst(this.ctx.wantBurst[0], this.ctx.wantBurst[1]);
this.ctx.wakingBurst = true;
this.ctx.wantBurst = null;
}
this._stepOverlay(now);
if (this.manualHold) {
this.ovKind = null;
this.ovPrev = null;
this.ovTarget = null;
this.overlay.t = 0;
this.notify.t = 0;
this.humDots.t = 0;
} else {
this._stepOverlay(now);
}
if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) {
if (!this.manualHold && this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) {
this.trick = TR.startTrick("spinWild", this.reduceMotion || this.paused);
this.celebrateAt = now + 6200;
}
if (!this.driven && now >= this.trickAt) {
if (!this.driven && !this.manualHold && now >= this.trickAt) {
if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
const z = Math.random();
if (V_T.has(this.state)) {
@@ -725,14 +992,17 @@
}
this.extras = { ...tf, turn: tf.turn, hop };
if (this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost;
if (!this.manualHold && this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost;
if (this.state !== "waking" && this.state !== "sleeping" && now >= this.eyeUntil) {
if (this.state !== "waking" && this.state !== "sleeping" && !this.playlistHold && now >= this.eyeUntil) {
const list = this.playlists[this.state];
this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length;
const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6;
this._morphEyes(list[this.eyeIdx], stiff);
this.eyeUntil = now + rand(...EYE_HOLD_MS[this.state]);
const hold = rand(...EYE_HOLD_MS[this.state]);
this.eyeHoldMs = hold;
this.eyeRemain = hold;
this.eyeUntil = now + hold;
}
const blinkCadence = BLINK_MS[this.state];
@@ -741,16 +1011,18 @@
this.blinkUntil = now + rand(...blinkCadence);
}
const blinkKey = EY.consumeBlink(this.blinkQueue, now);
this.blink.t = blinkKey ?? (this.blinkQueue.length ? this.blink.t : (this.extras.lidMul ?? pose.lid));
this.blink.t = this.paused && this.manualHold
? 1
: blinkKey ?? (this.blinkQueue.length ? this.blink.t : (this.extras.lidMul ?? pose.lid));
if (now >= this.gazeUntil) {
if (!this.manualHold && now >= this.gazeUntil) {
const gz = nextGaze(this.state);
this.gazeX.t = gz.x;
this.gazeY.t = gz.y;
this.gazeUntil = now + rand(...gz.hold);
}
if (WINK_STATES.has(this.state) && now >= this.winkUntil) {
if (!this.manualHold && WINK_STATES.has(this.state) && now >= this.winkUntil) {
this.winkAt = now;
this.winkEye = Math.random() < 0.5 ? 0 : 1;
this.winkUntil = now + rand(4500, 10000);
@@ -758,21 +1030,21 @@
this.emphasisBlend += ((this.emphasis ? 1 : 0) - this.emphasisBlend) * Rn(0.12);
if (this.emphasis) {
if (this.emphasis && !this.manualHold) {
this.eyeScale.t = Math.max(this.eyeScale.t, 1.32);
this.blink.t = Math.max(this.blink.t, 1.18);
}
const humming = this.state === "humming";
const loading = this.state === "loading";
if ((humming || loading) && !this.reduceMotion) {
if ((humming || loading) && !this.reduceMotion && !this.manualHold) {
const Zt = dtState;
const dn = loading ? 3 : 1.6;
const on = Zt < 0.5 ? 7 * K2(Zt / 0.5) : Zt < 1.3 ? 7 + (dn - 7) * K2((Zt - 0.5) / 0.8) : dn + 0.3 * Math.sin(Zt * 0.5);
this.ovSpin += on * dt;
}
if (this.reduceMotion) {
if (this.reduceMotion && !this.manualHold) {
this._morphEyes(this.playlists[this.state][0]);
this.spin.t = 0; this.tx.t = 0; this.ty.t = 0;
this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1;
@@ -822,8 +1094,10 @@
} else {
this.gazeSpin = 0;
}
this.notify.t = this.state === "notifying" ? 1 : 0;
this.humDots.t = this.state === "humming" ? 1 : 0;
if (!this.manualHold) {
this.notify.t = this.state === "notifying" ? 1 : 0;
this.humDots.t = this.state === "humming" ? 1 : 0;
}
let spinAngle = 0;
if (this.spinTurn) spinAngle = this.spinTurn.x;
@@ -840,8 +1114,8 @@
this.particles.update(now, dt, {
spinAngle,
sizeScale: this.partScale,
wideStyle: this.trick?.kind === "spinWild" || this.wildWide || humming,
sustainBelts: humming || loading,
wideStyle: !this.manualHold && (this.trick?.kind === "spinWild" || this.wildWide || humming),
sustainBelts: !this.manualHold && (humming || loading),
});
this._updatePointer(now);
@@ -887,7 +1161,7 @@
return;
}
this._raf = requestAnimationFrame((t) => this._tick(t));
this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold));
}
_paint(now) {
@@ -907,9 +1181,9 @@
const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix);
const bodyW = 1 - yl;
const ex = this.extras;
const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl;
const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl;
const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl;
const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl + this.manualTx;
const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl + this.manualTy;
const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl + this.manualSpin;
const sx = bodyW + ov.wre * yl;
const sy = this.squash.x * bodyW + ov.wre * yl;
this.group.setAttribute(
@@ -922,17 +1196,12 @@
const pencil = this.ovKind === "pencil" || this.ovPrev === "pencil";
const tear = geo.shapes.teardrop?.path;
const spinAmt = ex.turn;
const spinning = spinAmt != null;
const restRing = morphing
? FX.lerpRing(this.prevRing, FX.shapeRing(shape.path, R), morphK)
: FX.shapeRing(shape.path, R);
let liveRing = restRing;
let turned = false;
const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R, geo.solids) : null;
if (turnAt) {
liveRing = turnAt(spinAmt);
turned = true;
}
const posed = this._poseRing(restRing, R, spinAmt, morphing);
const liveRing = posed.ring;
const turned = posed.turned;
let faceTop = shape.top;
let faceBottom = shape.bottom;
if (morphing || turned) {
@@ -1007,6 +1276,8 @@
top: faceTop,
bottom: faceBottom,
emphasisBlend: this.emphasisBlend,
tune: this.eyeTune,
manualHold: this.manualHold,
});
const hum = clamp(this.humDots.x, 0, 1);
+24 -9
View File
@@ -36,7 +36,7 @@
const {
now, polys, morphT, shape, face, faceTune, uniformEyes, eyeScaleProp,
blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX,
overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint,
overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold,
} = opt;
const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI);
const $i = {
@@ -47,6 +47,8 @@
eye: face.eye * (faceTune?.size ?? 1),
leftDX: face.leftDX ?? 0,
};
const tune = opt.tune || null;
if (tune) $i.sx *= clamp(tune.spacing ?? 35, 0, 150) / 35;
const sX = uniformEyes ? $i.leftDX : 0;
const cents = [centroid(polys[0]), centroid(polys[1])];
let a1 = 0, o1 = 0;
@@ -57,8 +59,8 @@
const _ee = a1 + o1 > 0.5 ? clamp((l1 - pre) / (a1 + o1), 0.35, 4) : 4;
const Uee = (uniformEyes ? 1 : $i.eye) * clamp(eyeScaleProp, 0.25, 4);
const oX = Math.min(clamp(opt.eyeBoostX, 0.2, 2) * Uee, _ee / pulse);
const Hee = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse);
const u1 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3);
const Hee0 = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse);
const u10 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3);
const liveSpan = ringHint
? (y) => spanPoly(ringHint, y, Re)
: spanAt(shape.path, Re);
@@ -76,10 +78,20 @@
: [Re, shape.top];
for (let i = 0; i < 2; i++) {
if (tune && manualHold) continue;
const poly = polys[i];
const [Gn, Ti] = cents[i];
eyeEls[i].setAttribute("d", polyPath(poly));
const lid = winkLid(blinkX, now, winkAt, winkEye, i);
const sideTune = tune?.[i === 0 ? "left" : "right"];
const tuneW = sideTune ? clamp(sideTune.width ?? 20, 10, 100) / 20 : 1;
const tuneH = sideTune ? clamp(sideTune.height ?? 50, 10, 100) / 50 : 1;
const tuneS = sideTune ? clamp(sideTune.size ?? 1, 0.35, 2.2) : 1;
const tuneA = sideTune ? (sideTune.angle ?? 0) * Math.PI / 180 : 0;
const tuneX = sideTune ? (sideTune.x ?? 0) : 0;
const tuneY = sideTune ? (sideTune.y ?? 0) : 0;
const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse);
const u1 = u10 * tuneH * tuneS;
const Ea = Gn + (i === 0 ? sX : 0);
let Ca = Re + $i.x;
let Wo = (Ea - Re) * $i.sx;
@@ -135,8 +147,8 @@
Tre = Dke(clamp(Io0 / 0.5, 0, 1));
}
let Kj = Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5;
let Ko = Math.sin(now * 58e-5 + i) * 0.9;
let Kj = manualHold ? 0 : Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5;
let Ko = manualHold ? 0 : Math.sin(now * 58e-5 + i) * 0.9;
if (pointer) {
const Zl = Rn(0.16);
pointer.x += (pointer.tx - pointer.x) * Zl;
@@ -170,10 +182,10 @@
if (Ia2 - Frp > O2) O2 = Ia2 - Frp;
if (li2 - Frp < Xl) Xl = li2 - Frp;
}
const xre = Ca + Wo + Kj * $i.sx;
const xre = Ca + Wo + Kj * $i.sx + tuneX;
const lX = O2 <= Xl ? clamp(xre, O2, Xl) : (O2 + Xl) / 2;
let dd = lX + (xre - lX) * (1 - Tre);
let Yj = vl;
let Yj = vl + tuneY;
if (notifyX > 0.01) {
const xr = 20 * clamp(notifyX, 0, 1.4);
const Fr = dd - Yl[0], Ia = Yj - Yl[1];
@@ -188,18 +200,21 @@
}
}
const rot = tuneA
? ` rotate(${(tuneA * 180 / Math.PI).toFixed(2)})`
: "";
if (use3d) {
const FrM = clamp((turn != null ? _c : 1) * Hee * pulse, 0.02, 2.4);
const IaM = clamp(lid * u1 * pulse, 0.02, 2.4);
const liM = km * FrM, blM = Ree * FrM, IoM = Fee * IaM, uoM = zee * IaM;
eyeEls[i].setAttribute(
"transform",
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
);
} else {
eyeEls[i].setAttribute(
"transform",
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
`translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})`
);
}
}
+162 -16
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import AnimPanel from '@/components/AnimPanel.vue'
import Customizer from '@/components/Customizer.vue'
import PlaybackBar from '@/components/PlaybackBar.vue'
import CycleDialog from '@/components/CycleDialog.vue'
import ExportBar from '@/components/ExportBar.vue'
import GifDialog from '@/components/GifDialog.vue'
import Hero from '@/components/Hero.vue'
import AvatarStage from '@/components/AvatarStage.vue'
import Settings from '@/components/Settings.vue'
import SideRail, { type ViewId } from '@/components/SideRail.vue'
import Timeline from '@/components/Timeline.vue'
@@ -51,6 +52,7 @@ import {
type FondGif,
type FormatCycle
} from '@/ui/export'
import { parseManual, persistable, type ManualState } from '@/ui/pose/model'
import { ecris, lis } from '@/ui/stockage'
const view = ref<ViewId>('personnaliser')
@@ -65,7 +67,22 @@ const hero = ref<{
spin: () => void
orbitGaze: (ms?: number) => void
svg: () => SVGSVGElement | null
seekEye: (index: number, opts?: { snap?: boolean }) => void
flushPerformance: () => void
setPlaylistHold: (on: boolean) => void
setPaused: (on: boolean | 'hold-pose') => void
holdFrame: (at?: number) => void
freezeNow: (opts?: { settle?: boolean }) => unknown
playback: () => {
state: string
eyeIdx: number
holdMs: number
remainMs: number
held: boolean
frozen: boolean
} | null
} | null>(null)
const manual = ref<ManualState>(parseManual(lis('manuel')))
const restored = parseCycles(lis('cycles'))
const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()])
@@ -91,13 +108,39 @@ const playedState = computed(() => {
if (view.value === 'animations' || preview.value) return state.value
return expression.value
})
const avatarSize = computed(() => {
if (preview.value) return 560
if (view.value === 'personnaliser') return 580
return 440
})
const avatarMax = computed(() => {
if (preview.value) return 'max-w-[min(560px,calc(100dvh_-_6rem))]'
if (view.value === 'personnaliser')
return 'max-w-[min(600px,calc(100dvh_-_var(--timeline)_-_7rem))]'
return 'max-w-[min(460px,calc(100dvh_-_var(--timeline)_-_7rem))]'
})
watch(shape, (id) => ecris('forme', id))
watch(shape, (id) => {
ecris('forme', id)
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
})
watch(color, (id) => ecris('couleur', id))
watch(expression, (id) => {
ecris('expression', id)
if (view.value !== 'animations') state.value = id
if (view.value !== 'personnaliser') return
posePlay.value = true
poseStopped.value = false
poseIdx.value = 0
poseElapsed.value = 0
})
watch(
manual,
(value) => {
ecris('manuel', JSON.stringify(persistable(value)))
},
{ deep: true }
)
let pending: ReturnType<typeof setTimeout>
function enregistreCycles() {
@@ -145,10 +188,48 @@ function onSeek(t: number) {
state.value = cycle.value.blocks[index]?.state ?? 'idle'
}
const posePlay = ref(true)
const poseStopped = ref(false)
const poseIdx = ref(0)
const poseElapsed = ref(0)
const poseHold = ref(2)
function onSeekPose(index: number) {
const frozen = !!hero.value?.playback()?.frozen
hero.value?.seekEye(index, { snap: frozen })
poseIdx.value = index
}
function releasePoseFreeze() {
hero.value?.setPaused(false)
hero.value?.setPlaylistHold(false)
}
function applyPosePlayback() {
if (view.value !== 'personnaliser' || preview.value) return
if (posePlay.value) {
poseStopped.value = false
hero.value?.setPaused(false)
hero.value?.setPlaylistHold(false)
return
}
hero.value?.seekEye(poseIdx.value, { snap: true })
hero.value?.setPlaylistHold(true)
hero.value?.freezeNow({ settle: poseStopped.value })
}
function onStopPose() {
poseStopped.value = true
posePlay.value = false
hero.value?.setPlaylistHold(true)
hero.value?.freezeNow({ settle: true })
}
const gardeAnim = ref(false)
watch(view, (now) => {
watch(view, (now, prev) => {
intro.value = false
if (prev === 'personnaliser' && now !== 'personnaliser') releasePoseFreeze()
if (now === 'animations') {
gardeAnim.value = true
playing.value = true
@@ -157,10 +238,33 @@ watch(view, (now) => {
}
playing.value = now === 'reglages'
state.value = expression.value
if (now === 'personnaliser') {
void nextTick(() => {
hero.value?.flushPerformance()
applyPosePlayback()
})
}
})
watch(preview, (on) => {
if (on) playing.value = true
if (on) {
playing.value = true
releasePoseFreeze()
return
}
if (view.value === 'personnaliser') void nextTick(applyPosePlayback)
})
watch(posePlay, (on) => {
if (view.value !== 'personnaliser') return
if (on) {
poseStopped.value = false
hero.value?.setPaused(false)
hero.value?.setPlaylistHold(false)
return
}
hero.value?.setPlaylistHold(true)
hero.value?.freezeNow({ settle: poseStopped.value })
})
let raf = 0
@@ -169,6 +273,14 @@ function tick(ms: number) {
raf = requestAnimationFrame(tick)
const dt = last ? Math.min((ms - last) / 1000, 0.064) : 0
last = ms
if (view.value === 'personnaliser' && !preview.value) {
const snap = hero.value?.playback()
if (snap) {
poseIdx.value = snap.eyeIdx
poseHold.value = snap.holdMs / 1000
poseElapsed.value = Math.max(0, (snap.holdMs - snap.remainMs) / 1000)
}
}
if (!playing.value || (view.value !== 'animations' && !preview.value)) return
const blocs = cycle.value.blocks
const cur = blocs[block.value]
@@ -185,6 +297,18 @@ function tick(ms: number) {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') preview.value = false
if (e.code !== 'Space') return
if (view.value !== 'animations' && !preview.value) return
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return
const node = e.target
if (
node instanceof HTMLElement &&
node.closest('input, textarea, select, [contenteditable="true"], dialog')
) {
return
}
e.preventDefault()
playing.value = !playing.value
}
function onPageHide() {
@@ -256,6 +380,7 @@ watch(dialogueCycle, (ouverte) => {
})
watch([view, preview], () => {
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
if (view.value !== 'animations' || preview.value) annuleCycle()
})
@@ -316,6 +441,7 @@ async function exporte(id: ActionId, confirme = false) {
function onHeroReady(ok: boolean) {
ready.value = ok
if (ok) void nextTick(applyPosePlayback)
if (!intro.value) return
if (!ok) {
intro.value = false
@@ -358,7 +484,7 @@ onUnmounted(() => {
</button>
<div
class="scene min-h-full items-stretch justify-center p-8 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
class="scene min-h-full items-stretch justify-center p-8 lg:pr-3 max-lg:flex max-lg:flex-col max-lg:gap-10 max-lg:px-5"
:class="[
!preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
@@ -385,22 +511,24 @@ onUnmounted(() => {
<div
class="avatar flex aspect-square w-full items-center justify-center"
:class="[
preview
? 'max-w-[min(560px,calc(100dvh_-_6rem))]'
: 'max-w-[min(460px,calc(100dvh_-_var(--timeline)_-_7rem))]',
avatarMax,
nue && 'avatar--intro',
view === 'reglages' && !preview && 'avatar--geant'
view === 'reglages' && !preview && 'avatar--geant',
view === 'personnaliser' && !preview && !nue && 'avatar--bas'
]"
>
<Hero
<AvatarStage
ref="hero"
class="h-auto max-w-full"
:size="preview ? 560 : 440"
:size="avatarSize"
:shape="shape"
:color="color"
:state="playedState"
:follow="follow"
:manual="manual"
:tools="view === 'personnaliser' && !preview"
@ready="onHeroReady"
@update:manual="manual = $event"
/>
</div>
@@ -434,14 +562,19 @@ onUnmounted(() => {
<aside
v-if="!preview"
class="panneau scene__droite w-full lg:w-80 lg:shrink-0"
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'"
class="panneau scene__droite w-full lg:w-[21.25rem] lg:shrink-0"
:class="[
droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden',
view === 'personnaliser' && !intro && 'playback-host'
]"
>
<Customizer
v-show="view === 'personnaliser'"
class="playback-pad"
v-model:shape="shape"
v-model:color="color"
v-model:expression="expression"
v-model:manual="manual"
:ready="ready"
:active="view === 'personnaliser'"
/>
@@ -455,10 +588,24 @@ onUnmounted(() => {
:active="view === 'animations'"
@pick="addBlock"
/>
<PlaybackBar
v-if="view === 'personnaliser' && !intro"
v-model:playing="posePlay"
:stopped="poseStopped"
:expression="expression"
:cursor="poseIdx"
:elapsed="poseElapsed"
:hold="poseHold"
:shape="shape"
:color="color"
:active="view === 'personnaliser'"
:ready="ready"
@seek="onSeekPose"
@stop="onStopPose"
/>
</aside>
</div>
<Transition name="fondu">
<Timeline
v-if="view === 'animations' && !preview"
v-model:cycles="cycles"
@@ -473,7 +620,6 @@ onUnmounted(() => {
@preview="preview = true"
@exporter="dialogueCycle = true"
/>
</Transition>
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Hero from '@/components/Hero.vue'
import ManualCanvas, { type HeroHandle } from '@/components/ManualCanvas.vue'
import {
DEFAULT_EYE,
DEFAULT_EYES,
type EyeSide,
type EyeTune,
type ManualPart,
type ManualState
} from '@/ui/pose/model'
import type { HeadPose } from '@/ui/pose/math'
const props = withDefaults(
defineProps<{
size?: number
shape?: string
color?: string
state?: string
follow?: boolean
paper?: string
manual?: ManualState | null
tools?: boolean
}>(),
{
size: 440,
shape: 'blob',
color: 'black',
state: 'idle',
follow: false,
paper: '#f9f9f9',
manual: null,
tools: false
}
)
const emit = defineEmits<{
ready: [ok: boolean]
'update:manual': [ManualState]
}>()
const hero = ref<InstanceType<typeof Hero> | null>(null)
const liveHero = computed<HeroHandle | null>(() => hero.value)
const active = computed(() => !!props.manual?.on && props.tools)
function patch(next: Partial<ManualState>) {
if (!props.manual) return
emit('update:manual', { ...props.manual, ...next })
}
function applyEngine() {
const bot = hero.value
const manual = props.manual
if (!bot) return
if (!manual?.on || !props.tools) {
bot.setManualHold(false)
return
}
bot.setManualHold(true)
bot.setPose(manual.pose)
bot.setManualOffset(manual.offset)
bot.setEyeTune(engineEyes(manual.eyes))
}
function near(a: number, b: number) {
return Math.abs(a - b) < 1e-6
}
function sameSide(a: EyeSide, b: EyeSide) {
return (
near(a.width, b.width) &&
near(a.height, b.height) &&
near(a.size, b.size) &&
near(a.angle, b.angle) &&
near(a.x, b.x) &&
near(a.y, b.y)
)
}
function sameEyes(a: EyeTune, b: EyeTune) {
return sameSide(a.left, b.left) && sameSide(a.right, b.right) && near(a.spacing, b.spacing)
}
function engineSide(side: EyeSide): EyeSide {
return { ...side, x: side.x - DEFAULT_EYE.x, y: side.y - DEFAULT_EYE.y }
}
function engineEyes(eyes: EyeTune): EyeTune | null {
if (sameEyes(eyes, DEFAULT_EYES)) return null
return { left: engineSide(eyes.left), right: engineSide(eyes.right), spacing: eyes.spacing }
}
function clearSelection() {
if (props.manual?.selected != null) emit('update:manual', { ...props.manual, selected: null })
}
watch(
() => [props.tools, props.shape],
() => clearSelection()
)
watch(
() => [
props.manual?.on,
props.manual?.pose,
props.manual?.offset,
props.manual?.eyes,
props.tools,
hero.value
],
() => applyEngine(),
{ deep: true }
)
function onReady(ok: boolean) {
emit('ready', ok)
if (ok) void nextTick(applyEngine)
}
onBeforeUnmount(() => {
clearSelection()
hero.value?.setManualHold(false)
})
defineExpose({
spin: () => hero.value?.spin(),
orbitGaze: (ms?: number) => hero.value?.orbitGaze(ms),
svg: () => hero.value?.svg() ?? null,
seekEye: (index: number, opts?: { snap?: boolean }) => hero.value?.seekEye(index, opts),
flushPerformance: () => hero.value?.flushPerformance(),
setPlaylistHold: (on: boolean) => hero.value?.setPlaylistHold(on),
setPaused: (on: boolean | 'hold-pose') => hero.value?.setPaused(on),
holdFrame: (at?: number) => hero.value?.holdFrame(at),
freezeNow: (opts?: { settle?: boolean }) => hero.value?.freezeNow(opts) ?? null,
playback: () => hero.value?.playback() ?? null
})
</script>
<template>
<div
class="avatar-stage relative inline-block aspect-square max-w-full"
:style="{ width: `${props.size}px` }"
>
<Hero
ref="hero"
class="h-auto max-w-full"
:size="size"
:shape="shape"
:color="color"
:state="state"
:follow="follow && !active"
:paper="paper"
@ready="onReady"
/>
<ManualCanvas
v-if="active && manual"
:manual="manual"
:hero="liveHero"
@update:pose="(pose: HeadPose) => patch({ pose })"
@update:selected="(selected: ManualPart | null) => patch({ selected })"
@update:eyes="(eyes: EyeTune) => patch({ eyes })"
/>
</div>
</template>
+4 -2
View File
@@ -10,6 +10,7 @@ const props = withDefaults(
shape?: string
color?: string
size?: number
eye?: number
interactive?: boolean
showLabel?: boolean
active?: boolean
@@ -29,7 +30,8 @@ async function paint() {
color: props.color ?? 'black',
follow: false,
paper: '#f9f9f9',
size: props.size ?? 60
size: props.size ?? 60,
eye: props.eye
})
painted.value = !!mount
}
@@ -39,7 +41,7 @@ const dirty = ref(false)
onMounted(() => {
void paint()
})
watch(() => [props.state, props.shape, props.color], () => {
watch(() => [props.state, props.shape, props.color, props.eye], () => {
if (!props.active) {
dirty.value = true
return
+32 -1
View File
@@ -3,20 +3,51 @@ import { computed } from 'vue'
import BotTile from '@/components/BotTile.vue'
import { shapeName, stateName, t } from '@/i18n'
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog'
import { type ManualState } from '@/ui/pose/model'
const props = defineProps<{ ready?: boolean; active?: boolean }>()
const shape = defineModel<string>('shape', { required: true })
const color = defineModel<string>('color', { required: true })
const expression = defineModel<string>('expression', { required: true })
const manual = defineModel<ManualState>('manual', { required: true })
const shapes = computed(() => {
void props.ready
return liveShapes()
})
function patch(next: Partial<ManualState>) {
manual.value = { ...manual.value, ...next }
}
</script>
<template>
<div>
<h2 class="text-sm font-semibold">{{ t('panel.shape') }}</h2>
<h2 class="text-sm font-semibold">{{ t('manual.title') }}</h2>
<p class="mt-1 text-xs leading-relaxed text-[var(--muted)]">{{ t('manual.help') }}</p>
<button
type="button"
class="mt-2 flex w-full cursor-pointer items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition"
:class="
manual.on
? 'border-[var(--ink)] bg-white font-medium'
: 'border-[var(--line)] text-[var(--muted)] hover:border-[var(--muted)] hover:text-[var(--ink)]'
"
:aria-pressed="manual.on"
@click="patch({ on: !manual.on, selected: !manual.on ? 'body' : null })"
>
<span>{{ t('manual.on') }}</span>
<span
class="relative h-5 w-9 shrink-0 rounded-full transition"
:class="manual.on ? 'bg-[var(--ink)]' : 'bg-[var(--line)]'"
>
<span
class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition"
:class="manual.on && 'translate-x-4'"
/>
</span>
</button>
<h2 class="mt-5 text-sm font-semibold">{{ t('panel.shape') }}</h2>
<div class="mt-2 grid grid-cols-4 gap-1.5">
<BotTile
v-for="id in shapes"
+14 -1
View File
@@ -73,7 +73,20 @@ watch(
defineExpose({
spin: () => replica?.spin(),
orbitGaze: (ms?: number) => replica?.orbitGaze(ms),
svg: () => svg.value
svg: () => svg.value,
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => replica?.setPose(pose),
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) =>
replica?.setManualOffset(offset),
setEyeTune: (tune: unknown) => replica?.setEyeTune(tune),
setManualHold: (on: boolean) => replica?.setManualHold(on),
flushPerformance: () => replica?.flushPerformance(),
seekEye: (index: number, opts?: { snap?: boolean }) => replica?.seekEye(index, opts),
setPlaylistHold: (on: boolean) => replica?.setPlaylistHold(on),
setPaused: (on: boolean | 'hold-pose') => replica?.setPaused(on),
holdFrame: (at?: number) => replica?.holdFrame(at),
freezeNow: (opts?: { settle?: boolean }) => replica?.freezeNow(opts) ?? null,
playback: () => replica?.playback() ?? null,
parts: () => replica?.parts() ?? null
})
</script>
+821
View File
@@ -0,0 +1,821 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import {
openRingPath,
projectEyePoint,
projectFacePoint,
RADIUS,
roundedRectangle,
rotatePoseAroundAxis,
rotatePoseAroundCamera,
rotatePoseWithArcball,
rotationRing,
splitRingArcs,
unitVector,
wirePaths,
type HeadPose,
type Point3
} from '@/ui/pose/math'
import {
clampEyes,
clampPose,
DEFAULT_EYE,
DEFAULT_POSE,
scaleEye,
updateEye,
type EyeSide,
type EyeTune,
type ManualPart,
type ManualState
} from '@/ui/pose/model'
import {
beginManipulation,
finishManipulation,
previewManipulation,
type ManipulationSession
} from '@/ui/pose/session'
import { localHeadRadius, overlayToHost, toLocal, type LocalPoint } from '@/ui/pose/space'
export type HeroHandle = {
parts: () => {
svg: SVGSVGElement
body: SVGPathElement
eyes: SVGPathElement[]
group: SVGGElement
} | null
}
const props = defineProps<{
manual: ManualState
hero: HeroHandle | null
}>()
const emit = defineEmits<{
'update:pose': [HeadPose]
'update:selected': [ManualPart | null]
'update:eyes': [EyeTune]
}>()
const gizmoSvg = ref<SVGSVGElement | null>(null)
const manualLayer = ref<HTMLDivElement | null>(null)
const overlayStyle = ref<Record<string, string>>({})
const pose = computed(() => props.manual.pose)
const selected = computed(() => props.manual.selected)
const eyes = computed(() => props.manual.eyes)
const headRadius = ref(95)
const rings = computed(() => {
const split = (axis: 'x' | 'y' | 'z') => {
const { front, back } = splitRingArcs(rotationRing(pose.value, axis))
return {
front: front.map(openRingPath).filter(Boolean),
back: back.map(openRingPath).filter(Boolean)
}
}
return { x: split('x'), y: split('y'), z: split('z') }
})
const activeAxis = ref<'x' | 'y' | 'z' | 'view' | null>(null)
let wireGroup: SVGGElement | null = null
type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
type Drag =
| { kind: 'arcball'; start: LocalPoint; pose: HeadPose; radius: number }
| {
kind: 'axis'
axis: 'x' | 'y' | 'z'
start: LocalPoint
tangent: LocalPoint
pose: HeadPose
}
| { kind: 'view'; startAngle: number; pose: HeadPose }
| {
kind: 'eye'
handle: EyeHandle
side: 'left' | 'right'
start: LocalPoint
eyes: EyeTune
center: LocalPoint
widthAxis: LocalPoint
heightAxis: LocalPoint
spacingAxis: LocalPoint
startAngle: number
startDistance: number
scaleX: number
scaleY: number
}
const drag = ref<Drag | null>(null)
let poseSession: ManipulationSession<HeadPose> | null = null
let eyeSession: ManipulationSession<EyeTune> | null = null
type EyeFrame = {
visible: boolean
available: boolean
center: LocalPoint
widthAxis: LocalPoint
heightAxis: LocalPoint
widthHandle: LocalPoint
heightHandle: LocalPoint
rotateHandle: LocalPoint
sizeHandle: LocalPoint
outline: string
width: number
height: number
scaleX: number
scaleY: number
plusX: number
plusY: number
minusY: number
faceX: number
faceY: number
}
const emptyFrame = (): EyeFrame => ({
visible: false,
available: false,
center: [0, 0],
widthAxis: [1, 0],
heightAxis: [0, 1],
widthHandle: [0, 0],
heightHandle: [0, 0],
rotateHandle: [0, 0],
sizeHandle: [0, 0],
outline: '',
width: DEFAULT_EYE.width,
height: DEFAULT_EYE.height,
scaleX: 1,
scaleY: 1,
plusX: DEFAULT_EYE.width / 2,
plusY: DEFAULT_EYE.height / 2,
minusY: -DEFAULT_EYE.height / 2,
faceX: 0,
faceY: 0
})
const leftFrame = ref<EyeFrame>(emptyFrame())
const rightFrame = ref<EyeFrame>(emptyFrame())
const sceneSvg = () => props.hero?.parts()?.svg ?? null
const syncOverlay = () => {
const host = sceneSvg()
const layer = manualLayer.value
if (!host || !layer) return
const hostBox = host.getBoundingClientRect()
const layerBox = layer.getBoundingClientRect()
overlayStyle.value = {
left: `${(hostBox.left - layerBox.left).toFixed(2)}px`,
top: `${(hostBox.top - layerBox.top).toFixed(2)}px`,
right: 'auto',
bottom: 'auto',
width: `${hostBox.width.toFixed(2)}px`,
height: `${hostBox.height.toFixed(2)}px`
}
}
const localOf = (event: PointerEvent): LocalPoint | null =>
toLocal(sceneSvg(), event.clientX, event.clientY)
const gizmoOf = (event: PointerEvent): LocalPoint | null => {
const svg = gizmoSvg.value
if (!svg) return null
const rectangle = svg.getBoundingClientRect()
if (rectangle.width < 1) return null
return [
((event.clientX - rectangle.left) / rectangle.width) * 86 - 43,
((event.clientY - rectangle.top) / rectangle.height) * 86 - 43
]
}
const line = (from: LocalPoint, to: LocalPoint) =>
`M${from[0].toFixed(2)} ${from[1].toFixed(2)}L${to[0].toFixed(2)} ${to[1].toFixed(2)}`
const commitPose = (next: HeadPose) => emit('update:pose', clampPose(next))
const commitEyes = (next: EyeTune) => emit('update:eyes', clampEyes(next))
const xy = (point: Point3): LocalPoint => [point[0], point[1]]
const overlayPath = (points: Point3[]) => {
if (points.length < 2) return ''
return `M${points.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
}
const hostPath = (host: SVGSVGElement | null, points: Point3[]) => {
const mapped = points
.map((point) => overlayToHost(host, point[0], point[1]))
.filter((point): point is LocalPoint => !!point)
if (mapped.length < 2) return ''
return `M${mapped.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
}
const offsetAlong = (origin: LocalPoint, axis: LocalPoint, distance: number): LocalPoint => [
origin[0] + axis[0] * distance,
origin[1] + axis[1] * distance
]
const readEye = (tune: EyeSide, side: 'left' | 'right'): EyeFrame => {
const faceX = (side === 'left' ? -1 : 1) * eyes.value.spacing / 2 + tune.x
const faceY = tune.y
const width = Math.max(tune.width, 1)
const height = Math.max(tune.height, 1)
const project = (localX: number, localY: number) =>
projectEyePoint(pose.value, faceX, faceY, localX, localY, tune.angle, DEFAULT_POSE)
const outline = roundedRectangle(width, height).map(([localX, localY]) =>
project(localX, localY)
)
const center = xy(project(0, 0))
const widthHandle = xy(project(width / 2 + 9, 0))
const heightHandle = xy(project(0, -height / 2 - 9))
const rotateHandle = xy(project(0, -height / 2 - 30))
const sizeHandle = xy(project(width / 2 + 11, height / 2 + 11))
const plusX = Math.hypot(widthHandle[0] - center[0], widthHandle[1] - center[1])
const minusY = -Math.hypot(heightHandle[0] - center[0], heightHandle[1] - center[1])
return {
visible: outline.reduce((sum, point) => sum + point[2], 0) > 0,
available: true,
center,
widthAxis: unitVector(center, widthHandle),
heightAxis: unitVector(center, [
center[0] - (heightHandle[0] - center[0]),
center[1] - (heightHandle[1] - center[1])
]),
widthHandle,
heightHandle,
rotateHandle,
sizeHandle,
outline: overlayPath(outline),
width,
height,
scaleX: (2 * Math.max(plusX - 9, 0.001)) / width,
scaleY: (2 * Math.max(-minusY - 9, 0.001)) / height,
plusX,
plusY: Math.abs(minusY),
minusY,
faceX,
faceY
}
}
const paintStadiums = () => {
const parts = props.hero?.parts()
const host = sceneSvg()
if (!parts || !host) return
;(['left', 'right'] as const).forEach((side, index) => {
const el = parts.eyes[index]
const frame = side === 'left' ? leftFrame.value : rightFrame.value
if (!el) return
const tune = eyes.value[side]
const outline = roundedRectangle(frame.width, frame.height).map(([localX, localY]) =>
projectEyePoint(
pose.value,
frame.faceX,
frame.faceY,
localX,
localY,
tune.angle,
DEFAULT_POSE
)
)
el.setAttribute('d', hostPath(host, outline))
el.removeAttribute('transform')
el.style.display = frame.visible ? '' : 'none'
})
}
const sampleEyes = () => {
const host = sceneSvg()
leftFrame.value = readEye(eyes.value.left, 'left')
rightFrame.value = readEye(eyes.value.right, 'right')
paintStadiums()
headRadius.value = localHeadRadius(host, engineRadius())
}
const downOf = (frame: EyeFrame): LocalPoint => unitVector(frame.heightHandle, frame.center)
const spacingPoint = (left: EyeFrame, right: EyeFrame, selected: EyeFrame): LocalPoint => {
const midX = (eyes.value.left.x + eyes.value.right.x) / 2
const midY = (eyes.value.left.y + eyes.value.right.y) / 2
const point = projectFacePoint(
pose.value,
midX,
midY + selected.height / 2 + 34,
RADIUS,
[0, 0],
DEFAULT_POSE
)
if (Number.isFinite(point[0]) && Number.isFinite(point[1])) return [point[0], point[1]]
return offsetAlong(
[(left.center[0] + right.center[0]) / 2, (left.center[1] + right.center[1]) / 2],
downOf(selected),
selected.height / 2 + 34
)
}
const editor = computed(() => {
const side: 'left' | 'right' | null =
selected.value === 'left' ? 'left' : selected.value === 'right' ? 'right' : null
if (!side) return null
const frame = side === 'left' ? leftFrame.value : rightFrame.value
if (!frame.visible) return null
const other = side === 'left' ? rightFrame.value : leftFrame.value
const both = other.available
const mid: LocalPoint = both
? [(frame.center[0] + other.center[0]) / 2, (frame.center[1] + other.center[1]) / 2]
: frame.center
const spacingHandle = both
? spacingPoint(frame, other, frame)
: offsetAlong(frame.center, downOf(frame), frame.plusY + 34)
return {
side,
frame,
other,
both,
mid,
spacingHandle,
widthGuide: line(frame.center, frame.widthHandle),
heightGuide: line(frame.center, frame.heightHandle),
rotationGuide: line(frame.heightHandle, frame.rotateHandle),
spacingGuide: both
? `${line(frame.center, other.center)}${line(mid, spacingHandle)}`
: line(frame.center, spacingHandle)
}
})
const draggingHead = computed(() => !!drag.value && drag.value.kind !== 'eye')
const draggingHandle = computed(() => drag.value?.kind === 'eye')
const showWire = draggingHead
const engineRadius = () => window.GROK_GEO?.Re ?? 114.27
const ensureWires = () => {
const parts = props.hero?.parts()
if (!parts) return null
if (wireGroup?.isConnected) return wireGroup
const ns = 'http://www.w3.org/2000/svg'
const group = document.createElementNS(ns, 'g')
group.setAttribute('class', 'manual-wires')
group.setAttribute('aria-hidden', 'true')
const clip = parts.svg.querySelector('clipPath')
if (clip?.id) group.setAttribute('clip-path', `url(#${clip.id})`)
parts.body.insertAdjacentElement('afterend', group)
wireGroup = group
return group
}
const paintWires = () => {
const group = ensureWires()
if (!group) return
const radius = engineRadius()
const paths = showWire.value ? wirePaths(pose.value, radius, [radius, radius]) : []
const ns = 'http://www.w3.org/2000/svg'
while (group.childNodes.length > paths.length) group.lastChild?.remove()
paths.forEach((d, index) => {
let path = group.childNodes[index] as SVGPathElement | undefined
if (!path) {
path = document.createElementNS(ns, 'path')
path.setAttribute('class', 'wire')
group.appendChild(path)
}
path.setAttribute('d', d)
})
}
const paintFocus = () => {
const parts = props.hero?.parts()
if (!parts) return
const ns = 'http://www.w3.org/2000/svg'
let ring = parts.group.querySelector('.manual-head-outline') as SVGPathElement | null
if (draggingHead.value) {
if (!ring) {
ring = document.createElementNS(ns, 'path')
ring.setAttribute('class', 'manual-head-outline')
parts.group.appendChild(ring)
}
ring.setAttribute('d', parts.body.getAttribute('d') || '')
} else {
ring?.remove()
}
const left = parts.eyes[0]
const right = parts.eyes[1]
left?.classList.toggle('cyan-outline', selected.value === 'left')
right?.classList.toggle('cyan-outline', selected.value === 'right')
}
const clearDecor = () => {
wireGroup?.remove()
wireGroup = null
const parts = props.hero?.parts()
if (parts) parts.body.style.cursor = ''
parts?.group.querySelector('.manual-head-outline')?.remove()
parts?.eyes.forEach((el) => {
el.classList.remove('cyan-outline')
el.style.cursor = ''
el.style.display = ''
el.removeAttribute('transform')
})
}
const detachDrag = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', stop)
window.removeEventListener('pointercancel', cancel)
}
const attachDrag = (event: PointerEvent) => {
event.preventDefault()
;(event.currentTarget as Element | null)?.setPointerCapture?.(event.pointerId)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', stop)
window.addEventListener('pointercancel', cancel)
}
const setBodyCursor = (cursor: string) => {
const parts = props.hero?.parts()
if (!parts) return
parts.body.style.cursor = cursor
}
const startArcball = (event: PointerEvent) => {
const point = localOf(event)
if (!point) return
if (selected.value !== 'left' && selected.value !== 'right') emit('update:selected', 'body')
poseSession = beginManipulation(pose.value)
drag.value = {
kind: 'arcball',
start: point,
pose: pose.value,
radius: headRadius.value
}
setBodyCursor('grabbing')
attachDrag(event)
}
const startAxis = (axis: 'x' | 'y' | 'z', event: PointerEvent) => {
event.stopPropagation()
const point = gizmoOf(event)
if (!point) return
const ring = rotationRing(pose.value, axis)
let closest = 0
let best = Infinity
ring.slice(0, -1).forEach((ringPoint, index) => {
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
if (distance < best) {
closest = index
best = distance
}
})
const previous = ring[(closest - 1 + ring.length - 1) % (ring.length - 1)]!
const next = ring[(closest + 1) % (ring.length - 1)]!
poseSession = beginManipulation(pose.value)
activeAxis.value = axis
drag.value = {
kind: 'axis',
axis,
start: point,
tangent: unitVector(previous, next),
pose: pose.value
}
attachDrag(event)
}
const startView = (event: PointerEvent) => {
event.stopPropagation()
const point = gizmoOf(event)
if (!point) return
poseSession = beginManipulation(pose.value)
activeAxis.value = 'view'
drag.value = { kind: 'view', startAngle: Math.atan2(point[1], point[0]), pose: pose.value }
attachDrag(event)
}
const startHandle = (handle: EyeHandle, event: PointerEvent) => {
event.stopPropagation()
const live = editor.value
if (!live) return
const point = localOf(event)
if (!point) return
eyeSession = beginManipulation(eyes.value)
const left = leftFrame.value
const right = rightFrame.value
const both = left.visible && right.visible
const spacingFrom = both ? left.center : live.frame.center
const spacingTo = both ? right.center : live.spacingHandle
drag.value = {
kind: 'eye',
handle,
side: live.side,
start: point,
eyes: eyes.value,
center: live.frame.center,
widthAxis: unitVector(live.frame.center, live.frame.widthHandle),
heightAxis: unitVector(live.frame.center, live.frame.heightHandle),
spacingAxis: unitVector(spacingFrom, spacingTo),
startAngle: Math.atan2(point[1] - live.frame.center[1], point[0] - live.frame.center[0]),
startDistance: Math.max(
Math.hypot(point[0] - live.frame.center[0], point[1] - live.frame.center[1]),
1
),
scaleX: live.frame.scaleX,
scaleY: live.frame.scaleY
}
attachDrag(event)
}
const move = (event: PointerEvent) => {
const interaction = drag.value
if (!interaction) return
if (interaction.kind === 'arcball') {
const point = localOf(event)
if (!point) return
const next = rotatePoseWithArcball(
interaction.pose,
interaction.start,
point,
interaction.radius
)
if (poseSession) previewManipulation(poseSession, next, commitPose)
return
}
if (interaction.kind === 'eye') {
const point = localOf(event)
if (!point) return
const deltaX = point[0] - interaction.start[0]
const deltaY = point[1] - interaction.start[1]
const along = (axis: LocalPoint) => deltaX * axis[0] + deltaY * axis[1]
const start = interaction.eyes
const side = interaction.side
const current = start[side]
let next = start
if (interaction.handle === 'width') {
next = updateEye(
start,
side,
{
width: Math.max(10, Math.min(100, current.width + along(interaction.widthAxis) * 2))
},
props.manual.links
)
} else if (interaction.handle === 'height') {
next = updateEye(
start,
side,
{
height: Math.max(10, Math.min(100, current.height + along(interaction.heightAxis) * 2))
},
props.manual.links
)
} else if (interaction.handle === 'size') {
const distance = Math.hypot(point[0] - interaction.center[0], point[1] - interaction.center[1])
next = scaleEye(start, side, distance / interaction.startDistance, props.manual.links.size)
} else if (interaction.handle === 'spacing') {
next = clampEyes({
...start,
spacing: start.spacing + along(interaction.spacingAxis)
})
} else {
const currentAngle = Math.atan2(point[1] - interaction.center[1], point[0] - interaction.center[0])
const delta = Math.atan2(
Math.sin(currentAngle - interaction.startAngle),
Math.cos(currentAngle - interaction.startAngle)
)
next = updateEye(
start,
side,
{ angle: current.angle + (delta * 180) / Math.PI },
props.manual.links
)
}
if (eyeSession) previewManipulation(eyeSession, next, commitEyes)
return
}
const gizmoPoint = gizmoOf(event)
if (!gizmoPoint) return
if (interaction.kind === 'view') {
const currentAngle = Math.atan2(gizmoPoint[1], gizmoPoint[0])
const delta = Math.atan2(
Math.sin(currentAngle - interaction.startAngle),
Math.cos(currentAngle - interaction.startAngle)
)
const next = rotatePoseAroundCamera(interaction.pose, delta)
if (poseSession) previewManipulation(poseSession, next, commitPose)
return
}
const signed =
(gizmoPoint[0] - interaction.start[0]) * interaction.tangent[0] +
(gizmoPoint[1] - interaction.start[1]) * interaction.tangent[1]
const next = rotatePoseAroundAxis(interaction.pose, interaction.axis, signed * 1.5)
if (poseSession) previewManipulation(poseSession, next, commitPose)
}
const finishPose = (outcome: 'commit' | 'cancel') => {
if (poseSession)
finishManipulation(poseSession, outcome, { preview: commitPose, commit: commitPose })
poseSession = null
}
const finishEyes = (outcome: 'commit' | 'cancel') => {
if (eyeSession)
finishManipulation(eyeSession, outcome, { preview: commitEyes, commit: commitEyes })
eyeSession = null
}
const stop = () => {
if (drag.value?.kind === 'eye') finishEyes('commit')
else finishPose('commit')
drag.value = null
activeAxis.value = null
setBodyCursor('grab')
detachDrag()
}
const cancel = () => {
if (drag.value?.kind === 'eye') finishEyes('cancel')
else finishPose('cancel')
drag.value = null
activeAxis.value = null
setBodyCursor('grab')
detachDrag()
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && drag.value) {
event.stopPropagation()
cancel()
}
}
const onBodyDown = (event: PointerEvent) => {
if (event.button !== 0) return
event.stopPropagation()
startArcball(event)
}
const onEyeDown = (side: 'left' | 'right') => (event: PointerEvent) => {
if (event.button !== 0) return
event.stopPropagation()
emit('update:selected', side)
}
const bindParts = () => {
const parts = props.hero?.parts()
if (!parts) return () => {}
const left = parts.eyes[0]
const right = parts.eyes[1]
parts.body.style.cursor = 'grab'
if (left) left.style.cursor = 'pointer'
if (right) right.style.cursor = 'pointer'
const onLeft = onEyeDown('left')
const onRight = onEyeDown('right')
parts.body.addEventListener('pointerdown', onBodyDown)
left?.addEventListener('pointerdown', onLeft)
right?.addEventListener('pointerdown', onRight)
return () => {
parts.body.style.cursor = ''
if (left) left.style.cursor = ''
if (right) right.style.cursor = ''
parts.body.removeEventListener('pointerdown', onBodyDown)
left?.removeEventListener('pointerdown', onLeft)
right?.removeEventListener('pointerdown', onRight)
}
}
let unbind = () => {}
let bound = false
let raf = 0
const tick = () => {
raf = requestAnimationFrame(tick)
syncOverlay()
sampleEyes()
paintWires()
paintFocus()
if (!bound && props.hero?.parts()) {
unbind()
unbind = bindParts()
bound = true
}
}
watch(
() => props.hero,
() => {
unbind()
bound = false
unbind = bindParts()
bound = !!props.hero?.parts()
sampleEyes()
}
)
onMounted(() => {
window.addEventListener('keydown', onKey, true)
raf = requestAnimationFrame(tick)
syncOverlay()
unbind = bindParts()
bound = !!props.hero?.parts()
sampleEyes()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey, true)
cancelAnimationFrame(raf)
stop()
unbind()
clearDecor()
})
const resetHead = () => emit('update:pose', { ...DEFAULT_POSE })
</script>
<template>
<div ref="manualLayer" class="manual-layer">
<svg
class="manual-overlay"
:style="overlayStyle"
viewBox="-150 -150 300 300"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<g v-if="editor" class="eye-editor">
<path v-if="draggingHandle" class="selection-outline" :d="editor.frame.outline" />
<path class="editor-guide" :d="editor.widthGuide" />
<path class="editor-guide" :d="editor.heightGuide" />
<path class="editor-guide" :d="editor.rotationGuide" />
<path class="editor-guide" :d="editor.spacingGuide" />
<g class="editor-control" data-eye-handle="width" @pointerdown="startHandle('width', $event)">
<circle class="editor-handle" :cx="editor.frame.widthHandle[0]" :cy="editor.frame.widthHandle[1]" r="5.5" />
<text class="editor-label" :x="editor.frame.widthHandle[0]" :y="editor.frame.widthHandle[1] + 2.6">L</text>
</g>
<g class="editor-control" data-eye-handle="height" @pointerdown="startHandle('height', $event)">
<circle class="editor-handle" :cx="editor.frame.heightHandle[0]" :cy="editor.frame.heightHandle[1]" r="5.5" />
<text class="editor-label" :x="editor.frame.heightHandle[0]" :y="editor.frame.heightHandle[1] + 2.6">H</text>
</g>
<g class="editor-control" data-eye-handle="rotate" @pointerdown="startHandle('rotate', $event)">
<circle class="editor-handle" :cx="editor.frame.rotateHandle[0]" :cy="editor.frame.rotateHandle[1]" r="5.5" />
<text class="editor-label" :x="editor.frame.rotateHandle[0]" :y="editor.frame.rotateHandle[1] + 2.6">R</text>
</g>
<g class="editor-control" data-eye-handle="size" @pointerdown="startHandle('size', $event)">
<rect
class="editor-handle"
:x="editor.frame.sizeHandle[0] - 5"
:y="editor.frame.sizeHandle[1] - 5"
width="10"
height="10"
rx="2"
/>
<text class="editor-label" :x="editor.frame.sizeHandle[0]" :y="editor.frame.sizeHandle[1] + 2.6">S</text>
</g>
<g class="editor-control" data-eye-handle="spacing" @pointerdown="startHandle('spacing', $event)">
<rect
class="editor-handle"
:x="editor.spacingHandle[0] - 5"
:y="editor.spacingHandle[1] - 5"
width="10"
height="10"
rx="2"
/>
<text class="editor-label" :x="editor.spacingHandle[0]" :y="editor.spacingHandle[1] + 2.6">E</text>
</g>
</g>
</svg>
<div class="gizmo-cluster">
<svg ref="gizmoSvg" class="gizmo" viewBox="-43 -43 86 86" :aria-label="t('manual.gizmo')">
<g class="gizmo-back" aria-hidden="true">
<path v-for="(d, i) in rings.y.back" :key="'yb' + i" class="gizmo-orbit gizmo-y" :d="d" />
<path v-for="(d, i) in rings.x.back" :key="'xb' + i" class="gizmo-orbit gizmo-x" :d="d" />
<path v-for="(d, i) in rings.z.back" :key="'zb' + i" class="gizmo-orbit gizmo-z" :d="d" />
</g>
<circle class="gizmo-hit gizmo-camera-hit" cx="0" cy="0" r="38" @pointerdown="startView" />
<circle
class="gizmo-orbit gizmo-camera"
:class="{ 'is-active': activeAxis === 'view' }"
cx="0"
cy="0"
r="38"
/>
<g class="gizmo-front">
<g v-for="(d, i) in rings.y.front" :key="'yf' + i" class="gizmo-arc">
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('y', $event)" />
<path class="gizmo-orbit gizmo-y" :class="{ 'is-active': activeAxis === 'y' }" :d="d" />
</g>
<g v-for="(d, i) in rings.x.front" :key="'xf' + i" class="gizmo-arc">
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('x', $event)" />
<path class="gizmo-orbit gizmo-x" :class="{ 'is-active': activeAxis === 'x' }" :d="d" />
</g>
<g v-for="(d, i) in rings.z.front" :key="'zf' + i" class="gizmo-arc">
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('z', $event)" />
<path class="gizmo-orbit gizmo-z" :class="{ 'is-active': activeAxis === 'z' }" :d="d" />
</g>
</g>
</svg>
<button type="button" class="gizmo-reset" :aria-label="t('manual.reset')" @click="resetHead">
↺
</button>
</div>
<div class="axis-key" aria-hidden="true">
<i class="x" />X <i class="y" />Y <i class="z" />Z
</div>
</div>
</template>
+306
View File
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import PlaybackDetails from '@/components/PlaybackDetails.vue'
import PlaybackIdentity from '@/components/PlaybackIdentity.vue'
import PlaybackStep from '@/components/PlaybackStep.vue'
import { playlistOf } from '@/replica/motion'
import { stateName, t } from '@/i18n'
import { playbackStatus } from '@/ui/playback'
import { useSnapSheet } from '@/ui/useSnapSheet'
const props = defineProps<{
expression: string
cursor: number
elapsed: number
hold: number
shape: string
color: string
active?: boolean
ready?: boolean
stopped?: boolean
}>()
const emit = defineEmits<{
seek: [index: number]
stop: []
}>()
const playing = defineModel<boolean>('playing', { required: true })
const details = useTemplateRef<HTMLElement>('details')
const dock = useTemplateRef<HTMLElement>('dock')
const footer = useTemplateRef<HTMLElement>('footer')
const {
y,
expanded,
dragging,
opacity,
ready,
onPointerDown,
onPointerMove,
onPointerUp
} = useSnapSheet(details)
const poses = computed(() => {
void props.ready
return playlistOf(props.expression)
})
const name = computed(() => stateName(props.expression))
const status = computed(() => playbackStatus(playing.value, props.stopped))
function pick(index: number) {
emit('seek', index)
}
function publish() {
const host = dock.value?.closest('.playback-host')
if (!(host instanceof HTMLElement) || !footer.value) return
const visible = Math.max(72, footer.value.offsetHeight - y.value)
host.style.setProperty('--playback-clearance', `${visible + 6}px`)
}
watch(
[y, expanded, ready, () => poses.value.length],
() => void nextTick(publish),
{ flush: 'post' }
)
onMounted(() => void nextTick(publish))
watch(
() => props.cursor,
async () => {
await nextTick()
dock.value
?.querySelector<HTMLElement>('.playback-step[aria-pressed="true"]')
?.scrollIntoView({ inline: 'nearest', block: 'nearest' })
}
)
onBeforeUnmount(() => {
const host = dock.value?.closest('.playback-host')
if (host instanceof HTMLElement) host.style.removeProperty('--playback-clearance')
})
</script>
<template>
<div ref="dock" class="playback-dock">
<footer
ref="footer"
class="playback-bar"
:class="{
'is-expanded': expanded,
'is-dragging': dragging,
'is-ready': ready
}"
:style="{ transform: `translateY(${y}px)` }"
>
<div class="playback-handle-slot">
<button
type="button"
class="playback-handle"
:aria-expanded="expanded"
:aria-label="expanded ? t('playback.hide') : t('playback.show')"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<span />
</button>
</div>
<div class="playback-strip">
<div v-if="poses.length > 1" class="playback-steps">
<PlaybackStep
v-for="(eye, index) in poses"
:key="`${expression}-${eye}-${index}`"
:state="expression"
:shape="shape"
:color="color"
:eye="eye"
:current="index === cursor"
:elapsed="index === cursor ? elapsed : 0"
:duration="hold"
:active="active"
@pick="pick(index)"
/>
</div>
<div class="playback-controls">
<PlaybackIdentity :name="name" :status="status" />
<button
type="button"
class="playback-btn"
:aria-label="
playing ? t('playback.pause', { name }) : t('playback.play', { name })
"
@click="playing = !playing"
>
<svg v-if="!playing" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z"
/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<g fill="currentColor">
<path
d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z"
/>
<path
d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 3.17157 14 18V6Z"
/>
</g>
</svg>
</button>
<button
type="button"
class="playback-btn playback-btn--ghost"
:aria-label="t('playback.stop', { name })"
@click="emit('stop')"
>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3C4.11438 3 3.17157 3 2.58579 3.58579C2 4.17157 2 5.11438 2 7V17C2 18.8856 2 19.8284 2.58579 20.4142C3.17157 21 4.11438 21 6 21H18C19.8856 21 20.8284 21 21.4142 20.4142C22 19.8284 22 18.8856 22 17V7C22 5.11438 22 4.17157 21.4142 3.58579C20.8284 3 19.8856 3 18 3H6Z"
/>
</svg>
</button>
</div>
</div>
<div
ref="details"
class="playback-details-shell"
:style="{ opacity }"
:aria-hidden="!expanded"
>
<PlaybackDetails
:expression="expression"
:index="cursor"
:elapsed="elapsed"
:hold="hold"
/>
</div>
</footer>
</div>
</template>
<style scoped>
.playback-bar {
position: relative;
padding: 18px 6px 4px 12px;
border-top: 1px solid #d9dce3;
background: rgb(250 251 253 / 96%);
box-shadow: 0 -10px 30px rgb(24 28 35 / 8%);
backdrop-filter: blur(14px);
}
.playback-bar.is-ready {
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
}
.playback-bar.is-dragging {
transition: none;
}
.playback-handle-slot {
position: absolute;
z-index: 2;
top: 0;
right: 0;
left: 0;
display: flex;
height: 18px;
align-items: center;
justify-content: center;
}
.playback-handle {
display: grid;
width: 64px;
height: 18px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
cursor: grab;
place-items: center;
touch-action: none;
}
.playback-handle:active {
cursor: grabbing;
}
.playback-handle > span {
width: 38px;
height: 4px;
border-radius: 999px;
background: #b9bfca;
transition:
width 160ms ease,
background-color 160ms ease;
}
.playback-handle:hover > span,
.playback-handle:focus-visible > span {
width: 44px;
background: #5b7fe5;
}
.playback-strip {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.playback-steps {
display: flex;
min-width: 0;
flex: 1;
gap: 5px;
overflow-x: auto;
padding: 2px 8px 2px 2px;
scrollbar-color: #cbd2df transparent;
scrollbar-width: thin;
}
.playback-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
min-width: 0;
}
.playback-steps + .playback-controls {
padding-left: 14px;
border-left: 1px solid #dfe3eb;
}
.playback-controls :deep(.playback-identity) {
max-width: 64px;
}
.playback-btn {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border: 0;
border-radius: 10px;
background: #edf1ff;
color: #4168d5;
cursor: pointer;
}
.playback-btn--ghost {
background: transparent;
color: #7f8692;
}
.playback-btn:hover {
background: #e0e7ff;
}
.playback-btn--ghost:hover {
background: #f0f1f4;
color: #343943;
}
.playback-details-shell {
will-change: opacity;
}
@media (prefers-reduced-motion: reduce) {
.playback-bar.is-ready,
.playback-handle > span {
transition: none;
}
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FIRST_BLINK_MS, blinkRangeOf, holdRangeOf, playlistOf } from '@/replica/motion'
import { secondes, secondesCourtes, stateName, t } from '@/i18n'
const props = defineProps<{
expression: string
index: number
elapsed: number
hold: number
}>()
const poses = computed(() => playlistOf(props.expression))
const hold = computed(() => holdRangeOf(props.expression))
const blink = computed(() => blinkRangeOf(props.expression))
function span(range: [number, number]) {
return `${secondesCourtes(range[0] / 1000, 1)}–${secondesCourtes(range[1] / 1000, 1)}`
}
</script>
<template>
<div class="playback-details">
<div class="playback-details-header">
<div>
<p class="eyebrow">{{ t('playback.details') }}</p>
<h2>{{ stateName(expression) }}</h2>
<p>{{ t('playback.summary', { n: poses.length }) }}</p>
</div>
<span class="playback-badge">{{ t('playback.mode') }} · {{ t('playback.loop') }}</span>
</div>
<div class="playback-detail-grid">
<div>
<span>{{ t('playback.clips') }}</span>
<strong>{{ poses.length }}</strong>
<small>{{ span(hold) }}</small>
</div>
<div>
<span>{{ t('playback.blinkFirst') }}</span>
<strong>{{ blink ? span(FIRST_BLINK_MS) : t('playback.blinkOff') }}</strong>
<small>{{ t('playback.blinkAfter') }}</small>
</div>
<div>
<span>{{ t('playback.blinkGap') }}</span>
<strong>{{ blink ? span(blink) : t('playback.blinkOff') }}</strong>
<small>{{ t('playback.blinkRand') }}</small>
</div>
<div>
<span>{{ t('playback.current') }}</span>
<strong>{{ t('playback.stepOf', { n: index + 1, m: poses.length }) }}</strong>
<small>
{{ secondesCourtes(elapsed, 1) }} / {{ secondes(props.hold) }}
</small>
</div>
</div>
</div>
</template>
<style scoped>
.playback-details {
display: grid;
gap: 14px;
padding: 16px 2px 6px;
border-top: 1px solid #dfe3eb;
}
.playback-details-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.playback-details-header > div {
min-width: 0;
}
.eyebrow {
margin: 0 0 4px;
color: #7185c4;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.playback-details-header h2 {
margin: 0;
font-size: 17px;
letter-spacing: -0.03em;
}
.playback-details-header p:last-child {
margin: 5px 0 0;
color: #7f8692;
font-size: 10px;
line-height: 1.45;
}
.playback-badge {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: #edf1ff;
color: #4168d5;
font-size: 8px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.playback-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.playback-detail-grid > div {
display: grid;
min-width: 0;
gap: 3px;
padding: 10px;
border: 1px solid #e1e5ec;
border-radius: 11px;
background: white;
}
.playback-detail-grid span,
.playback-detail-grid small {
overflow: hidden;
color: #858b96;
font-size: 8px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.playback-detail-grid span {
letter-spacing: 0.04em;
text-transform: uppercase;
}
.playback-detail-grid strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { Cle } from '@/i18n'
import { t } from '@/i18n'
import type { PlaybackStatus } from '@/ui/playback'
defineProps<{ name: string; status: PlaybackStatus }>()
const LABEL: Record<PlaybackStatus, Cle> = {
playing: 'playback.playing',
paused: 'playback.paused',
stopped: 'playback.stopped'
}
</script>
<template>
<span class="playback-identity" :class="'is-' + status">
<i />
<span>
<small>{{ t(LABEL[status]) }}</small>
<strong>{{ name }}</strong>
</span>
</span>
</template>
<style scoped>
.playback-identity {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
color: #33834b;
}
.playback-identity.is-paused {
color: #8a6a24;
}
.playback-identity.is-stopped {
color: #7f8692;
}
.playback-identity i {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent);
}
.playback-identity > span {
display: grid;
min-width: 0;
gap: 1px;
}
.playback-identity small {
color: #9297a1;
font-size: 8px;
font-weight: 750;
letter-spacing: 0.06em;
line-height: 1;
text-transform: uppercase;
}
.playback-identity strong {
overflow: hidden;
color: #343943;
font-size: 10px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import BotTile from '@/components/BotTile.vue'
import { stateName, t } from '@/i18n'
import { countdownAngle } from '@/ui/playback'
const props = defineProps<{
state: string
shape: string
color: string
eye: number
current: boolean
elapsed: number
duration: number
active?: boolean
}>()
const emit = defineEmits<{ pick: [] }>()
</script>
<template>
<button
type="button"
class="playback-step"
:aria-pressed="current"
:aria-label="t('timeline.blockAria', { state: stateName(state), duration: duration })"
@click="emit('pick')"
>
<BotTile
:label="stateName(state)"
:selected="false"
:state="state"
:shape="shape"
:color="color"
:eye="eye"
:size="44"
:interactive="false"
:show-label="false"
:active="active"
/>
<span
v-if="current"
class="playback-countdown"
aria-hidden="true"
:style="{ '--countdown': `${countdownAngle(elapsed, duration)}deg` }"
/>
</button>
</template>
<style scoped>
.playback-step {
position: relative;
width: 48px;
height: 48px;
flex: 0 0 48px;
overflow: hidden;
padding: 0;
border: 1px solid #e1e5ec;
border-radius: 11px;
background: white;
box-shadow: 0 3px 10px rgb(24 28 35 / 5%);
cursor: pointer;
}
.playback-step :deep(div) {
width: 100%;
height: 100%;
border: 0;
padding: 0;
}
.playback-step :deep(svg) {
width: 100%;
height: 100%;
}
.playback-step[aria-pressed='true'] {
border-color: #aebff0;
background: #edf1ff;
box-shadow: 0 0 0 2px rgb(91 127 229 / 16%);
}
.playback-countdown {
position: absolute;
bottom: 4px;
left: 4px;
width: 14px;
height: 14px;
border: 2px solid white;
border-radius: 50%;
background: conic-gradient(#4f72d8 var(--countdown), white 0);
box-shadow: 0 1px 4px rgb(24 28 35 / 22%);
pointer-events: none;
}
</style>
+7 -7
View File
@@ -146,7 +146,7 @@ function onRemove() {
4,5rem = la marge de la scene (2rem) + la gouttiere de la grille (2,5rem) : la
colonne de gauche a une largeur NULLE en dehors des reglages, mais la grille
garde son `column-gap`, donc la colonne de l'avatar commence a 72 px et non a
32. C'est la symetrie de 24,5rem = panneau (20) + gouttiere (2,5) + marge (2).
32. C'est la symetrie de 24,5rem = panneau (21,25) + gouttiere (2,5) + marge (0,75).
Le FOND n'apparait que sous 64rem, et ce n'est pas un choix d'habillage.
Au-dessus, la page ne defile pas (`#app { overflow: clip }`) et la scene
@@ -167,7 +167,7 @@ function onRemove() {
qui defile derriere. Le bouton, lui, reste — un disque plein se lit
par-dessus n'importe quoi, et il est la commande principale de la vue. La
meme paire de valeurs est de toute facon affichee dans la barre d'outils,
en bas a droite. -->
en bas a gauche. -->
<div class="absolute -top-5 left-1/2 flex -translate-x-1/2 items-center gap-3">
<span class="text-sm font-medium tabular-nums max-lg:hidden">{{ mmss(at) }}</span>
<button
@@ -262,14 +262,14 @@ function onRemove() {
@seek="emit('seek', $event)"
/>
<!-- barre d'outils, dans le coin : loupe, compteur, aperçu -->
<div class="flex shrink-0 items-center justify-end gap-4 max-sm:gap-2">
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" />
<p class="text-xs tabular-nums text-[var(--muted)]">
<!-- barre d'outils : le temps a gauche, loupe et apercu a droite -->
<div class="flex shrink-0 items-center gap-4 max-sm:gap-2">
<p class="mr-auto text-xs tabular-nums text-[var(--muted)]">
<span class="text-[var(--ink)]">{{ mmss(at) }}</span> / {{ mmss(total) }}
</p>
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" />
<!-- infobulle au survol ET au focus clavier, comme la barre laterale -->
<span class="group relative flex">
<button
-1
View File
@@ -393,7 +393,6 @@ function onRulerMove(e: PointerEvent) {
data-carte
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight ArrowLeft ArrowRight"
@keydown.enter.prevent="block = i"
@keydown.space.prevent="block = i"
@keydown.left="onCardKey(i, $event)"
@keydown.right="onCardKey(i, $event)"
>
+38
View File
@@ -79,6 +79,28 @@ const en: typeof fr = {
blockDurationAria: 'Duration of {state}, {duration}',
blockRemoveAria: 'Remove {state}'
},
playback: {
details: 'Animation details',
show: 'Show animation details',
hide: 'Hide animation details',
playing: 'Playing',
paused: 'Paused',
stopped: 'Stopped',
play: 'Play {name}',
pause: 'Pause {name}',
stop: 'Stop {name}',
mode: 'Playback mode',
loop: 'loop',
clips: 'Poses',
current: 'Current pose',
stepOf: '{n} / {m}',
summary: 'The eye poses of this expression, looping.',
blinkFirst: 'First blink',
blinkAfter: 'after launch',
blinkGap: 'Blink interval',
blinkRand: 'randomized',
blinkOff: 'Off'
},
dialog: {
cancel: 'Cancel',
nameCreateTitle: 'New cycle',
@@ -152,6 +174,22 @@ const en: typeof fr = {
units: {
seconds: '{n} s',
secondsShort: '{n}s'
},
manual: {
title: 'Manual adjust',
help: 'When on, drag the body to turn it, click an eye for L/H/S/R/E, or drag the rings at the top right.',
on: 'Enable manual adjust',
reset: 'Reset',
head: 'Head rotation',
body: 'Body',
spin: 'Spin',
eyes: 'Eyes',
width: 'W',
height: 'H',
size: 'S',
spacing: 'Gap',
gizmo: 'Rotation gizmo',
plane: 'Move in the picture plane'
}
}
+38
View File
@@ -78,6 +78,28 @@ const fr = {
blockDurationAria: 'Durée de {state}, {duration}',
blockRemoveAria: 'Retirer {state}'
},
playback: {
details: 'Détails de l’animation',
show: 'Afficher les détails de l’animation',
hide: 'Masquer les détails de l’animation',
playing: 'En lecture',
paused: 'En pause',
stopped: 'Arrêté',
play: 'Lancer {name}',
pause: 'Mettre {name} en pause',
stop: 'Arrêter {name}',
mode: 'Mode de lecture',
loop: 'boucle',
clips: 'Poses',
current: 'Pose en cours',
stepOf: '{n} / {m}',
summary: 'Les poses d’œil de cette expression, en boucle.',
blinkFirst: 'Premier clignement',
blinkAfter: 'après le lancement',
blinkGap: 'Intervalle du clignement',
blinkRand: 'tirage aléatoire',
blinkOff: 'Désactivé'
},
dialog: {
cancel: 'Annuler',
nameCreateTitle: 'Nouveau cycle',
@@ -152,6 +174,22 @@ const fr = {
units: {
seconds: '{n} s',
secondsShort: '{n}s'
},
manual: {
title: 'Réglage manuel',
help: 'Une fois activé, glisse le corps pour le tourner, clique un œil pour L/H/S/R/E, ou les anneaux en haut à droite.',
on: 'Activer le réglage manuel',
reset: 'Réinitialiser',
head: 'Rotation de la tête',
body: 'Corps',
spin: 'Roulis',
eyes: 'Yeux',
width: 'L',
height: 'H',
size: 'S',
spacing: 'Écart',
gizmo: 'Manipulateur de rotation',
plane: 'Déplacer dans le plan'
}
}
+38
View File
@@ -78,6 +78,28 @@ const zh: typeof fr = {
blockDurationAria: '{state} 的时长,{duration}',
blockRemoveAria: '移除 {state}'
},
playback: {
details: '动画详情',
show: '显示动画详情',
hide: '隐藏动画详情',
playing: '播放中',
paused: '已暂停',
stopped: '已停止',
play: '播放 {name}',
pause: '暂停 {name}',
stop: '停止 {name}',
mode: '播放模式',
loop: '循环',
clips: '眼位',
current: '当前眼位',
stepOf: '{n} / {m}',
summary: '这个表情的眼位循环。',
blinkFirst: '首次眨眼',
blinkAfter: '启动后',
blinkGap: '眨眼间隔',
blinkRand: '随机抽取',
blinkOff: '关闭'
},
dialog: {
cancel: '取消',
nameCreateTitle: '新建循环',
@@ -150,6 +172,22 @@ const zh: typeof fr = {
units: {
seconds: '{n} 秒',
secondsShort: '{n}秒'
},
manual: {
title: '手动调节',
help: '打开后拖身体转向,点眼睛出 L/H/S/R/E,或拖右上角的环旋转。',
on: '启用手动调节',
reset: '重置',
head: '头部旋转',
body: '身体',
spin: '扭转',
eyes: '眼睛',
width: '宽',
height: '高',
size: '大小',
spacing: '间距',
gizmo: '旋转控制器',
plane: '在画面上平移'
}
}
+30
View File
@@ -1,5 +1,12 @@
/** Load local geometry, then the replica IIFE engine. Missing geometry keeps the chrome. */
export type ReplicaParts = {
svg: SVGSVGElement
body: SVGPathElement
eyes: SVGPathElement[]
group: SVGGElement
}
export type ReplicaBot = {
setState: (name: string, opts?: { resetEyes?: boolean }) => void
setShape: (name: string) => void
@@ -7,16 +14,35 @@ export type ReplicaBot = {
setFollowPointer: (on: boolean) => void
setEyeColor: (color: string | null) => void
setPaused: (on: boolean | 'hold-pose') => void
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => void
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) => void
setEyeTune: (tune: unknown) => void
setManualHold: (on: boolean) => void
flushPerformance: () => void
parts: () => ReplicaParts
spinOnce: (turns?: number) => void
bounceOnce: () => void
burstOnce: () => void
orbitGaze: (ms?: number) => void
holdFrame: (at?: number) => void
freezeNow: (opts?: { settle?: boolean }) => ReplicaPlayback
step: (dt: number) => void
setSize: (px: number) => void
seekEye: (index: number, opts?: { snap?: boolean }) => void
setPlaylistHold: (on: boolean) => void
playback: () => ReplicaPlayback
destroy: () => void
}
export type ReplicaPlayback = {
state: string
eyeIdx: number
holdMs: number
remainMs: number
held: boolean
frozen: boolean
}
declare global {
interface Window {
GROK_GEO?: {
@@ -26,6 +52,9 @@ declare global {
}
GROK_TABLES?: {
GROUPS: Array<{ label: string; states: string[] }>
EYE_PLAYLIST?: Record<string, number[]>
EYE_HOLD_MS?: Record<string, [number, number]>
BLINK_MS?: Record<string, [number, number] | null>
INK: Record<string, { lightFrom?: string; lightTo: string; darkFrom?: string; darkTo?: string }>
}
GrokCharacter?: new (svg: SVGSVGElement, opts: Record<string, unknown>) => ReplicaBot
@@ -42,6 +71,7 @@ function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-replica="${src}"]`)
if (existing) {
// IIFE already evaluated. replica/src/*.js changes need a full page refresh.
resolve()
return
}
+46 -2
View File
@@ -1,4 +1,10 @@
import { loadReplica, replicaReady, type ReplicaBot } from './boot'
import {
loadReplica,
replicaReady,
type ReplicaBot,
type ReplicaParts,
type ReplicaPlayback
} from './boot'
export type ReplicaMount = {
apply: (input: ReplicaInput) => void
@@ -6,6 +12,18 @@ export type ReplicaMount = {
orbitGaze: (ms?: number) => void
step: (dt: number) => void
setState: (name: string) => void
setPose: (pose: { turn?: number; tilt?: number; roll?: number }) => void
setManualOffset: (offset: { tx?: number; ty?: number; spin?: number }) => void
setEyeTune: (tune: unknown) => void
setManualHold: (on: boolean) => void
flushPerformance: () => void
seekEye: (index: number, opts?: { snap?: boolean }) => void
setPlaylistHold: (on: boolean) => void
setPaused: (on: boolean | 'hold-pose') => void
holdFrame: (at?: number) => void
freezeNow: (opts?: { settle?: boolean }) => ReplicaPlayback | null
playback: () => ReplicaPlayback | null
parts: () => ReplicaParts | null
destroy: () => void
}
@@ -16,6 +34,7 @@ export type ReplicaInput = {
follow: boolean
paper: string
size: number
eye?: number
}
const TILE_MAX = 6
@@ -75,7 +94,8 @@ export async function mountReplica(
followPointer: live && input.follow && !driven,
eyeColor: input.paper,
paused: driven ? false : live ? false : 'hold-pose',
driven
driven,
eyeIndex: input.eye
})
const noop = {
apply: () => {},
@@ -83,6 +103,18 @@ export async function mountReplica(
orbitGaze: () => {},
step: () => {},
setState: () => {},
setPose: () => {},
setManualOffset: () => {},
setEyeTune: () => {},
setManualHold: () => {},
flushPerformance: () => {},
seekEye: () => {},
setPlaylistHold: () => {},
setPaused: () => {},
holdFrame: () => {},
freezeNow: () => null,
playback: () => null,
parts: () => null,
destroy: () => {}
}
if (!live && !driven) {
@@ -109,6 +141,18 @@ export async function mountReplica(
bot.setState(name, { resetEyes: true })
last = { ...last, state: name }
},
setPose: (pose) => bot.setPose(pose),
setManualOffset: (offset) => bot.setManualOffset(offset),
setEyeTune: (tune) => bot.setEyeTune(tune),
setManualHold: (on) => bot.setManualHold(on),
flushPerformance: () => bot.flushPerformance(),
seekEye: (index, opts) => bot.seekEye(index, opts),
setPlaylistHold: (on) => bot.setPlaylistHold(on),
setPaused: (on) => bot.setPaused(on),
holdFrame: (at) => bot.holdFrame(at),
freezeNow: (opts) => bot.freezeNow(opts),
playback: () => bot.playback(),
parts: () => bot.parts(),
destroy: () => bot.destroy()
}
}
+29
View File
@@ -0,0 +1,29 @@
/** Playlist, holds and blinks of a single replica state. Reads live tables. */
export type HoldRange = [number, number]
const FALLBACK_HOLD: HoldRange = [2000, 3600]
export const FIRST_BLINK_MS: HoldRange = [1500, 7000]
export const BLINK_DUR_MS = 300
export function playlistOf(state: string): number[] {
const list = window.GROK_TABLES?.EYE_PLAYLIST?.[state]
return list?.length ? [...list] : [0]
}
export function holdRangeOf(state: string): HoldRange {
const range = window.GROK_TABLES?.EYE_HOLD_MS?.[state]
if (!range || range.length < 2) return FALLBACK_HOLD
return [range[0]!, range[1]!]
}
export function blinkRangeOf(state: string): HoldRange | null {
const range = window.GROK_TABLES?.BLINK_MS?.[state]
if (!range || range.length < 2) return null
return [range[0]!, range[1]!]
}
export function holdMid(range: HoldRange): number {
return (range[0] + range[1]) / 2
}
+353 -18
View File
@@ -367,7 +367,7 @@ html::-webkit-scrollbar {
.scene {
display: grid;
grid-template-columns: 0 minmax(0, 1fr) 20rem;
grid-template-columns: 0 minmax(0, 1fr) 21.25rem;
column-gap: 2.5rem;
transition: grid-template-columns var(--bascule);
/*
@@ -388,7 +388,7 @@ html::-webkit-scrollbar {
}
.scene--gauche {
grid-template-columns: 20rem minmax(0, 1fr) 0;
grid-template-columns: 21.25rem minmax(0, 1fr) 0;
}
/*
@@ -396,9 +396,9 @@ html::-webkit-scrollbar {
* bien que ce soit la meme regle —
*
* - l'arrivee sur le site, ou c'est le RETOUR a `.scene` (la colonne de droite
* qui reprend ses 20rem) qui fait glisser la boule a sa place ;
* qui reprend ses 21,25rem) qui fait glisser la boule a sa place ;
* - l'apercu, ou les panneaux sont demontes. Sans cette classe la grille leur
* reserve quand meme leur piste — une colonne de 20rem plus sa gouttiere —
* reserve quand meme leur piste — une colonne de 21,25rem plus sa gouttiere —
* et l'avatar se retrouve centre dans une colonne decalee, donc a 180 px a
* gauche du centre de l'ecran. Demonter un panneau ne retire pas sa piste.
*
@@ -436,6 +436,10 @@ html::-webkit-scrollbar {
transform: translate(30vw, 14vh) scale(2.5);
}
.avatar--bas {
transform: translate(0, 2.25rem) scale(1);
}
.scene__gauche {
grid-column: 1;
}
@@ -518,9 +522,9 @@ html::-webkit-scrollbar {
* jamais parce qu'elle est posee sur la FENETRE.
*
* `left`/`right` sont exactement les siens : la bande couvre la colonne de
* l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere + marge)
* et centre son contenu. La vue Personnaliser n'a qu'une seule disposition de
* grille, donc cette position est constante.
* l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere +
* marge droite) et centre son contenu. La vue Personnaliser n'a qu'une seule
* disposition de grille, donc cette position est constante.
*/
.barre-export {
position: fixed;
@@ -537,7 +541,7 @@ html::-webkit-scrollbar {
*/
top: calc(
2rem + (100dvh - 3rem - var(--timeline)) / 2 +
min(460px, 100dvh - var(--timeline) - 7rem) / 2 - 0.5rem
min(600px, 100dvh - var(--timeline) - 7rem) / 2 - 0.5rem + 3.25rem
);
}
}
@@ -593,27 +597,47 @@ html::-webkit-scrollbar {
opacity: 0;
}
.fondu-enter-active,
.fondu-leave-active {
transition: opacity 0.35s ease-out;
.playback-host {
position: relative;
}
.fondu-enter-from,
.fondu-leave-to {
opacity: 0;
.playback-pad {
padding-bottom: var(--playback-clearance, 7.5rem);
}
.playback-dock {
position: fixed;
z-index: 25;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
pointer-events: none;
}
.playback-dock > * {
pointer-events: auto;
}
@media (width >= 64rem) {
.playback-dock {
right: 0.75rem;
bottom: 0;
left: auto;
width: 21.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
.scene,
.panneau,
.panneau--ouvert,
.avatar,
.fondu-enter-active,
.fondu-leave-active {
.avatar {
transition: none;
}
.barre-export {
.barre-export,
.playback-bar.is-ready {
transition: none;
}
@@ -638,3 +662,314 @@ body {
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.manual-layer {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
}
.manual-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.manual-overlay .body-gizmo-hitbox,
.manual-overlay .body-gizmo-plane-hitbox,
.manual-overlay .body-gizmo-reset,
.manual-overlay .editor-control {
pointer-events: auto;
}
.body-gizmo-ring,
.body-gizmo-axis {
fill: none;
stroke-width: 2;
pointer-events: none;
}
.body-gizmo-hitbox {
fill: none;
stroke: transparent;
stroke-width: 14;
cursor: grab;
}
.body-gizmo-ring {
stroke-width: 1.6;
}
.body-gizmo-hitbox:hover + .body-gizmo-ring,
.body-gizmo-ring.is-active,
.body-gizmo-hitbox:hover + .body-gizmo-axis,
.body-gizmo-axis.is-active {
stroke-width: 3.2;
filter: brightness(1.1);
}
.body-gizmo-handle {
stroke: #fff;
stroke-width: 1;
}
.body-gizmo-handle.gizmo-x,
.gizmo-x {
stroke: #1f9d55;
fill: #1f9d55;
}
.body-gizmo-handle.gizmo-y,
.gizmo-y {
stroke: #d64545;
fill: #d64545;
}
.body-gizmo-handle.gizmo-z,
.gizmo-z {
stroke: #3b63d4;
fill: #3b63d4;
}
.body-gizmo-axis.gizmo-x,
.body-gizmo-axis.gizmo-y,
.body-gizmo-ring.gizmo-z {
fill: none;
}
.body-gizmo-origin {
fill: #17203a;
stroke: #fff;
stroke-width: 1.4;
pointer-events: none;
}
.body-gizmo-plane-hitbox {
fill: transparent;
cursor: move;
}
.body-gizmo-plane-hitbox:hover + .body-gizmo-origin,
.body-gizmo-origin.is-active {
fill: #3b63d4;
}
.body-gizmo-reset {
cursor: pointer;
}
.body-gizmo-reset circle {
fill: rgb(255 255 255 / 92%);
stroke: rgb(23 32 58 / 16%);
}
.body-gizmo-reset text {
fill: #17203a;
font-size: 8px;
text-anchor: middle;
}
.body-gizmo-reset:hover circle,
.body-gizmo-reset:focus-visible circle {
fill: #17203a;
}
.body-gizmo-reset:hover text,
.body-gizmo-reset:focus-visible text {
fill: #fff;
}
.body-gizmo-label {
fill: #6e7382;
font-size: 7px;
font-weight: 600;
pointer-events: none;
}
.wire {
fill: none;
stroke: rgb(255 255 255 / 28%);
stroke-width: 1;
pointer-events: none;
vector-effect: non-scaling-stroke;
}
.cyan-outline {
stroke: #51ddff;
stroke-width: 2.6;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.manual-head-outline {
fill: none;
stroke: #51ddff;
stroke-width: 5.5;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 1.5px rgb(81 221 255 / 90%)) drop-shadow(0 0 4px rgb(81 221 255 / 32%));
}
.selection-outline {
fill: none;
stroke: #51ddff;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.editor-guide {
fill: none;
stroke: rgb(81 221 255 / 58%);
stroke-width: 1.15;
stroke-dasharray: 3 3;
pointer-events: none;
}
.editor-control {
cursor: grab;
pointer-events: auto;
touch-action: none;
}
.editor-control:active {
cursor: grabbing;
}
.editor-handle {
fill: #111316;
stroke: #51ddff;
stroke-width: 2;
}
.editor-label {
fill: #fff;
font-size: 6px;
font-weight: 900;
text-anchor: middle;
pointer-events: none;
user-select: none;
}
.gizmo-cluster {
position: absolute;
z-index: 4;
top: 2%;
right: 2%;
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
gap: 5px;
pointer-events: none;
}
.gizmo {
width: 94px;
height: 94px;
overflow: visible;
pointer-events: auto;
touch-action: none;
user-select: none;
}
.gizmo-reset {
width: 26px;
height: 26px;
border: 1px solid rgb(23 32 58 / 12%);
border-radius: 8px;
background: rgb(255 255 255 / 88%);
color: #17203a;
font-size: 14px;
line-height: 1;
pointer-events: auto;
cursor: pointer;
}
.gizmo-reset:hover {
background: #17203a;
color: #fff;
}
.gizmo-orbit {
fill: none;
stroke-width: 2;
pointer-events: none;
vector-effect: non-scaling-stroke;
transition:
stroke-width 140ms ease,
filter 140ms ease,
opacity 140ms ease;
}
.gizmo-hit {
fill: none;
stroke: transparent;
stroke-width: 14;
cursor: ew-resize;
pointer-events: stroke;
touch-action: none;
vector-effect: non-scaling-stroke;
}
.gizmo-arc:hover .gizmo-orbit,
.gizmo-orbit.is-active,
.gizmo-camera-hit:hover + .gizmo-orbit,
.gizmo-orbit.gizmo-camera.is-active {
stroke-width: 5;
filter: brightness(1.1);
}
.gizmo-back .gizmo-orbit {
stroke-width: 1.15;
opacity: 0.28;
}
.gizmo-camera {
stroke: #c9a227;
}
.gizmo-orbit.gizmo-x,
.gizmo-orbit.gizmo-y,
.gizmo-orbit.gizmo-z {
fill: none;
}
.axis-key {
position: absolute;
right: 4%;
bottom: 3%;
display: flex;
align-items: center;
gap: 6px;
color: #6e7382;
font-size: 10px;
pointer-events: none;
}
.axis-key i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 99px;
}
.axis-key i.x {
background: #1f9d55;
}
.axis-key i.y {
background: #d64545;
}
.axis-key i.z {
background: #3b63d4;
}
+9 -1
View File
@@ -21,6 +21,12 @@ export function svgAutonome(svg: SVGSVGElement, taille: number, viewBox = viewBo
function flattenSvg(root: SVGSVGElement) {
const clone = root.cloneNode(true) as SVGSVGElement
clone.querySelectorAll('.manual-wires, .manual-head-outline').forEach((el) => el.remove())
clone.querySelectorAll('.cyan-outline').forEach((el) => {
el.classList.remove('cyan-outline')
el.removeAttribute('stroke')
el.removeAttribute('stroke-width')
})
const walk = (src: Element, dst: Element) => {
const cs = getComputedStyle(src)
for (const attr of ['fill', 'stroke'] as const) {
@@ -36,7 +42,9 @@ function flattenSvg(root: SVGSVGElement) {
}
dst.setAttribute('style', next)
}
const kids = [...src.children]
const kids = [...src.children].filter(
(child) => !child.matches('.manual-wires, .manual-head-outline')
)
const copies = [...dst.children]
for (let i = 0; i < kids.length; i++) walk(kids[i]!, copies[i]!)
}
+25
View File
@@ -0,0 +1,25 @@
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
export function playbackStatus(playing: boolean, stopped = false): PlaybackStatus {
if (playing) return 'playing'
return stopped ? 'stopped' : 'paused'
}
export function clampSheet(y: number, collapsed: number): number {
return Math.min(collapsed, Math.max(0, y))
}
export function snapExpanded(y: number, velocityY: number, collapsed: number): boolean {
if (collapsed <= 0) return false
return y + velocityY * 0.16 < collapsed / 2
}
export function detailsOpacity(y: number, collapsed: number): number {
if (collapsed <= 0) return 1
return 1 - Math.min(1, Math.max(0, y / collapsed))
}
export function countdownAngle(elapsed: number, duration: number): number {
if (duration <= 0) return 0
return (1 - Math.min(1, Math.max(0, elapsed / duration))) * 360
}
+356
View File
@@ -0,0 +1,356 @@
export type Quaternion = readonly [number, number, number, number]
export type Point3 = readonly [number, number, number]
export type HeadPose = { turn: number; tilt: number; roll: number }
export const RADIUS = 120
const RING = 30
const FOCAL = 620
const PERSPECTIVE = 1
const QUARTER_ARC_SAMPLES = 14
export const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value))
export const radians = (degrees: number) => (degrees * Math.PI) / 180
export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
const length = Math.hypot(w, x, y, z) || 1
return [w / length, x / length, y / length, z / length]
}
export const multiplyQuaternions = (
[aw, ax, ay, az]: Quaternion,
[bw, bx, by, bz]: Quaternion
): Quaternion =>
normalizeQuaternion([
aw * bw - ax * bx - ay * by - az * bz,
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw
])
export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
const halfAngle = angle / 2
const sine = Math.sin(halfAngle)
return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
}
export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
}
export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
const cross: Point3 = [
from[1] * to[2] - from[2] * to[1],
from[2] * to[0] - from[0] * to[2],
from[0] * to[1] - from[1] * to[0]
]
return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
}
export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
const matrix00 = 1 - 2 * (y * y + z * z)
const matrix01 = 2 * (x * y - z * w)
const matrix10 = 2 * (x * y + z * w)
const matrix11 = 1 - 2 * (x * x + z * z)
const matrix20 = 2 * (x * z - y * w)
const matrix21 = 2 * (y * z + x * w)
const matrix22 = 1 - 2 * (x * x + y * y)
const headX = Math.asin(clamp(matrix21, -1, 1))
if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
}
export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
const tx = 2 * (y * pz - z * py)
const ty = 2 * (z * px - x * pz)
const tz = 2 * (x * py - y * px)
return [
px + w * tx + (y * tz - z * ty),
py + w * ty + (z * tx - x * tz),
pz + w * tz + (x * ty - y * tx)
]
}
const nearestEquivalentAngle = (angle: number, current: number) => {
let result = angle
while (result - current > 180) result -= 360
while (result - current < -180) result += 360
return clamp(result, -365, 365)
}
export const POSE_HOME: HeadPose = { turn: 33, tilt: -19, roll: 38 }
export const conjugateFlipY = ([w, x, y, z]: Quaternion): Quaternion => [w, -x, y, -z]
export const orientationOf = (pose: HeadPose): Quaternion =>
quaternionFromEuler(radians(pose.tilt), radians(pose.turn), radians(pose.roll))
export const poseFromOrientation = (pose: HeadPose, orientation: Quaternion): HeadPose => {
const [rx, ry, rz] = quaternionToEuler(orientation)
return {
tilt: nearestEquivalentAngle((rx * 180) / Math.PI, pose.tilt),
turn: nearestEquivalentAngle((ry * 180) / Math.PI, pose.turn),
roll: nearestEquivalentAngle((rz * 180) / Math.PI, pose.roll)
}
}
export const visualOrientation = (pose: HeadPose, home: HeadPose = POSE_HOME): Quaternion => {
const [w, x, y, z] = orientationOf(home)
return multiplyQuaternions(orientationOf(pose), [w, -x, -y, -z])
}
export const screenOrientation = (pose: HeadPose, home: HeadPose = POSE_HOME): Quaternion =>
conjugateFlipY(visualOrientation(pose, home))
export const poseFromScreen = (
pose: HeadPose,
screen: Quaternion,
home: HeadPose = POSE_HOME
): HeadPose =>
poseFromOrientation(pose, multiplyQuaternions(conjugateFlipY(screen), orientationOf(home)))
export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
export const rotatePoseAroundAxis = (pose: HeadPose, axis: 'x' | 'y' | 'z', deltaDegrees: number) => {
const start = screenOrientation(pose)
const worldAxis = rotateWithQuaternion(start, axisVector(axis))
return poseFromScreen(
pose,
multiplyQuaternions(quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)), start)
)
}
export const rotatePoseAroundCamera = (pose: HeadPose, deltaRadians: number) =>
poseFromScreen(
pose,
multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), screenOrientation(pose))
)
const arcballVector = (
[xValue, yValue]: readonly [number, number],
radius = RADIUS
): Point3 => {
const x = xValue / radius
const y = yValue / radius
const squaredLength = x * x + y * y
if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
const length = Math.sqrt(squaredLength)
return [x / length, y / length, 0]
}
export const rotatePoseWithArcball = (
pose: HeadPose,
startPoint: readonly [number, number],
currentPoint: readonly [number, number],
radius = RADIUS
) => {
const start = screenOrientation(pose)
const delta = quaternionFromVectors(
arcballVector(startPoint, radius),
arcballVector(currentPoint, radius)
)
return poseFromScreen(pose, multiplyQuaternions(delta, start))
}
export const rotationRing = (pose: HeadPose, axis: 'x' | 'y' | 'z', radius = RING): Point3[] =>
Array.from({ length: 97 }, (_, index) => {
const angle = (index / 96) * Math.PI * 2
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
const point: Point3 =
axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
const rotated = rotateWithQuaternion(screenOrientation(pose), point)
return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
})
export const ringPath = (points: Point3[]) =>
`M${points[0]![0]} ${points[0]![1]}${points
.slice(1)
.map((point) => `L${point[0]} ${point[1]}`)
.join('')}Z`
export const openRingPath = (points: Point3[]) => {
if (!points.length) return ''
return `M${points[0]![0]} ${points[0]![1]}${points
.slice(1)
.map((point) => `L${point[0]} ${point[1]}`)
.join('')}`
}
export const splitRingArcs = (points: Point3[]): { front: Point3[][]; back: Point3[][] } => {
const front: Point3[][] = []
const back: Point3[][] = []
if (points.length < 2) return { front, back }
const cut = (from: Point3, to: Point3): Point3 => {
const span = to[2] - from[2]
const t = Math.abs(span) < 1e-8 ? 0.5 : from[2] / -span
return [
from[0] + (to[0] - from[0]) * t,
from[1] + (to[1] - from[1]) * t,
0
]
}
let current: Point3[] = [points[0]!]
let facing = points[0]![2] >= 0
for (let i = 1; i < points.length; i++) {
const point = points[i]!
const nowFront = point[2] >= 0
if (nowFront !== facing) {
const mid = cut(current[current.length - 1]!, point)
current.push(mid)
;(facing ? front : back).push(current)
current = [mid, point]
facing = nowFront
} else {
current.push(point)
}
}
if (current.length > 1) (facing ? front : back).push(current)
return { front, back }
}
export const unitVector = (
from: readonly [number, number] | Point3,
to: readonly [number, number] | Point3
): readonly [number, number] => {
const x = to[0] - from[0]
const y = to[1] - from[1]
const length = Math.hypot(x, y) || 1
return [x / length, y / length]
}
const polyline = (points: Point3[]) => {
if (points.length < 2) return ''
return `M${points[0]![0].toFixed(2)} ${points[0]![1].toFixed(2)}${points
.slice(1)
.map((point) => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
.join('')}`
}
const visibleWire = (points: Point3[]) => {
const segments: Point3[][] = []
let segment: Point3[] = []
for (const point of points) {
if (point[2] > 0) segment.push(point)
else if (segment.length) {
segments.push(segment)
segment = []
}
}
if (segment.length) segments.push(segment)
return segments
.filter((item) => item.length > 1)
.map(polyline)
.join('')
}
export const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
const halfWidth = width / 2
const halfHeight = height / 2
const cornerRadius = Math.min(halfHeight, halfWidth)
const points: (readonly [number, number])[] = []
const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
for (let index = 0; index < samples; index += 1) {
const progress = index / samples
points.push([
start[0] + (end[0] - start[0]) * progress,
start[1] + (end[1] - start[1]) * progress
])
}
}
const addArc = (centerX: number, centerY: number, startAngle: number) => {
for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
points.push([
centerX + Math.cos(angle) * cornerRadius,
centerY + Math.sin(angle) * cornerRadius
])
}
}
addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
return points
}
export const projectEyePoint = (
pose: HeadPose,
faceX: number,
faceY: number,
localX: number,
localY: number,
angleDeg: number,
home: HeadPose = POSE_HOME
): Point3 => {
const angle = radians(angleDeg)
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
return projectFacePoint(
pose,
faceX + localX * cosine - localY * sine,
faceY + localX * sine + localY * cosine,
RADIUS,
[0, 0],
home
)
}
export const projectFacePoint = (
pose: HeadPose,
x: number,
y: number,
radius: number,
origin: readonly [number, number] = [radius, radius],
home: HeadPose = POSE_HOME
): Point3 => {
const longitude = x / radius
const latitude = y / radius
const cosine = Math.cos(latitude)
const local: Point3 = [
cosine * Math.sin(longitude),
Math.sin(latitude),
cosine * Math.cos(longitude)
]
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
const depth = rotated[2] * radius
const scale = FOCAL / Math.max(FOCAL - depth * PERSPECTIVE, 0.0001)
return [
origin[0] + rotated[0] * radius * scale,
origin[1] + rotated[1] * radius * scale,
rotated[2]
]
}
export const wirePaths = (
pose: HeadPose,
radius: number,
origin: readonly [number, number] = [0, 0]
): string[] => {
const project = (longitude: number, latitude: number): Point3 =>
projectFacePoint(
pose,
longitude * radius,
latitude * radius,
radius,
origin
)
const parallels = [-60, -30, 0, 30, 60].map((latitude) =>
Array.from({ length: 73 }, (_, index) => project(radians(-180 + index * 5), radians(latitude)))
)
const meridians = Array.from({ length: 12 }, (_, index) => -150 + index * 30).map((longitude) =>
Array.from({ length: 37 }, (_, step) => project(radians(longitude), radians(-90 + step * 5)))
)
return [...parallels, ...meridians].map(visibleWire).filter(Boolean)
}
+221
View File
@@ -0,0 +1,221 @@
import { clamp, type HeadPose } from './math'
export type EyeSide = {
width: number
height: number
size: number
angle: number
x: number
y: number
}
export type EyeTune = {
left: EyeSide
right: EyeSide
spacing: number
}
export type EyeLinks = { width: boolean; height: boolean; size: boolean }
export type BodyOffset = { tx: number; ty: number; spin: number }
export type ManualPart = 'body' | 'left' | 'right'
export type ManualState = {
on: boolean
pose: HeadPose
offset: BodyOffset
eyes: EyeTune
links: EyeLinks
selected: ManualPart | null
}
export const DEFAULT_POSE: HeadPose = { turn: 17, tilt: -14, roll: 29 }
export const DEFAULT_OFFSET: BodyOffset = { tx: 0, ty: 0, spin: 0 }
export const DEFAULT_EYE: EyeSide = { width: 20, height: 50, size: 1, angle: 0, x: 0, y: -7 }
export const DEFAULT_EYES: EyeTune = {
left: { ...DEFAULT_EYE },
right: { ...DEFAULT_EYE },
spacing: 35
}
export const DEFAULT_LINKS: EyeLinks = { width: true, height: true, size: true }
export const defaultManual = (): ManualState => ({
on: false,
pose: { ...DEFAULT_POSE },
offset: { ...DEFAULT_OFFSET },
eyes: {
left: { ...DEFAULT_EYE },
right: { ...DEFAULT_EYE },
spacing: 35
},
links: { ...DEFAULT_LINKS },
selected: null
})
const num = (value: unknown, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback
const eyeOf = (value: unknown, fallback: EyeSide): EyeSide => {
const raw = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const width = num(raw.width, fallback.width)
const height = num(raw.height, fallback.height)
return {
width: clamp(width <= 5 ? width * 20 : width, 10, 110),
height: clamp(height <= 5 ? height * 50 : height, 10, 110),
size: clamp(num(raw.size, fallback.size), 0.35, 2.2),
angle: num(raw.angle, fallback.angle),
x: clamp(num(raw.x, fallback.x), -48, 48),
y: clamp(num(raw.y, fallback.y), -48, 48)
}
}
export const clampPose = (pose: HeadPose): HeadPose => ({
turn: clamp(pose.turn, -365, 365),
tilt: clamp(pose.tilt, -365, 365),
roll: clamp(pose.roll, -365, 365)
})
export const clampOffset = (offset: BodyOffset): BodyOffset => ({
tx: clamp(offset.tx, -90, 90),
ty: clamp(offset.ty, -90, 90),
spin: clamp(offset.spin, -180, 180)
})
export const clampEyes = (eyes: EyeTune): EyeTune => ({
left: eyeOf(eyes.left, DEFAULT_EYE),
right: eyeOf(eyes.right, DEFAULT_EYE),
spacing: clamp(
(() => {
const value = num(eyes.spacing, 35)
return value <= 5 ? value * 35 : value
})(),
0,
150
)
})
export const parseManual = (raw: string | null): ManualState => {
const base = defaultManual()
if (!raw) return base
try {
const data = JSON.parse(raw) as Record<string, unknown>
const pose = data.pose && typeof data.pose === 'object' ? (data.pose as HeadPose) : base.pose
const offset =
data.offset && typeof data.offset === 'object' ? (data.offset as BodyOffset) : base.offset
const eyes = data.eyes && typeof data.eyes === 'object' ? (data.eyes as EyeTune) : base.eyes
const links = data.links && typeof data.links === 'object' ? (data.links as EyeLinks) : base.links
const selected =
data.selected === 'body' || data.selected === 'left' || data.selected === 'right'
? data.selected
: null
return {
on: !!data.on,
pose: clampPose({
turn: num(pose.turn, DEFAULT_POSE.turn),
tilt: num(pose.tilt, DEFAULT_POSE.tilt),
roll: num(pose.roll, DEFAULT_POSE.roll)
}),
offset: clampOffset({
tx: num(offset.tx, 0),
ty: num(offset.ty, 0),
spin: num(offset.spin, 0)
}),
eyes: clampEyes(eyes),
links: {
width: links.width !== false,
height: links.height !== false,
size: links.size !== false
},
selected
}
} catch {
return base
}
}
export const persistable = (state: ManualState) => ({
on: state.on,
pose: state.pose,
offset: state.offset,
eyes: state.eyes,
links: state.links
})
export const updateEye = (
eyes: EyeTune,
side: 'left' | 'right',
patch: Partial<EyeSide>,
links: EyeLinks
): EyeTune => {
const next = {
left: { ...eyes.left },
right: { ...eyes.right },
spacing: eyes.spacing
}
next[side] = { ...next[side], ...patch }
const other = side === 'left' ? 'right' : 'left'
;(['width', 'height', 'size'] as const).forEach((key) => {
if (patch[key] != null && links[key]) next[other][key] = patch[key]!
})
return clampEyes(next)
}
export const scaleEye = (
eyes: EyeTune,
side: 'left' | 'right',
factor: number,
linked: boolean
): EyeTune => {
const apply = (eye: EyeSide): EyeSide => ({
...eye,
width: clamp(eye.width * factor, 10, 110),
height: clamp(eye.height * factor, 10, 110)
})
const next = {
left: { ...eyes.left },
right: { ...eyes.right },
spacing: eyes.spacing
}
next[side] = apply(next[side])
if (linked) {
const other = side === 'left' ? 'right' : 'left'
next[other] = apply(next[other])
}
return clampEyes(next)
}
export const resetHead = (state: ManualState): ManualState => ({
...state,
pose: { ...DEFAULT_POSE }
})
export const resetBody = (state: ManualState): ManualState => ({
...state,
offset: { ...DEFAULT_OFFSET }
})
export const resetEyes = (state: ManualState): ManualState => ({
...state,
eyes: {
left: { ...DEFAULT_EYE },
right: { ...DEFAULT_EYE },
spacing: 35
}
})
export const resetAll = (state: ManualState): ManualState => ({
...state,
pose: { ...DEFAULT_POSE },
offset: { ...DEFAULT_OFFSET },
eyes: {
left: { ...DEFAULT_EYE },
right: { ...DEFAULT_EYE },
spacing: 35
},
selected: null
})
+35
View File
@@ -0,0 +1,35 @@
export type ManipulationSession<Value> = {
initial: Value
latest: Value
}
export const beginManipulation = <Value>(initial: Value): ManipulationSession<Value> => ({
initial,
latest: initial
})
export const beginManipulationFromRenderedValue = <Value>(
readRenderedValue: () => Value
): ManipulationSession<Value> => beginManipulation(readRenderedValue())
export const previewManipulation = <Value>(
session: ManipulationSession<Value>,
next: Value,
preview?: (value: Value) => void
) => {
session.latest = next
preview?.(next)
}
export const finishManipulation = <Value>(
session: ManipulationSession<Value>,
outcome: 'commit' | 'cancel',
handlers: { preview: (value: Value) => void; commit: (value: Value) => void }
) => {
if (outcome === 'cancel') {
handlers.preview(session.initial)
return session.initial
}
handlers.commit(session.latest)
return session.latest
}
+99
View File
@@ -0,0 +1,99 @@
export type LocalPoint = readonly [number, number]
export type Frame = {
x: number
y: number
w: number
h: number
}
export const VIEW = 300
export const HALF = VIEW / 2
export const HEAD_RE = 114.2705
export const localHeadRadius = (svg: SVGSVGElement | null, radius = HEAD_RE) => {
if (!svg) return 95
const box = svg.viewBox.baseVal
if (!box.width) return 95
return (radius / box.width) * VIEW
}
export const toLocal = (
svg: SVGSVGElement | null,
clientX: number,
clientY: number
): LocalPoint | null => {
if (!svg) return null
const rectangle = svg.getBoundingClientRect()
if (rectangle.width < 1 || rectangle.height < 1) return null
return [
((clientX - rectangle.left) / rectangle.width) * VIEW - HALF,
((clientY - rectangle.top) / rectangle.height) * VIEW - HALF
]
}
export const overlayToHost = (
host: SVGSVGElement | null,
x: number,
y: number
): LocalPoint | null => {
if (!host) return null
const box = host.viewBox.baseVal
const w = box.width || VIEW
const h = box.height || VIEW
return [box.x + ((x + HALF) / VIEW) * w, box.y + ((y + HALF) / VIEW) * h]
}
export const hostToLocal = (
host: SVGSVGElement | null,
x: number,
y: number
): LocalPoint | null => {
if (!host) return null
const matrix = host.getScreenCTM()
if (!matrix) return toLocal(host, x, y)
const point = host.createSVGPoint()
point.x = x
point.y = y
const screen = point.matrixTransform(matrix)
return toLocal(host, screen.x, screen.y)
}
export const mapElementPoint = (
el: SVGGraphicsElement,
x: number,
y: number,
host: SVGSVGElement | null
): LocalPoint | null => {
const svg = el.ownerSVGElement
const screenMatrix = el.getScreenCTM()
const localMatrix = el.getCTM()
const hostMatrix = host?.getScreenCTM()
if (!svg || (!screenMatrix && (!localMatrix || !hostMatrix))) return null
const point = svg.createSVGPoint()
point.x = x
point.y = y
const screen = screenMatrix
? point.matrixTransform(screenMatrix)
: point.matrixTransform(localMatrix!).matrixTransform(hostMatrix!)
return toLocal(host ?? svg, screen.x, screen.y)
}
export const frameOf = (
el: SVGGraphicsElement | null | undefined,
overlay: SVGSVGElement | null
): Frame | null => {
if (!el || !overlay) return null
if (el.style.display === 'none') return null
const box = el.getBoundingClientRect()
const host = overlay.getBoundingClientRect()
if (box.width < 0.5 || box.height < 0.5 || host.width < 1) return null
const sx = VIEW / host.width
const sy = VIEW / host.height
return {
x: (box.left + box.width / 2 - host.left) * sx - HALF,
y: (box.top + box.height / 2 - host.top) * sy - HALF,
w: box.width * sx,
h: box.height * sy
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
const PREFIXE = 'study:'
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'expression', 'langue'] as const
const NOMS = ['cycles', 'cycle', 'forme', 'couleur', 'expression', 'langue', 'manuel'] as const
export type NomStocke = (typeof NOMS)[number]
+104
View File
@@ -0,0 +1,104 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { clampSheet, snapExpanded } from '@/ui/playback'
const TAP = 6
export function useSnapSheet(details: Ref<HTMLElement | null>) {
const y = ref(0)
const expanded = ref(false)
const dragging = ref(false)
const collapsed = ref(0)
const ready = ref(false)
const opacity = computed(() => {
const max = collapsed.value
if (max <= 0) return expanded.value ? 1 : 0
return 1 - Math.min(1, Math.max(0, y.value / max))
})
let originY = 0
let originOffset = 0
let lastT = 0
let lastClientY = 0
let velocity = 0
let observer: ResizeObserver | null = null
function measure() {
const h = details.value?.getBoundingClientRect().height ?? 0
if (h <= 0) return
const first = !ready.value
collapsed.value = h
if (first) {
y.value = expanded.value ? 0 : h
ready.value = true
return
}
if (!dragging.value) y.value = expanded.value ? 0 : h
}
function snap(open: boolean) {
expanded.value = open
y.value = open ? 0 : collapsed.value
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
dragging.value = true
originY = e.clientY
originOffset = y.value
lastT = e.timeStamp
lastClientY = e.clientY
velocity = 0
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value) return
const dt = e.timeStamp - lastT
if (dt > 0) velocity = ((e.clientY - lastClientY) / dt) * 1000
lastT = e.timeStamp
lastClientY = e.clientY
y.value = clampSheet(originOffset + (e.clientY - originY), collapsed.value)
}
function onPointerUp(e: PointerEvent) {
if (!dragging.value) return
dragging.value = false
if (Math.abs(e.clientY - originY) < TAP) {
snap(!expanded.value)
return
}
snap(snapExpanded(y.value, velocity, collapsed.value))
}
onMounted(async () => {
await nextTick()
measure()
if (!details.value) return
observer = new ResizeObserver(measure)
observer.observe(details.value)
})
watch(details, (el, prev) => {
if (prev) observer?.unobserve(prev)
if (!el) return
observer ??= new ResizeObserver(measure)
observer.observe(el)
measure()
})
onBeforeUnmount(() => observer?.disconnect())
return {
y,
expanded,
dragging,
collapsed,
opacity,
ready,
snap,
onPointerDown,
onPointerMove,
onPointerUp
}
}