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/ /tmp/
snap-out/ snap-out/
# Upstream reference checkout for editor/geometry comparison
bible-strong-avatar-lab/
# Never commit unpacked app dumps, unpack scripts, or third-party marks # Never commit unpacked app dumps, unpack scripts, or third-party marks
extracted/ extracted/
extract-asar.js extract-asar.js
+4
View File
@@ -42,6 +42,10 @@
setPaused: (v) => bot.setPaused(v), setPaused: (v) => bot.setPaused(v),
setMode: (m) => bot.setMode(m), setMode: (m) => bot.setMode(m),
setFollowPointer: (v) => bot.setFollowPointer(v), 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), setEmphasis: (v) => bot.setEmphasis(v),
spin: (n) => bot.spinOnce(n), spin: (n) => bot.spinOnce(n),
bounce: () => bot.bounceOnce(), 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 }, 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 }, 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 { class GrokCharacter {
constructor(svg, opts = {}) { constructor(svg, opts = {}) {
@@ -46,13 +48,22 @@
this.eyeTopology = opts.eyeTopology ?? this.loginWrap; this.eyeTopology = opts.eyeTopology ?? this.loginWrap;
this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null); 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.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.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.uniformEyes = opts.uniformEyes ?? (this.loginWrap ? UNIFORM_EYES : false);
this.eyeScaleProp = opts.eyeScale ?? (this.loginWrap ? shapeEyeScale(this.shapeName) : 1); this.eyeScaleProp = opts.eyeScale ?? (this.loginWrap ? shapeEyeScale(this.shapeName) : 1);
this.emphasis = !!opts.emphasis; this.emphasis = !!opts.emphasis;
this.followPointer = !!opts.followPointer; this.followPointer = !!opts.followPointer;
this.gazeTarget = opts.gazeTarget || null; this.gazeTarget = opts.gazeTarget || null;
this.frozen = opts.paused === "hold-pose"; this.frozen = opts.paused === "hold-pose";
this.holdAt = 900;
this.clockHold = 0;
this.frozenAt = 0;
this.driven = !!opts.driven; this.driven = !!opts.driven;
this.paused = !!opts.paused; this.paused = !!opts.paused;
this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
@@ -84,6 +95,10 @@
this.eyeTo = 0; this.eyeTo = 0;
this.eyeStiffness = 7; this.eyeStiffness = 7;
this.eyeIdx = 0; this.eyeIdx = 0;
this.eyeHoldMs = 0;
this.eyeRemain = 0;
this.playlistHold = false;
this.eyePinned = false;
this._fromPolys = null; this._fromPolys = null;
this.t0 = performance.now(); this.t0 = performance.now();
@@ -134,6 +149,19 @@
this.setColor(this.colorId, this.scheme); this.setColor(this.colorId, this.scheme);
this._applyPoseScale(); this._applyPoseScale();
this.setState(this.state, { resetEyes: true }); 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.driven) this._bindPointer();
if (this.frozen) { if (this.frozen) {
this.holdFrame(); this.holdFrame();
@@ -188,6 +216,10 @@
this.holdFrame(); this.holdFrame();
return; return;
} }
if (!v && this.frozen && this.frozenAt) {
this.clockHold += performance.now() - this.frozenAt;
}
this.frozenAt = 0;
this.frozen = false; this.frozen = false;
this.paused = !!v; this.paused = !!v;
if (!this.paused) this._armTick(); if (!this.paused) this._armTick();
@@ -195,12 +227,16 @@
_armTick() { _armTick() {
if (this.frozen || this.driven || this._raf) return; if (this.frozen || this.driven || this._raf) return;
this.last = performance.now(); this.last = this._clock();
this._raf = requestAnimationFrame((t) => this._tick(t)); this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold));
} }
_wake() { _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(); 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) { setGazeTarget(pt) {
this.gazeTarget = pt; this.gazeTarget = pt;
} }
@@ -277,7 +418,47 @@
} }
_clock() { _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 } = {}) { setState(name, { resetEyes = false } = {}) {
@@ -302,7 +483,11 @@
} else if (name !== "sleeping" && name !== "waking") { } else if (name !== "sleeping" && name !== "waking") {
this._morphEyes(list[0], name === "excited" ? 10 : 8); 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]; const blink = BLINK_MS[name];
this.blinkUntil = blink ? this.stateAt + rand(1500, 7000) : Infinity; this.blinkUntil = blink ? this.stateAt + rand(1500, 7000) : Infinity;
this.gazeUntil = this.stateAt + rand(500, 1400); 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) { spinOnce(turns = 1) {
if (this.manualHold) return;
this._wake(); this._wake();
this._pn(turns); this._pn(turns);
} }
bounceOnce() { bounceOnce() {
if (this.manualHold) return;
this._wake(); this._wake();
this._hop(performance.now()); this._hop(performance.now());
} }
burstOnce() { 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) { 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.gazeOrbit = { at: performance.now(), dur: Math.max(ms, 1), from: Math.PI * 2 };
this._armTick(); this._armTick();
} }
@@ -372,6 +606,7 @@
holdFrame(at = 900) { holdFrame(at = 900) {
this.frozen = true; this.frozen = true;
this.paused = true; this.paused = true;
this.holdAt = at;
if (this._raf) { if (this._raf) {
cancelAnimationFrame(this._raf); cancelAnimationFrame(this._raf);
this._raf = 0; this._raf = 0;
@@ -409,6 +644,11 @@
const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 }; const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 };
snapSpring(this.gazeX, gz.x); snapSpring(this.gazeX, gz.x);
snapSpring(this.gazeY, gz.y); 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.x = 1;
this.eyeMorph.v = 0; this.eyeMorph.v = 0;
this.eyeMorph.t = 1; this.eyeMorph.t = 1;
@@ -514,6 +754,19 @@
this.clipPath.setAttribute("d", geo.shapes[this.shapeName].path); 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) { _morphEyes(index, stiffness = 7) {
if (index === this.eyeTo && this.eyeMorph.t === 1) return; if (index === this.eyeTo && this.eyeMorph.t === 1) return;
const t = clamp(this.eyeMorph.x, 0, 1); const t = clamp(this.eyeMorph.x, 0, 1);
@@ -544,7 +797,7 @@
} }
_cycleShapeTrick() { _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.trickCycle = (this.trickCycle + 1) % 5;
this.wildWide = false; this.wildWide = false;
if (this.trickCycle === 0) this._pn(1); if (this.trickCycle === 0) this._pn(1);
@@ -654,16 +907,21 @@
prev: this.poseOut, prev: this.poseOut,
}); });
this.poseOut = pose; this.poseOut = pose;
this.spin.t = pose.spin; this.spin.t = this.manualHold ? 0 : pose.spin;
this.tx.t = pose.tx; this.tx.t = this.manualHold ? 0 : pose.tx;
this.ty.t = pose.ty; this.ty.t = this.manualHold ? 0 : pose.ty;
this.squash.t = pose.squash; this.squash.t = this.manualHold ? 1 : pose.squash;
this.eyeScale.t = pose.eyeBoost; this.eyeScale.t = this.manualHold ? 1 : pose.eyeBoost;
if (this.ctx.tyKick) { 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.ty.v += this.ctx.tyKick;
this.ctx.tyKick = 0; this.ctx.tyKick = 0;
} }
if (this.ctx.spinKick) { if (!this.manualHold && this.ctx.spinKick) {
this.spin.v += this.ctx.spinKick; this.spin.v += this.ctx.spinKick;
this.ctx.spinKick = 0; this.ctx.spinKick = 0;
} }
@@ -684,24 +942,33 @@
EY.queueBlink(this.blinkQueue, now); EY.queueBlink(this.blinkQueue, now);
this.ctx.wantBlink = false; this.ctx.wantBlink = false;
} }
if (this.ctx.wantPn) { if (!this.manualHold && this.ctx.wantPn) {
this._pn(...this.ctx.wantPn); this._pn(...this.ctx.wantPn);
this.ctx.wantPn = null; 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.particles.burst(this.ctx.wantBurst[0], this.ctx.wantBurst[1]);
this.ctx.wakingBurst = true; this.ctx.wakingBurst = true;
this.ctx.wantBurst = null; 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.trick = TR.startTrick("spinWild", this.reduceMotion || this.paused);
this.celebrateAt = now + 6200; 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) { if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) {
const z = Math.random(); const z = Math.random();
if (V_T.has(this.state)) { if (V_T.has(this.state)) {
@@ -725,14 +992,17 @@
} }
this.extras = { ...tf, turn: tf.turn, hop }; 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]; const list = this.playlists[this.state];
this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length; this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length;
const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6; const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6;
this._morphEyes(list[this.eyeIdx], stiff); 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]; const blinkCadence = BLINK_MS[this.state];
@@ -741,16 +1011,18 @@
this.blinkUntil = now + rand(...blinkCadence); this.blinkUntil = now + rand(...blinkCadence);
} }
const blinkKey = EY.consumeBlink(this.blinkQueue, now); 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); const gz = nextGaze(this.state);
this.gazeX.t = gz.x; this.gazeX.t = gz.x;
this.gazeY.t = gz.y; this.gazeY.t = gz.y;
this.gazeUntil = now + rand(...gz.hold); 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.winkAt = now;
this.winkEye = Math.random() < 0.5 ? 0 : 1; this.winkEye = Math.random() < 0.5 ? 0 : 1;
this.winkUntil = now + rand(4500, 10000); this.winkUntil = now + rand(4500, 10000);
@@ -758,21 +1030,21 @@
this.emphasisBlend += ((this.emphasis ? 1 : 0) - this.emphasisBlend) * Rn(0.12); 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.eyeScale.t = Math.max(this.eyeScale.t, 1.32);
this.blink.t = Math.max(this.blink.t, 1.18); this.blink.t = Math.max(this.blink.t, 1.18);
} }
const humming = this.state === "humming"; const humming = this.state === "humming";
const loading = this.state === "loading"; const loading = this.state === "loading";
if ((humming || loading) && !this.reduceMotion) { if ((humming || loading) && !this.reduceMotion && !this.manualHold) {
const Zt = dtState; const Zt = dtState;
const dn = loading ? 3 : 1.6; 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); 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; this.ovSpin += on * dt;
} }
if (this.reduceMotion) { if (this.reduceMotion && !this.manualHold) {
this._morphEyes(this.playlists[this.state][0]); this._morphEyes(this.playlists[this.state][0]);
this.spin.t = 0; this.tx.t = 0; this.ty.t = 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; this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1;
@@ -822,8 +1094,10 @@
} else { } else {
this.gazeSpin = 0; this.gazeSpin = 0;
} }
this.notify.t = this.state === "notifying" ? 1 : 0; if (!this.manualHold) {
this.humDots.t = this.state === "humming" ? 1 : 0; this.notify.t = this.state === "notifying" ? 1 : 0;
this.humDots.t = this.state === "humming" ? 1 : 0;
}
let spinAngle = 0; let spinAngle = 0;
if (this.spinTurn) spinAngle = this.spinTurn.x; if (this.spinTurn) spinAngle = this.spinTurn.x;
@@ -840,8 +1114,8 @@
this.particles.update(now, dt, { this.particles.update(now, dt, {
spinAngle, spinAngle,
sizeScale: this.partScale, sizeScale: this.partScale,
wideStyle: this.trick?.kind === "spinWild" || this.wildWide || humming, wideStyle: !this.manualHold && (this.trick?.kind === "spinWild" || this.wildWide || humming),
sustainBelts: humming || loading, sustainBelts: !this.manualHold && (humming || loading),
}); });
this._updatePointer(now); this._updatePointer(now);
@@ -887,7 +1161,7 @@
return; return;
} }
this._raf = requestAnimationFrame((t) => this._tick(t)); this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold));
} }
_paint(now) { _paint(now) {
@@ -907,9 +1181,9 @@
const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix); const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix);
const bodyW = 1 - yl; const bodyW = 1 - yl;
const ex = this.extras; const ex = this.extras;
const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * 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; 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; 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 sx = bodyW + ov.wre * yl;
const sy = this.squash.x * bodyW + ov.wre * yl; const sy = this.squash.x * bodyW + ov.wre * yl;
this.group.setAttribute( this.group.setAttribute(
@@ -922,17 +1196,12 @@
const pencil = this.ovKind === "pencil" || this.ovPrev === "pencil"; const pencil = this.ovKind === "pencil" || this.ovPrev === "pencil";
const tear = geo.shapes.teardrop?.path; const tear = geo.shapes.teardrop?.path;
const spinAmt = ex.turn; const spinAmt = ex.turn;
const spinning = spinAmt != null;
const restRing = morphing const restRing = morphing
? FX.lerpRing(this.prevRing, FX.shapeRing(shape.path, R), morphK) ? FX.lerpRing(this.prevRing, FX.shapeRing(shape.path, R), morphK)
: FX.shapeRing(shape.path, R); : FX.shapeRing(shape.path, R);
let liveRing = restRing; const posed = this._poseRing(restRing, R, spinAmt, morphing);
let turned = false; const liveRing = posed.ring;
const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R, geo.solids) : null; const turned = posed.turned;
if (turnAt) {
liveRing = turnAt(spinAmt);
turned = true;
}
let faceTop = shape.top; let faceTop = shape.top;
let faceBottom = shape.bottom; let faceBottom = shape.bottom;
if (morphing || turned) { if (morphing || turned) {
@@ -1007,6 +1276,8 @@
top: faceTop, top: faceTop,
bottom: faceBottom, bottom: faceBottom,
emphasisBlend: this.emphasisBlend, emphasisBlend: this.emphasisBlend,
tune: this.eyeTune,
manualHold: this.manualHold,
}); });
const hum = clamp(this.humDots.x, 0, 1); const hum = clamp(this.humDots.x, 0, 1);
+24 -9
View File
@@ -36,7 +36,7 @@
const { const {
now, polys, morphT, shape, face, faceTune, uniformEyes, eyeScaleProp, now, polys, morphT, shape, face, faceTune, uniformEyes, eyeScaleProp,
blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX, 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; } = opt;
const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI); const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI);
const $i = { const $i = {
@@ -47,6 +47,8 @@
eye: face.eye * (faceTune?.size ?? 1), eye: face.eye * (faceTune?.size ?? 1),
leftDX: face.leftDX ?? 0, 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 sX = uniformEyes ? $i.leftDX : 0;
const cents = [centroid(polys[0]), centroid(polys[1])]; const cents = [centroid(polys[0]), centroid(polys[1])];
let a1 = 0, o1 = 0; 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 _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 Uee = (uniformEyes ? 1 : $i.eye) * clamp(eyeScaleProp, 0.25, 4);
const oX = Math.min(clamp(opt.eyeBoostX, 0.2, 2) * Uee, _ee / pulse); 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 Hee0 = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse);
const u1 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3); const u10 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3);
const liveSpan = ringHint const liveSpan = ringHint
? (y) => spanPoly(ringHint, y, Re) ? (y) => spanPoly(ringHint, y, Re)
: spanAt(shape.path, Re); : spanAt(shape.path, Re);
@@ -76,10 +78,20 @@
: [Re, shape.top]; : [Re, shape.top];
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
if (tune && manualHold) continue;
const poly = polys[i]; const poly = polys[i];
const [Gn, Ti] = cents[i]; const [Gn, Ti] = cents[i];
eyeEls[i].setAttribute("d", polyPath(poly)); eyeEls[i].setAttribute("d", polyPath(poly));
const lid = winkLid(blinkX, now, winkAt, winkEye, i); 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); const Ea = Gn + (i === 0 ? sX : 0);
let Ca = Re + $i.x; let Ca = Re + $i.x;
let Wo = (Ea - Re) * $i.sx; let Wo = (Ea - Re) * $i.sx;
@@ -135,8 +147,8 @@
Tre = Dke(clamp(Io0 / 0.5, 0, 1)); 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 Kj = manualHold ? 0 : 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 Ko = manualHold ? 0 : Math.sin(now * 58e-5 + i) * 0.9;
if (pointer) { if (pointer) {
const Zl = Rn(0.16); const Zl = Rn(0.16);
pointer.x += (pointer.tx - pointer.x) * Zl; pointer.x += (pointer.tx - pointer.x) * Zl;
@@ -170,10 +182,10 @@
if (Ia2 - Frp > O2) O2 = Ia2 - Frp; if (Ia2 - Frp > O2) O2 = Ia2 - Frp;
if (li2 - Frp < Xl) Xl = li2 - 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; const lX = O2 <= Xl ? clamp(xre, O2, Xl) : (O2 + Xl) / 2;
let dd = lX + (xre - lX) * (1 - Tre); let dd = lX + (xre - lX) * (1 - Tre);
let Yj = vl; let Yj = vl + tuneY;
if (notifyX > 0.01) { if (notifyX > 0.01) {
const xr = 20 * clamp(notifyX, 0, 1.4); const xr = 20 * clamp(notifyX, 0, 1.4);
const Fr = dd - Yl[0], Ia = Yj - Yl[1]; 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) { if (use3d) {
const FrM = clamp((turn != null ? _c : 1) * Hee * pulse, 0.02, 2.4); const FrM = clamp((turn != null ? _c : 1) * Hee * pulse, 0.02, 2.4);
const IaM = clamp(lid * u1 * 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; const liM = km * FrM, blM = Ree * FrM, IoM = Fee * IaM, uoM = zee * IaM;
eyeEls[i].setAttribute( eyeEls[i].setAttribute(
"transform", "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 { } else {
eyeEls[i].setAttribute( eyeEls[i].setAttribute(
"transform", "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"> <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 AnimPanel from '@/components/AnimPanel.vue'
import Customizer from '@/components/Customizer.vue' import Customizer from '@/components/Customizer.vue'
import PlaybackBar from '@/components/PlaybackBar.vue'
import CycleDialog from '@/components/CycleDialog.vue' import CycleDialog from '@/components/CycleDialog.vue'
import ExportBar from '@/components/ExportBar.vue' import ExportBar from '@/components/ExportBar.vue'
import GifDialog from '@/components/GifDialog.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 Settings from '@/components/Settings.vue'
import SideRail, { type ViewId } from '@/components/SideRail.vue' import SideRail, { type ViewId } from '@/components/SideRail.vue'
import Timeline from '@/components/Timeline.vue' import Timeline from '@/components/Timeline.vue'
@@ -51,6 +52,7 @@ import {
type FondGif, type FondGif,
type FormatCycle type FormatCycle
} from '@/ui/export' } from '@/ui/export'
import { parseManual, persistable, type ManualState } from '@/ui/pose/model'
import { ecris, lis } from '@/ui/stockage' import { ecris, lis } from '@/ui/stockage'
const view = ref<ViewId>('personnaliser') const view = ref<ViewId>('personnaliser')
@@ -65,7 +67,22 @@ const hero = ref<{
spin: () => void spin: () => void
orbitGaze: (ms?: number) => void orbitGaze: (ms?: number) => void
svg: () => SVGSVGElement | null 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) } | null>(null)
const manual = ref<ManualState>(parseManual(lis('manuel')))
const restored = parseCycles(lis('cycles')) const restored = parseCycles(lis('cycles'))
const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()]) const cycles = ref<Cycle[]>(restored.length ? restored : [defaultCycle()])
@@ -91,13 +108,39 @@ const playedState = computed(() => {
if (view.value === 'animations' || preview.value) return state.value if (view.value === 'animations' || preview.value) return state.value
return expression.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(color, (id) => ecris('couleur', id))
watch(expression, (id) => { watch(expression, (id) => {
ecris('expression', id) ecris('expression', id)
if (view.value !== 'animations') state.value = 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> let pending: ReturnType<typeof setTimeout>
function enregistreCycles() { function enregistreCycles() {
@@ -145,10 +188,48 @@ function onSeek(t: number) {
state.value = cycle.value.blocks[index]?.state ?? 'idle' 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) const gardeAnim = ref(false)
watch(view, (now) => { watch(view, (now, prev) => {
intro.value = false intro.value = false
if (prev === 'personnaliser' && now !== 'personnaliser') releasePoseFreeze()
if (now === 'animations') { if (now === 'animations') {
gardeAnim.value = true gardeAnim.value = true
playing.value = true playing.value = true
@@ -157,10 +238,33 @@ watch(view, (now) => {
} }
playing.value = now === 'reglages' playing.value = now === 'reglages'
state.value = expression.value state.value = expression.value
if (now === 'personnaliser') {
void nextTick(() => {
hero.value?.flushPerformance()
applyPosePlayback()
})
}
}) })
watch(preview, (on) => { 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 let raf = 0
@@ -169,6 +273,14 @@ function tick(ms: number) {
raf = requestAnimationFrame(tick) raf = requestAnimationFrame(tick)
const dt = last ? Math.min((ms - last) / 1000, 0.064) : 0 const dt = last ? Math.min((ms - last) / 1000, 0.064) : 0
last = ms 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 if (!playing.value || (view.value !== 'animations' && !preview.value)) return
const blocs = cycle.value.blocks const blocs = cycle.value.blocks
const cur = blocs[block.value] const cur = blocs[block.value]
@@ -185,6 +297,18 @@ function tick(ms: number) {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') preview.value = false 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() { function onPageHide() {
@@ -256,6 +380,7 @@ watch(dialogueCycle, (ouverte) => {
}) })
watch([view, preview], () => { watch([view, preview], () => {
if (manual.value.selected) manual.value = { ...manual.value, selected: null }
if (view.value !== 'animations' || preview.value) annuleCycle() if (view.value !== 'animations' || preview.value) annuleCycle()
}) })
@@ -316,6 +441,7 @@ async function exporte(id: ActionId, confirme = false) {
function onHeroReady(ok: boolean) { function onHeroReady(ok: boolean) {
ready.value = ok ready.value = ok
if (ok) void nextTick(applyPosePlayback)
if (!intro.value) return if (!intro.value) return
if (!ok) { if (!ok) {
intro.value = false intro.value = false
@@ -358,7 +484,7 @@ onUnmounted(() => {
</button> </button>
<div <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="[ :class="[
!preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]', !preview && 'scene--timeline lg:pb-[calc(var(--timeline)_+_1rem)]',
!preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]', !preview && view === 'animations' && 'pb-[calc(var(--timeline)_+_1rem)]',
@@ -385,22 +511,24 @@ onUnmounted(() => {
<div <div
class="avatar flex aspect-square w-full items-center justify-center" class="avatar flex aspect-square w-full items-center justify-center"
:class="[ :class="[
preview avatarMax,
? 'max-w-[min(560px,calc(100dvh_-_6rem))]'
: 'max-w-[min(460px,calc(100dvh_-_var(--timeline)_-_7rem))]',
nue && 'avatar--intro', nue && 'avatar--intro',
view === 'reglages' && !preview && 'avatar--geant' view === 'reglages' && !preview && 'avatar--geant',
view === 'personnaliser' && !preview && !nue && 'avatar--bas'
]" ]"
> >
<Hero <AvatarStage
ref="hero" ref="hero"
class="h-auto max-w-full" class="h-auto max-w-full"
:size="preview ? 560 : 440" :size="avatarSize"
:shape="shape" :shape="shape"
:color="color" :color="color"
:state="playedState" :state="playedState"
:follow="follow" :follow="follow"
:manual="manual"
:tools="view === 'personnaliser' && !preview"
@ready="onHeroReady" @ready="onHeroReady"
@update:manual="manual = $event"
/> />
</div> </div>
@@ -434,14 +562,19 @@ onUnmounted(() => {
<aside <aside
v-if="!preview" v-if="!preview"
class="panneau scene__droite w-full lg:w-80 lg:shrink-0" class="panneau scene__droite w-full lg:w-[21.25rem] lg:shrink-0"
:class="droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden'" :class="[
droite ? 'panneau--ouvert max-lg:order-2' : 'max-lg:hidden',
view === 'personnaliser' && !intro && 'playback-host'
]"
> >
<Customizer <Customizer
v-show="view === 'personnaliser'" v-show="view === 'personnaliser'"
class="playback-pad"
v-model:shape="shape" v-model:shape="shape"
v-model:color="color" v-model:color="color"
v-model:expression="expression" v-model:expression="expression"
v-model:manual="manual"
:ready="ready" :ready="ready"
:active="view === 'personnaliser'" :active="view === 'personnaliser'"
/> />
@@ -455,10 +588,24 @@ onUnmounted(() => {
:active="view === 'animations'" :active="view === 'animations'"
@pick="addBlock" @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> </aside>
</div> </div>
<Transition name="fondu">
<Timeline <Timeline
v-if="view === 'animations' && !preview" v-if="view === 'animations' && !preview"
v-model:cycles="cycles" v-model:cycles="cycles"
@@ -473,7 +620,6 @@ onUnmounted(() => {
@preview="preview = true" @preview="preview = true"
@exporter="dialogueCycle = true" @exporter="dialogueCycle = true"
/> />
</Transition>
<p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p> <p v-if="view === 'reglages' && !preview" class="wordmark" aria-hidden="true">STUDY</p>
</template> </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 shape?: string
color?: string color?: string
size?: number size?: number
eye?: number
interactive?: boolean interactive?: boolean
showLabel?: boolean showLabel?: boolean
active?: boolean active?: boolean
@@ -29,7 +30,8 @@ async function paint() {
color: props.color ?? 'black', color: props.color ?? 'black',
follow: false, follow: false,
paper: '#f9f9f9', paper: '#f9f9f9',
size: props.size ?? 60 size: props.size ?? 60,
eye: props.eye
}) })
painted.value = !!mount painted.value = !!mount
} }
@@ -39,7 +41,7 @@ const dirty = ref(false)
onMounted(() => { onMounted(() => {
void paint() void paint()
}) })
watch(() => [props.state, props.shape, props.color], () => { watch(() => [props.state, props.shape, props.color, props.eye], () => {
if (!props.active) { if (!props.active) {
dirty.value = true dirty.value = true
return return
+32 -1
View File
@@ -3,20 +3,51 @@ import { computed } from 'vue'
import BotTile from '@/components/BotTile.vue' import BotTile from '@/components/BotTile.vue'
import { shapeName, stateName, t } from '@/i18n' import { shapeName, stateName, t } from '@/i18n'
import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog' 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 props = defineProps<{ ready?: boolean; active?: boolean }>()
const shape = defineModel<string>('shape', { required: true }) const shape = defineModel<string>('shape', { required: true })
const color = defineModel<string>('color', { required: true }) const color = defineModel<string>('color', { required: true })
const expression = defineModel<string>('expression', { required: true }) const expression = defineModel<string>('expression', { required: true })
const manual = defineModel<ManualState>('manual', { required: true })
const shapes = computed(() => { const shapes = computed(() => {
void props.ready void props.ready
return liveShapes() return liveShapes()
}) })
function patch(next: Partial<ManualState>) {
manual.value = { ...manual.value, ...next }
}
</script> </script>
<template> <template>
<div> <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"> <div class="mt-2 grid grid-cols-4 gap-1.5">
<BotTile <BotTile
v-for="id in shapes" v-for="id in shapes"
+14 -1
View File
@@ -73,7 +73,20 @@ watch(
defineExpose({ defineExpose({
spin: () => replica?.spin(), spin: () => replica?.spin(),
orbitGaze: (ms?: number) => replica?.orbitGaze(ms), 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> </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 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 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 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. 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 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 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 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, 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"> <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> <span class="text-sm font-medium tabular-nums max-lg:hidden">{{ mmss(at) }}</span>
<button <button
@@ -262,14 +262,14 @@ function onRemove() {
@seek="emit('seek', $event)" @seek="emit('seek', $event)"
/> />
<!-- barre d'outils, dans le coin : loupe, compteur, aperçu --> <!-- barre d'outils : le temps a gauche, loupe et apercu a droite -->
<div class="flex shrink-0 items-center justify-end gap-4 max-sm:gap-2"> <div class="flex shrink-0 items-center gap-4 max-sm:gap-2">
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" /> <p class="mr-auto text-xs tabular-nums text-[var(--muted)]">
<p class="text-xs tabular-nums text-[var(--muted)]">
<span class="text-[var(--ink)]">{{ mmss(at) }}</span> / {{ mmss(total) }} <span class="text-[var(--ink)]">{{ mmss(at) }}</span> / {{ mmss(total) }}
</p> </p>
<ZoomSlider v-model:zoom="zoom" :min="MIN_ZOOM" :max="MAX_ZOOM" />
<!-- infobulle au survol ET au focus clavier, comme la barre laterale --> <!-- infobulle au survol ET au focus clavier, comme la barre laterale -->
<span class="group relative flex"> <span class="group relative flex">
<button <button
-1
View File
@@ -393,7 +393,6 @@ function onRulerMove(e: PointerEvent) {
data-carte data-carte
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight ArrowLeft ArrowRight" aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight ArrowLeft ArrowRight"
@keydown.enter.prevent="block = i" @keydown.enter.prevent="block = i"
@keydown.space.prevent="block = i"
@keydown.left="onCardKey(i, $event)" @keydown.left="onCardKey(i, $event)"
@keydown.right="onCardKey(i, $event)" @keydown.right="onCardKey(i, $event)"
> >
+38
View File
@@ -79,6 +79,28 @@ const en: typeof fr = {
blockDurationAria: 'Duration of {state}, {duration}', blockDurationAria: 'Duration of {state}, {duration}',
blockRemoveAria: 'Remove {state}' 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: { dialog: {
cancel: 'Cancel', cancel: 'Cancel',
nameCreateTitle: 'New cycle', nameCreateTitle: 'New cycle',
@@ -152,6 +174,22 @@ const en: typeof fr = {
units: { units: {
seconds: '{n} s', seconds: '{n} s',
secondsShort: '{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}', blockDurationAria: 'Durée de {state}, {duration}',
blockRemoveAria: 'Retirer {state}' 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: { dialog: {
cancel: 'Annuler', cancel: 'Annuler',
nameCreateTitle: 'Nouveau cycle', nameCreateTitle: 'Nouveau cycle',
@@ -152,6 +174,22 @@ const fr = {
units: { units: {
seconds: '{n} s', seconds: '{n} s',
secondsShort: '{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}', blockDurationAria: '{state} 的时长,{duration}',
blockRemoveAria: '移除 {state}' 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: { dialog: {
cancel: '取消', cancel: '取消',
nameCreateTitle: '新建循环', nameCreateTitle: '新建循环',
@@ -150,6 +172,22 @@ const zh: typeof fr = {
units: { units: {
seconds: '{n} 秒', seconds: '{n} 秒',
secondsShort: '{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. */ /** 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 = { export type ReplicaBot = {
setState: (name: string, opts?: { resetEyes?: boolean }) => void setState: (name: string, opts?: { resetEyes?: boolean }) => void
setShape: (name: string) => void setShape: (name: string) => void
@@ -7,16 +14,35 @@ export type ReplicaBot = {
setFollowPointer: (on: boolean) => void setFollowPointer: (on: boolean) => void
setEyeColor: (color: string | null) => void setEyeColor: (color: string | null) => void
setPaused: (on: boolean | 'hold-pose') => 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 spinOnce: (turns?: number) => void
bounceOnce: () => void bounceOnce: () => void
burstOnce: () => void burstOnce: () => void
orbitGaze: (ms?: number) => void orbitGaze: (ms?: number) => void
holdFrame: (at?: number) => void holdFrame: (at?: number) => void
freezeNow: (opts?: { settle?: boolean }) => ReplicaPlayback
step: (dt: number) => void step: (dt: number) => void
setSize: (px: number) => void setSize: (px: number) => void
seekEye: (index: number, opts?: { snap?: boolean }) => void
setPlaylistHold: (on: boolean) => void
playback: () => ReplicaPlayback
destroy: () => void destroy: () => void
} }
export type ReplicaPlayback = {
state: string
eyeIdx: number
holdMs: number
remainMs: number
held: boolean
frozen: boolean
}
declare global { declare global {
interface Window { interface Window {
GROK_GEO?: { GROK_GEO?: {
@@ -26,6 +52,9 @@ declare global {
} }
GROK_TABLES?: { GROK_TABLES?: {
GROUPS: Array<{ label: string; states: string[] }> 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 }> INK: Record<string, { lightFrom?: string; lightTo: string; darkFrom?: string; darkTo?: string }>
} }
GrokCharacter?: new (svg: SVGSVGElement, opts: Record<string, unknown>) => ReplicaBot GrokCharacter?: new (svg: SVGSVGElement, opts: Record<string, unknown>) => ReplicaBot
@@ -42,6 +71,7 @@ function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-replica="${src}"]`) const existing = document.querySelector(`script[data-replica="${src}"]`)
if (existing) { if (existing) {
// IIFE already evaluated. replica/src/*.js changes need a full page refresh.
resolve() resolve()
return 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 = { export type ReplicaMount = {
apply: (input: ReplicaInput) => void apply: (input: ReplicaInput) => void
@@ -6,6 +12,18 @@ export type ReplicaMount = {
orbitGaze: (ms?: number) => void orbitGaze: (ms?: number) => void
step: (dt: number) => void step: (dt: number) => void
setState: (name: string) => 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 destroy: () => void
} }
@@ -16,6 +34,7 @@ export type ReplicaInput = {
follow: boolean follow: boolean
paper: string paper: string
size: number size: number
eye?: number
} }
const TILE_MAX = 6 const TILE_MAX = 6
@@ -75,7 +94,8 @@ export async function mountReplica(
followPointer: live && input.follow && !driven, followPointer: live && input.follow && !driven,
eyeColor: input.paper, eyeColor: input.paper,
paused: driven ? false : live ? false : 'hold-pose', paused: driven ? false : live ? false : 'hold-pose',
driven driven,
eyeIndex: input.eye
}) })
const noop = { const noop = {
apply: () => {}, apply: () => {},
@@ -83,6 +103,18 @@ export async function mountReplica(
orbitGaze: () => {}, orbitGaze: () => {},
step: () => {}, step: () => {},
setState: () => {}, setState: () => {},
setPose: () => {},
setManualOffset: () => {},
setEyeTune: () => {},
setManualHold: () => {},
flushPerformance: () => {},
seekEye: () => {},
setPlaylistHold: () => {},
setPaused: () => {},
holdFrame: () => {},
freezeNow: () => null,
playback: () => null,
parts: () => null,
destroy: () => {} destroy: () => {}
} }
if (!live && !driven) { if (!live && !driven) {
@@ -109,6 +141,18 @@ export async function mountReplica(
bot.setState(name, { resetEyes: true }) bot.setState(name, { resetEyes: true })
last = { ...last, state: name } 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() 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 { .scene {
display: grid; display: grid;
grid-template-columns: 0 minmax(0, 1fr) 20rem; grid-template-columns: 0 minmax(0, 1fr) 21.25rem;
column-gap: 2.5rem; column-gap: 2.5rem;
transition: grid-template-columns var(--bascule); transition: grid-template-columns var(--bascule);
/* /*
@@ -388,7 +388,7 @@ html::-webkit-scrollbar {
} }
.scene--gauche { .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 — * bien que ce soit la meme regle —
* *
* - l'arrivee sur le site, ou c'est le RETOUR a `.scene` (la colonne de droite * - 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 * - 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 * 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. * 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); transform: translate(30vw, 14vh) scale(2.5);
} }
.avatar--bas {
transform: translate(0, 2.25rem) scale(1);
}
.scene__gauche { .scene__gauche {
grid-column: 1; grid-column: 1;
} }
@@ -518,9 +522,9 @@ html::-webkit-scrollbar {
* jamais parce qu'elle est posee sur la FENETRE. * jamais parce qu'elle est posee sur la FENETRE.
* *
* `left`/`right` sont exactement les siens : la bande couvre la colonne de * `left`/`right` sont exactement les siens : la bande couvre la colonne de
* l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere + marge) * l'avatar (4,5rem = marge + gouttiere ; 24,5rem = panneau + gouttiere +
* et centre son contenu. La vue Personnaliser n'a qu'une seule disposition de * marge droite) et centre son contenu. La vue Personnaliser n'a qu'une seule
* grille, donc cette position est constante. * disposition de grille, donc cette position est constante.
*/ */
.barre-export { .barre-export {
position: fixed; position: fixed;
@@ -537,7 +541,7 @@ html::-webkit-scrollbar {
*/ */
top: calc( top: calc(
2rem + (100dvh - 3rem - var(--timeline)) / 2 + 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; opacity: 0;
} }
.fondu-enter-active, .playback-host {
.fondu-leave-active { position: relative;
transition: opacity 0.35s ease-out;
} }
.fondu-enter-from, .playback-pad {
.fondu-leave-to { padding-bottom: var(--playback-clearance, 7.5rem);
opacity: 0; }
.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) { @media (prefers-reduced-motion: reduce) {
.scene, .scene,
.panneau, .panneau,
.panneau--ouvert, .panneau--ouvert,
.avatar, .avatar {
.fondu-enter-active,
.fondu-leave-active {
transition: none; transition: none;
} }
.barre-export { .barre-export,
.playback-bar.is-ready {
transition: none; transition: none;
} }
@@ -638,3 +662,314 @@ body {
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -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) { function flattenSvg(root: SVGSVGElement) {
const clone = root.cloneNode(true) as 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 walk = (src: Element, dst: Element) => {
const cs = getComputedStyle(src) const cs = getComputedStyle(src)
for (const attr of ['fill', 'stroke'] as const) { for (const attr of ['fill', 'stroke'] as const) {
@@ -36,7 +42,9 @@ function flattenSvg(root: SVGSVGElement) {
} }
dst.setAttribute('style', next) 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] const copies = [...dst.children]
for (let i = 0; i < kids.length; i++) walk(kids[i]!, copies[i]!) 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 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] 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
}
}