feat:加上手动调节与表情回放
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '在画面上平移'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]!)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user