aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-09 09:47:24 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-09 09:47:24 +0900
commitd5b8d58a82a040a2facb5d226b64648aa66b2629 (patch)
treeb9c90118b304dc62aecd1241c5aa7f423cfece3f /public/bluebey-studio/src
parent74326a4c4d433dbc113fa0748f14ad0337258776 (diff)
bluebey-studio: /bluebey-studio/ をテスト公開用にBasic認証つきで復帰
準拠版アプリを public/bluebey-studio/ に戻し、.htaccess でBasic認証(ユーザー bluebey)を設定。/bluebey-studio/ のみ保護し他は素通し。本公開は市の確認後に認証を外す想定。
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/animation.js204
-rw-r--r--public/bluebey-studio/src/background.js666
-rw-r--r--public/bluebey-studio/src/clip.js202
-rw-r--r--public/bluebey-studio/src/exporter.js306
-rw-r--r--public/bluebey-studio/src/face.js375
-rw-r--r--public/bluebey-studio/src/faceArt.js1283
-rw-r--r--public/bluebey-studio/src/gacha.js481
-rw-r--r--public/bluebey-studio/src/gion.js226
-rw-r--r--public/bluebey-studio/src/glbExport.js148
-rw-r--r--public/bluebey-studio/src/handDrawn.js391
-rw-r--r--public/bluebey-studio/src/hats.js135
-rw-r--r--public/bluebey-studio/src/history.js105
-rw-r--r--public/bluebey-studio/src/look.js184
-rw-r--r--public/bluebey-studio/src/main.js2798
-rw-r--r--public/bluebey-studio/src/model.js283
-rw-r--r--public/bluebey-studio/src/outline.js368
-rw-r--r--public/bluebey-studio/src/panel.js2506
-rw-r--r--public/bluebey-studio/src/presets.js668
-rw-r--r--public/bluebey-studio/src/props.js646
-rw-r--r--public/bluebey-studio/src/rig.js212
-rw-r--r--public/bluebey-studio/src/style.css880
-rw-r--r--public/bluebey-studio/src/styles.js457
-rw-r--r--public/bluebey-studio/src/textOutlines.js432
-rw-r--r--public/bluebey-studio/src/trace.js410
-rw-r--r--public/bluebey-studio/src/ui.js367
-rw-r--r--public/bluebey-studio/src/urlState.js237
-rw-r--r--public/bluebey-studio/src/zip.js218
27 files changed, 15188 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js
new file mode 100644
index 0000000..e569d12
--- /dev/null
+++ b/public/bluebey-studio/src/animation.js
@@ -0,0 +1,204 @@
+/**
+ * The "living" layer: idle body motion, blinking and idle eye drift.
+ *
+ * These never touch the saved state. `pose()` returns the base pose with the
+ * idle offsets added, and `eyeOpen`/`look` are multipliers the caller merges
+ * into the face parameters, so turning the animation off restores exactly the
+ * pose and expression the user had before.
+ */
+
+export class Animator {
+ constructor({ state }) {
+ this.state = state;
+ this.time = 0;
+ this.blinkPhase = -1;
+ this.blinkDuration = 0.17;
+ this.blinkTimer = 2 + Math.random() * 1.5;
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.idleWeight = 0;
+ this.snotScale = 1;
+ }
+
+ reset() {
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.blinkPhase = -1;
+ this.blinkTimer = 2;
+ this.time = 0;
+ this.snotScale = 1;
+ }
+
+ update(dt) {
+ const speed = clamp(this.state.anim.speed ?? 1, 0.1, 3);
+ const step = Math.min(dt, 0.1) * speed;
+ this.time += step;
+
+ const moving = (this.state.anim.mode ?? 'off') !== 'off';
+ this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4);
+
+ // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks
+ // again. It is a real object (see src/main.js), so the motion is published here
+ // as a plain multiplier the render loop applies to the bubble's scale.
+ this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep'
+ ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3))
+ : 1;
+
+ this.updateBlink(step);
+ this.updateLook(step);
+ }
+
+ updateBlink(dt) {
+ if ((this.state.anim.mode ?? 'off') === 'sleep') {
+ // Asleep: the eyes stay shut, whatever the blink setting says.
+ this.eyeOpen = 0;
+ this.blinkPhase = -1;
+ return;
+ }
+ if (!this.state.anim.blink) {
+ this.eyeOpen = 1;
+ this.blinkPhase = -1;
+ return;
+ }
+ if (this.blinkPhase >= 0) {
+ this.blinkPhase += dt / this.blinkDuration;
+ if (this.blinkPhase >= 1) {
+ this.blinkPhase = -1;
+ this.eyeOpen = 1;
+ const base = Math.max(0.5, this.state.anim.blinkInterval ?? 3.4);
+ // Blink again sooner sometimes, so it does not look metronomic.
+ this.blinkTimer = base * (0.55 + Math.random());
+ if (Math.random() < 0.22) this.blinkTimer *= 0.35; // occasional double blink
+ } else {
+ this.eyeOpen = 1 - Math.pow(Math.sin(Math.PI * this.blinkPhase), 0.8);
+ }
+ return;
+ }
+ this.blinkTimer -= dt;
+ if (this.blinkTimer <= 0) this.blinkPhase = 0;
+ }
+
+ updateLook(dt) {
+ if (!this.state.anim.lookAround) {
+ this.look.x = 0;
+ this.look.y = 0;
+ return;
+ }
+ const t = this.time;
+ this.look.x = Math.sin(t * 0.37) * 0.3 + Math.sin(t * 0.13 + 1.7) * 0.14;
+ this.look.y = Math.sin(t * 0.29 + 0.6) * 0.2;
+ }
+
+ /** Base pose plus the current movement offsets; `pose` is `{ bones, root }`. */
+ pose(basePose = {}) {
+ const bones = { ...(basePose.bones ?? {}) };
+ const root = [...(basePose.root ?? [0, 0, 0])];
+ const mode = this.state.anim.mode ?? 'off';
+ if (mode === 'off' || this.idleWeight <= 0.001) return { bones, root };
+
+ const w = this.idleWeight;
+ const t = this.time;
+ const add = (name, dx, dy, dz) => {
+ const current = bones[name] ?? [0, 0, 0];
+ bones[name] = [current[0] + dx * w, current[1] + dy * w, current[2] + dz * w];
+ };
+
+ if (mode === 'walk') {
+ // A waddle for a character with no legs: a step bob, a side-to-side rock
+ // and alternating arms and feet.
+ //
+ // Z is the forward/back swing, and it is mirrored between the sides - so the
+ // SAME value on both arms swings them opposite ways, which is what a walk
+ // wants. X (which the first version used) is the *lift*, so opposite signs
+ // there just raised one flipper and dropped the other one.
+ // `legsupport` pivots at the middle of the body, so the legs take a much
+ // smaller angle than the arms. The legs swing on the OPPOSITE phase to the
+ // arms, so when a hand comes forward it is the opposite foot that steps out
+ // (the same value as the arms put them in step, i.e. a "same-side" waddle).
+ const phase = Math.sin(t * 2.4);
+ const bob = Math.abs(Math.sin(t * 2.4));
+ add('master', -2, 0, phase * 4);
+ add('arm.l', 6, 0, phase * 26);
+ add('arm.r', 6, 0, phase * 26);
+ add('hand.l', 0, 0, phase * 10);
+ add('hand.r', 0, 0, phase * 10);
+ add('legsupport.l', 0, 0, -phase * 9);
+ add('legsupport.r', 0, 0, -phase * 9);
+ root[1] += bob * 0.05;
+ return { bones, root };
+ }
+
+ if (mode === 'wave') {
+ // One flipper is held up and sweeps back and forth, with the hand lagging a
+ // little behind it; the body and the other flipper rock along gently. The
+ // lift is kept modest: raising the arm much further folds the flipper over
+ // the top of the head, where its inner edge cuts into the face, so the X
+ // here (and its swing) stop well short of that. The Z swing is what reads
+ // as the wave, and it is biased slightly *back* for the same reason.
+ const wave = Math.sin(t * 3.2);
+ add('arm.l', 66 + wave * 8, 0, 6 + wave * 10);
+ add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 14);
+ add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4);
+ add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3);
+ return { bones, root };
+ }
+
+ if (mode === 'sleep') {
+ // Slow breathing: the body rises and settles, the flippers drift, and the
+ // bubble swells and shrinks (that swell is `snotScale`, read by the loop).
+ const breath = 0.5 - 0.5 * Math.cos(t * 1.3);
+ add('master', Math.sin(t * 0.65) * 1.1, 0, 0);
+ add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5);
+ add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5);
+ root[1] += breath * 0.03;
+ return { bones, root };
+ }
+
+ // Default: gentle breathing, as if standing there alive.
+ add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6);
+ add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5);
+ add('arm.r', 0, 0, -Math.sin(t * 1.28 + 0.5) * 4.5);
+ add('hand.l', 0, 0, Math.sin(t * 1.05 + 1) * 3);
+ add('hand.r', 0, 0, -Math.sin(t * 1.05 + 1) * 3);
+ root[1] += Math.sin(t * 1.5) * 0.022;
+ return { bones, root };
+ }
+}
+
+/**
+ * Records the viewport canvas to a WebM blob while the animation plays.
+ * Chrome and Edge support `MediaRecorder` on `canvas.captureStream()`.
+ */
+export function createRecorder(canvas) {
+ if (typeof MediaRecorder === 'undefined' || !canvas.captureStream) return null;
+ const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
+ .find((type) => MediaRecorder.isTypeSupported?.(type));
+ return { canvas, mimeType: mimeType ?? '', recorder: null, chunks: [] };
+}
+
+export function startRecording(session) {
+ if (!session) return false;
+ const stream = session.canvas.captureStream(30);
+ const recorder = session.mimeType
+ ? new MediaRecorder(stream, { mimeType: session.mimeType, videoBitsPerSecond: 8000000 })
+ : new MediaRecorder(stream);
+ session.chunks = [];
+ session.recorder = recorder;
+ recorder.ondataavailable = (event) => { if (event.data?.size) session.chunks.push(event.data); };
+ recorder.start(100);
+ return true;
+}
+
+export function stopRecording(session) {
+ return new Promise((resolve) => {
+ if (!session?.recorder || session.recorder.state === 'inactive') {
+ resolve(null);
+ return;
+ }
+ const { recorder, chunks } = session;
+ recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
+ recorder.stop();
+ });
+}
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js
new file mode 100644
index 0000000..adc7f94
--- /dev/null
+++ b/public/bluebey-studio/src/background.js
@@ -0,0 +1,666 @@
+/**
+ * The studio backdrop, as a DOM layer that sits behind the WebGL canvas.
+ *
+ * The renderer is created with `alpha: true`, so whenever it is cleared to
+ * alpha 0 the page shows through. Painting the backdrop into a sibling layer
+ * placed *under* `#view` therefore gives the character a scene without touching
+ * the scene graph: the CC0 photo, the user's own picture and the phone's camera
+ * feed are all just CSS on one element, and no texture has to be uploaded per
+ * frame.
+ *
+ * `backdropStyle()` is deliberately pure. It is the only place that turns the
+ * `view.background*` slice into a CSS descriptor, so the behaviour `apply()`
+ * ships with is exactly the behaviour the unit tests cover.
+ */
+
+/** The library already downloaded into `assets/backgrounds/` (see CREDITS.json).
+ * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes
+ * with a floor, a horizon and perspective, which is what gives a picture depth
+ * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the
+ * exceptions: original illustrations drawn for this studio by the author, not
+ * CC0 photos. */
+export const BACKGROUND_PRESETS = [
+ { name: 'empty_warehouse_01', label: '倉庫' },
+ { name: 'ballroom', label: '広間' },
+ { name: 'kloppenheim_06_puresky', label: '空と雲' },
+ { name: 'autumn_park', label: '秋の公園' },
+ // The places a councillor explains things in, or stands in to make a point.
+ // Poly Haven has no true classroom, desert or 社長室, so these are the closest
+ // real places it does have.
+ { name: 'newman_cafeteria', label: '学校' },
+ { name: 'wooden_lounge', label: '社長室(木の部屋)' },
+ { name: 'minedump_flats', label: '砂漠' },
+ { name: 'spiaggia_di_mondello', label: '海岸' },
+ // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。
+ { name: 'mutoujima', label: '無人島' },
+ { name: 'dokan', label: '土管のある空き地' },
+ { name: 'uchu', label: '宇宙船の中' },
+ // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。
+ { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' },
+ { name: 'basement_boxing_ring', label: '地下室のリング' },
+ { name: 'autoshop_01', label: '自動車工場' },
+ { name: 'urban_alley_01', label: '路地' },
+];
+
+const DEFAULT_PRESET = 'autumn_park';
+
+/**
+ * Manga effect-line backdrops, drawn *procedurally* (no file, no network).
+ *
+ * They are the classic stress marks: a burst of lines from behind the character
+ * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and
+ * short lines ringing a wide ellipse so the empty middle reads as isolation
+ * (`ellipse`).
+ * Each is a canvas painted black on white; the same generator runs for the
+ * preview, the PNG and the standalone build.
+ */
+export const EFFECT_PRESETS = [
+ { name: 'focus', label: '集中線' },
+ { name: 'fall', label: '落ち込み線' },
+ { name: 'speed', label: '疾走線' },
+ { name: 'ellipse', label: '楕円集中線' },
+];
+
+const DEFAULT_EFFECT = 'focus';
+
+/** The canvas every effect is painted on, so preview and export agree. */
+export const EFFECT_SIZE = { width: 1200, height: 800 };
+
+/**
+ * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks
+ * the same on every run, in every build, and in the tests.
+ */
+function makeRandom(seedText) {
+ let seed = 2166136261;
+ for (let i = 0; i < seedText.length; i++) {
+ seed ^= seedText.charCodeAt(i);
+ seed = Math.imul(seed, 16777619);
+ }
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the
+ * same name and size it always issues the same black-on-white strokes, so the
+ * unit tests can pin the look down and the cache below stays sound.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {'focus'|'fall'|'speed'|'ellipse'} name
+ * @param {{width:number,height:number}} [size]
+ */
+export function drawEffectLines(ctx, name, size = EFFECT_SIZE) {
+ const width = positiveOrOne(size?.width);
+ const height = positiveOrOne(size?.height);
+ const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ const random = makeRandom(kind);
+
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, width, height);
+ ctx.strokeStyle = '#111111';
+ ctx.lineCap = 'butt';
+
+ if (kind === 'focus') {
+ // A burst: lines radiate from a point behind the head, and stop short of it
+ // so the character's face is not crossed by ink.
+ const cx = width * 0.5;
+ const cy = height * 0.42;
+ const reach = Math.hypot(width, height) * 1.1;
+ const near = Math.min(width, height);
+ for (let i = 0; i < 150; i++) {
+ const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03;
+ const inner = near * (0.12 + random() * 0.5);
+ ctx.lineWidth = 1 + random() * 4;
+ ctx.beginPath();
+ ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
+ ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach);
+ ctx.stroke();
+ }
+ } else if (kind === 'fall') {
+ // Lines raining down from the top edge, of uneven length.
+ for (let i = 0; i < 90; i++) {
+ const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8));
+ const length = height * (0.25 + random() * 0.7);
+ ctx.lineWidth = 1 + random() * 3;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, length);
+ ctx.stroke();
+ }
+ } else if (kind === 'speed') {
+ // Lines streaming in from one side or the other.
+ for (let i = 0; i < 70; i++) {
+ const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6));
+ const length = width * (0.35 + random() * 0.65);
+ const x = random() < 0.5 ? 0 : width - length;
+ ctx.lineWidth = 1 + random() * 3.5;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + length, y);
+ ctx.stroke();
+ }
+ } else if (kind === 'ellipse') {
+ // A dense ring of short lines *outside* a wide ellipse: the blank inside is
+ // the point (the 「ひとり」 panel), so nothing is drawn there. The start
+ // points are spaced by arc length, not by angle - an equal angular step
+ // bunches the lines at the two ends of a wide ellipse and leaves gaps along
+ // the flat top and bottom, which is what made the old ring look uneven. Each
+ // line then leaves along the ellipse's outward normal, so the inner edge
+ // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and
+ // every line is anchored to the ring, so none reads as a stray scratch.
+ const cx = width * 0.5;
+ const cy = height * 0.5;
+ const rx = Math.min(width * 0.42, height * 0.9);
+ const ry = rx * 0.55;
+ const lines = 200;
+ const reach = Math.min(width, height) * 0.24;
+
+ // Cumulative arc length around the ring, so the lines can be spread evenly
+ // around it rather than evenly by angle.
+ const samples = 512;
+ const arc = [0];
+ for (let s = 1; s <= samples; s++) {
+ const prev = ((s - 1) / samples) * Math.PI * 2;
+ const next = (s / samples) * Math.PI * 2;
+ const dx = (Math.cos(next) - Math.cos(prev)) * rx;
+ const dy = (Math.sin(next) - Math.sin(prev)) * ry;
+ arc.push(arc[s - 1] + Math.hypot(dx, dy));
+ }
+ const total = arc[samples];
+
+ let s = 1;
+ for (let i = 0; i < lines; i++) {
+ const target = ((i + 0.5) / lines) * total;
+ while (s < samples && arc[s] < target) s++;
+ const span = arc[s] - arc[s - 1] || 1;
+ const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2;
+ const cos = Math.cos(t);
+ const sin = Math.sin(t);
+ // The outward normal of an ellipse points along (cos/rx, sin/ry), not along
+ // the radius from the centre, so the lines meet the ring at a right angle.
+ const nx = cos / rx;
+ const ny = sin / ry;
+ const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1);
+ ctx.lineWidth = 1 + random() * 3.5;
+ const startX = cx + cos * rx;
+ const startY = cy + sin * ry;
+ ctx.beginPath();
+ ctx.moveTo(startX, startY);
+ ctx.lineTo(startX + nx * length, startY + ny * length);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+}
+
+/** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */
+const FIT_STYLES = {
+ cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' },
+ contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' },
+ stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' },
+ tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' },
+};
+const DEFAULT_FIT = 'cover';
+
+/** A live frame is a replaced element, so the same fit maps to `object-fit`. */
+const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' };
+
+const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']);
+
+/** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */
+const GYRO_INTERVAL_MS = 33;
+
+/** Coerce to a finite number, or `null` when the value is not one. */
+function finiteOrNull(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+function normalizeMode(mode) {
+ return MODES.has(mode) ? mode : 'solid';
+}
+
+function clamp01(value) {
+ const n = finiteOrNull(value);
+ if (n == null) return 0;
+ return Math.min(1, Math.max(0, n));
+}
+
+function nonNegative(value) {
+ const n = finiteOrNull(value);
+ return n == null ? 0 : Math.max(0, n);
+}
+
+function positiveOrOne(value) {
+ const n = finiteOrNull(value);
+ return n == null || n <= 0 ? 1 : n;
+}
+
+/** The shortest signed distance between two angles, in degrees. */
+function wrapDeg(degrees) {
+ return ((degrees + 540) % 360) - 180;
+}
+
+/**
+ * Turn the `view` slice into the plain descriptor `apply()` renders from.
+ * Every field is optional, so a half-written state still yields a usable look;
+ * an unknown `background` falls back to `solid` and an unknown `backgroundFit`
+ * to `cover`.
+ *
+ * `offset` is in fractions of the layer size, and `visible` is false only for
+ * `transparent` (where the page background is meant to show through).
+ */
+export function backdropStyle(viewState) {
+ const state = viewState && typeof viewState === 'object' ? viewState : {};
+ const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT];
+ const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object'
+ ? state.backgroundOffset
+ : {};
+
+ return {
+ backgroundSize: fit.backgroundSize,
+ backgroundRepeat: fit.backgroundRepeat,
+ mirror: state.cameraMirror === true,
+ darken: clamp01(state.backgroundDarken),
+ blur: nonNegative(state.backgroundBlur),
+ offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 },
+ scale: positiveOrOne(state.backgroundScale),
+ visible: normalizeMode(state.background) !== 'transparent',
+ };
+}
+
+/** `assets/backgrounds/<name>.webp` - what the multi-file build serves. */
+function defaultResolveUrl(name) {
+ return `assets/backgrounds/${name}.webp`;
+}
+
+/**
+ * Build the backdrop layer for a `#stage` element.
+ *
+ * `resolveUrl(name)` maps a preset name to an image URL; the single-file build
+ * passes one that reads an inlined map, while the default points at
+ * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so
+ * the host can repaint.
+ *
+ * The returned object is the only way in: the layer never reads or writes the
+ * studio state itself, `apply(viewState)` is the single entry point.
+ */
+export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
+ if (!stage || typeof stage.prepend !== 'function') {
+ throw new Error('createBackdrop: ステージ要素(#stage)が必要です');
+ }
+
+ const doc = stage.ownerDocument ?? globalThis.document;
+ const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl;
+ const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {};
+
+ const el = doc.createElement('div');
+ el.className = 'backdrop';
+ el.setAttribute('aria-hidden', 'true');
+ Object.assign(el.style, {
+ position: 'absolute',
+ inset: '0',
+ zIndex: '0',
+ overflow: 'hidden',
+ pointerEvents: 'none',
+ transformOrigin: 'center',
+ });
+
+ // The image surface is a plain div so blur/scale/tint stay pure CSS.
+ const media = doc.createElement('div');
+ Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' });
+
+ // The camera feed is a muted, inline, autoplaying video for the AR mode.
+ const video = doc.createElement('video');
+ video.muted = true;
+ video.playsInline = true;
+ video.autoplay = true;
+ video.setAttribute('muted', '');
+ video.setAttribute('playsinline', '');
+ video.setAttribute('aria-hidden', 'true');
+ Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' });
+
+ // The dark veil sits on top of whichever surface is showing, so the character
+ // keeps its contrast against a busy photo.
+ const veil = doc.createElement('div');
+ Object.assign(veil.style, { position: 'absolute', inset: '0' });
+
+ el.append(media, video, veil);
+ // Prepended, i.e. before the canvas; the canvas is lifted back above it.
+ stage.prepend(el);
+
+ const canvas = stage.querySelector('canvas');
+ if (canvas) {
+ canvas.style.position = 'relative';
+ canvas.style.zIndex = '1';
+ }
+
+ /** The most recent state slice, so the imperative helpers keep its styling. */
+ let last = {};
+ let stream = null;
+
+ let gyroEnabled = false;
+ let gyroAttached = false;
+ let gyroBase = null;
+ let gyroCallback = null;
+ let lastGyroAt = 0;
+
+ const effectUrls = new Map();
+
+ /** The data URL for an effect-line backdrop, generated once and cached. */
+ function effectUrl(name) {
+ const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ if (effectUrls.has(key)) return effectUrls.get(key);
+ const surface = doc.createElement('canvas');
+ surface.width = EFFECT_SIZE.width;
+ surface.height = EFFECT_SIZE.height;
+ const surfaceCtx = surface.getContext('2d');
+ if (!surfaceCtx) return null;
+ drawEffectLines(surfaceCtx, key, EFFECT_SIZE);
+ const url = surface.toDataURL('image/png');
+ effectUrls.set(key, url);
+ return url;
+ }
+
+ function imageUrlOf(mode, viewState) {
+ if (mode === 'image') {
+ return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage
+ ? viewState.backgroundImage
+ : null;
+ }
+ if (mode === 'effect') {
+ const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect
+ ? viewState.backgroundEffect
+ : DEFAULT_EFFECT;
+ return effectUrl(name);
+ }
+ if (mode !== 'preset') return null;
+
+ const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset
+ ? viewState.backgroundPreset
+ : DEFAULT_PRESET;
+ try {
+ const url = resolve(name);
+ if (typeof url === 'string' && url) return url;
+ } catch {
+ // Fall through to the file path: an unknown preset is not worth throwing.
+ }
+ return defaultResolveUrl(name);
+ }
+
+ function colorOr(value) {
+ return typeof value === 'string' && value ? value : '#ffffff';
+ }
+
+ /**
+ * Fit, mirror, blur and the blur bleeding past the edges (the negative inset
+ * keeps `filter: blur()` from fading the border to transparent).
+ */
+ function styleSurface(node, style) {
+ node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0';
+ node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none';
+ node.style.transform = style.mirror ? 'scaleX(-1)' : 'none';
+
+ if (node === media) {
+ node.style.backgroundSize = style.backgroundSize;
+ node.style.backgroundRepeat = style.backgroundRepeat;
+ } else {
+ node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover';
+ }
+ }
+
+ function apply(viewState) {
+ last = viewState && typeof viewState === 'object' ? viewState : {};
+ const style = backdropStyle(last);
+
+ const wanted = normalizeMode(last.background);
+ const url = imageUrlOf(wanted, last);
+ // A preset that cannot be resolved is shown as a plain colour, never blank.
+ const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect';
+ const mode = drawable && !url ? 'solid' : wanted;
+
+ el.style.display = style.visible ? 'block' : 'none';
+ el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent';
+ el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0
+ ? 'none'
+ : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`;
+
+ veil.style.background = `rgba(0, 0, 0, ${style.darken})`;
+ veil.style.display = style.darken > 0 ? 'block' : 'none';
+
+ const showCamera = style.visible && mode === 'camera';
+ const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect');
+ // Leaving the AR mode must release the camera, not just hide the video.
+ if (!showCamera && stream) stopCamera();
+
+ styleSurface(media, style);
+ styleSurface(video, style);
+ media.style.display = showImage ? 'block' : 'none';
+ video.style.display = showCamera ? 'block' : 'none';
+ media.style.backgroundImage = showImage ? `url("${url}")` : 'none';
+
+ needsRender();
+ }
+
+ /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */
+ function loadPreset(name) {
+ const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET;
+ apply({ ...last, background: 'preset', backgroundPreset: preset });
+ }
+
+ /** Apply a stored data URL (as kept in `view.backgroundImage`). */
+ function applyImage(dataUrl) {
+ if (typeof dataUrl !== 'string' || !dataUrl) {
+ apply({ ...last, background: 'solid', backgroundImage: null });
+ return;
+ }
+ apply({ ...last, background: 'image', backgroundImage: dataUrl });
+ }
+
+ /** Read a user File into a data URL, show it, and hand the URL back to save. */
+ async function loadImageFile(file) {
+ const dataUrl = await new Promise((resolveUrl, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolveUrl(String(reader.result));
+ reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした'));
+ reader.readAsDataURL(file);
+ });
+ applyImage(dataUrl);
+ return dataUrl;
+ }
+
+ /* Shown whenever the page is not a secure context. This is a fact about the
+ * web platform, not about any one browser, so the wording must not point a
+ * finger at the browser the user happens to be holding. */
+ const INSECURE_CAMERA_REASON =
+ 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。';
+
+ /**
+ * Japanese, actionable text for the ways `getUserMedia` usually fails. On a
+ * phone this toast is all the user has to go on, so each case names the exact
+ * thing to tap instead of just stating that something went wrong.
+ */
+ function cameraFailureReason(error) {
+ switch (error?.name) {
+ case 'NotAllowedError':
+ case 'PermissionDeniedError':
+ return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。';
+ case 'NotFoundError':
+ case 'DevicesNotFoundError':
+ return '使えるカメラが見つかりませんでした';
+ case 'NotReadableError':
+ case 'TrackStartError':
+ return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)';
+ case 'OverconstrainedError':
+ case 'ConstraintNotSatisfiedError':
+ return '指定したカメラを使用できません';
+ case 'SecurityError':
+ // No `mediaDevices` at all is the usual symptom, but this covers the
+ // same cause when a sandboxed frame raises the error instead.
+ return INSECURE_CAMERA_REASON;
+ default:
+ return 'カメラを起動できませんでした';
+ }
+ }
+
+ async function startCamera(facing = 'environment') {
+ const mediaDevices = globalThis.navigator?.mediaDevices;
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
+ // An insecure page has no `mediaDevices` at all. Say so plainly rather
+ // than leaving the user to think their camera or browser is broken.
+ return {
+ ok: false,
+ reason: globalThis.isSecureContext === false
+ ? INSECURE_CAMERA_REASON
+ : 'カメラを使うには https か localhost が必要です',
+ };
+ }
+
+ stopCamera();
+ try {
+ const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } });
+ stream = next;
+ video.srcObject = next;
+ try {
+ await video.play();
+ } catch {
+ // A blocked autoplay still resolves to a painting stream in practice.
+ }
+ apply({ ...last, background: 'camera', cameraFacing: facing });
+ return { ok: true };
+ } catch (error) {
+ stopCamera();
+ return { ok: false, reason: cameraFailureReason(error) };
+ }
+ }
+
+ function stopCamera() {
+ const active = stream;
+ stream = null;
+ if (active) {
+ try {
+ for (const track of active.getTracks()) track.stop();
+ } catch {
+ // A track that cannot be stopped is already gone as far as we care.
+ }
+ }
+ if (video) {
+ try {
+ video.pause();
+ } catch {
+ // Pausing a video without a source is expected to throw.
+ }
+ video.srcObject = null;
+ video.style.display = 'none';
+ }
+ }
+
+ function emitGyro(yaw, pitch, roll) {
+ gyroCallback?.({ yaw, pitch, roll });
+ }
+
+ /**
+ * Attach the orientation listener and take the current pose as zero, so the
+ * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`.
+ */
+ function enableGyro() {
+ gyroEnabled = true;
+ gyroBase = null;
+ if (!gyroAttached && typeof globalThis.addEventListener === 'function') {
+ globalThis.addEventListener('deviceorientation', handleOrientation);
+ gyroAttached = true;
+ }
+ emitGyro(0, 0, 0);
+ }
+
+ /**
+ * Degrees of turn from the pose gyro started at. `yaw` grows clockwise
+ * (turning the device to the right); `pitch` and `roll` follow the raw
+ * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz.
+ */
+ function handleOrientation(event) {
+ if (!gyroEnabled) return;
+
+ const alpha = typeof event?.alpha === 'number' ? event.alpha : null;
+ const beta = typeof event?.beta === 'number' ? event.beta : null;
+ const gamma = typeof event?.gamma === 'number' ? event.gamma : null;
+ if (alpha == null && beta == null && gamma == null) return;
+
+ if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 };
+
+ const now = Date.now();
+ if (now - lastGyroAt < GYRO_INTERVAL_MS) return;
+ lastGyroAt = now;
+
+ emitGyro(
+ alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha),
+ beta == null ? 0 : wrapDeg(beta - gyroBase.beta),
+ gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma),
+ );
+ }
+
+ /** Ask for orientation permission (iOS) and start listening. */
+ async function requestGyro() {
+ const OrientationEvent = globalThis.DeviceOrientationEvent;
+ if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false;
+
+ if (typeof OrientationEvent.requestPermission === 'function') {
+ let granted = false;
+ try {
+ granted = (await OrientationEvent.requestPermission()) === 'granted';
+ } catch {
+ granted = false;
+ }
+ if (!granted) return false;
+ }
+
+ enableGyro();
+ return true;
+ }
+
+ /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */
+ function onGyro(callback) {
+ gyroCallback = typeof callback === 'function' ? callback : null;
+ if (!gyroEnabled) enableGyro();
+ return () => {
+ if (gyroCallback === callback) gyroCallback = null;
+ };
+ }
+
+ function dispose() {
+ stopCamera();
+ if (gyroAttached) {
+ globalThis.removeEventListener?.('deviceorientation', handleOrientation);
+ gyroAttached = false;
+ }
+ gyroEnabled = false;
+ gyroCallback = null;
+ gyroBase = null;
+ el.remove();
+ }
+
+ return {
+ el,
+ apply,
+ loadPreset,
+ loadImageFile,
+ applyImage,
+ startCamera,
+ stopCamera,
+ /** The data URL of a drawn effect line, so exports can composite the same
+ * bitmap the preview shows. */
+ effectUrl,
+ get cameraActive() {
+ return stream !== null;
+ },
+ requestGyro,
+ onGyro,
+ snapshot() {},
+ dispose,
+ };
+}
diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js
new file mode 100644
index 0000000..4fdcfd9
--- /dev/null
+++ b/public/bluebey-studio/src/clip.js
@@ -0,0 +1,202 @@
+import * as THREE from 'three';
+
+/**
+ * 見えない壁 (the invisible wall): a clipping plane that hides whatever falls
+ * behind it, so the character can be buried in the wall and only the rest of the
+ * body shows.
+ *
+ * The wall is a *finite* rectangle: an invisible quad that writes depth and no
+ * colour, drawn before everything else in the opaque pass, so the character
+ * behind it is culled by the depth test. That is what a real wall does, and it
+ * is the only way to bound the effect - a `renderer.clippingPlanes` entry is an
+ * infinite half-space, so it can never be limited to a rectangle.
+ *
+ * Nothing needs to agree per-material: the depth buffer does the work, so the
+ * body, the outline hulls, the face plates, the ink pass and the offscreen
+ * renders an export uses are all hidden by the same wall.
+ *
+ * `wallPlane` is pure (no three.js maths), so the geometry can be unit-tested
+ * without a renderer.
+ */
+
+const DEG = Math.PI / 180;
+
+/**
+ * A hair toward the kept side. The guide sits exactly on the cut, and a plane
+ * on its own boundary is half inside the discarded half - nudging it along the
+ * normal keeps all of it on the visible side.
+ */
+const GUIDE_EPSILON = 0.002;
+
+/**
+ * The plane a wall setting lies in.
+ *
+ * The wall is a *finite* quad, so this no longer decides which half of space is
+ * hidden - the quad's own depth does that. What `apply` needs from here is the
+ * normal, which is the direction the quad faces (and so the direction the wall
+ * lies along), plus the constant of the plane through the point it sits on.
+ *
+ * By default the wall lies in the XY plane through `(x, y, z)`; `yaw` turns it
+ * about Y and `tilt` leans it about X afterwards.
+ *
+ * @param {{x?:number,y?:number,z?:number,yaw?:number,tilt?:number}} [wall]
+ * @returns {{normal:[number,number,number], constant:number}}
+ */
+export function wallPlane(wall = {}) {
+ const yaw = (wall.yaw ?? 0) * DEG;
+ const tilt = (wall.tilt ?? 0) * DEG;
+ const cosYaw = Math.cos(yaw);
+ const sinYaw = Math.sin(yaw);
+ const cosTilt = Math.cos(tilt);
+ const sinTilt = Math.sin(tilt);
+
+ // Start from +Z, turn about Y, then lean about the (already turned) X axis.
+ let nx = sinYaw;
+ let ny = -cosYaw * sinTilt;
+ let nz = cosYaw * cosTilt;
+
+ const length = Math.hypot(nx, ny, nz) || 1;
+ nx /= length;
+ ny /= length;
+ nz /= length;
+
+ // Constant so the plane passes through (x, y, z): dot(n, p) + c = 0.
+ const constant = -(nx * (wall.x ?? 0) + ny * (wall.y ?? 0) + nz * (wall.z ?? 0));
+ return { normal: [nx, ny, nz], constant };
+}
+
+/** The faint plane shown while placing the wall. Hidden from every export. */
+/**
+ * The wall itself: an invisible, *finite* quad that writes depth but no colour.
+ *
+ * It is drawn before everything else in the opaque pass (`renderOrder`), so the
+ * character behind it is culled by the depth test. That is what a real wall does,
+ * and - unlike a clip plane, which is an infinite half-space - it only hides what
+ * the rectangle actually covers. So the size sliders are the wall's real size.
+ */
+function makeWall() {
+ const material = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ side: THREE.DoubleSide,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
+ mesh.name = 'wall';
+ // Before everything, including the silhouette-only hulls (renderOrder -1), so
+ // its depth hides a nose buried in the wall (see styles.js hullMaterialFor).
+ mesh.renderOrder = -2;
+ mesh.visible = false;
+ mesh.castShadow = false;
+ mesh.receiveShadow = false;
+ return mesh;
+}
+
+function makeGuide() {
+ const geometry = new THREE.PlaneGeometry(1, 1);
+ const material = new THREE.MeshBasicMaterial({
+ color: 0x8a4fe0,
+ transparent: true,
+ opacity: 0.16,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.name = 'wall-guide';
+ mesh.userData.isHelper = true;
+ mesh.visible = false;
+
+ // An edge, so the plane's extent is legible even where the fill is faint.
+ const edge = new THREE.LineSegments(
+ new THREE.EdgesGeometry(geometry),
+ new THREE.LineBasicMaterial({ color: 0x8a4fe0, transparent: true, opacity: 0.55, toneMapped: false }),
+ );
+ edge.userData.isHelper = true;
+ mesh.add(edge);
+ return mesh;
+}
+
+export function createClipper({ scene, renderer, model }) {
+ // A little larger than the character: big enough to read as a wall, small
+ // enough not to cover the whole viewport.
+ const guideSpan = Math.max(model.size.x, model.size.y, model.size.z) * 1.5;
+ const FROM = new THREE.Vector3(0, 0, 1); // the plane the quad starts in
+
+ function makeSlot() {
+ const occluder = makeWall();
+ const guide = makeGuide();
+ scene.add(guide);
+ scene.add(occluder);
+ return { occluder, guide, normal: new THREE.Vector3(0, 0, 1) };
+ }
+ // Two walls. Each slot keeps its own rectangle, so the two are independent.
+ const slots = [makeSlot(), makeSlot()];
+
+ /**
+ * @param {Array<object>} walls up to two `state.render.wall` settings, in slot
+ * order.
+ * @param {{x?:number,y?:number,z?:number}} [offset] the character's own
+ * translation. A wall's position is stored *relative to the character*, so
+ * adding this makes the walls travel with the body when it is moved.
+ * @param {number} [yaw] the character's own turn about Y, in radians. The stored
+ * position is rotated by it (and added to the wall's own `yaw`), so a wall
+ * stays glued to the body when the character is turned.
+ *
+ * The guide is drawn only when `wall.guide` is on, which is also when the wall
+ * can be grabbed in the viewport. Off, the wall is invisible: the cut still
+ * applies, so you can see the character half-hidden with nothing in the way.
+ */
+ function apply(walls, offset, yaw = 0) {
+ const list = Array.isArray(walls) ? walls : [walls];
+ const ox = offset?.x ?? 0;
+ const oy = offset?.y ?? 0;
+ const oz = offset?.z ?? 0;
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const yawDeg = (yaw * 180) / Math.PI;
+ slots.forEach((slot, index) => {
+ const wall = list[index];
+ if (!wall || wall.on !== true) {
+ slot.occluder.visible = false;
+ slot.guide.visible = false;
+ return;
+ }
+ // The stored position is relative to the character, so it turns with the
+ // body: rotate (x, z) about Y by the character's yaw, then add the offset.
+ const lx = wall.x ?? 0;
+ const lz = wall.z ?? 0;
+ const x = cos * lx + sin * lz + ox;
+ const y = (wall.y ?? 0) + oy;
+ const z = -sin * lx + cos * lz + oz;
+ const { normal } = wallPlane({ yaw: (wall.yaw ?? 0) + yawDeg, tilt: wall.tilt, x, y, z });
+ slot.normal.set(normal[0], normal[1], normal[2]);
+ const span = guideSpan * Math.max(0.05, wall.size ?? 1);
+ // The wall and its guide are the same rectangle, turned to face along the
+ // plane's normal: the wall is the occluder, the guide is the tinted copy
+ // that is shown only while placing.
+ for (const mesh of [slot.occluder, slot.guide]) {
+ mesh.quaternion.setFromUnitVectors(FROM, slot.normal);
+ mesh.scale.setScalar(span);
+ }
+ slot.occluder.position.set(x, y, z);
+ slot.occluder.visible = true;
+ slot.guide.position.set(x, y, z).addScaledVector(slot.normal, GUIDE_EPSILON);
+ slot.guide.visible = wall.guide === true;
+ });
+ }
+
+ return {
+ guides: slots.map((slot) => slot.guide),
+ occluders: slots.map((slot) => slot.occluder),
+ apply,
+ dispose() {
+ for (const slot of slots) {
+ for (const mesh of [slot.guide, slot.occluder]) {
+ mesh.geometry.dispose();
+ mesh.material.dispose();
+ mesh.removeFromParent();
+ }
+ }
+ },
+ };
+}
diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js
new file mode 100644
index 0000000..ea9356e
--- /dev/null
+++ b/public/bluebey-studio/src/exporter.js
@@ -0,0 +1,306 @@
+import { traceAlphaContours, contoursToPathData } from './trace.js';
+import { roughenContours } from './handDrawn.js';
+import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js';
+
+/**
+ * Export helpers.
+ *
+ * Everything renders through the live renderer at a temporary resolution, so an
+ * export always matches what is on screen (same camera, same framing) and the
+ * only difference is the pixel size.
+ */
+
+/** Render the current view into an offscreen 2D canvas at an arbitrary size. */
+export function renderStill(view, { width, height, background }) {
+ const { renderer, scene, camera } = view;
+ const previousAspect = camera.aspect;
+
+ renderer.setPixelRatio(1);
+ renderer.setSize(width, height, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+
+ applyBackground(renderer, background);
+ // The screen-space outline renders the scene itself, so it replaces the plain
+ // render rather than following it. (buildSVG passes outlineOptions of its own:
+ // it wants the ink and nothing else, because the ink is what it traces.)
+ if (view.outline && view.outlineOptions?.enabled) {
+ view.outline.setSize(width, height);
+ view.outline.render(() => renderer.render(scene, camera), { camera, ...view.outlineOptions });
+ } else {
+ renderer.render(scene, camera);
+ }
+
+ // Copy before restoring: resizing the renderer throws the frame away.
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ canvas.getContext('2d').drawImage(renderer.domElement, 0, 0);
+
+ camera.aspect = previousAspect;
+ camera.updateProjectionMatrix();
+ view.restore?.();
+
+ return canvas;
+}
+
+/** `background` is `{ mode: 'transparent' }` or `{ mode: 'solid', color }`. */
+export function applyBackground(renderer, background) {
+ if (!background || background.mode === 'transparent') {
+ renderer.setClearColor(0x000000, 0);
+ } else {
+ renderer.setClearColor(background.color ?? '#ffffff', 1);
+ }
+}
+
+/** A PNG data URL of the current view, scaled up by `scale`. */
+export async function capturePNG(view, { scale = 2, background, width, height }) {
+ const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280;
+ const baseHeight = view.height || view.renderer.domElement.clientHeight || 800;
+ const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale)));
+ const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale)));
+ const canvas = renderStill(view, { width: outWidth, height: outHeight, background });
+ const blob = await canvasToBlob(canvas);
+ return { blob, canvas, width: outWidth, height: outHeight };
+}
+
+/**
+ * Encode a canvas as a PNG blob. Exported because the caller composites the
+ * backdrop and the 擬音 stamps onto the rendered canvas afterwards, and then has
+ * to re-encode it.
+ */
+export function canvasToBlob(canvas) {
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
+}
+
+export async function copyCanvasToClipboard(canvas) {
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
+ throw new Error('この環境ではクリップボードにコピーできません');
+ }
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+}
+
+/**
+ * Vector line art: render the outline-only style, trace the coverage mask of
+ * the result and emit one even-odd path. Rendering at a high resolution keeps
+ * the traced curves smooth.
+ */
+export function buildSVG(view, {
+ styles,
+ face,
+ faceParams,
+ width = 2048,
+ threshold = 0.4,
+ lineColor = '#111111',
+ lineWidth = null,
+ background = null,
+ handDrawn = null,
+ outlinePixels = 2,
+} = {}) {
+ const previousStyle = styles.style;
+ const previousMode = face.styleMode;
+
+ // The `outline` style keeps every body mesh invisible (colorWrite off) and lets
+ // the outline passes draw the lines, exactly as on screen: the hulls ink the
+ // body, the screen-space pass inks the leaves. What reaches the trace is
+ // therefore the ink alone - and a traced stroke comes out as its own outline,
+ // i.e. two nested contours that `fill-rule="evenodd"` fills as a line of the
+ // same width. That is what finally gives the leaves, which no inverted hull can
+ // outline, clean even lines in the vector file too.
+ //
+ // `withOutlineFor` in main.js sets that split up for this call, and guards it so
+ // a renderer without the outline pass still falls back to a hull-only trace.
+ const screenSpace = Boolean(view.outline);
+
+ styles.setStyle('outline');
+ face.setParams(faceParams, 'line');
+ face.flush(performance.now(), 0);
+
+ const aspect = (view.height || 800) / (view.width || 1280);
+ const height = Math.max(64, Math.round(width * aspect));
+ // `radius` is in screen pixels, so it has to grow with the render: the SVG is
+ // drawn far larger than the viewport, and a 2px line would become a hairline.
+ const scale = width / Math.max(1, view.width || 1280);
+ const canvas = renderStill(
+ {
+ ...view,
+ outlineOptions: {
+ enabled: screenSpace,
+ color: lineColor,
+ radius: outlinePixels * scale,
+ },
+ },
+ { width, height, background: { mode: 'transparent' } },
+ );
+
+ styles.setStyle(previousStyle);
+ face.setParams(faceParams, previousMode);
+ face.flush(performance.now(), 0);
+
+ const pixels = canvas.getContext('2d').getImageData(0, 0, width, height).data;
+ const alpha = new Uint8Array(width * height);
+ for (let i = 0; i < alpha.length; i++) alpha[i] = pixels[i * 4 + 3];
+
+ let contours = traceAlphaContours(alpha, width, height, {
+ threshold,
+ simplifyTolerance: 0.6,
+ minArea: 5,
+ });
+ // 手描き風: nudge the traced outlines so they read as pen strokes instead of
+ // the mathematically smooth curves a mask trace produces.
+ if (handDrawn && handDrawn.amount > 0) {
+ contours = roughenContours(contours, {
+ amount: handDrawn.amount,
+ seed: handDrawn.seed ?? 1,
+ scale: handDrawn.scale ?? 40,
+ passes: handDrawn.passes ?? 1,
+ });
+ }
+ const d = contoursToPathData(contours, (x, y) => [x, y], 2);
+
+ const rect = background
+ ? `<rect width="${width}" height="${height}" fill="${background}"/>\n`
+ : '';
+ const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : '';
+
+ return {
+ contours: contours.length,
+ svg: `<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
+<title>ぶるべー 線画</title>
+${rect}<path fill="${lineColor}"${widthAttr} fill-rule="evenodd" d="${d}"/>
+</svg>
+`,
+ };
+}
+
+/** Alignment guides: blue on the white paper, light enough to paint over. */
+const FACE_MAP_GUIDE = 'rgba(90, 140, 220, 0.55)';
+/** Half-length of the centre ticks, in artwork-window pixels. */
+const FACE_MAP_TICK = 16;
+
+/**
+ * Build the「下地」image an author paints a custom face texture on.
+ *
+ * The studio loads a hand-drawn image by drawing it at the artwork window's own
+ * offset and size (see `Face.loadImage` / `placeInWindow`), so an image the size
+ * of the *plate canvas*, with its artwork aligned to `offsetX/offsetY`, imports
+ * 1:1. That is exactly what this hands out: the live drawing as a reference, an
+ * opaque white window to paint on, and faint guides for the eye centres, the lid
+ * line or the mouth chord.
+ *
+ * The white and the guides go on a **copy**: the plate canvas *is* the live
+ * texture, and painting it would show up in the view.
+ *
+ * @param {import('./face.js').Face} face
+ * @param {'eyes'|'mouth'} kind
+ * @returns {HTMLCanvasElement}
+ */
+export function buildFaceMap(face, kind) {
+ const eyes = kind === 'eyes';
+ const layout = eyes ? face.eyeLayout : face.mouthLayout;
+ const plate = eyes ? face.eyeCanvas : face.mouthCanvas;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = layout.width;
+ canvas.height = layout.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(plate, 0, 0);
+
+ const { offsetX, offsetY, window: win } = layout;
+ // `destination-over` so the white paper lands *under* the copy: filling it
+ // normally would erase the very drawing the author lines the new art up against.
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(offsetX, offsetY, win.width, win.height);
+ ctx.globalCompositeOperation = 'source-over';
+
+ // The plate canvas keeps the artwork at its original pixel size (only the canvas
+ // grows around it), so a fixed-width stroke reads the same on both parts.
+ ctx.strokeStyle = FACE_MAP_GUIDE;
+ ctx.fillStyle = FACE_MAP_GUIDE;
+ ctx.lineWidth = 2;
+
+ // Where the drawing has to fit, and where its middle is. The cross uses short
+ // ticks, not full-width lines, so it cannot be mistaken for artwork.
+ ctx.strokeRect(offsetX, offsetY, win.width, win.height);
+ const cx = offsetX + win.width / 2;
+ const cy = offsetY + win.height / 2;
+ guideLine(ctx, cx - FACE_MAP_TICK, cy, cx + FACE_MAP_TICK, cy);
+ guideLine(ctx, cx, cy - FACE_MAP_TICK, cx, cy + FACE_MAP_TICK);
+
+ if (eyes) {
+ // Both eyeballs (`radius`) and the lid line through their centres, so a
+ // hand-drawn brow or eye can be placed against the parametric ones.
+ const [a, b] = EYE_LAYOUT.eyes;
+ const y = offsetY + a.cy;
+ guideLine(ctx, offsetX + a.cx, y, offsetX + b.cx, y);
+ for (const eye of EYE_LAYOUT.eyes) {
+ const ex = offsetX + eye.cx;
+ const ey = offsetY + eye.cy;
+ ctx.beginPath();
+ ctx.arc(ex, ey, EYE_LAYOUT.radius, 0, Math.PI * 2);
+ ctx.stroke();
+ // A small dot marks the exact centre, where the eyeball pivots.
+ ctx.beginPath();
+ ctx.arc(ex, ey, ctx.lineWidth * 1.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ } else {
+ // The lip line the mouth is drawn around: end to end, plus a tick at the centre.
+ const y = offsetY + MOUTH_LAYOUT.chordY;
+ const { centreX, halfChord } = MOUTH_LAYOUT;
+ guideLine(ctx, offsetX + centreX - halfChord, y, offsetX + centreX + halfChord, y);
+ guideLine(ctx, offsetX + centreX, y - FACE_MAP_TICK, offsetX + centreX, y + FACE_MAP_TICK);
+ }
+
+ return canvas;
+}
+
+/** One guide segment, kept out of the map builders so the placements stay readable. */
+function guideLine(ctx, x0, y0, x1, y1) {
+ ctx.beginPath();
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x1, y1);
+ ctx.stroke();
+}
+
+export function downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.append(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
+}
+
+export function downloadText(text, filename, type = 'application/json') {
+ downloadBlob(new Blob([text], { type }), filename);
+}
+
+export function readFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
+}
+
+export function readFileAsArrayBuffer(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+export function timestamp() {
+ const now = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
+}
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
new file mode 100644
index 0000000..f42b823
--- /dev/null
+++ b/public/bluebey-studio/src/face.js
@@ -0,0 +1,375 @@
+import * as THREE from 'three';
+import { drawEyes, drawMouth, canvasLayout } from './faceArt.js';
+
+/**
+ * Owns the two procedural textures (eyes and mouth) and keeps them in sync with
+ * the face parameters.
+ *
+ * The artwork is carried by the model's face plates, but those plates are shells
+ * of the *whole* front half of the body, so their UVs cover far more than the
+ * 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto
+ * 0..1 and the canvas is enlarged to match, which is what removes the
+ * clamp-to-edge smear at the border and what gives a moved tear or mouth room to
+ * move (see `canvasLayout` in src/faceArt.js).
+ *
+ * Both parts can independently fall back to the original hand-drawn texture
+ * that ships inside the GLB.
+ */
+export class Face {
+ constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) {
+ this.eyeMesh = eyeMesh;
+ this.mouthMesh = mouthMesh;
+ // The `hair-plate`, if the model has one, is never drawn: ぶるべー has no
+ // hair. Hiding the mesh outright (rather than showing an empty texture) keeps
+ // it from tinting the head on platforms where transparent textures do.
+ this.hairMesh = hairMesh;
+ if (hairMesh) hairMesh.visible = false;
+
+ // Rewrite each plate's UVs so its own range fills 0..1, and work out the
+ // canvas that keeps the artwork exactly where the 2022 textures put it.
+ this.eyeLayout = remapPlate(eyeMesh);
+ this.mouthLayout = remapPlate(mouthMesh);
+
+ // The hand-drawn eye textures bake the face colour into their background,
+ // which is invisible in the lit "real" style but shows up as a flat purple
+ // patch in the flat and line-art styles. Key it out once, here. Both kinds
+ // of original are window-sized artwork, so they belong *in* the window, not
+ // stretched across the enlarged canvas.
+ this.originals = {
+ eyes: Object.fromEntries(
+ Object.entries(originals.eyes).map(([key, texture]) => [key, keyOutBackground(texture, this.eyeLayout)]),
+ ),
+ mouth: placeInWindowTexture(originals.mouth, this.mouthLayout),
+ };
+
+ this.customNames = new Map();
+ this.eyeCanvas = makeCanvas(this.eyeLayout);
+ this.mouthCanvas = makeCanvas(this.mouthLayout);
+ this.eyeCtx = this.eyeCanvas.getContext('2d');
+ this.mouthCtx = this.mouthCanvas.getContext('2d');
+
+ this.eyeTexture = makeTexture(this.eyeCanvas);
+ this.mouthTexture = makeTexture(this.mouthCanvas);
+
+ this.params = null;
+ this.styleMode = 'paint';
+ this.eyeSource = 'parametric';
+ this.mouthSource = 'parametric';
+ this.dirty = true;
+ this.lastDraw = -Infinity;
+
+ // The face plates are shells of the body surface, pushed a hair outwards by
+ // the loader, so they win the depth test against the body by themselves.
+ // Nudging them with a polygon offset as well keeps the two from z-fighting
+ // along grazing angles. Depth *testing* stays on: that is what lets the nose
+ // (which pokes further out) and an arm waved in front of the face hide the
+ // artwork the way they should - it only ever hid the old planes because
+ // those did not reach far enough down the head.
+ const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]);
+ if (hairMesh) orders.set(hairMesh, 3);
+ for (const mesh of [eyeMesh, mouthMesh, ...(hairMesh ? [hairMesh] : [])]) {
+ const material = mesh.material;
+ material.polygonOffset = true;
+ material.polygonOffsetFactor = -1;
+ material.polygonOffsetUnits = -2;
+ material.depthTest = true;
+ material.depthWrite = false;
+ // The GLB may describe the plate material as opaque; the artwork is a
+ // texture with alpha, so blend instead of replacing what is behind it.
+ material.transparent = true;
+ material.side = THREE.DoubleSide;
+ material.needsUpdate = true;
+ // Tears are drawn on the eye plate and the mouth on the other one, and
+ // both sit on the same shell: draw the mouth first so a teardrop can fall
+ // across it instead of being painted over.
+ mesh.renderOrder = orders.get(mesh) ?? 0;
+ }
+
+ this.applyTextures();
+ }
+
+ /** Show or hide the mouth without touching the artwork. */
+ setMouthVisible(visible) {
+ if (this.mouthMesh.visible !== visible) this.mouthMesh.visible = visible;
+ }
+
+ applyTextures() {
+ const eyeMap = this.eyeSource === 'parametric'
+ ? this.eyeTexture
+ : (this.originals.eyes[this.eyeSource] ?? this.eyeTexture);
+ const mouthMap = this.mouthSource === 'parametric'
+ ? this.mouthTexture
+ : (this.originals.mouth ?? this.mouthTexture);
+
+ if (this.eyeMesh.material.map !== eyeMap) {
+ this.eyeMesh.material.map = eyeMap;
+ this.eyeMesh.material.needsUpdate = true;
+ }
+ if (this.mouthMesh.material.map !== mouthMap) {
+ this.mouthMesh.material.map = mouthMap;
+ this.mouthMesh.material.needsUpdate = true;
+ }
+ }
+
+ /**
+ * Load a hand-drawn image and use it for the eyes or the mouth. Any size is
+ * accepted; it is scaled to the layout the model expects (1024 x 380).
+ * Returns the key the new artwork is registered under.
+ */
+ async loadImage(kind, file) {
+ const layout = kind === 'eyes' ? this.eyeLayout : this.mouthLayout;
+ const bitmap = await createImageBitmap(file);
+ // Any size is accepted; it is scaled into the artwork window, which is where
+ // the plate's rewritten UVs expect it.
+ const canvas = placeInWindow(bitmap, layout);
+ if (typeof bitmap.close === 'function') bitmap.close();
+ const texture = makeTexture(canvas);
+
+ if (kind === 'eyes') {
+ const key = `custom-${this.customNames.size + 1}`;
+ this.customNames.set(key, file.name);
+ this.originals.eyes[key] = texture;
+ this.setSource('eyes', key);
+ return key;
+ }
+ this.originals.mouth = texture;
+ this.setSource('mouth', 'original');
+ return 'original';
+ }
+
+ /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
+ setSource(kind, source) {
+ if (kind === 'eyes') {
+ if (this.eyeSource === source) return;
+ this.eyeSource = source;
+ } else {
+ if (this.mouthSource === source) return;
+ this.mouthSource = source;
+ }
+ this.applyTextures();
+ this.dirty = true;
+ }
+
+ setParams(params, styleMode = this.styleMode) {
+ this.params = params;
+ this.styleMode = styleMode;
+ this.dirty = true;
+ }
+
+ /**
+ * Redraw the procedural textures when they are stale. `minInterval` throttles
+ * redraws while animating; pass 0 before an export so nothing is left pending.
+ */
+ flush(now = performance.now(), minInterval = 0) {
+ if (!this.dirty) return false;
+ if (now - this.lastDraw < minInterval) return false;
+ this.redraw();
+ this.lastDraw = now;
+ return true;
+ }
+
+ redraw() {
+ const p = this.params;
+ if (!p) {
+ this.dirty = false;
+ return;
+ }
+ // Hiding the mouth has to hide the plane itself, not just stop drawing on
+ // it - otherwise the last drawing stays on screen.
+ this.setMouthVisible(p.mouth.visible !== false);
+ // A tongue poking over a *closed* lip can reach up behind the 3D nose, which
+ // would hide it. In that one case let the mouth artwork win the depth test,
+ // so the tongue reads; otherwise the nose (and an arm) still hide the mouth
+ // the way they should. The lip line and the rest of the mouth sit clear of
+ // the nose, so only the tongue can overlap it.
+ const mouth = p.mouth ?? {};
+ const tongueOverLip = (mouth.round ?? 0) <= 0.004
+ && (mouth.open ?? 0) <= 0.004
+ && (mouth.tongue ?? 1) > 0.01;
+ this.mouthMesh.material.depthTest = !tongueOverLip;
+ const mode = this.styleMode === 'line' ? 'line' : 'paint';
+
+ if (this.eyeSource === 'parametric') {
+ const layout = this.eyeLayout;
+ const ctx = this.eyeCtx;
+ ctx.clearRect(0, 0, layout.width, layout.height);
+ ctx.save();
+ // The artwork window sits at an offset inside the bigger canvas.
+ ctx.translate(layout.offsetX, layout.offsetY);
+ drawEyes(ctx, {
+ mode,
+ eyes: { left: p.eyes.left, right: p.eyes.right },
+ style: {
+ white: p.eyes.white,
+ iris: p.eyes.iris,
+ line: p.eyes.line,
+ irisScale: p.eyes.irisScale,
+ lookMax: p.eyes.lookMax,
+ highlight: p.eyes.highlight,
+ lidWidth: p.eyes.lidWidth,
+ lowerLid: p.eyes.lowerLid,
+ lidShape: p.eyes.lidShape,
+ lidTilt: p.eyes.lidTilt,
+ lashes: p.eyes.lashes,
+ // 眼鏡 / サングラス are shared by both eyes and drawn into this same
+ // texture, so they travel with the other shared eye fields.
+ glasses: p.eyes.glasses,
+ heartScale: p.eyes.heartScale,
+ heartColor: p.eyes.heartColor,
+ tearColor: p.eyes.tearColor,
+ // Where the artwork's real edges are, so a brow lifted too far or a
+ // tear dropped too low can stop inside the artwork (see `canvasLayout`).
+ limits: layout.limits,
+ },
+ });
+ ctx.restore();
+ this.eyeTexture.needsUpdate = true;
+ }
+
+ if (this.mouthSource === 'parametric' && p.mouth.visible) {
+ const layout = this.mouthLayout;
+ const ctx = this.mouthCtx;
+ ctx.clearRect(0, 0, layout.width, layout.height);
+ ctx.save();
+ ctx.translate(layout.offsetX, layout.offsetY);
+ drawMouth(ctx, { mode, ...p.mouth, limits: layout.limits });
+ ctx.restore();
+ this.mouthTexture.needsUpdate = true;
+ }
+
+ this.dirty = false;
+ }
+}
+
+function makeCanvas({ width, height }, scale = 1) {
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(width * scale);
+ canvas.height = Math.round(height * scale);
+ return canvas;
+}
+
+/**
+ * Rewrite a plate's UVs so its own range maps onto 0..1, and return the canvas
+ * that keeps the artwork at its original pixel size (`canvasLayout`).
+ *
+ * The plate covers the whole front half of the body, so its UVs used to run well
+ * outside the artwork (v -1.02..1.93 on the eye plate). Everything past the edge
+ * clamped to the canvas border, which is what stretched a tear or a brow that
+ * reached it. After this the whole plate samples real canvas, so nothing clamps.
+ */
+function remapPlate(mesh) {
+ const uv = mesh?.geometry?.attributes?.uv;
+ if (!uv) return canvasLayout(null);
+ let u0 = Infinity;
+ let u1 = -Infinity;
+ let v0 = Infinity;
+ let v1 = -Infinity;
+ for (let i = 0; i < uv.count; i += 1) {
+ const u = uv.getX(i);
+ const v = uv.getY(i);
+ if (u < u0) u0 = u;
+ if (u > u1) u1 = u;
+ if (v < v0) v0 = v;
+ if (v > v1) v1 = v;
+ }
+ const du = Math.max(1e-6, u1 - u0);
+ const dv = Math.max(1e-6, v1 - v0);
+ for (let i = 0; i < uv.count; i += 1) {
+ uv.setXY(i, (uv.getX(i) - u0) / du, (uv.getY(i) - v0) / dv);
+ }
+ uv.needsUpdate = true;
+ return canvasLayout({ u0, u1, v0, v1 });
+}
+
+/**
+ * Draw a hand-drawn 1024 x 380 image where the artwork window now sits, on a
+ * canvas as big as the plate's own UV range. The plates cover far more than the
+ * window (see `canvasLayout`) and the UVs were rewritten to match, so the window
+ * is exactly where that image belongs.
+ */
+function placeInWindow(source, layout) {
+ const canvas = document.createElement('canvas');
+ canvas.width = layout.width;
+ canvas.height = layout.height;
+ if (source) {
+ canvas.getContext('2d').drawImage(
+ source, layout.offsetX, layout.offsetY, layout.window.width, layout.window.height,
+ );
+ }
+ return canvas;
+}
+
+/** `placeInWindow` for an existing texture (keeps `null` as `null`). */
+function placeInWindowTexture(texture, layout) {
+ const image = texture?.image;
+ if (!image || !image.width || !image.height) return texture ?? null;
+ return makeTexture(placeInWindow(image, layout));
+}
+
+function makeTexture(canvas) {
+ const texture = new THREE.CanvasTexture(canvas);
+ // glTF puts v = 0 at the top of the image and the loader uploads the model's
+ // own textures with flipY = false; matching that keeps the artwork aligned
+ // with the mesh UVs.
+ texture.flipY = false;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.premultiplyAlpha = false;
+ texture.needsUpdate = true;
+ return clampToEdge(texture);
+}
+
+/**
+ * The face plates are shells of the whole body, so their UVs run well past the
+ * 0..1 of the artwork (a plate corner can sit at v = -1.26). glTF's default
+ * sampler repeats, which would stamp the drawing back onto the model several
+ * times - a frown flicked up onto the forehead, a smile's end onto its side.
+ * Clamping sends everything outside the artwork to the blank edge of the canvas
+ * instead, where nothing is drawn.
+ */
+function clampToEdge(texture) {
+ if (!texture) return texture;
+ texture.wrapS = THREE.ClampToEdgeWrapping;
+ texture.wrapT = THREE.ClampToEdgeWrapping;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+/**
+ * Make the flat colour that fills the background of a hand-drawn texture
+ * transparent. The edge is feathered rather than hard, so the anti-aliased
+ * pixels along the artwork do not leave a pale fringe.
+ */
+function keyOutBackground(texture, layout) {
+ const image = texture?.image;
+ if (!image || !image.width || !image.height) return texture;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(image, 0, 0);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
+
+ // The background is uniform, so the top-left pixel is a reliable sample.
+ const baseR = pixels[0];
+ const baseG = pixels[1];
+ const baseB = pixels[2];
+ const fullyClear = 30; // at or below this distance: transparent
+ const fullySolid = 96; // at or above this distance: untouched
+
+ for (let i = 0; i < pixels.length; i += 4) {
+ const distance = Math.abs(pixels[i] - baseR)
+ + Math.abs(pixels[i + 1] - baseG)
+ + Math.abs(pixels[i + 2] - baseB);
+ if (distance <= fullyClear) {
+ pixels[i + 3] = 0;
+ } else if (distance < fullySolid) {
+ const ratio = (distance - fullyClear) / (fullySolid - fullyClear);
+ pixels[i + 3] = Math.round(pixels[i + 3] * ratio);
+ }
+ }
+ ctx.putImageData(imageData, 0, 0);
+
+ return makeTexture(placeInWindow(canvas, layout));
+}
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
new file mode 100644
index 0000000..4aaa2d6
--- /dev/null
+++ b/public/bluebey-studio/src/faceArt.js
@@ -0,0 +1,1283 @@
+/**
+ * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now
+ * drawn from parameters onto a canvas, which is then used as the texture of the
+ * two overlay planes that already exist on the model.
+ *
+ * The plates have a linear UV mapping fitted to the original artwork, so drawing
+ * in "texture pixels" lands exactly where the original artwork did. Every constant
+ * below was measured from the original textures (see README for the numbers), so
+ * the defaults reproduce the 2022 artwork while every part of it stays editable.
+ *
+ * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows
+ * downwards. `canvasLayout` then says where that window sits on the bigger canvas
+ * a face plate actually needs (see below), and `Face` hands the bounds back as
+ * `limits`, so the drawing can clamp against the real edge instead of assuming
+ * the window is all there is.
+ *
+ * glTF stores v downwards too, and the textures are uploaded with flipY = false,
+ * so no flipping is needed anywhere.
+ */
+
+const TAU = Math.PI * 2;
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+const HUGE = 4000;
+
+/**
+ * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every
+ * constant below is written in this space.
+ */
+export const ART_WINDOW = { width: 1024, height: 380 };
+
+/** Keep this much clear at the canvas border, so filtering has room to breathe. */
+const EDGE_MARGIN = 8;
+
+/** What a drawing falls back to when no plate bounds are supplied. */
+const WINDOW_LIMITS = {
+ top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width,
+};
+
+/**
+ * The canvas a face plate's drawing needs, and where the artwork window sits on it.
+ *
+ * WHY: the plates are the *whole* front half of the body, so their UVs run far
+ * outside the 0..1 the artwork was drawn in (measured on the shipped model: the
+ * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a
+ * twentieth of the border). Everything outside 0..1 used to clamp to the canvas
+ * edge row, so the moment a drawing reached the border - a tear dropped low, a
+ * brow lifted, a thick mouth pushed down - that edge row was copied across the
+ * rest of the plate and the mark stretched into a long smear.
+ *
+ * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which
+ * removes the clamp altogether. The map is affine, so the artwork keeps its exact
+ * pixel size and position and merely shifts by `offset`; the canvas grows by the
+ * same factor, and those extra rows are the room a moved tear, brow or mouth
+ * needs (`limits` says where the real edge now is, in window coordinates).
+ *
+ * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range
+ * @param {{width:number,height:number}} [window] the artwork window
+ */
+export function canvasLayout(range, window = ART_WINDOW) {
+ const u0 = Number.isFinite(range?.u0) ? range.u0 : 0;
+ const u1 = Number.isFinite(range?.u1) ? range.u1 : 1;
+ const v0 = Number.isFinite(range?.v0) ? range.v0 : 0;
+ const v1 = Number.isFinite(range?.v1) ? range.v1 : 1;
+ const du = Math.max(1e-6, u1 - u0);
+ const dv = Math.max(1e-6, v1 - v0);
+ const width = Math.max(1, Math.round(window.width * du));
+ const height = Math.max(1, Math.round(window.height * dv));
+ // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly.
+ const offsetX = Math.round(-u0 * window.width) + 0;
+ const offsetY = Math.round(-v0 * window.height) + 0;
+ return {
+ width,
+ height,
+ offsetX,
+ offsetY,
+ window,
+ // The canvas edges in the artwork's own coordinates. `top` is negative when
+ // the plate reaches above the window: that is the extra room.
+ limits: {
+ top: -offsetY + 0,
+ bottom: height - offsetY,
+ left: -offsetX + 0,
+ right: width - offsetX,
+ },
+ };
+}
+
+/** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */
+export const EYE_LAYOUT = {
+ radius: 84,
+ irisRadius: 62.5,
+ highlightRadius: 28,
+ highlightOffset: { x: 29.5, y: -18 },
+ // The model's left eye (its own left, +x) sits in the u > 0.5 half.
+ eyes: [
+ { key: 'right', cx: 267.5, cy: 240, towardNose: 1 },
+ { key: 'left', cx: 755.5, cy: 240, towardNose: -1 },
+ ],
+ lidRadiusFactor: 1.35,
+ lidStroke: 14,
+ // A teardrop hangs well below the eye. The plate keeps the whole canvas, so
+ // the drop can sit low without being clipped (measured with a ruler grid).
+ tear: { size: 34, offsetX: 66, offsetY: 88 },
+ closedLine: {
+ // The single shut line is the *long* form of the eyelid, the same reach as
+ // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not
+ // change how wide the eye reads.
+ length: 205,
+ offsetX: 6,
+ offsetY: -10,
+ slantDeg: 3.9,
+ bow: 3,
+ // 2- and 3-line shut eyes, copied from the original `eyes-close-tight`
+ // artwork: three strokes sharing one vertex that points at the nose, opening
+ // to a wide bird's foot / arrow shape. `spread` is half the height the arms
+ // open to at the far end.
+ armSpan: 205,
+ spread: 62,
+ vertex: 87.5,
+ armX: 104,
+ },
+ arch: { edgesUp: 8, apexUp: 44 },
+ // まつげ: a few strokes flicking out from the upper-outer lid (see drawEyes).
+ // `raise` lifts them a touch above the lid edge (2% of the lid slider).
+ lash: { length: 30, width: 8, angles: [46, 66, 86], raise: 0.04 },
+ // "ふつう"の目の瞳を、両目とも少し鼻側に寄せてかわいく見せる(px)。
+ irisInward: 7,
+};
+
+
+/**
+ * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows.
+ *
+ * Not measured off any artwork - the character has no glasses - but the numbers
+ * are tied to the eyeball so a lens is always a little wider than the eye it
+ * covers.
+ *
+ * The two kinds get separate shapes on purpose. They used to share one ellipse
+ * and differ only in how dark the lens was filled, which made them hard to tell
+ * apart; the round 眼鏡 below and the wide, angular サングラス in
+ * `SUNGLASSES_LAYOUT` now read as different objects at a glance.
+ */
+const GLASSES_LAYOUT = {
+ widthFactor: 1.08, // lens half-width, as a multiple of the eyeball radius
+ heightFactor: 1.0, // a circle, so the round 眼鏡 stays round (a flatter
+ // ellipse used to drift towards the shades below)
+ drop: 20, // the lens centre hangs this far below the eye centre (artwork px)
+ bridgeRise: 8, // how much the bridge arcs up over the nose
+ bridgeWidth: 0.75, // bridge thickness, as a fraction of the frame stroke
+ templeLength: 1.5, // temple stub length, as a multiple of the eyeball radius
+ templeRise: 30, // how far the temple climbs towards the side of the head
+};
+
+/**
+ * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a
+ * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner*
+ * set further out (`tipX`), and whose top edge then sweeps back down towards the
+ * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is
+ * short, so the lens tapers inwards. The eye is allowed to poke out above and
+ * below it, so the lens no longer has to cover the whole eyeball. A level bar
+ * joins the two inner ends, so the pair still reads as one dark visor. The ratios
+ * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the
+ * same thing for both kinds.
+ */
+const SUNGLASSES_LAYOUT = {
+ widthFactor: 1.6, // a long lens: the outer end reaches well past the eyeball
+ heightFactor: 0.58, // slim, so the eye is free to show above and below it
+ drop: 8, // sits a little lower than the round pair
+ corner: 0.05, // corner rounding, as a fraction of the half-height; kept
+ // tiny so the outer corner stays pointed
+ topSkew: 1.0, // the pointed corner rises this far above the lens top (x half-height)
+ innerTop: 0.42, // the inner top is low, which is what makes the top edge climb
+ innerBottom: 0.34, // the inner bottom is pinched up towards the nose (x half-height)
+ outerEndX: 0.75, // the low outer end sits this far out (x half-width)
+ outerBottom: 0.82, // ...and this deep (x half-height)
+ tipX: 1.22, // the pointed corner juts this far out (x half-width)
+ bridgeWidth: 1.15, // a short, thick bar - thicker than the frame stroke
+ bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height)
+ templeLength: 1.15, // a short stub, like the round pair's but a little shorter
+ templeRise: 24,
+ // A heavy rim. It used to be slimmer than the round pair's stroke, which read
+ // as a thin pair of shades; the reference look is a chunky frame.
+ frameFactor: 1.6,
+};
+
+/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole
+ * 1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer
+ * has to be squeezed into a band. The only limit left is the canvas itself. */
+export const MOUTH_LAYOUT = {
+ centreX: 511.5,
+ chordY: 88,
+ halfChord: 420.5,
+ sag: 177,
+ // With the ends and the depth both fixed - which is what the slider promises -
+ // the only thing left to choose about the curve is *where* it bends. 1/3 is a
+ // quadratic Bézier (a parabola): it is flattest at the apex and falls away
+ // fastest near the ends. Pulling the cubic's control points in towards the ends
+ // makes the middle of the smile straighter still and lets the fall happen near
+ // the corners, which is what reads as a *gentler* curve at the same depth.
+ sagBend: 0.2,
+ thickness: 21,
+ // How big the round "O" oval gets at `round: 1`, as a multiple of the smile's
+ // own sag. The oval's size comes from this and `round` alone: `thickness` is
+ // only the stroke weight, so 口の太さ and 丸く開く no longer move together.
+ roundScale: 1.3,
+ // Kept for reference: the 2022 plane only showed these rows.
+ bandTop: 46,
+ bandBottom: 332,
+ // Only used to cap the *size* of the round "O" mouth, so the surprise face
+ // stays a mouth and not a hole. The mouth's travel no longer stops here: the
+ // drawing is given the plate's real edges through `limits`.
+ safeBottom: 372,
+ // The nose is a separate mesh that pokes out in front of the plate. The plate
+ // is depth tested, so the nose hides whatever is drawn behind it - but a frown
+ // arcs *up* into that hiding place, so slide the mouth down until the middle
+ // of the arc clears the nose. Only the middle is checked: a smile curves away
+ // from the nose, and checking its ends instead is what used to pin the whole
+ // mouth in place and make the height slider do nothing.
+ noseClear: 132,
+ // The original hand-drawn mouth is a *shallow* arc with tall corner strokes
+ // flicking up at the ends (measured from the artwork: the arc's own sag is
+ // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that
+ // split is what makes `smile: 1` read as the original; making the arc itself
+ // deep instead looked too steep.
+ //
+ // The tongue rises from the lip line to a rounded top just above the corner
+ // strokes. Measured off the original: its crown is a *super-ellipse* -
+ // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth,
+ // almost flat top. It is convex upwards, but it is **not** a point.
+ tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 },
+ corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 },
+ openRise: 64,
+ // Was 0.25: opening the mouth used to flatten the smile to stay inside the
+ // texture band. There is no band to stay inside now.
+ openFlatten: 0,
+};
+
+/* ------------------------------------------------------------------ helpers */
+
+function circlePath(ctx, cx, cy, r) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, TAU);
+ ctx.closePath();
+}
+
+function fillCircle(ctx, cx, cy, r, color) {
+ circlePath(ctx, cx, cy, r);
+ ctx.fillStyle = color;
+ ctx.fill();
+}
+
+/**
+ * A heart, used for the "love" eyes. The path is wider than it is tall, which
+ * is what makes it read as a heart rather than a blob at small sizes.
+ */
+function heartPath(ctx, cx, cy, r) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy + r * 0.80);
+ ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40);
+ ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80);
+ ctx.closePath();
+}
+
+function fillHeart(ctx, cx, cy, r, color) {
+ heartPath(ctx, cx, cy, r);
+ ctx.fillStyle = color;
+ ctx.fill();
+}
+
+/** A teardrop, used by the crying expression. */
+function dropPath(ctx, cx, cy, size) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - size * 1.32);
+ ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78);
+ ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32);
+ ctx.closePath();
+}
+
+function strokeCircle(ctx, cx, cy, r, color, width) {
+ circlePath(ctx, cx, cy, r);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.stroke();
+}
+
+function tracePolyline(ctx, points, closed) {
+ if (!points.length) return;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
+ if (closed) ctx.closePath();
+}
+
+function strokePolyline(ctx, points, { color, width, closed = false }) {
+ if (points.length < 2) return;
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ tracePolyline(ctx, points, closed);
+ ctx.stroke();
+ ctx.restore();
+}
+
+function fillPolygon(ctx, points, color) {
+ if (points.length < 3) return;
+ ctx.save();
+ ctx.fillStyle = color;
+ tracePolyline(ctx, points, true);
+ ctx.fill();
+ ctx.restore();
+}
+
+/**
+ * Trace a closed polygon with rounded corners. Used for the squarish
+ * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps.
+ * Each corner is cut back by `radius` (clamped so short edges cannot overlap)
+ * and joined with a quadratic through the original vertex.
+ */
+function traceRoundedPolygon(ctx, points, radius) {
+ const n = points.length;
+ ctx.beginPath();
+ for (let i = 0; i < n; i++) {
+ const prev = points[(i + n - 1) % n];
+ const cur = points[i];
+ const next = points[(i + 1) % n];
+ const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1;
+ const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1;
+ const cut = Math.min(radius, inLen / 2, outLen / 2);
+ const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut };
+ const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut };
+ if (i === 0) ctx.moveTo(from.x, from.y);
+ else ctx.lineTo(from.x, from.y);
+ ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y);
+ }
+ ctx.closePath();
+}
+
+/** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */
+function quadraticPoints(p0, p1, p2, steps) {
+ const out = [];
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps;
+ const u = 1 - t;
+ out.push({
+ x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
+ y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
+ });
+ }
+ return out;
+}
+
+/** A point on a cubic Bézier. */
+function cubicPoint(p0, p1, p2, p3, t) {
+ const u = 1 - t;
+ const a = u * u * u;
+ const b = 3 * u * u * t;
+ const c = 3 * u * t * t;
+ const d = t * t * t;
+ return {
+ x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
+ y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
+ };
+}
+
+/** Points along a cubic Bézier, `steps` segments (steps + 1 points). */
+function cubicPoints(p0, p1, p2, p3, steps) {
+ const out = [];
+ for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps));
+ return out;
+}
+
+/**
+ * The four control points of the mouth's centreline: the same two ends, the same
+ * depth, but `bend` decides where the curve actually bends (see `sagBend`).
+ */
+function mouthControls(left, right, sag, bend) {
+ const width = right.x - left.x;
+ const y = left.y + (4 / 3) * sag;
+ return {
+ p0: left,
+ p1: { x: left.x + width * bend, y },
+ p2: { x: right.x - width * bend, y },
+ p3: right,
+ };
+}
+
+function rotate(points, pivot, degrees) {
+ if (!degrees) return points;
+ const a = (degrees * Math.PI) / 180;
+ const cos = Math.cos(a);
+ const sin = Math.sin(a);
+ return points.map((p) => {
+ const dx = p.x - pivot.x;
+ const dy = p.y - pivot.y;
+ return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos };
+ });
+}
+
+/* --------------------------------------------------------------------- eyes */
+
+/**
+ * Intersect the canvas clip with the eyeball disc and the (possibly closed)
+ * lids. Because the overlay plane is transparent and sits in front of the face,
+ * clipping is all that is needed: whatever is clipped away shows the real
+ * shaded face behind it. That is what makes the eyelid blend perfectly without
+ * baking a face-coloured background into the texture.
+ */
+/** Clip to the upper and lower lids only, without the eyeball circle. */
+function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
+ const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180;
+ const rotated = Math.abs(tilt) > 1e-4;
+ const spin = () => {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate(tilt);
+ ctx.translate(-eye.cx, -eye.cy);
+ };
+ if (rotated) spin();
+
+ const lower = clamp(lowerLid, 0, 1);
+ // The upper and lower lids are independent: 上まぶたの高さ sets where the upper
+ // lid sits on its own, so raising 下まぶたの高さ does not drag the upper lid
+ // down with it. A blink still shuts the eye because `open` reaching 0 (or the
+ // upper lid dropping to the lower one) hands over to the shut-eye drawing
+ // before this clip is used.
+ const upper = 1 - clamp(open, 0, 1);
+ if (upper > 0.0005) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ const lowest = eye.cy - eye.r + 2 * eye.r * upper;
+ ctx.beginPath();
+ if (flat) {
+ // A flat lid: a straight edge straight across the eye.
+ ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE);
+ } else {
+ const cy = lowest - rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy + HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy + HUGE);
+ }
+ ctx.closePath();
+ ctx.clip();
+ }
+
+ const bottom = lower;
+ if (bottom > 0.0005) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ const highest = eye.cy + eye.r - 2 * eye.r * bottom;
+ ctx.beginPath();
+ if (flat) {
+ ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE);
+ } else {
+ const cy = highest + rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy - HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy - HUGE);
+ }
+ ctx.closePath();
+ ctx.clip();
+ }
+
+ if (rotated) {
+ // Undo the rotation for the caller, but keep the clip we just set.
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate(-tilt);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+}
+
+/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */
+function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
+ circlePath(ctx, eye.cx, eye.cy, eye.r);
+ ctx.clip();
+ clipLids(ctx, eye, open, lowerLid, flat, tiltDeg);
+}
+
+function lidPath(ctx, eye, amount, lower, flat = false) {
+ ctx.beginPath();
+ if (flat) {
+ // A straight lid edge; the caller clips it to the eyeball.
+ const y = lower
+ ? eye.cy + eye.r - 2 * eye.r * amount
+ : eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.moveTo(eye.cx - eye.r * 2, y);
+ ctx.lineTo(eye.cx + eye.r * 2, y);
+ return;
+ }
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ if (!lower) {
+ const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true);
+ } else {
+ const highest = eye.cy + eye.r - 2 * eye.r * amount;
+ ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false);
+ }
+}
+
+/** The "eyes shut" artwork: a line, a chevron or a happy arch. */
+function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity) {
+ const layout = EYE_LAYOUT.closedLine;
+ const { line, width } = style;
+ // 形の大きさ is a multiplier on the shape's *fitting* size. With the white shown
+ // that fitting size is what just fits inside the eyeball, so the default (1)
+ // sits in the white; raising it may push the shape out past the white, which is
+ // allowed. `maxHalf` is Infinity when the white is hidden, so nothing is scaled
+ // back there.
+ const k = (unitHalf) => shapeScale * (maxHalf === Infinity ? 1 : Math.min(1, maxHalf / unitHalf));
+
+ if (spec.closed === 'chevron') {
+ // The chevron points *at* the nose, matching the original artwork.
+ const scale = k(layout.armX);
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
+ const armX = eye.cx - eye.towardNose * layout.armX * scale;
+ const spread = layout.spread * (spec.spread ?? 1) * scale;
+ strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width });
+ strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width });
+ return;
+ }
+
+ if (spec.closed === 'three') {
+ // A real "3": an upper bowl and a lower bowl that meet at a single pinch on
+ // the nose side. Stacking two C's instead made the two lobes sit on top of
+ // each other; joining them at a point is what makes it read as the digit.
+ const r = 34 * k(1.7 * 34);
+ // Which way the digit faces. It used to follow the nose side, which made the
+ // pair a mirror image; each eye can now be set either way, because a pair of
+ // mirrored 3s does not always read the way you want.
+ const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose;
+ const sx = eye.cx;
+ const cy = eye.cy + layout.offsetY;
+ const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y });
+ const pinch = at(0.72, 0);
+ const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26);
+ const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26);
+ strokePolyline(ctx, upper, { color: line, width });
+ strokePolyline(ctx, lower, { color: line, width });
+ return;
+ }
+
+ if (spec.closed === 'arch') {
+ const scale = k(eye.r);
+ const edges = { x: eye.r * scale, y: EYE_LAYOUT.arch.edgesUp * scale };
+ const points = quadraticPoints(
+ { x: eye.cx - edges.x, y: eye.cy + edges.y },
+ { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 * scale },
+ { x: eye.cx + edges.x, y: eye.cy + edges.y },
+ 24,
+ );
+ strokePolyline(ctx, points, { color: line, width });
+ return;
+ }
+
+ // Default: the gently slanted line of the original artwork, mirrored so it
+ // always slopes down towards the nose. With `closedLines` set to 2 or 3 the
+ // strokes share one endpoint instead - a ">" or bird's-foot shape, which is
+ // how manga draws a happily squeezed-shut eye.
+ const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3);
+ const scale = k(layout.length / 2);
+ if (count >= 2) {
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
+ // Reach as far as the single line does, not just a short stub: the arms are
+ // the *long* form of the same eyelid the one-line version covers.
+ const outX = -eye.towardNose * layout.armSpan * scale;
+ const spread = layout.spread * scale;
+ const arms = count === 2
+ ? [{ x: outX, y: -spread }, { x: outX, y: spread }]
+ : [
+ { x: outX, y: -spread },
+ { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread) * scale, y: 0 },
+ { x: outX, y: spread },
+ ];
+ for (const arm of arms) {
+ strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width });
+ }
+ return;
+ }
+
+ const half = (layout.length / 2) * (spec.length ?? 1) * scale;
+ const midX = eye.cx - eye.towardNose * layout.offsetX * scale;
+ const midY = eye.cy + layout.offsetY * scale;
+ const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180;
+ const bow = (layout.bow ?? 0) * (spec.slant ?? 1) * scale;
+ const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half };
+ const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half };
+ const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20);
+ strokePolyline(ctx, points, { color: line, width });
+}
+
+
+
+
+
+
+/**
+ * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its
+ * temple.
+ *
+ * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork)
+ * so the lens sits in front of the eye, and before that eye's tear so a teardrop
+ * falls past the lens. Nothing about a pair of glasses is per-eye, so the two
+ * halves are mirrored from `eye.towardNose` and meet at the face's midline. The
+ * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one.
+ *
+ * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so
+ * the paper behind shows through exactly as the eyeball's white does (see the note
+ * in `drawEyes`); the frames are then stroked in the line colour.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {{cx:number,cy:number,r:number,towardNose:number}} eye
+ * @param {boolean} line true in 線画 mode
+ * @param {object} style the bag `drawEyes` received
+ * @param {{top:number,bottom:number,left:number,right:number}} limits
+ */
+function drawGlasses(ctx, eye, line, style, limits) {
+ const spec = style.glasses;
+ if (!spec?.enabled) return;
+
+ const r = eye.r;
+ const scale = clamp(spec.scale ?? 1, 0.2, 3);
+ // The two kinds share everything about *where* they sit but not their shape:
+ // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above).
+ const shades = spec.kind === 'sunglasses';
+ const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT;
+ const rx = r * L.widthFactor * scale;
+ const ry = rx * L.heightFactor;
+ const frame = Math.max(
+ 1,
+ (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1),
+ );
+ const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33');
+ // The outward direction (towards the temple) for this eye. The angular lens is
+ // built in a local frame whose u runs outwards, so the two sides mirror exactly.
+ const out = -eye.towardNose;
+ // The pointed outer corner makes the shades taller on that side; clamp against
+ // the full height so even that point cannot reach the canvas border.
+ const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry;
+
+ // The face's midline: the two eyes are symmetric about it, so it is where the
+ // two halves of the bridge meet and what `tilt` turns the pair about.
+ const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2;
+ // `lensGap` slides the two lenses apart (positive) or together (negative) along
+ // the face, symmetrically about the midline. It is clamped so the inner edges may
+ // meet at the midline but the two lenses can never cross each other.
+ const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx);
+ const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE);
+ // Keep the lens (and so everything that hangs off it) inside the artwork. The
+ // rest of the file clamps against `limits` for the same reason: a mark that
+ // reaches the canvas border gets the edge row copied across the whole plate.
+ const lensCx = clamp(
+ eye.cx + out * gap,
+ limits.left + EDGE_MARGIN + rx,
+ limits.right - EDGE_MARGIN - rx,
+ );
+ const lensCy = clamp(
+ eye.cy + L.drop + (spec.offsetY ?? 0),
+ limits.top + EDGE_MARGIN + halfV,
+ limits.bottom - EDGE_MARGIN - halfV,
+ );
+ // Local (u, v) -> canvas, with u outwards and v downwards.
+ const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v });
+
+ ctx.save();
+ // `tilt` leans the whole pair at once. Rotating about the midline keeps the
+ // bridge centred between the lenses instead of swinging it off to one side.
+ if (spec.tilt) {
+ ctx.translate(midX, lensCy);
+ ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180);
+ ctx.translate(-midX, -lensCy);
+ }
+
+ // --- lens ------------------------------------------------------------
+ // The one path is filled and then stroked, so the fill and the frame can never
+ // drift apart.
+ if (shades) {
+ // A long cat-eye, not an ellipse: the outer end starts low, rises to a
+ // pointed, lifted outer corner set further out, and the top edge sweeps back
+ // towards the nose. The inner end is short, so the lens tapers inwards.
+ const lens = [
+ at(-rx, -ry * L.innerTop), // inner top (near the nose)
+ at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)), // pointed, lifted outer corner
+ at(rx * L.outerEndX, ry * L.outerBottom), // low outer end
+ at(-rx, ry * L.innerBottom), // inner bottom (pinched towards the nose)
+ ];
+ traceRoundedPolygon(ctx, lens, ry * L.corner);
+ if (!line) {
+ ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
+ ctx.fillStyle = spec.lensColor ?? '#2b2433';
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = frame;
+ ctx.stroke();
+ } else {
+ ctx.beginPath();
+ ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU);
+ ctx.closePath();
+ if (!line) {
+ ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
+ ctx.fillStyle = spec.lensColor ?? '#2b2433';
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = frame;
+ ctx.stroke();
+ }
+
+ // --- bridge ----------------------------------------------------------
+ // Each eye draws the half of the bridge from its own lens to the midline.
+ const innerX = eye.towardNose > 0
+ ? Math.min(lensCx + rx, midX)
+ : Math.max(lensCx - rx, midX);
+ if (shades) {
+ // A short, thick, level bar high on the lenses: with the two lenses it reads
+ // as one continuous visor across the eyes.
+ const barY = lensCy - ry * L.bridgeLift;
+ strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], {
+ color: colour,
+ width: frame * L.bridgeWidth,
+ });
+ } else {
+ // The two halves meet at the midline with a horizontal tangent, so the join is smooth.
+ const noseX = lensCy - L.bridgeRise;
+ strokePolyline(ctx, quadraticPoints(
+ { x: innerX, y: lensCy },
+ { x: (innerX + midX) / 2, y: noseX },
+ { x: midX, y: noseX },
+ 12,
+ ), { color: colour, width: frame * L.bridgeWidth });
+ }
+
+ // --- temple ----------------------------------------------------------
+ // A stub outwards from the outer edge of the lens. It stays short on purpose:
+ // the plate only reaches so far, and a temple that ran off it would smear. The
+ // round pair's outer edge is a vertical line at the lens centre height. The
+ // shades' outer end drops low, so their arm leaves from the *top* edge instead
+ // - from the pointed outer corner - which is where a cat-eye's arm attaches.
+ const outerX = shades
+ ? lensCx - eye.towardNose * rx * L.tipX
+ : lensCx - eye.towardNose * rx;
+ const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy;
+ const reach = clamp(
+ outerX - eye.towardNose * r * L.templeLength,
+ limits.left + EDGE_MARGIN,
+ limits.right - EDGE_MARGIN,
+ );
+ strokePolyline(ctx, [
+ { x: outerX, y: outerY },
+ {
+ x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX),
+ y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN),
+ },
+ ], { color: colour, width: frame });
+
+ ctx.restore();
+}
+
+/**
+ * Draw the pair of eyes into a 1024 x 380 canvas.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} p
+ * @param {'paint'|'line'} [p.mode] paint = filled cartoon eyes, line = outline only
+ * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }`
+ * @param {object} [p.style] colours and shared shaping parameters
+ */
+/**
+ * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or
+ * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ
+ * rolls only set `open`/`closed`/`irisShape`, so this falls back to those.
+ */
+function eyeShapeKey(spec) {
+ const shape = spec.shape;
+ const explicitShut = shape === 'three' || shape === 'arch'
+ || (typeof shape === 'string' && shape.startsWith('line'));
+ // An explicit shut artwork is used as-is; the lid height only trims it.
+ if (explicitShut) return shape;
+ // A lowered lid on an open or heart eye reads as a shut line - a blink. It must
+ // NOT borrow a 3 or an arch from a stale `closed`, which used to flash on screen
+ // for a frame when blinking after picking 3/わらう and then switching to heart.
+ if ((spec.open ?? 1) <= 0.02) {
+ if (!shape) {
+ // Older saved states have no `shape`; derive the shut artwork from `closed`.
+ if (spec.closed === 'three') return 'three';
+ if (spec.closed === 'arch') return 'arch';
+ return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`;
+ }
+ return 'line1';
+ }
+ if (shape) return shape;
+ return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+}
+
+export function drawEyes(ctx, p) {
+ const mode = p.mode ?? 'paint';
+ const line = mode === 'line';
+ const style = p.style ?? {};
+ const limits = style.limits ?? WINDOW_LIMITS;
+ const white = style.white ?? '#ffffff';
+ const irisColor = style.iris ?? '#150e1b';
+ const lineColor = style.line ?? '#55386e';
+ const lookMax = style.lookMax ?? 1;
+ const heartScale = 2;
+ const heartColor = style.heartColor ?? '#e0344f';
+ // A lens that is effectively opaque hides the eye completely, so a blink would
+ // show only as a flicker of the shut artwork through the lens. While such a
+ // lens is on, the eyes are drawn open and no blink is visible at all. This is a
+ // rendering decision, not an animation one - the blink still runs underneath.
+ const lensOpaque = style.glasses?.enabled === true
+ && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99;
+
+ for (const layout of EYE_LAYOUT.eyes) {
+ const spec = (p.eyes ?? {})[layout.key] ?? {};
+ const eye = {
+ // `eyeX` slides this eye - and its lid and tear - sideways on its own.
+ cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400),
+ cy: layout.cy,
+ r: EYE_LAYOUT.radius,
+ towardNose: layout.towardNose,
+ };
+ // Each lid can be set per eye, falling back to the shared value.
+ const lidWidth = spec.lidWidth ?? style.lidWidth ?? EYE_LAYOUT.lidStroke;
+ const lidFlat = (spec.lidShape ?? style.lidShape ?? 'curve') === 'flat';
+ // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint).
+ const lidTilt = clamp(spec.lidTilt ?? style.lidTilt ?? 0, -45, 45);
+ const lineStyle = { line: lineColor, width: lidWidth };
+ // Which artwork this eye shows. `shape` is the explicit choice; older saved
+ // states and the おまかせ rolls only set `open`/`closed`, so fall back.
+ const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec);
+ const explicitShut = typeof spec.shape === 'string'
+ && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line'));
+ const isHeart = shapeKey === 'heart';
+ const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line');
+ // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a
+ // line, the 3 and the arch share one path with the older fields.
+ const shapeSpec = isShutShape
+ ? {
+ ...spec,
+ closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'),
+ closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1),
+ }
+ : spec;
+ // The upper lid (`open`) and the lower lid are independent, and both shape
+ // every eye now: the shut artwork is drawn as the eye's content and is
+ // trimmed by them. A shut eye saved the old way stored open = 0 meaning
+ // "shut"; lift it or the artwork would be clipped away. An explicit shut
+ // `shape` keeps its own lid height, so the lid can be lowered onto it.
+ const rawOpen = spec.open ?? 1;
+ const open = lensOpaque
+ ? 1
+ : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1);
+ const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1);
+ // 大きさ: one knob for the iris, the heart and the shut artwork.
+ const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2);
+ const irisRadius = EYE_LAYOUT.irisRadius * shapeScale;
+ // `white` is tri-state: only the plain open eye shows it by default.
+ const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open';
+ // While the white is shown, a shape that would poke out of the eyeball is
+ // scaled back inside it; `shapeHalfLimit` is the half-size it may reach.
+ const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity;
+
+ // --- tear ----------------------------------------------------------
+ // A teardrop hangs below the eye, so it is drawn on top of everything else
+ // (the opaque eyeball would otherwise cover it) and for a shut eye too. The
+ // amount, the height and the tilt are all *per eye*, because one eye crying
+ // while the other does not is a real expression.
+ const drawTear = () => {
+ if (spec.tearOn !== true) return;
+ const amount = clamp(spec.tear ?? 0.3, 0, 1.6);
+ if (amount <= 0.01) return;
+ const size = EYE_LAYOUT.tear.size * amount;
+ const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX;
+ let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0);
+ // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip
+ // inside the artwork: a drop that reached the canvas border used to be
+ // smeared down the chin by the clamp.
+ const tearBottom = limits.bottom - EDGE_MARGIN;
+ if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78;
+ ctx.save();
+ if (spec.tearTilt) {
+ ctx.translate(tearX, tearY);
+ ctx.rotate((spec.tearTilt * Math.PI) / 180);
+ ctx.translate(-tearX, -tearY);
+ }
+ dropPath(ctx, tearX, tearY, size);
+ if (!line) {
+ ctx.fillStyle = style.tearColor ?? '#8fd8ff';
+ ctx.fill();
+ }
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = lidWidth * 0.55;
+ ctx.stroke();
+ ctx.restore();
+ };
+
+ // How far the gaze can push the content. A heart (or a shut line shown on the
+ // white) is much bigger than the iris, so it gets a smaller travel - and with
+ // the white shown the content is also clipped to the eyeball - so it never
+ // pokes outside the white.
+ const heartFit = isHeart && showWhite
+ ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale))
+ : 1;
+ const heartRadius = Math.min(
+ EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit,
+ EYE_LAYOUT.radius * 2.1,
+ );
+ let travelBase = irisRadius;
+ if (isHeart) travelBase = heartRadius;
+ const eyeTravel = Math.max(0, EYE_LAYOUT.radius - travelBase) * lookMax;
+ let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel;
+ let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel;
+ const dist = Math.hypot(dx, dy);
+ if (dist > eyeTravel && dist > 0) {
+ dx = (dx / dist) * eyeTravel;
+ dy = (dy / dist) * eyeTravel;
+ }
+
+ // --- the eye's content, clipped by the lids -------------------------
+ // Every shape is drawn the same way now: a white backing, then the artwork
+ // (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids.
+ // The shut artwork and a heart both reach past the eyeball, so they are only
+ // lid-clipped - but once the white is shown they are clipped to the eyeball
+ // too, so they stay inside it. Clipping the heart by its *lids* rather than by
+ // the eyeball is also what lets a raised lower lid cover it from below: the
+ // heart replaces the eyeball, but it must still sit behind both lids.
+ ctx.save();
+ if ((isShutShape || isHeart) && !showWhite) {
+ clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
+ } else if (!isHeart || open < 0.999 || showWhite) {
+ clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
+ }
+ if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
+ if (isShutShape) {
+ ctx.translate(dx, dy);
+ drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit);
+ } else if (isHeart) {
+ fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor);
+ } else {
+ const irisX = eye.cx + dx + eye.towardNose * EYE_LAYOUT.irisInward;
+ const irisY = eye.cy + dy;
+ fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
+ // 光彩 (the white glint) is per eye, falling back to the shared switch.
+ const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false;
+ if (highlightOn) {
+ const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale;
+ const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale;
+ const hr = EYE_LAYOUT.highlightRadius * shapeScale;
+ if (line) {
+ // Punch the sparkle out of the iris rather than painting it white. On
+ // screen the paper behind shows through, and in the SVG export (which
+ // only sees alpha) it stays a hole in the pupil.
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ fillCircle(ctx, hx, hy, hr, '#000000');
+ ctx.restore();
+ } else {
+ fillCircle(ctx, hx, hy, hr, white);
+ }
+ }
+ }
+ ctx.restore();
+
+ // Between the eyeball and the tear: the lens covers the eye, and a tear still
+ // falls in front of it.
+ drawGlasses(ctx, eye, line, style, limits);
+
+ drawTear();
+
+ // --- lid strokes ---------------------------------------------------
+ // Faded in as the lid starts to cover the eye, so opening the eye all the
+ // way leaves the clean original artwork with no extra line.
+ const lidFade = clamp((0.95 - open) / 0.1, 0, 1);
+ // The tilt is mirrored between the eyes, so a positive value reads the same
+ // way on both (an inward, angry-looking squint).
+ const eyeTilt = lidTilt * eye.towardNose;
+ // The upper lid line has to sit where the clip put it - and that is set by
+ // `open` alone, so it stays put when the lower lid moves.
+ const upperAmt = clamp(open, 0, 1);
+ // A heart is a replacement for the eyeball, not an eye behind a lid: it keeps
+ // no lower-lid line, which used to cut across the heart.
+ const showLowerLid = lowerLid > 0.01;
+ if (lidFade > 0.01 || showLowerLid) {
+ ctx.save();
+ if (eyeTilt) {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate((eyeTilt * Math.PI) / 180);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+ circlePath(ctx, eye.cx, eye.cy, eye.r);
+ ctx.clip();
+ ctx.globalAlpha = line ? 1 : lidFade;
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = lidWidth;
+ if (lidFade > 0.01) {
+ lidPath(ctx, eye, upperAmt, false, lidFlat);
+ ctx.stroke();
+ }
+ if (showLowerLid) {
+ ctx.globalAlpha = line ? 1 : lowerLid;
+ lidPath(ctx, eye, lowerLid, true, lidFlat);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ // --- まつげ --------------------------------------------------------
+ // Short strokes flicking outwards from the upper-outer lid. They sit outside
+ // the eyeball, so they are drawn after the lid block and are not clipped.
+ if (spec.lashes ?? style.lashes) {
+ // The lashes ride the upper lid: they drop as the lid lowers (2r per unit of
+ // open) and tilt with まぶたの傾き, so they stay on the lid edge. まつげの位置
+ // slides them along the lid, まつげの角度 tilts the strokes themselves.
+ const outDir = -eye.towardNose;
+ const lidDrop = 2 * eye.r * (1 - open);
+ const lashPos = clamp(spec.lashPos ?? style.lashPos ?? 0, -80, 80);
+ const lashAngle = clamp(spec.lashAngle ?? style.lashAngle ?? 0, -80, 80) * outDir;
+ const a = (lashAngle * Math.PI) / 180;
+ const ca = Math.cos(a);
+ const sa = Math.sin(a);
+ ctx.save();
+ if (eyeTilt) {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate((eyeTilt * Math.PI) / 180);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+ for (const deg of EYE_LAYOUT.lash.angles) {
+ const t = ((deg + lashPos) * Math.PI) / 180;
+ const ux = outDir * Math.cos(t);
+ const uy = -Math.sin(t);
+ // The stroke points along the radial direction, tilted by まつげの角度.
+ const dx = ux * ca - uy * sa;
+ const dy = ux * sa + uy * ca;
+ const r0 = eye.r * 0.96;
+ const bx = eye.cx + ux * r0;
+ const by = eye.cy + uy * r0 + lidDrop - eye.r * EYE_LAYOUT.lash.raise;
+ strokePolyline(ctx, [
+ { x: bx, y: by },
+ { x: bx + dx * EYE_LAYOUT.lash.length, y: by + dy * EYE_LAYOUT.lash.length },
+ ], { color: lineColor, width: EYE_LAYOUT.lash.width });
+ }
+ ctx.restore();
+ }
+
+ // --- outline (line-art mode only) ---------------------------------
+ if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75);
+ }
+}
+
+/* -------------------------------------------------------------------- mouth */
+
+/**
+ * Draw the mouth into a 1024 x 380 canvas.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} p
+ * @param {'paint'|'line'} [p.mode]
+ */
+export function drawMouth(ctx, p) {
+ const mode = p.mode ?? 'paint';
+ const line = mode === 'line';
+ const L = MOUTH_LAYOUT;
+ const limits = p.limits ?? WINDOW_LIMITS;
+ // The artwork's real edges, in the artwork's own coordinates, with a margin
+ // kept clear. `top` is negative when the plate reaches above the window, which
+ // is the room a moved mouth has to work with.
+ const edgeLeft = limits.left + EDGE_MARGIN;
+ const edgeRight = limits.right - EDGE_MARGIN;
+ const edgeTop = limits.top + EDGE_MARGIN;
+ const edgeBottom = limits.bottom - EDGE_MARGIN;
+
+ const openAmount = clamp(p.open ?? 0, 0, 1);
+ const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3);
+ const cornerAmount = clamp(p.corners ?? 1, 0, 1.6);
+ // The corner strokes flick out past the ends of the chord, so the ends cannot
+ // use the whole canvas: a very wide mouth used to smear sideways off the plate.
+ const cornerReach = L.corner.toX * cornerAmount;
+ const halfChord = Math.min(
+ L.halfChord * clamp(p.width ?? 1, 0.2, 1.6),
+ Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX),
+ );
+
+ // A negative smile bulges the line upwards, which reads as a frown.
+ const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4);
+ // A frown's middle is the highest point of the mouth, and that is exactly
+ // where the nose is in the way, so slide the whole mouth down until the curve
+ // is in the open. The push is worked out from the *base* position and the
+ // height slider is added afterwards: adding it first made the push cancel the
+ // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a
+ // frowning mouth like むっと.
+ const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw));
+ let y0 = base + (p.offsetY ?? 0);
+ // The smile flattens slightly as the mouth opens, so the whole mouth keeps
+ // fitting inside the band the mouth plane actually shows.
+ let sag = sagRaw * (1 - L.openFlatten * openAmount);
+ // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of
+ // the chord, so the allowance is not symmetric - padding the bottom by the
+ // corner's depth used to cost most of the height slider's travel.
+ const cornerRise = L.corner.toY * cornerAmount + thickness;
+ const bottomEdge = y0 + Math.max(0, sag) + thickness;
+ if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom;
+ const topEdge = y0 + Math.min(0, sag) - cornerRise;
+ if (topEdge < edgeTop) y0 += edgeTop - topEdge;
+ const pivot = { x: L.centreX, y: y0 + sag * 0.5 };
+ const tilt = p.tilt ?? 0;
+
+ const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44');
+ const innerColor = p.innerColor ?? '#4a0f1e';
+ const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d');
+
+ const left = { x: L.centreX - halfChord, y: y0 };
+ const right = { x: L.centreX + halfChord, y: y0 };
+ const lip = mouthControls(left, right, sag, L.sagBend);
+ const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96);
+
+ const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6);
+
+ // --- a round "O" mouth (surprised, singing) ---------------------------
+ const roundAmount = clamp(p.round ?? 0, 0, 1);
+ if (roundAmount > 0.004) {
+ let ry = L.sag * 1.05 * L.roundScale * roundAmount;
+ // Keep the oval below the nose and inside the plate, but allow it to grow to
+ // roughly half the head. The stroke's share is a fixed margin, not the live
+ // `thickness`: otherwise 口の太さ would quietly resize the oval too.
+ ry = Math.min(ry, (edgeBottom - L.thickness / 2 - L.noseClear) / 2.1);
+ const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 1.4);
+ const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - L.thickness / 2 - ry * 1.02);
+ if (ry > 5) {
+ ctx.save();
+ ctx.translate(pivot.x, pivot.y);
+ ctx.rotate((tilt * Math.PI) / 180);
+ ctx.translate(-pivot.x, -pivot.y);
+ ctx.beginPath();
+ ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU);
+ if (!line) {
+ ctx.fillStyle = innerColor;
+ ctx.fill();
+ if (tongueAmount > 0.01) {
+ ctx.beginPath();
+ ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU);
+ ctx.fillStyle = tongueColor;
+ ctx.fill();
+ }
+ }
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = line ? thickness * 0.8 : thickness;
+ ctx.stroke();
+ ctx.restore();
+ }
+ return;
+ }
+
+ // The smile line is the upper lip. Opening the mouth drops the lower jaw
+ // below it, and the room for that is limited by the texture band the mouth
+ // plane shows - otherwise the jaw is silently clipped away.
+ // Everything has to fit in the part of the plane that faces the camera.
+ let rise = L.openRise * openAmount;
+ const budget = edgeBottom - thickness / 2 - y0;
+ if (sag + rise > budget) {
+ rise = Math.max(0, budget - Math.min(sag, budget));
+ if (sag + rise > budget) sag = Math.max(-80, budget - rise);
+ }
+ const jawCubic = mouthControls(left, right, sag + rise, L.sagBend);
+ const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96);
+ const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t);
+ const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t);
+ const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt);
+
+ const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95);
+ const tongueWidth = L.tongue.width * tongueAmount;
+ const tongueHeight = L.tongue.height * tongueAmount;
+
+ /**
+ * The tongue, rising from `curve` to `height` above its middle.
+ *
+ * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can
+ * sit along it without this needing to know what kind of curve it is.
+ */
+ function drawTongue(pointAt, curve, height) {
+ const arcLength = Math.max(1, chordLength(curve));
+ const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45);
+ const t0 = clamp(tonguePos - dt, 0, 1);
+ const t1 = clamp(tonguePos + dt, 0, 1);
+ const base = [];
+ for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16));
+ const mid = pointAt(clamp(tonguePos, 0, 1));
+ const half = tongueWidth / 2;
+ // The foot runs left to right; the crown comes back right to left, so the two
+ // together are already a closed ring.
+ const footRight = base[base.length - 1];
+ const footLeft = base[0];
+ const crown = [];
+ const steps = 26;
+ for (let i = 0; i <= steps; i++) {
+ const f = i / steps;
+ const dx = 1 - 2 * f;
+ const footY = footRight.y + (footLeft.y - footRight.y) * f;
+ crown.push({
+ x: mid.x + dx * half,
+ y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)),
+ });
+ }
+ const shaped = rotate([...base, ...crown], pivot, tilt);
+ if (line) {
+ strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true });
+ return;
+ }
+ tracePolyline(ctx, shaped, true);
+ ctx.fillStyle = tongueColor;
+ ctx.fill();
+ }
+
+ if (openAmount > 0.004) {
+ if (!line) fillPolygon(ctx, openShape, innerColor);
+ // The tongue rises from the lower lip *into* the mouth, so it has to arrive
+ // with the opening: at a hair's width the jaw curve is a sliver, and the
+ // tongue used to burst straight out of it and sit on the chin.
+ const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1);
+ if (tongueAmount > 0.01 && tongueOpen > 0.01) {
+ ctx.save();
+ tracePolyline(ctx, openShape, true);
+ ctx.clip();
+ // Inside an open mouth the tongue sits on the lower jaw.
+ drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3));
+ ctx.restore();
+ }
+ // One outline around the whole mouth reads as lips; the smile stroke alone
+ // would leave the lower edge as a bare colour change.
+ strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true });
+ } else {
+ // Closed lips - but the tongue still pokes over the lip. That is what the
+ // original artwork does (the mouth reads as a smile with the tongue showing,
+ // like ペコちゃん), and it is why a plain line looked wrong there. The lip
+ // line goes on afterwards, so the tongue comes out from under it.
+ if (tongueAmount > 0.01) {
+ drawTongue(lipAt, arc, tongueHeight);
+ }
+ strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness });
+ }
+
+ // --- corner marks ---------------------------------------------------
+ if (cornerAmount > 0.01) {
+ const C = L.corner;
+ const cornerAngle = p.cornerAngle ?? 0;
+ for (const [end, side] of [[left, -1], [right, 1]]) {
+ const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount };
+ const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount };
+ // A smooth curve, not a three-point kink. `mid` is the control point, so it
+ // is pulled twice as far as the bow should reach; both corners bow the same
+ // way (downwards on screen), which is what the original artwork does.
+ const control = {
+ x: (from.x + to.x) / 2,
+ y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount,
+ };
+ const curve = quadraticPoints(from, control, to, 16);
+ // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so
+ // both sides move together.
+ const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve;
+ strokePolyline(ctx, rotate(shaped, pivot, tilt), {
+ color: line ? strokeColor : (p.cornerColor ?? '#725497'),
+ // The *length* of the corner mark follows `corners`, but its thickness
+ // does not: `corners: 0.71` is the length that matches the original, and
+ // the original's stroke is the full thickness.
+ width: C.width,
+ });
+ }
+ }
+}
+
+/** Approximate length of a polyline. */
+function chordLength(points) {
+ let total = 0;
+ for (let i = 1; i < points.length; i++) {
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
+ }
+ return total;
+}
diff --git a/public/bluebey-studio/src/gacha.js b/public/bluebey-studio/src/gacha.js
new file mode 100644
index 0000000..c44f80f
--- /dev/null
+++ b/public/bluebey-studio/src/gacha.js
@@ -0,0 +1,481 @@
+/**
+ * Seeded "おまかせ" rolls for the face and the pose.
+ *
+ * The studio is a hundred sliders, and that is exactly the problem: when you sit
+ * down to make an expression you reach for the same three settings every time.
+ * This module is the antidote - one press and a stranger picks the numbers for
+ * you, from ranges a person would actually dial in, so the result is plausible
+ * rather than a mash of the full slider span.
+ *
+ * Two ideas hold it together:
+ *
+ * - A roll picks an *emotion* first (ふつう, うれしい, びっくり, ...), then samples
+ * every part inside that emotion's window. Correlated parts read as one face;
+ * an independent draw per slider reads as noise.
+ *
+ * - The randomness is seeded, and the seed is a short base36 string that fits in
+ * a URL. `rollAll(seed)` is a pure function of that string, so a look the user
+ * liked can be reproduced and shared (`?seed=...`) instead of lost. A "seed"
+ * is either a number or text; text that is already a base36 number is read as
+ * one, so `decodeSeed(seed)` is always accepted back by `makeRandom`.
+ *
+ * The face and pose vocabulary, and every bound quoted below, follows
+ * `src/presets.js` and the slider ranges in `src/panel.js`. Nothing here touches
+ * the DOM or the app state: the functions return patches for `applyPatch`.
+ */
+
+/**
+ * FNV-1a over UTF-16 code units, for text that is not already a seed.
+ */
+function fnv1a(text) {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < text.length; i += 1) {
+ hash ^= text.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+}
+
+/**
+ * Normalise any seed to a 32-bit integer. Text that is already a base36 number
+ * is read as one - that is what `decodeSeed` emits, so it has to come back in
+ * unchanged - and anything else is hashed. `hashSeed`, `makeRandom` and
+ * `decodeSeed` all go through this, so the four entry points never disagree.
+ */
+function toSeedInt(seed) {
+ if (typeof seed === 'number' && Number.isFinite(seed)) return Math.trunc(seed) >>> 0;
+ const text = String(seed ?? '').trim();
+ if (/^[0-9a-z]+$/.test(text)) {
+ const parsed = parseInt(text, 36);
+ if (Number.isFinite(parsed)) return parsed >>> 0;
+ }
+ return fnv1a(text);
+}
+
+/**
+ * Turn text into a stable 32-bit seed (for `?seed=` in a URL).
+ *
+ * A word becomes an FNV-1a hash; text that already looks like a base36 seed is
+ * passed through, so `decodeSeed`'s output round-trips.
+ */
+export function hashSeed(text) {
+ return toSeedInt(text);
+}
+
+/**
+ * A seeded PRNG (mulberry32). Returns `() => number` in `[0, 1)`.
+ * Accepts a number or a string; strings go through the same rule as `decodeSeed`.
+ */
+export function makeRandom(seed) {
+ let state = toSeedInt(seed);
+ return function random() {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * The short, readable form of a seed (base36), for the "現在のシード" field.
+ * Idempotent: `decodeSeed(decodeSeed(x)) === decodeSeed(x)`, and the result is
+ * accepted straight back by `makeRandom`.
+ */
+export function decodeSeed(seed) {
+ return (toSeedInt(seed) >>> 0).toString(36);
+}
+
+function rand(random, min, max) {
+ return min + random() * (max - min);
+}
+
+/** An angle in whole degrees, so the pose matches the step-1 sliders. */
+function deg(random, min, max) {
+ return Math.round(rand(random, min, max));
+}
+
+function round2(value) {
+ return Math.round(value * 100) / 100;
+}
+
+function pick(random, list) {
+ return list[Math.floor(random() * list.length)];
+}
+
+function chance(random, probability) {
+ return random() < probability;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+/**
+ * The mood table. Each entry samples only inside a range that reads as that
+ * emotion, which is what keeps the parts agreeing with each other.
+ *
+ * Fields: `eyes` (open/irisScale/lookMax/lookX/lookY, all in slider units),
+ * `mouth` (smile/open/round/corners/cornerAngle/tilt/offsetY/tongue/width/
+ * thickness), plus optional `tear`, `lowerLid`, `heart`, `wink` and `shutBoth`.
+ */
+const MOODS = [
+ {
+ id: 'normal',
+ eyes: {
+ open: [0.92, 1], irisScale: [0.95, 1.06], lookMax: [0.55, 1],
+ lookX: [-0.28, 0.28], lookY: [-0.16, 0.16],
+ },
+ mouth: {
+ smile: [0.62, 0.95], open: [0, 0.05], width: [0.9, 1.1], thickness: [0.9, 1.15],
+ corners: [0.8, 1.2], cornerAngle: [-6, 6], tilt: [-4, 4], offsetY: [-5, 6],
+ tongue: [0, 1.1],
+ },
+ },
+ {
+ id: 'happy',
+ eyes: {
+ open: [0.88, 1], irisScale: [0.96, 1.08], lookMax: [0.5, 0.95],
+ lookX: [-0.3, 0.3], lookY: [-0.2, 0.05],
+ },
+ mouth: {
+ smile: [1.05, 1.3], open: [0.02, 0.22], width: [0.95, 1.2], thickness: [0.9, 1.15],
+ corners: [1.05, 1.4], cornerAngle: [0, 12], tilt: [-4, 6], offsetY: [-8, 2],
+ tongue: [0.9, 1.35],
+ },
+ },
+ {
+ id: 'surprised',
+ eyes: {
+ open: [0.95, 1], irisScale: [0.8, 0.9], lookMax: [0.3, 0.5],
+ lookX: [-0.15, 0.15], lookY: [-0.1, 0.1],
+ },
+ mouth: {
+ smile: [0, 0.2], open: [0.1, 0.32], round: [0.55, 0.8], width: [0.82, 0.98],
+ thickness: [1, 1.2], corners: [0, 0.15], cornerAngle: [-3, 3], tilt: [-3, 3],
+ offsetY: [-6, 6], tongue: [0, 0.4],
+ },
+ },
+ {
+ id: 'sad',
+ eyes: {
+ open: [0.55, 0.75], irisScale: [0.95, 1.05], lookMax: [0.45, 0.85],
+ lookX: [-0.2, 0.2], lookY: [-0.35, -0.12],
+ },
+ mouth: {
+ smile: [-0.7, -0.4], open: [0, 0.05], width: [0.68, 0.88], thickness: [1.05, 1.3],
+ corners: [0, 0.1], cornerAngle: [-4, 4], tilt: [-5, 5], offsetY: [6, 16],
+ tongue: [0, 0.1],
+ },
+ lowerLid: [0.16, 0.28],
+ tear: [0.6, 1.2],
+ tearChance: 1,
+ },
+ {
+ id: 'angry',
+ eyes: {
+ open: [0.55, 0.7], irisScale: [0.82, 0.92], lookMax: [0.5, 0.9],
+ lookX: [-0.15, 0.15], lookY: [-0.16, 0.02],
+ },
+ mouth: {
+ smile: [-0.85, -0.5], open: [0, 0.04], width: [0.78, 0.94], thickness: [1.05, 1.3],
+ corners: [0, 0.05], cornerAngle: [-6, 6], tilt: [-6, 6], offsetY: [8, 18],
+ tongue: [0, 0.05],
+ },
+ lowerLid: [0.08, 0.2],
+ },
+ {
+ id: 'love',
+ eyes: {
+ open: [1, 1], irisScale: [0.9, 1.02], lookMax: [0.4, 0.8],
+ lookX: [-0.16, 0.16], lookY: [-0.36, -0.2],
+ },
+ mouth: {
+ smile: [1.15, 1.3], open: [0.08, 0.3], width: [0.98, 1.18], thickness: [0.98, 1.2],
+ corners: [1.2, 1.4], cornerAngle: [4, 14], tilt: [2, 8], offsetY: [-2, 6],
+ tongue: [1.1, 1.4],
+ },
+ heart: true,
+ },
+ {
+ id: 'sleepy',
+ eyes: {
+ open: [0.32, 0.5], irisScale: [0.95, 1.05], lookMax: [0.4, 0.7],
+ lookX: [-0.18, 0.18], lookY: [-0.26, -0.08],
+ },
+ mouth: {
+ smile: [0.5, 0.82], open: [0, 0.08], width: [0.82, 1], thickness: [0.92, 1.1],
+ corners: [0.5, 0.9], cornerAngle: [-4, 4], tilt: [-7, -1], offsetY: [4, 13],
+ tongue: [0.3, 0.8],
+ },
+ lowerLid: [0.1, 0.24],
+ tear: [0.2, 0.5],
+ tearChance: 0.5,
+ shutBoth: { chance: 0.4, closed: 'line', closedLines: 1 },
+ },
+ {
+ id: 'wink',
+ eyes: {
+ open: [0.95, 1], irisScale: [0.95, 1.05], lookMax: [0.5, 0.9],
+ lookX: [-0.2, 0.2], lookY: [-0.15, 0.1],
+ },
+ mouth: {
+ smile: [1, 1.3], open: [0.02, 0.18], width: [0.95, 1.15], thickness: [0.92, 1.12],
+ corners: [0.9, 1.2], cornerAngle: [4, 12], tilt: [-5, 5], offsetY: [-6, 4],
+ tongue: [0.8, 1.25],
+ },
+ wink: true,
+ },
+];
+
+/**
+ * Sample the eyes. Both eyes normally agree (a tiny jitter keeps the face from
+ * looking printed); a wink shuts exactly one, and ねむい sometimes shuts both.
+ * A shut-eye style below 1 line is only ever picked when that eye is closed.
+ */
+function rollEyes(random, spec) {
+ const [openMin, openMax] = spec.open;
+ let leftOpen = round2(rand(random, openMin, openMax));
+ let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1));
+ let leftShape = 'open';
+ let rightShape = 'open';
+ let leftClosed = 'line';
+ let rightClosed = 'line';
+ let leftLines = 1;
+ let rightLines = 1;
+
+ if (spec.wink) {
+ const side = chance(random, 0.5) ? 'left' : 'right';
+ const lines = pick(random, [2, 3]);
+ // A shut eye is the artwork with the lids open now; the lid height is its own
+ // slider, so `open` is 1 and the shape says which line it is.
+ if (side === 'left') {
+ leftOpen = 1;
+ leftLines = lines;
+ leftShape = `line${lines}`;
+ } else {
+ rightOpen = 1;
+ rightLines = lines;
+ rightShape = `line${lines}`;
+ }
+ } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) {
+ leftOpen = 1;
+ rightOpen = 1;
+ leftClosed = spec.shutBoth.closed;
+ rightClosed = spec.shutBoth.closed;
+ leftLines = spec.shutBoth.closedLines;
+ rightLines = spec.shutBoth.closedLines;
+ const shape = spec.shutBoth.closed === 'line'
+ ? `line${spec.shutBoth.closedLines}`
+ : spec.shutBoth.closed;
+ leftShape = shape;
+ rightShape = shape;
+ }
+
+ const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1]));
+ const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1]));
+
+ return {
+ left: { shape: leftShape, open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' },
+ right: { shape: rightShape, open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' },
+ irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])),
+ lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])),
+ highlight: true,
+ };
+}
+
+/** Sample the mouth. A round "O" and a smile are mutually exclusive by design. */
+function rollMouth(random, spec) {
+ return {
+ visible: true,
+ smile: round2(rand(random, spec.smile[0], spec.smile[1])),
+ open: round2(rand(random, spec.open[0], spec.open[1])),
+ round: spec.round ? round2(rand(random, spec.round[0], spec.round[1])) : 0,
+ width: round2(rand(random, spec.width[0], spec.width[1])),
+ thickness: round2(rand(random, spec.thickness[0], spec.thickness[1])),
+ tilt: Math.round(rand(random, spec.tilt[0], spec.tilt[1])),
+ offsetY: Math.round(rand(random, spec.offsetY[0], spec.offsetY[1])),
+ corners: round2(rand(random, spec.corners[0], spec.corners[1])),
+ cornerAngle: Math.round(rand(random, spec.cornerAngle[0], spec.cornerAngle[1])),
+ tongue: round2(rand(random, spec.tongue[0], spec.tongue[1])),
+ };
+}
+
+/**
+ * A patch for the `face` section: `{ eyes, mouth }`, ready for
+ * `applyPatch(state.face, patch)`.
+ *
+ * Bounds, in case you are reading this from the tests:
+ * eyes.open 0..1, irisScale 0.8..1.1, lookMax 0.3..1,
+ * lookX -0.35..0.35, lookY -0.45..0.2,
+ * mouth.smile -1..1.4 (negative = frown, only when the eyes agree),
+ * mouth.open 0..0.35, mouth.round 0 or 0.4..0.8,
+ * corners 0..1.4, cornerAngle -30..30, tilt -8..8, offsetY -20..30,
+ * width 0.6..1.3, thickness 0.7..1.4, tongue 0..1.6,
+ * eyes.left/right.tear 0..1.6.
+ */
+export function randomFace(random) {
+ const mood = pick(random, MOODS);
+ const eyes = rollEyes(random, mood.eyes);
+ const mouth = rollMouth(random, mood.mouth);
+
+ // Tears only read on a sad or sleepy face: a frown, or eyes that are half shut.
+ // A fully shut eye never cries.
+ const halfShut = eyes.left.open > 0 && eyes.left.open < 0.78;
+ if (mood.tear && eyes.left.open > 0 && (mouth.smile < 0 || halfShut)
+ && chance(random, mood.tearChance ?? 1)) {
+ // The tears are per eye, so both have to be set. (A crying face wants both;
+ // the shape still allows one eye to cry on its own.)
+ const amount = round2(rand(random, mood.tear[0], mood.tear[1]));
+ eyes.left.tear = amount;
+ eyes.right.tear = amount;
+ eyes.left.tearOn = true;
+ eyes.right.tearOn = true;
+ }
+
+ if (mood.lowerLid) {
+ eyes.lowerLid = round2(rand(random, mood.lowerLid[0], mood.lowerLid[1]));
+ }
+
+ // A heart pupil needs both eyes open, or it reads as a broken iris.
+ if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) {
+ eyes.left.irisShape = 'heart';
+ eyes.right.irisShape = 'heart';
+ eyes.left.shape = 'heart';
+ eyes.right.shape = 'heart';
+ eyes.heartScale = round2(rand(random, 0.9, 1.15));
+ eyes.heartColor = '#e0344f';
+ eyes.highlight = false;
+ }
+
+ return { eyes, mouth };
+}
+
+/**
+ * The pose moves. Each composes one to three bones (never the whole rig) inside
+ * safe limits: arms up to ~74°, master lean up to 24°, root offsets small.
+ *
+ * Bounds: master [-20..24, -28..28, -14..14], armsupport [-18..76, -12..12,
+ * -24..24], arm [-14..20, -8..8, -12..12], legsupport [-8..24, -8..8, -12..12],
+ * root x/z -0.15..0.15 and y 0..0.45.
+ */
+const POSE_MOVES = [
+ // A lean or a nod - one bone, the safest thing in the table.
+ (random) => ({
+ bones: { master: [deg(random, 6, 24), deg(random, -10, 10), deg(random, -6, 6)] },
+ }),
+ // A curious tilt to one side.
+ (random) => {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return { bones: { master: [deg(random, -6, 8), 0, sign * deg(random, 8, 14)] } };
+ },
+ // A wave from one arm.
+ (random) => {
+ const side = pick(random, ['l', 'r']);
+ return {
+ bones: {
+ [`armsupport.${side}`]: [deg(random, 42, 74), deg(random, -8, 8), deg(random, -14, 14)],
+ [`arm.${side}`]: [deg(random, 6, 18), 0, 0],
+ },
+ };
+ },
+ // A two-armed cheer, with a small hop.
+ (random) => ({
+ bones: {
+ master: [deg(random, -14, -2), 0, 0],
+ 'armsupport.l': [deg(random, 52, 74), 0, deg(random, 4, 18)],
+ 'armsupport.r': [deg(random, 52, 74), 0, -deg(random, 4, 18)],
+ },
+ root: [0, round2(rand(random, 0.05, 0.3)), 0],
+ }),
+ // Introducing something off to one side.
+ (random) => {
+ const side = pick(random, ['l', 'r']);
+ const sign = side === 'l' ? 1 : -1;
+ const other = side === 'l' ? 'r' : 'l';
+ return {
+ bones: {
+ master: [deg(random, 2, 8), sign * deg(random, 16, 26), 0],
+ [`armsupport.${side}`]: [deg(random, 24, 40), 0, sign * deg(random, 10, 20)],
+ [`armsupport.${other}`]: [deg(random, 2, 14), 0, 0],
+ },
+ };
+ },
+ // A little hop on the spot.
+ (random) => ({
+ bones: {
+ master: [deg(random, -12, -2), 0, 0],
+ 'legsupport.l': [deg(random, 8, 22), 0, deg(random, -8, 8)],
+ 'legsupport.r': [deg(random, 8, 22), 0, deg(random, -8, 8)],
+ },
+ root: [0, round2(rand(random, 0.18, 0.42)), 0],
+ }),
+ // Both arms up in a simple stretch.
+ (random) => ({
+ bones: {
+ 'armsupport.l': [deg(random, 46, 74), 0, deg(random, 4, 16)],
+ 'armsupport.r': [deg(random, 46, 74), 0, -deg(random, 4, 16)],
+ },
+ }),
+ // A dance sway: one arm leads, the other follows.
+ (random) => {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return {
+ bones: {
+ master: [deg(random, -4, 4), 0, sign * deg(random, 4, 12)],
+ 'armsupport.l': [deg(random, 30, 46), 0, sign * deg(random, 8, 18)],
+ 'armsupport.r': [deg(random, 12, 26), 0, -sign * deg(random, 8, 18)],
+ },
+ root: [round2(rand(random, -0.08, 0.08)), 0, 0],
+ };
+ },
+];
+
+/** A patch for the `pose` section: `{ bones, root }`. */
+export function randomPose(random) {
+ const move = pick(random, POSE_MOVES)(random);
+ return { bones: move.bones, root: move.root ?? [0, 0, 0] };
+}
+
+/**
+ * A patch for the `lookAt` section. About half the time it enables a target off
+ * to one side, so the character glances away instead of staring at the camera.
+ * Bounds: |x| 1.2..3.2 when enabled, y 1.6..3.4, z 2..4.2, amount 0.5..1.
+ */
+export function randomLook(random) {
+ if (chance(random, 0.45)) {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return {
+ lookAt: {
+ enabled: true,
+ x: round2(sign * rand(random, 1.2, 3.2)),
+ y: round2(rand(random, 1.6, 3.4)),
+ z: round2(rand(random, 2, 4.2)),
+ turnBody: chance(random, 0.35),
+ amount: round2(rand(random, 0.5, 1)),
+ },
+ };
+ }
+ return {
+ lookAt: { enabled: false, x: 0, y: 2.6, z: 3, turnBody: false, amount: round2(rand(random, 0.6, 1)) },
+ };
+}
+
+/**
+ * One press of おまかせ: face, pose and look, all from the same seed.
+ *
+ * @param {number | string} seed a number, or text that `decodeSeed` also accepts
+ * @returns {{ seed: string, patch: { face: object, pose: object, lookAt: object } }}
+ * `seed` is the readable base36 form, for the URL and the seed field.
+ */
+export function rollAll(seed) {
+ const random = makeRandom(seed);
+ return {
+ seed: decodeSeed(seed),
+ patch: {
+ face: randomFace(random),
+ pose: randomPose(random),
+ lookAt: randomLook(random).lookAt,
+ },
+ };
+}
diff --git a/public/bluebey-studio/src/gion.js b/public/bluebey-studio/src/gion.js
new file mode 100644
index 0000000..832313b
--- /dev/null
+++ b/public/bluebey-studio/src/gion.js
@@ -0,0 +1,226 @@
+/**
+ * 擬音 (マンガのオノマトペ) のスタンプ.
+ *
+ * The source images are dense sheets: several sounds, each in a white-outline and
+ * a solid version, packed so tightly that an automatic slice (outline tracing /
+ * connected components) tears a single character into fragments. So nothing here
+ * guesses at a sheet's layout. Instead the user draws a rectangle over the sheet
+ * in the picker and the app crops exactly that rectangle, which means the feature
+ * works for any sheet that arrives later.
+ *
+ * That is why the crops are stored as *source pixels* (`sx, sy, sw, sh`) taken
+ * from the bitmap's own `naturalWidth` / `naturalHeight`, never as fractions of
+ * some assumed grid: the sheet's pixel size is read at runtime.
+ *
+ * The module is pure and DOM-free in the same sense as `trace.js` and
+ * `caption.js`: no globals are touched except a read of the injected
+ * `__BLUEBEY_GIONS__` lookup, and `drawGion` takes the loaded bitmaps as an
+ * argument, so the caller owns loading and the tests can drive the geometry in
+ * Node with a stub context.
+ */
+
+/**
+ * The sheets the studio offers. It ships with none: the original otarunet sheet
+ * may not be redistributed with the app, and the images the author draws later are
+ * added here. So this list starts empty and the 擬音 section stays out of the panel
+ * until an entry appears.
+ *
+ * To add one: put the image in `assets/manga-gion/`, add `{ name, label }` here
+ * (`name` is the file name *with* its extension, e.g. `dokaan.png`) and add the
+ * same `name` to `GION_NAMES` in `tools/build-standalone.mjs` for the one-file
+ * build. Use an image with a transparent background - the crop is drawn as-is, so
+ * a white background would sit on the picture as a white rectangle.
+ */
+export const GION_SHEETS = [];
+
+/**
+ * The default display width of a stamp, in pixels at 1x. A crop that is tall and
+ * thin will be narrower than this after the aspect is applied; the user resizes
+ * it from the panel either way.
+ */
+export const DEFAULT_STAMP_WIDTH = 240;
+
+/**
+ * Where a sheet's image lives. The single-file build inlines every sheet as a
+ * data URL in `window.__BLUEBEY_GIONS__`; the normal build reads the file.
+ *
+ * @param {string} name the sheet file name, with its extension, e.g. `dokaan.png`
+ * @returns {string}
+ */
+export function gionSheetUrl(name) {
+ const inlined = globalThis.__BLUEBEY_GIONS__ ?? {};
+ return inlined[name] ?? `assets/manga-gion/${name}`;
+}
+
+/** Clamp `value` into `lo..hi`; a non-finite value becomes `lo`. */
+export function clamp(value, lo, hi) {
+ if (!Number.isFinite(value)) return lo;
+ return Math.min(hi, Math.max(lo, value));
+}
+
+/** True when `image` has pixels to draw (a not-yet-loaded Image has none). */
+export function imageReady(image) {
+ if (!image) return false;
+ const width = Number(image.naturalWidth ?? image.width ?? 0);
+ const height = Number(image.naturalHeight ?? image.height ?? 0);
+ // `complete` is the browser's own answer; a stub in a test omits it, so only an
+ // explicit `false` blocks the draw.
+ return width > 0 && height > 0 && image.complete !== false;
+}
+
+/** The bitmap's width / height. 1 when it is not loaded yet. */
+export function imageAspect(image) {
+ const width = toPositive(image?.naturalWidth ?? image?.width, 1);
+ const height = toPositive(image?.naturalHeight ?? image?.height, 1);
+ return width / height;
+}
+
+/**
+ * The rectangle a stamp occupies on screen, in pixels, with rotation left out
+ * (the caller rotates about the returned centre).
+ *
+ * `item.x` / `item.y` are the centre as a fraction of the viewport, `item.w` is
+ * the width in pixels at 1x, and the height follows the bitmap's aspect ratio,
+ * so a wide crop and a tall crop are both placed by their width alone.
+ *
+ * @param {object} item a stamp: `{ x, y, w, ... }`
+ * @param {object} [viewport] `{ width, height, scale }` of the target surface
+ * @param {number} [aspect] bitmap width / height
+ * @returns {{x: number, y: number, w: number, h: number, cx: number, cy: number}}
+ */
+export function stampRect(item, { width = 0, height = 0, scale = 1 } = {}, aspect = 1) {
+ const w = toPositive(item?.w, DEFAULT_STAMP_WIDTH) * positiveScale(scale);
+ const h = w / toPositive(aspect, 1);
+ const cx = toFinite(item?.x, 0.5) * toNonNegative(width, 0);
+ const cy = toFinite(item?.y, 0.5) * toNonNegative(height, 0);
+ return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy };
+}
+
+/** Turn two corner points into a rectangle with a positive width and height. */
+export function normalizeRect(a, b) {
+ const ax = toFinite(a?.x, 0);
+ const ay = toFinite(a?.y, 0);
+ const bx = toFinite(b?.x, 0);
+ const by = toFinite(b?.y, 0);
+ return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) };
+}
+
+/**
+ * Map a marquee (drawn in screen pixels over the fitted sheet) to a pixel crop of
+ * the source bitmap.
+ *
+ * `sheetRect` is where the sheet is currently displayed, so the marquee becomes a
+ * fraction of the sheet and then a fraction of the bitmap - the sheet's own pixel
+ * size comes from `natural`, never from a hard-coded number. The crop is clamped
+ * to the bitmap and is at least 1x1, so it can always be drawn.
+ *
+ * @param {{x: number, y: number, w: number, h: number}} marquee screen pixels
+ * @param {{x: number, y: number, w: number, h: number}} sheetRect screen pixels
+ * @param {object} natural the sheet image (or any `{ naturalWidth, naturalHeight }`)
+ * @returns {{sx: number, sy: number, sw: number, sh: number}}
+ */
+export function cropFromMarquee(marquee, sheetRect, natural) {
+ const nw = Math.max(1, Math.round(toPositive(natural?.naturalWidth, 1)));
+ const nh = Math.max(1, Math.round(toPositive(natural?.naturalHeight, 1)));
+ const rect = {
+ x: toFinite(marquee?.x, 0),
+ y: toFinite(marquee?.y, 0),
+ w: toNonNegative(marquee?.w, 0),
+ h: toNonNegative(marquee?.h, 0),
+ };
+ const displayW = toNonNegative(sheetRect?.w, 0);
+ const displayH = toNonNegative(sheetRect?.h, 0);
+
+ const left = displayW > 0 ? clamp((rect.x - sheetRect.x) / displayW, 0, 1) : 0;
+ const right = displayW > 0 ? clamp((rect.x + rect.w - sheetRect.x) / displayW, 0, 1) : 0;
+ const top = displayH > 0 ? clamp((rect.y - sheetRect.y) / displayH, 0, 1) : 0;
+ const bottom = displayH > 0 ? clamp((rect.y + rect.h - sheetRect.y) / displayH, 0, 1) : 0;
+
+ const sx = clamp(Math.round(left * nw), 0, nw - 1);
+ const sy = clamp(Math.round(top * nh), 0, nh - 1);
+ return {
+ sx,
+ sy,
+ sw: clamp(Math.round((right - left) * nw), 1, nw - sx),
+ sh: clamp(Math.round((bottom - top) * nh), 1, nh - sy),
+ };
+}
+
+/**
+ * Fit a bitmap inside a box, keeping its aspect ratio and centring it. The picker
+ * uses this to show a whole sheet - however large - at a size the user can drag
+ * over.
+ *
+ * @param {object} natural the sheet image
+ * @param {{width: number, height: number}} box the available area, in pixels
+ * @param {object} [options]
+ * @param {number} [options.padding=0] a margin to keep inside the box
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function fitSheet(natural, box, { padding = 0 } = {}) {
+ const nw = toPositive(natural?.naturalWidth ?? natural?.width, 1);
+ const nh = toPositive(natural?.naturalHeight ?? natural?.height, 1);
+ const pad = toNonNegative(padding, 0);
+ const availW = Math.max(0, toNonNegative(box?.width, 0) - pad * 2);
+ const availH = Math.max(0, toNonNegative(box?.height, 0) - pad * 2);
+ const scale = Math.min(availW / nw, availH / nh);
+ const w = nw * scale;
+ const h = nh * scale;
+ return { x: pad + (availW - w) / 2, y: pad + (availH - h) / 2, w, h };
+}
+
+/**
+ * Paint every stamp onto a 2D context.
+ *
+ * The same function runs over the live viewport overlay (scale 1) and into the
+ * exported PNG (scale = output pixels / CSS pixels), which is what keeps the
+ * preview and the file in step. A stamp whose sheet has not loaded yet is simply
+ * skipped, so the rest of the picture is never held up by one image.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Array<object>} items the stamps
+ * @param {Map<string, CanvasImageSource>} images loaded sheets, by name
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ */
+export function drawGion(ctx, items, images, { width = 0, height = 0, scale = 1 } = {}) {
+ if (!ctx || !Array.isArray(items)) return;
+ const viewport = { width, height, scale: positiveScale(scale) };
+ for (const item of items) {
+ const image = images?.get?.(item?.sheet);
+ if (!imageReady(image)) continue;
+ const rect = stampRect(item, viewport, imageAspect(image));
+ if (!(rect.w > 0) || !(rect.h > 0)) continue;
+ const rot = toFinite(item?.rot, 0);
+ ctx.save();
+ ctx.translate(rect.cx, rect.cy);
+ if (rot !== 0) ctx.rotate((rot * Math.PI) / 180);
+ // The flip is applied before the draw so the same source rect feeds both.
+ if (item?.flip) ctx.scale(-1, 1);
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, -rect.w / 2, -rect.h / 2, rect.w, rect.h);
+ ctx.restore();
+ }
+}
+
+/* ----------------------------------------------------------------- numbers */
+
+function toFinite(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function toPositive(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
+}
+
+function positiveScale(value) {
+ return toPositive(value, 1);
+}
diff --git a/public/bluebey-studio/src/glbExport.js b/public/bluebey-studio/src/glbExport.js
new file mode 100644
index 0000000..6e3e997
--- /dev/null
+++ b/public/bluebey-studio/src/glbExport.js
@@ -0,0 +1,148 @@
+import * as THREE from 'three';
+import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
+
+/**
+ * Hand the posed character back as a 3D file.
+ *
+ * WHY: a PNG or a WebM only travels as pixels. Being able to take the pose, the
+ * expression and the props into Blender (or embed them elsewhere as a model)
+ * turns the studio into a front end for the character instead of a picture
+ * maker, and it costs nothing: what is on screen already *is* a glTF scene, so
+ * the only job here is to hand the live objects to three's exporter with the
+ * right options and a Blob around the result.
+ *
+ * The caller passes exactly what it wants in the file - the character root and
+ * any props - so no lights, helpers or ground ever enter it. The inverted-hull
+ * outlines are the one wrinkle: they are visible (they are part of the look)
+ * but they are duplicates of the body geometry, so exporting them would double
+ * the file and leave a black shell around the character. `needsTemporaryHide`
+ * decides what to hide for the duration, and `exportGLB` restores it after.
+ */
+
+const GLTF_BINARY_TYPE = 'model/gltf-binary';
+const GLTF_JSON_TYPE = 'model/gltf+json';
+/** The largest texture a GPU can be assumed to handle everywhere. */
+const MAX_TEXTURE_SIZE = 4096;
+
+/**
+ * True for objects the exporter must skip: the inverted-hull outlines (named
+ * `...:outline`), anything the app tagged as a helper or as excluded from the
+ * export, and anything already hidden. The name check is the important one: an
+ * outline is visible on screen, so `onlyVisible` alone would not drop it.
+ *
+ * @param {THREE.Object3D} object
+ * @returns {boolean}
+ */
+export function needsTemporaryHide(object) {
+ if (!object) return false;
+ if (typeof object.name === 'string' && object.name.endsWith(':outline')) return true;
+ if (object.userData?.isHelper === true) return true;
+ if (object.userData?.excludeFromExport === true) return true;
+ return object.visible === false;
+}
+
+/** Triangles in a geometry, ignoring draw ranges (the export does too). */
+function triangleCount(geometry) {
+ if (!geometry) return 0;
+ const index = geometry.index;
+ const count = index ? index.count : geometry.attributes?.position?.count ?? 0;
+ return Math.floor(count / 3);
+}
+
+/**
+ * Count what `exportGLB` will write, for the summary the app shows first.
+ *
+ * Walks the same pruned tree the exporter sees, so hiding an outline or a
+ * helper is reflected in the numbers. A `SkinnedMesh` is also a `Mesh`, so it
+ * counts in both `meshes` and `skinnedMeshes`; a material shared by several
+ * meshes counts once, and every texture a counted material refers to counts
+ * once.
+ *
+ * @param {THREE.Object3D[]} objects
+ * @returns {{ meshes: number, skinnedMeshes: number, materials: number, textures: number, triangles: number }}
+ */
+export function describeScene(objects) {
+ const materials = new Set();
+ const textures = new Set();
+ let meshes = 0;
+ let skinnedMeshes = 0;
+ let triangles = 0;
+
+ const visit = (object) => {
+ if (!object || needsTemporaryHide(object)) return;
+ if (object.isMesh) {
+ meshes += 1;
+ if (object.isSkinnedMesh) skinnedMeshes += 1;
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) {
+ if (!material) continue;
+ materials.add(material);
+ for (const value of Object.values(material)) {
+ if (value && value.isTexture) textures.add(value);
+ }
+ }
+ triangles += triangleCount(object.geometry);
+ }
+ for (const child of object.children ?? []) visit(child);
+ };
+
+ for (const root of Array.isArray(objects) ? objects : []) visit(root);
+ return { meshes, skinnedMeshes, materials: materials.size, textures: textures.size, triangles };
+}
+
+/**
+ * Hide every object the file must not contain, remembering what to restore.
+ * Stops at the first excluded ancestor: the exporter skips a hidden subtree, so
+ * there is no need to walk inside one.
+ */
+function hideExcluded(object, hidden) {
+ if (needsTemporaryHide(object)) {
+ hidden.push({ object, visible: object.visible });
+ object.visible = false;
+ return;
+ }
+ for (const child of object.children ?? []) hideExcluded(child, hidden);
+}
+
+/**
+ * Export `objects` as a GLB (or a `.gltf` JSON) Blob.
+ *
+ * @param {THREE.Object3D[]} objects the character root and any props
+ * @param {{ binary?: boolean, name?: string }} [options]
+ * @returns {Promise<Blob>} `model/gltf-binary`, or `model/gltf+json` when not binary
+ */
+export async function exportGLB(objects, { binary = true, name = 'bluebey' } = {}) {
+ const roots = (Array.isArray(objects) ? objects : []).filter(Boolean);
+ if (roots.length === 0) {
+ throw new Error('exportGLB: objects is empty, there is nothing to export');
+ }
+ if (describeScene(roots).meshes === 0) {
+ throw new Error('exportGLB: objects contain no visible meshes to export');
+ }
+
+ const hidden = [];
+ try {
+ for (const root of roots) hideExcluded(root, hidden);
+
+ // The exporter names a bare array of objects "AuxScene". Wrapping them in a
+ // named Scene keeps the caller's objects where they are (we push straight
+ // into `children`, exactly as the exporter does) and gives the file a real
+ // scene name.
+ const scene = new THREE.Scene();
+ scene.name = name;
+ for (const root of roots) scene.children.push(root);
+
+ const exporter = new GLTFExporter();
+ const result = await exporter.parseAsync(scene, {
+ binary,
+ onlyVisible: true,
+ truncateDrawRange: false,
+ maxTextureSize: MAX_TEXTURE_SIZE,
+ });
+
+ if (binary) return new Blob([result], { type: GLTF_BINARY_TYPE });
+ return new Blob([JSON.stringify(result)], { type: GLTF_JSON_TYPE });
+ } finally {
+ for (const { object, visible } of hidden) object.visible = visible;
+ }
+}
diff --git a/public/bluebey-studio/src/handDrawn.js b/public/bluebey-studio/src/handDrawn.js
new file mode 100644
index 0000000..e7fbce5
--- /dev/null
+++ b/public/bluebey-studio/src/handDrawn.js
@@ -0,0 +1,391 @@
+import { contoursToPathData } from './trace.js';
+
+/**
+ * Hand-drawn distortion for traced contours.
+ *
+ * WHY: `trace.js` recovers the silhouette of the mascot from a rendered alpha
+ * mask. The result is geometrically faithful but *mechanically* smooth: it reads
+ * as the outline of a printed sticker, not as a pen stroke. This module nudges
+ * the traced points along a smooth, seeded wobble so the very same silhouette
+ * looks inked by hand, without changing its point count or where it sits.
+ *
+ * The displacement is value noise interpolated along the contour, so neighbouring
+ * points move by nearly the same amount and the outline stays a wobbly *line*
+ * rather than pixel jitter. Everything is seeded (never `Math.random`), so a
+ * build is reproducible, and the module is pure: it never touches the DOM and
+ * its only import is the path-data helper in `trace.js`, which keeps the output
+ * format identical to the un-roughened export.
+ */
+
+/** Lattice cells in the non-wrapping noise table (the pattern repeats after this). */
+const NOISE_PERIOD = 4096;
+
+const EPSILON = 1e-9;
+
+/**
+ * mulberry32: a tiny, fast 32-bit generator. Good enough for visual noise and,
+ * crucially, fully reproducible; the seed is the only source of variation.
+ *
+ * @param {number} seed
+ * @returns {() => number} values in [0, 1)
+ */
+function mulberry32(seed) {
+ let a = seed >>> 0;
+ return function next() {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** Hermite ramp: 0 at t=0, 1 at t=1, flat at both ends (C1 continuity). */
+function smoothstep(t) {
+ return t * t * (3 - 2 * t);
+}
+
+/**
+ * A circular table of random values in -1..1. `count` is the number of cells in
+ * one lap; sampling wraps at `count`, which is what lets a closed stroke's
+ * wobble meet itself exactly at the seam.
+ *
+ * @param {number} seed
+ * @param {number} count
+ * @returns {Float64Array}
+ */
+function buildNoiseTable(seed, count) {
+ const rng = mulberry32(seed);
+ const table = new Float64Array(count);
+ for (let i = 0; i < count; i++) table[i] = rng() * 2 - 1;
+ return table;
+}
+
+/**
+ * Smoothly interpolate the table at `t`, wrapping around its ends. `smoothstep`
+ * makes the value continuous and its slope continuous at every cell boundary, so
+ * the wobble has no visible kinks.
+ */
+function sampleTable(table, t) {
+ const len = table.length;
+ const base = Math.floor(t);
+ const f = t - base;
+ const i0 = ((base % len) + len) % len;
+ const i1 = (i0 + 1) % len;
+ const a = table[i0];
+ const b = table[i1];
+ return a + (b - a) * smoothstep(f);
+}
+
+/**
+ * One-dimensional value noise, exposed mainly so tests can pin its behaviour.
+ * The returned function is continuous, roughly in -1..1, deterministic for a
+ * given seed, and returns 0 for non-finite input.
+ *
+ * @param {number} [seed=1]
+ * @returns {(t: number) => number}
+ */
+export function makeNoise(seed = 1) {
+ const table = buildNoiseTable(seed, NOISE_PERIOD);
+ return function noise(t) {
+ if (!Number.isFinite(t)) return 0;
+ return sampleTable(table, t);
+ };
+}
+
+/** Euclidean distance between two points. */
+function distance(a, b) {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+/** Cumulative arc length of every point, measured from the first. */
+function arcPositions(points) {
+ const pos = new Float64Array(points.length);
+ for (let i = 1; i < points.length; i++) {
+ pos[i] = pos[i - 1] + distance(points[i - 1], points[i]);
+ }
+ return pos;
+}
+
+/** True when a closed contour repeats its first point at the end. */
+function hasClosingDuplicate(points) {
+ const first = points[0];
+ const last = points[points.length - 1];
+ return Math.abs(first.x - last.x) <= EPSILON && Math.abs(first.y - last.y) <= EPSILON;
+}
+
+/**
+ * Neighbour index for each point, honouring the wrap of a closed contour. Open
+ * ends get -1, which makes the tangent fall back to a one-sided difference.
+ */
+function neighborIndices(count, closed, duplicate) {
+ const prev = new Int32Array(count);
+ const next = new Int32Array(count);
+ if (!closed) {
+ for (let i = 0; i < count; i++) {
+ prev[i] = i > 0 ? i - 1 : -1;
+ next[i] = i < count - 1 ? i + 1 : -1;
+ }
+ return { prev, next };
+ }
+ // A repeated closing point is a copy of point 0, so the ring has one fewer
+ // distinct vertex and the last index borrows point 0's two neighbours, which
+ // is what makes its wobble identical to the first point's.
+ const ring = duplicate ? count - 1 : count;
+ const firstPrev = (ring - 1) % ring;
+ const firstNext = ring > 1 ? 1 : 0;
+ for (let i = 0; i < count; i++) {
+ if (duplicate && i === count - 1) {
+ prev[i] = firstPrev;
+ next[i] = firstNext;
+ } else {
+ prev[i] = (i - 1 + ring) % ring;
+ next[i] = (i + 1) % ring;
+ }
+ }
+ return { prev, next };
+}
+
+/**
+ * Unit tangent at `i`, measured from the point before to the point after so the
+ * wobble follows the stroke instead of the sampling. Returns null for a
+ * degenerate point, where there is no direction to displace along.
+ */
+function tangentAt(points, i, prev, next) {
+ const before = prev[i] >= 0 ? points[prev[i]] : points[i];
+ const after = next[i] >= 0 ? points[next[i]] : points[i];
+ const dx = after.x - before.x;
+ const dy = after.y - before.y;
+ const len = Math.hypot(dx, dy);
+ if (!(len > EPSILON)) return null;
+ return { x: dx / len, y: dy / len };
+}
+
+/**
+ * Fade the wobble to zero at both ends of an open stroke, over `ramp` units.
+ * Without this the ends would fly off the traced geometry; a smooth ramp keeps
+ * the stroke anchored while still looking freehand.
+ */
+function edgeWindow(s, length, ramp) {
+ if (!(ramp > 0)) return 1;
+ const head = Math.min(1, s / ramp);
+ const tail = Math.min(1, (length - s) / ramp);
+ return smoothstep(head) * smoothstep(tail);
+}
+
+/** Derive a distinct, deterministic seed for each extra pass. */
+function mixSeed(seed, pass) {
+ return (seed + pass * 0x9e3779b1) >>> 0;
+}
+
+/** Apply one wobble pass. `roughenPolyline` owns the input copy and the passes. */
+function roughenOnce(points, { amount, seed, closed, scale }) {
+ const count = points.length;
+ const copy = () => points.map((p) => ({ x: p.x, y: p.y }));
+ if (count < 2 || !(amount > 0) || !(scale > 0)) return copy();
+
+ const duplicate = closed ? hasClosingDuplicate(points) : false;
+ const pos = arcPositions(points);
+ const length = pos[count - 1];
+ let perimeter = length;
+ if (closed) perimeter += distance(points[count - 1], points[0]);
+
+ const { prev, next } = neighborIndices(count, closed, duplicate);
+
+ // A closed stroke samples a circular table with a whole number of cells per
+ // lap, so the last point lands on the first cell and the seam closes. An open
+ // stroke samples plain (non-wrapping) noise and fades it out near the ends.
+ let table;
+ let cells = 0;
+ if (closed) {
+ if (!(perimeter > 0)) return copy();
+ cells = Math.max(2, Math.round(perimeter / scale));
+ table = buildNoiseTable(seed, cells);
+ } else {
+ table = buildNoiseTable(seed, NOISE_PERIOD);
+ }
+ const ramp = Math.min(scale, length * 0.25);
+
+ const out = new Array(count);
+ for (let i = 0; i < count; i++) {
+ const tangent = tangentAt(points, i, prev, next);
+ const p = points[i];
+ if (!tangent) {
+ out[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ const t = closed ? (pos[i] / perimeter) * cells : pos[i] / scale;
+ let weight = sampleTable(table, t);
+ if (!closed) weight *= edgeWindow(pos[i], length, ramp);
+ const shift = amount * weight;
+ if (shift === 0) {
+ // Keep the original exactly (also avoids turning -0 into 0).
+ out[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ // Displace perpendicular to the tangent, i.e. along the local pen normal.
+ out[i] = { x: p.x - tangent.y * shift, y: p.y + tangent.x * shift };
+ }
+ if (closed && duplicate) {
+ // Belt and braces: pin the explicit seam shut after any rounding.
+ out[count - 1] = { x: out[0].x, y: out[0].y };
+ }
+ return out;
+}
+
+/**
+ * Displace every point of a polyline perpendicular to its local direction by
+ * smooth noise. The input is never mutated and the point count never changes.
+ *
+ * Open polylines keep their first and last point exactly; closed ones wrap, so
+ * the wobble is continuous across the seam. `amount` is the peak displacement in
+ * the same units as the points, and `scale` is how much arc length one wobble
+ * spans (larger = lazier, longer wobble). With `passes > 1` the displacement is
+ * re-noised a few times at a share of `amount`, so the peak stays within
+ * `amount` however many passes are used.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @param {object} [options]
+ * @param {number} [options.amount=2] peak displacement, in point units
+ * @param {number} [options.seed=1] deterministic seed
+ * @param {boolean} [options.closed=false] treat the polyline as a ring
+ * @param {number} [options.scale=40] arc length covered by one wobble
+ * @param {number} [options.passes=1] number of noise layers
+ * @returns {Array<{x: number, y: number}>} a new array of new points
+ */
+export function roughenPolyline(points, options = {}) {
+ const list = Array.isArray(points) ? points : [];
+ const amount = options.amount ?? 2;
+ const seed = options.seed ?? 1;
+ const closed = options.closed ?? false;
+ const scale = options.scale ?? 40;
+ const passes = Math.max(1, Math.floor(options.passes ?? 1));
+
+ let current = list.map((p) => ({ x: p.x, y: p.y }));
+ if (current.length < 2 || amount === 0) return current;
+ for (let pass = 0; pass < passes; pass++) {
+ current = roughenOnce(current, {
+ amount: amount / passes,
+ seed: mixSeed(seed, pass),
+ closed,
+ scale,
+ });
+ }
+ return current;
+}
+
+/**
+ * Roughen a list of contours, with the closed/open choice per contour. Following
+ * `trace.js`'s convention, a contour is assumed to be a closed ring unless the
+ * caller says otherwise: pass `options.closed` as a boolean for all of them or
+ * as an array of flags indexed like `contours`.
+ *
+ * @param {Array<Array<{x: number, y: number}>>} contours
+ * @param {object} [options] see `roughenPolyline`, plus `closed` as an array
+ * @returns {Array<Array<{x: number, y: number}>>}
+ */
+export function roughenContours(contours, options = {}) {
+ const list = Array.isArray(contours) ? contours : [];
+ const closedOption = options.closed;
+ const out = [];
+ for (let i = 0; i < list.length; i++) {
+ const closed = Array.isArray(closedOption) ? closedOption[i] ?? true : closedOption ?? true;
+ out.push(roughenPolyline(list[i], { ...options, closed }));
+ }
+ return out;
+}
+
+/**
+ * Roughen a list of contours and return their SVG `d` attribute, using exactly
+ * the format of `contoursToPathData`: one `M … L … Z` subpath per contour,
+ * coordinates rounded to `options.round` decimal places (default 2, the same
+ * meaning as that function's `decimals` argument).
+ *
+ * @param {Array<Array<{x: number, y: number}>>} contours
+ * @param {object} [options] roughening options, plus `round` and `mapPoint`
+ * @param {number} [options.round=2] decimal places in the output
+ * @param {(x: number, y: number) => [number, number]} [options.mapPoint]
+ * @returns {string}
+ */
+export function handDrawnPathData(contours, options = {}) {
+ const roughened = roughenContours(contours, options);
+ const mapPoint = options.mapPoint ?? ((x, y) => [x, y]);
+ return contoursToPathData(roughened, mapPoint, options.round ?? 2);
+}
+
+/**
+ * Offset a stroke to both sides by a width that breathes slightly along its
+ * length, giving the `[left, right]` polylines a pen stroke can be filled
+ * between. Both sides keep the point count and order of `points`.
+ *
+ * The width only varies (it never reaches zero), so the two sides stay well
+ * defined; `variation` is the fraction of `width` the wobble may add or remove.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @param {object} [options]
+ * @param {number} [options.width=3] full stroke width
+ * @param {number} [options.seed=1]
+ * @param {boolean} [options.closed=false]
+ * @param {number} [options.variation=0.35] relative width wobble
+ * @param {number} [options.scale=40] arc length covered by one width wobble
+ * @returns {[Array<{x: number, y: number}>, Array<{x: number, y: number}>]}
+ */
+export function taperStroke(points, options = {}) {
+ const list = Array.isArray(points) ? points : [];
+ const width = options.width ?? 3;
+ const seed = options.seed ?? 1;
+ const closed = options.closed ?? false;
+ const variation = options.variation ?? 0.35;
+ const scale = options.scale ?? 40;
+
+ const count = list.length;
+ const left = new Array(count);
+ const right = new Array(count);
+ if (count === 0) return [left, right];
+ if (count === 1) {
+ left[0] = { x: list[0].x, y: list[0].y };
+ right[0] = { x: list[0].x, y: list[0].y };
+ return [left, right];
+ }
+
+ const duplicate = closed ? hasClosingDuplicate(list) : false;
+ const pos = arcPositions(list);
+ const length = pos[count - 1];
+ let perimeter = length;
+ if (closed) perimeter += distance(list[count - 1], list[0]);
+
+ const { prev, next } = neighborIndices(count, closed, duplicate);
+
+ let table;
+ let cells = 0;
+ if (closed && perimeter > 0 && scale > 0) {
+ cells = Math.max(2, Math.round(perimeter / scale));
+ table = buildNoiseTable(seed, cells);
+ } else {
+ table = buildNoiseTable(seed, NOISE_PERIOD);
+ }
+ const span = scale > 0 ? scale : 1;
+ const halfWidth = Math.abs(width) / 2;
+
+ for (let i = 0; i < count; i++) {
+ const tangent = tangentAt(list, i, prev, next);
+ const p = list[i];
+ if (!tangent) {
+ left[i] = { x: p.x, y: p.y };
+ right[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ const t = closed && cells > 0 ? (pos[i] / perimeter) * cells : pos[i] / span;
+ // Clamped well above zero so both sides keep a usable offset even when the
+ // caller asks for a large `variation`.
+ const factor = Math.max(0.05, 1 + variation * sampleTable(table, t));
+ const w = halfWidth * factor;
+ left[i] = { x: p.x - tangent.y * w, y: p.y + tangent.x * w };
+ right[i] = { x: p.x + tangent.y * w, y: p.y - tangent.x * w };
+ }
+ if (closed && duplicate) {
+ left[count - 1] = { x: left[0].x, y: left[0].y };
+ right[count - 1] = { x: right[0].x, y: right[0].y };
+ }
+ return [left, right];
+}
diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js
new file mode 100644
index 0000000..7a54c46
--- /dev/null
+++ b/public/bluebey-studio/src/hats.js
@@ -0,0 +1,135 @@
+import * as THREE from 'three';
+
+/**
+ * Hats for ぶるべー. Each hat is a small group of primitives whose base sits at
+ * y = 0, so the app can drop it straight onto the top of the head (see
+ * `hatMount` in src/main.js). The character's front is +z, so a brim or a badge
+ * faces +z.
+ *
+ * These are deliberately not textured: like the props, they are built from a few
+ * rounded primitives so they take a clean outline and read in every render style.
+ */
+
+const material = (color, opts = {}) => new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.72,
+ metalness: 0,
+ ...opts,
+});
+
+function add(parent, geometry, mat, x = 0, y = 0, z = 0) {
+ const mesh = new THREE.Mesh(geometry, mat);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ parent.add(mesh);
+ return mesh;
+}
+
+const cylinder = (rt, rb, h, seg = 24) => new THREE.CylinderGeometry(rt, rb, h, seg);
+const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI) => (
+ new THREE.SphereGeometry(r, w, h, phiStart, phiLength, thetaStart, thetaLength)
+);
+const box = (w, h, d) => new THREE.BoxGeometry(w, h, d);
+
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). Every hat part already gets the light default
+ * tone, but two parts of the *same* hat then come out the same shade - so a part
+ * that sits flush on another (a band on a crown) loses its seam. Tagging it with
+ * a darker tone puts that seam back, without touching リアル / フラット, where the
+ * part's own colour already draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
+/** シルクハット: a tall black crown, a narrow band and a flat brim. */
+function buildSilk() {
+ const group = new THREE.Group();
+ const black = material('#1b1722');
+ const band = material('#3a2f4a');
+ add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0);
+ add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0);
+ tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34);
+ return group;
+}
+
+/** キャップ: a baseball cap - a low dome, a rim and a bill at the front. */
+function buildCap() {
+ const group = new THREE.Group();
+ const blue = material('#3f6fd0');
+ const dark = material('#2f54a4');
+ // The crown: a hemisphere whose flat face sits on y = 0.
+ const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0);
+ crown.scale.set(1, 0.92, 1);
+ tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30);
+ // The bill, tipped down a little at the front.
+ const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30);
+ bill.scale.set(1, 1, 1.25);
+ bill.rotation.x = -0.12;
+ tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30);
+ return group;
+}
+
+/** コック帽: a tall pleated toque - a cylindrical band under a big puffy crown. */
+function buildChef() {
+ const group = new THREE.Group();
+ const white = material('#f7f6f2');
+ const shade = material('#e6e3da');
+ // The band: a tall cylinder that sits on the head.
+ add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0);
+ tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26);
+ // The crown: a big squashed sphere ringed with lobes, so the top reads as the
+ // classic pleated toque rather than a plain dome.
+ const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0);
+ puff.scale.set(1, 0.82, 1);
+ for (let i = 0; i < 8; i += 1) {
+ const a = (i / 8) * Math.PI * 2;
+ const lobe = add(group, sphere(0.5, 14, 12), white, Math.cos(a) * 0.92, 1.52, Math.sin(a) * 0.92);
+ lobe.scale.set(1, 0.9, 1);
+ }
+ const top = add(group, sphere(0.62, 16, 12), white, 0, 1.92, 0);
+ top.scale.set(1, 0.78, 1);
+ return group;
+}
+
+/** 消防士の帽子: a red helmet with a brim, a top ridge and a small front badge. */
+function buildFire() {
+ const group = new THREE.Group();
+ const red = material('#c5342f');
+ const dark = material('#8f241f');
+ const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 });
+ const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0);
+ dome.scale.set(1, 0.95, 1);
+ const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28);
+ brim.scale.set(1, 1, 1.1);
+ // The ridge along the top, front to back.
+ const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28);
+ crest.rotation.x = -0.05;
+ tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22);
+ return group;
+}
+
+const BUILDERS = {
+ silk: buildSilk,
+ cap: buildCap,
+ chef: buildChef,
+ fire: buildFire,
+};
+
+/** The hats offered in the panel: `none` clears it. */
+export const HAT_LIBRARY = [
+ { id: 'none', label: 'なし' },
+ { id: 'silk', label: 'シルクハット' },
+ { id: 'cap', label: 'キャップ' },
+ { id: 'chef', label: 'コック帽' },
+ { id: 'fire', label: '消防士' },
+];
+
+/** Builds the named hat, or `null` for `none` / an unknown name. */
+export function buildHat(id) {
+ const builder = BUILDERS[id];
+ return builder ? builder() : null;
+}
diff --git a/public/bluebey-studio/src/history.js b/public/bluebey-studio/src/history.js
new file mode 100644
index 0000000..d988b50
--- /dev/null
+++ b/public/bluebey-studio/src/history.js
@@ -0,0 +1,105 @@
+/**
+ * Undo / redo for the whole studio state.
+ *
+ * The studio is a big pile of sliders, and before this every experiment was
+ * one-way: nudging the wrong slider meant dialling the old value back by hand.
+ * A history of whole-state snapshots is the simplest thing that can possibly
+ * work here, because the state is already the single source of truth and every
+ * control funnels through `applyState`.
+ *
+ * Two details matter for it to feel right rather than merely correct:
+ *
+ * - Dragging a slider fires an event per pixel, which would bury the history in
+ * hundreds of near-identical entries. Entries therefore carry a label, and a
+ * new entry with the *same* label within `coalesceMs` REPLACES the previous
+ * one instead of stacking on top of it. So a whole drag becomes one step.
+ *
+ * - `view.backgroundImage` can be a multi-megabyte data URL, and `state` also
+ * holds the caption text. The snapshots copy objects by hand rather than via
+ * `JSON.parse(JSON.stringify(...))`, because assigning a string in JavaScript
+ * shares it instead of duplicating it - so a hundred snapshots of a heavy
+ * state stay cheap.
+ */
+
+const SHALLOW_TYPES = new Set(['string', 'number', 'boolean', 'undefined']);
+
+/** Deep copy that shares string data (and handles the odd null/array). */
+function copy(value) {
+ if (value === null || SHALLOW_TYPES.has(typeof value)) return value;
+ if (Array.isArray(value)) return value.map(copy);
+ if (typeof value === 'object') {
+ const out = {};
+ for (const [key, inner] of Object.entries(value)) out[key] = copy(inner);
+ return out;
+ }
+ return value; // functions, symbols: not part of the saved state
+}
+
+export class History {
+ constructor({ limit = 120, coalesceMs = 700, onChange = null } = {}) {
+ this.limit = Math.max(2, limit);
+ this.coalesceMs = coalesceMs;
+ this.onChange = onChange;
+ /** @type {{ state: object, label: string, at: number }[]} */
+ this.entries = [];
+ this.index = -1;
+ }
+
+ /** Forget everything and start from `state` (call after load/reset). */
+ reset(state, label = 'start') {
+ this.entries = [{ state: copy(state), label, at: Date.now() }];
+ this.index = 0;
+ this.onChange?.(this);
+ }
+
+ get canUndo() { return this.index > 0; }
+ get canRedo() { return this.index >= 0 && this.index < this.entries.length - 1; }
+
+ /** Label of the step undo would jump to, for the button tooltip. */
+ get undoLabel() { return this.canUndo ? this.entries[this.index].label : null; }
+ get redoLabel() { return this.canRedo ? this.entries[this.index + 1].label : null; }
+
+ /**
+ * Record the state *after* a change. Repeating the same label in quick
+ * succession (a slider drag) keeps a single entry that follows the value.
+ */
+ push(state, label = '変更') {
+ const now = Date.now();
+ const top = this.entries[this.index];
+ const sameDrag = top
+ && top.label === label
+ && now - top.at <= this.coalesceMs
+ && this.index === this.entries.length - 1;
+
+ if (sameDrag) {
+ this.entries[this.index] = { state: copy(state), label, at: now };
+ } else {
+ this.entries.length = this.index + 1;
+ this.entries.push({ state: copy(state), label, at: now });
+ if (this.entries.length > this.limit) this.entries.shift();
+ this.index = this.entries.length - 1;
+ }
+ this.onChange?.(this);
+ return this;
+ }
+
+ /** The previous snapshot, or `null` when there is nothing to go back to. */
+ undo() {
+ if (!this.canUndo) return null;
+ this.index -= 1;
+ this.onChange?.(this);
+ return copy(this.entries[this.index].state);
+ }
+
+ redo() {
+ if (!this.canRedo) return null;
+ this.index += 1;
+ this.onChange?.(this);
+ return copy(this.entries[this.index].state);
+ }
+
+ /** A plain description of where we are, for tests and debug output. */
+ describe() {
+ return this.entries.map((entry, i) => `${i === this.index ? '*' : ' '}${entry.label}`).join(' | ');
+ }
+}
diff --git a/public/bluebey-studio/src/look.js b/public/bluebey-studio/src/look.js
new file mode 100644
index 0000000..d755c3b
--- /dev/null
+++ b/public/bluebey-studio/src/look.js
@@ -0,0 +1,184 @@
+import * as THREE from 'three';
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
+
+/**
+ * The "look" of the scene, as opposed to the character's shape: body colours,
+ * mirroring, shadows and the lighting environment.
+ *
+ * These all live here rather than in `main.js` because they are a *policy* about
+ * how the model should be presented, and they need to be re-applied as a group
+ * whenever any of them changes. `main.js` only has to call `apply()`.
+ *
+ * Two things are worth knowing:
+ *
+ * - Colours are written onto the model's ORIGINAL materials. The flat/toon
+ * style caches its own materials derived from them, so `styles.refreshColors()`
+ * has to be called afterwards or the toon shading keeps the old colour.
+ *
+ * - Shadow softness is `LightShadow.radius` with a PCF soft shadow map, plus a
+ * hand-drawn contact blob for the "ground shadow only" mode. (VSM was tried
+ * for its wider blur and cut the shadow off in a straight line where it met
+ * the feet - see the note in `apply`.)
+ */
+
+export const ENVIRONMENTS = [
+ { value: 'gradient', label: 'スタジオ(明るい)' },
+ { value: 'room', label: '室内(自然な反射)' },
+ { value: 'none', label: 'なし(のっぺり)' },
+];
+
+/** Material name -> which entry of `render.colors` it takes. */
+const PART_COLORS = {
+ blb: 'body',
+ Hand: 'accent',
+ Foots: 'feet',
+ Nose: 'nose',
+ Leaf: 'leaf',
+ Vein: 'vein',
+};
+
+const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0));
+
+/**
+ * A soft blob that sits under the feet: the shadow a toy would cast on a table.
+ * It is a plain plane with a radial gradient, so it is cheap and it works in the
+ * line-art styles too, where the real shadow map is switched off.
+ */
+function makeContactShadow(size) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 128;
+ canvas.height = 128;
+ const ctx = canvas.getContext('2d');
+ const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62);
+ gradient.addColorStop(0, 'rgba(0,0,0,0.55)');
+ gradient.addColorStop(0.55, 'rgba(0,0,0,0.28)');
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 128, 128);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const material = new THREE.MeshBasicMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.renderOrder = -0.5;
+ mesh.userData.isHelper = true;
+ mesh.name = 'contact-shadow';
+ mesh.scale.setScalar(Math.max(1, size.x) * 1.15);
+ return mesh;
+}
+
+export function createLook({ renderer, scene, styles, model, ground, key, character, gradientEnvironment }) {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ const roomEnvironment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture;
+
+ const contact = makeContactShadow(model.size);
+ scene.add(contact);
+
+ const environmentFor = (name) => {
+ if (name === 'room') return roomEnvironment;
+ if (name === 'none') return null;
+ return gradientEnvironment ?? null;
+ };
+
+ function applyColors(colors) {
+ if (!colors) return;
+ let touched = false;
+ for (const mesh of model.parts.body) {
+ const material = styles.originals.get(mesh) ?? mesh.material;
+ const part = PART_COLORS[material?.name];
+ if (!part || !colors[part]) continue;
+ if (!material.color) material.color = new THREE.Color();
+ material.color.set(colors[part]);
+ touched = true;
+ }
+ // The toon materials are cached copies, so they need the same edit.
+ if (touched) styles.refreshColors?.();
+ return touched;
+ }
+
+ return {
+ contact,
+
+ /**
+ * @param {object} render `state.render`
+ * @param {{ line?: boolean }} [context] `line` is true in the line-art
+ * styles, where shadows and reflections are deliberately switched off.
+ */
+ apply(render, { line = false } = {}) {
+ if (!render) return;
+ applyColors(render.colors);
+
+ // Mirroring by negative scale is safe: the renderer flips the winding for
+ // a negative determinant, and the normals go through the inverse-transpose.
+ character.scale.x = render.mirror ? -1 : 1;
+
+ const wantsShadow = render.shadow !== false && !line;
+ const blobOnly = render.contactShadow === true;
+ ground.visible = wantsShadow && !blobOnly;
+ ground.material.opacity = clamp01(render.shadowOpacity ?? 0.22);
+ key.castShadow = wantsShadow && !blobOnly;
+ key.shadow.needsUpdate = true;
+
+ // The blob is drawn in BOTH modes. On its own it *is* the shadow (the
+ // "ground shadow only" setting); with the shadow map on it fills the gap
+ // where the body hides its own shadow right at the feet, which otherwise
+ // reads as "the shadow is cut off". It is the only thing keeping the
+ // character looking like it is standing on the floor rather than above it.
+ // The blob sits at the feet. It used to be drawn whenever the shadow was on,
+ // which left a faint ring behind at the origin once the character was moved;
+ // it is now opt-in (`render.contactBlob`, default off). It is always shown in
+ // the 「接地影だけ」 mode, where it is the only shadow there is.
+ contact.visible = wantsShadow && (blobOnly || render.contactBlob === true);
+ contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6));
+ const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6));
+ contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25));
+ contact.position.y = 0.012;
+
+ const softness = Number(render.shadowSoftness) || 1.6;
+ // PCF, always. three 0.186 removed PCFSoftShadowMap (asking for it warns and
+ // falls back to this anyway), and VSM needs a depth-variance bias that cut
+ // the shadow away in a straight line right at the feet. `radius` is what
+ // softens PCF.
+ if (renderer.shadowMap.type !== THREE.PCFShadowMap) {
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+ // Every shadow-receiving material has to be recompiled for the switch.
+ ground.material.needsUpdate = true;
+ for (const mesh of model.parts.body) {
+ const material = mesh.material;
+ if (Array.isArray(material)) material.forEach((m) => { m.needsUpdate = true; });
+ else if (material) material.needsUpdate = true;
+ }
+ }
+ // PCF's radius is in *shadow map texels*, and the map covers about 15 world
+ // units, so a radius of 1-2 is invisible. This scales it into something the
+ // eye can see without the sampling turning into noise.
+ if ('radius' in key.shadow) key.shadow.radius = Math.max(0.5, softness * 5);
+
+ const environment = line ? null : environmentFor(render.environment);
+ if (scene.environment !== environment) scene.environment = environment;
+ if ('environmentIntensity' in scene) {
+ scene.environmentIntensity = Number(render.envIntensity ?? 1);
+ }
+ },
+
+ /** The colours a theme would set, as a plain `{ part: '#rrggbb' }`. */
+ palette(theme) {
+ return { ...(theme?.colors ?? {}) };
+ },
+
+ dispose() {
+ contact.geometry.dispose();
+ contact.material.map?.dispose();
+ contact.material.dispose();
+ contact.removeFromParent();
+ roomEnvironment.dispose();
+ pmrem.dispose();
+ },
+ };
+}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
new file mode 100644
index 0000000..f861ade
--- /dev/null
+++ b/public/bluebey-studio/src/main.js
@@ -0,0 +1,2798 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { loadModel } from './model.js';
+import { Face } from './face.js';
+import { Styles, isLineStyle } from './styles.js';
+import { Rig } from './rig.js';
+import { Animator, createRecorder, startRecording, stopRecording } from './animation.js';
+import { defaultState, applyPatch } from './presets.js';
+import { buildPanel } from './panel.js';
+import * as exporter from './exporter.js';
+import { toast } from './ui.js';
+import { History } from './history.js';
+import { createLook } from './look.js';
+import { createClipper } from './clip.js';
+import { buildHat } from './hats.js';
+import { createBackdrop, backdropStyle } from './background.js';
+import { captionFontStack } from './textOutlines.js';
+import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
+import { rollAll, decodeSeed } from './gacha.js';
+import { encodeState, decodeState } from './urlState.js';
+import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js';
+import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js';
+
+const DEG = Math.PI / 180;
+/** The other direction: what three's Spherical reports is radians. */
+const RAD_TO_DEG = 180 / Math.PI;
+const MODEL_URL = 'assets/bluebey.glb';
+
+/** How far the eyes can swing for a look-at target, in radians. */
+const MAX_LOOK_YAW = 0.5;
+const MAX_LOOK_PITCH = 0.34;
+
+/** Undo steps are labelled by the part of the state that changed. */
+const SCOPE_LABELS = {
+ all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', gion: '擬音',
+};
+
+/**
+ * The single-file build inlines the fonts and the backdrop library, so the same
+ * code reads a data URL there and a file URL in the normal build.
+ */
+function assetUrl(file, kind) {
+ const inlined = globalThis.__BLUEBEY_FONTS__ ?? {};
+ if (kind === 'background') {
+ const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {};
+ return backdrops[file] ?? `assets/backgrounds/${file}.webp`;
+ }
+ if (kind === 'beard') {
+ const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
+ return beards[file] ?? `assets/beards/${file}`;
+ }
+ return inlined[file] ?? `assets/fonts/${file}`;
+}
+
+/**
+ * Where the model comes from. The normal build fetches `assets/bluebey.glb`;
+ * the single-file build (tools/build-standalone.mjs) inlines the same bytes as
+ * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened
+ * straight from disk without any web server.
+ */
+function resolveModelSource() {
+ const injected = globalThis.__BLUEBEY_MODEL__;
+ if (!injected) return MODEL_URL;
+ if (typeof injected !== 'string') return injected;
+ try {
+ const binary = atob(injected);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+ } catch (error) {
+ console.error('inline model could not be decoded, falling back to the file', error);
+ return MODEL_URL;
+ }
+}
+
+const state = defaultState();
+
+const app = {
+ state,
+ model: null,
+ container: null,
+ ready: false,
+ needsRender: true,
+ /** The 擬音 stamp the panel is editing, so the viewport can outline it. */
+ gionSelected: null,
+ /** Set by init(), used by the panel. */
+ actions: {},
+};
+
+/* ------------------------------------------------------------------ startup */
+
+// The view-rig classes are declared below this point; deferring by a microtask
+// guarantees the whole module has finished evaluating before anything is built.
+queueMicrotask(() => {
+ init().catch((error) => {
+ console.error(error);
+ showLoadError(error);
+ });
+});
+
+async function init() {
+ const canvas = document.getElementById('view');
+ // #stage owns the layout; the canvas only fills it (see style.css).
+ const stage = document.getElementById('stage') ?? canvas;
+
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+ });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+
+ const scene = new THREE.Scene();
+ const environment = makeEnvironment(renderer);
+
+ const key = new THREE.DirectionalLight(0xffffff, 2.1);
+ key.castShadow = true;
+ key.shadow.mapSize.set(1536, 1536);
+ key.shadow.bias = -0.0008;
+ key.shadow.normalBias = 0.02;
+ const fill = new THREE.DirectionalLight(0xffffff, 0.5);
+ const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9);
+ scene.add(key, key.target, fill, ambient);
+
+ // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from
+ // ever hiding the character, so moving ぶるべー below it no longer makes the body
+ // (or its textures) vanish - the shadow still falls on it, but it never occludes.
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(200, 200),
+ new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }),
+ );
+ ground.rotation.x = -Math.PI / 2;
+ ground.receiveShadow = true;
+ scene.add(ground);
+
+ // The camera lives in two places: `state.view` (what the panel, the saved file
+ // and a shared link use) and the orbit controls (what the mouse moves). Capture
+ // the controls' own position back into the state on every change, so the two
+ // never disagree - otherwise any refresh of the view yanked the camera back to
+ // wherever the panel had last put it.
+ const view = new ViewRig(canvas, () => {
+ view.captureInto(state.view);
+ app.needsRender = true;
+ });
+
+ /**
+ * The screen-space outline (see src/outline.js). The normal pass must not see
+ * the ground, the contact shadow or the gizmo, so they are excluded once those
+ * objects exist (see below).
+ */
+ const outline = createScreenOutline({
+ renderer,
+ scene,
+ camera: view.camera,
+ width: canvas.clientWidth || 1280,
+ height: canvas.clientHeight || 800,
+ });
+ outline.exclude([ground]);
+
+ /**
+ * The drawing buffer must follow the canvas' CSS size. Without this the
+ * browser stretches the default 300x150 buffer across the window and the
+ * whole picture looks coarse (and every exported trace is built from those
+ * few pixels).
+ *
+ * But it must not be *unlimited* either: on a large or high-density display
+ * the buffer can reach many millions of pixels, and with antialiasing, a
+ * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds
+ * per frame - which looks exactly like "nothing is showing". So the pixel
+ * count is capped and the ratio is lowered automatically on slow hardware.
+ */
+ const MAX_DRAWING_PIXELS = 2_600_000;
+ const QUALITY_FLOOR = 0.5;
+ let quality = Math.min(window.devicePixelRatio || 1, 2);
+ let autoReductions = 0;
+
+ function fitQuality(width, height, wanted) {
+ let ratio = wanted;
+ while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) {
+ ratio = Math.round((ratio - 0.1) * 100) / 100;
+ }
+ return Math.max(QUALITY_FLOOR, ratio);
+ }
+
+ let lastResize = 0;
+ let resizeBurst = 0;
+
+ /** Declared early: the viewport can resize before the model has loaded. */
+ let overlayCanvas = null;
+ /** Set while undo/redo replays a snapshot, so it is not recorded again. */
+ let suspendHistory = false;
+
+ // ------------------------------------------------------------------- 擬音
+ // `redrawOverlay` runs from the very first `resizeViewport`, so the sheet cache
+ // and the drag handles have to exist before it does (a `const` cannot be read
+ // before it is evaluated). The drawing itself lives further down.
+ const gionImages = new Map(); // sheet name -> loaded Image
+ const gionLoading = new Map(); // sheet name -> in-flight Promise
+ const gionHandles = new Map(); // stamp id -> transparent drag div
+ let gionDrag = null;
+ /** Hands out stamp ids; a counter so removing one never reuses a live id. */
+ let gionSeq = 0;
+
+ function resizeViewport() {
+ const width = stage.clientWidth || window.innerWidth;
+ const height = stage.clientHeight || window.innerHeight;
+
+ // Safety net: if something ever feeds the viewport size back into itself,
+ // stop flipping the drawing buffer on and off and say so.
+ const now = performance.now();
+ if (now - lastResize < 60) {
+ resizeBurst += 1;
+ if (resizeBurst > 30 && resizeBurst % 30 === 0) {
+ console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst);
+ }
+ if (resizeBurst > 30) return;
+ } else {
+ resizeBurst = 0;
+ }
+ lastResize = now;
+
+ const wanted = Math.min(window.devicePixelRatio || 1, 2);
+ quality = fitQuality(width, height, wanted);
+ renderer.setPixelRatio(quality);
+ renderer.setSize(width, height, false);
+ outline.setSize(width * quality, height * quality);
+ view.resize();
+ redrawOverlay();
+ app.needsRender = true;
+ }
+ resizeViewport();
+ new ResizeObserver(() => resizeViewport()).observe(stage);
+
+ // Losing the WebGL context leaves a permanently blank canvas, so say so
+ // instead of leaving the user staring at nothing.
+ canvas.addEventListener('webglcontextlost', (event) => {
+ event.preventDefault();
+ showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n'
+ + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。');
+ });
+ canvas.addEventListener('webglcontextrestored', () => {
+ app.needsRender = true;
+ });
+
+ // -------------------------------------------------------------- the model
+ const model = await loadModel(resolveModelSource(), {
+ onProgress: (ratio) => setLoadingProgress(ratio),
+ });
+ app.model = model;
+
+ // The character hangs off a mirror group of its own, so "左右反転" flips the
+ // model without touching the pose offset (`container.position`) or the props.
+ const mirrorGroup = new THREE.Group();
+ mirrorGroup.add(model.root);
+ const container = new THREE.Group();
+ container.add(mirrorGroup);
+ scene.add(container);
+ app.container = container;
+
+ // 小物 live outside the mirror group: they are scenery, not part of the body.
+ const propRoot = new THREE.Group();
+ scene.add(propRoot);
+
+ // --- 鼻ちょうちん: a modelled bubble hung under the nose --------------------
+ // The nose is a *skinned* part of the body, so a child of the nose mesh would
+ // sit at the origin instead of on the face. The whole body (nose included)
+ // rides the `master` bone, so the bubble is parented there, placed at the nose's
+ // rest position in that bone's local frame, and read from the face state each
+ // frame in the render loop.
+ function applySnotBubble(group, snot) {
+ if (!group) return;
+ group.visible = snot?.enabled === true;
+ if (!group.visible) return;
+ // The offsets are applied in the *bone's* frame, along the world axes measured
+ // once at rest, so the bubble travels with the body. Recomputing them against
+ // the live world matrix made it snap back whenever the body had moved.
+ const k = 0.01; // artwork px -> world units, enough to nudge the bubble
+ const { localBase, axes } = group.userData;
+ group.position.copy(localBase)
+ .addScaledVector(axes.x, (snot.offsetX ?? 0) * k)
+ .addScaledVector(axes.y, (snot.offsetY ?? 0) * k)
+ .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k);
+ group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1;
+ group.scale.setScalar(group.userData.baseScale);
+ group.userData.material.color.set(snot.color ?? '#dfe8ff');
+ }
+
+ const snotBubble = (() => {
+ const nose = model.parts.noseMesh;
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!nose || !master || !nose.isSkinnedMesh) return null;
+ model.root.updateMatrixWorld(true);
+ // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the
+ // back of the model, in fact) - sample the skinned positions instead, which is
+ // where the nose actually ends up.
+ const attr = nose.geometry.attributes.position;
+ const step = Math.max(1, Math.floor(attr.count / 400));
+ const point = new THREE.Vector3();
+ const centre = new THREE.Vector3();
+ let samples = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ centre.add(point);
+ samples += 1;
+ }
+ if (!samples) return null;
+ centre.multiplyScalar(1 / samples);
+ nose.localToWorld(centre);
+ let radius = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ nose.localToWorld(point);
+ radius = Math.max(radius, point.distanceTo(centre));
+ }
+ if (!Number.isFinite(radius) || radius <= 0) radius = 0.25;
+ const material = new THREE.MeshStandardMaterial({
+ color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92,
+ });
+ const group = new THREE.Group();
+ group.name = 'snot-bubble';
+ const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material);
+ group.add(bubble);
+ const bone = master.bone;
+ const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7));
+ const localBase = bone.worldToLocal(baseWorld.clone());
+ // World-axis directions expressed in the bone's frame, captured at rest: the
+ // sliders keep meaning "right / up / towards the viewer" while the bubble rides
+ // with the body.
+ const boneQuat = new THREE.Quaternion();
+ bone.getWorldQuaternion(boneQuat);
+ const invQuat = boneQuat.clone().invert();
+ const axes = {
+ x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat),
+ y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat),
+ z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat),
+ };
+ group.position.copy(localBase);
+ group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 };
+ group.visible = false;
+ bone.add(group);
+ return group;
+ })();
+ const snotMeshes = snotBubble ? [...snotBubble.children] : [];
+ applySnotBubble(snotBubble, state.face.eyes.snot);
+
+ // The hat rides the `master` bone like the snot bubble does: it is parented
+ // there, placed at the top of the head in that bone's local frame, and turned
+ // so it is world-aligned at rest (the bone's own rest rotation cancelled once).
+ // The character's front is +z, so the hats are modelled facing +z.
+ const hatMount = (() => {
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!master) return null;
+ model.root.updateMatrixWorld(true);
+ const bone = master.bone;
+ const group = new THREE.Group();
+ group.name = 'hat';
+ group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0)));
+ group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert());
+ bone.add(group);
+ return group;
+ })();
+
+ let hatKindCurrent = null;
+ let hatObject = null;
+ /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */
+ let customHatObject = null;
+ const hatMeshes = [];
+ /** Last transform applied, so the render loop only wakes when it changes. */
+ const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null };
+ /** Set once the outline pass exists, so a hat change can refresh the exclude set. */
+ let refreshOutlineExclusion = null;
+ /** Set once the style system exists, so a hat can register for styles + outline. */
+ let hatStyleRegister = null;
+ let hatStyleUnregister = null;
+
+ /**
+ * Swap the mounted hat for `built` (or clear it), keeping the style system and
+ * the outline exclusion in step.
+ *
+ * The old meshes are collected BEFORE they are unregistered: `removeMesh`
+ * deletes the outline hull (a child of the hat group) while we traverse, which
+ * used to shorten `children` mid-loop and call `.traverse` on `undefined`.
+ */
+ function mountHat(built) {
+ if (hatObject) {
+ const old = [];
+ hatObject.traverse((o) => { if (o.isMesh) old.push(o); });
+ hatMount.remove(hatObject);
+ for (const mesh of old) hatStyleUnregister?.(mesh);
+ // The custom model is kept so it can be re-chosen; a built hat is dropped.
+ if (hatObject !== customHatObject) {
+ for (const mesh of old) {
+ mesh.geometry?.dispose();
+ if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
+ else mesh.material?.dispose();
+ }
+ }
+ hatObject = null;
+ }
+ hatMeshes.length = 0;
+ if (built) {
+ hatMount.add(built);
+ hatObject = built;
+ const meshes = [];
+ built.traverse((o) => { if (o.isMesh) meshes.push(o); });
+ for (const mesh of meshes) {
+ hatMeshes.push(mesh);
+ hatStyleRegister?.(mesh);
+ }
+ }
+ refreshOutlineExclusion?.();
+ }
+
+ function applyHat(kind) {
+ if (!hatMount) return;
+ const custom = state.face.hat?.custom === true && customHatObject;
+ const key = custom ? 'custom' : kind;
+ if (key !== hatKindCurrent) {
+ hatKindCurrent = key;
+ mountHat(custom ? customHatObject : buildHat(kind));
+ }
+
+ // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the
+ // character's front and +x its right: a plain translation and rotation read
+ // the way the sliders say.
+ const hat = state.face.hat ?? {};
+ const x = hat.x ?? 0;
+ const z = hat.z ?? 0;
+ const height = hat.height ?? 0;
+ const tiltX = hat.tiltX ?? 0;
+ const tiltZ = hat.tiltZ ?? 0;
+ if (hatObject) {
+ hatObject.position.set(x, height, z);
+ hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180);
+ }
+ if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ hatXform.x = x;
+ hatXform.z = z;
+ hatXform.height = height;
+ hatXform.tiltX = tiltX;
+ hatXform.tiltZ = tiltZ;
+ app.needsRender = true;
+ }
+ }
+ /**
+ * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0
+ * (the mount sits on the head), and scaled to about the head's width, then
+ * mounted like any built hat.
+ */
+ async function loadHatModel(file) {
+ if (!hatMount) return;
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const loader = new GLTFLoader();
+ const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject));
+ const root = gltf.scene;
+ const box = new THREE.Box3().setFromObject(root);
+ const size = box.getSize(new THREE.Vector3());
+ const target = Math.max(0.2, model.size.x * 0.95);
+ const scale = target / Math.max(size.x, size.z, 0.001);
+ root.scale.setScalar(scale);
+ box.setFromObject(root);
+ const center = box.getCenter(new THREE.Vector3());
+ root.position.x -= center.x;
+ root.position.z -= center.z;
+ root.position.y -= box.min.y;
+ root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } });
+ customHatObject = root;
+ state.face.hat.custom = true;
+ state.face.hat.kind = 'none';
+ hatKindCurrent = null;
+ refresh('all');
+ app.panel?.sync();
+ }
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ const halfHeight = model.size.y * 0.5;
+ view.frame(model.size, halfHeight);
+
+ // shadow camera big enough for the whole character
+ const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6;
+ const shadowCamera = key.shadow.camera;
+ shadowCamera.left = -radius;
+ shadowCamera.right = radius;
+ shadowCamera.top = radius;
+ shadowCamera.bottom = -radius;
+ shadowCamera.near = 0.5;
+ shadowCamera.far = radius * 9;
+ shadowCamera.updateProjectionMatrix();
+ key.target.position.set(0, halfHeight, 0);
+
+ // The light's offset from the character, so the key light (and therefore its
+ // shadow camera) can follow the character around: moving far from the origin
+ // used to leave the shadow behind and cut it off at the shadow camera's edge.
+ const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3);
+ function updateKeyLight() {
+ key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z);
+ key.position.copy(key.target.position).add(keyOffset);
+ }
+
+ // ------------------------------------------------------------- controllers
+ const styles = new Styles({ meshes: model.parts.body });
+ // Hats are built at runtime, so they are not in the GLB's mesh list. Register
+ // them with the style system (and the current hat) so they follow flat/line art
+ // and get an outline hull like every other part.
+ hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true });
+ hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
+ for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true });
+ const face = new Face({
+ eyeMesh: model.parts.eyeMesh,
+ mouthMesh: model.parts.mouthMesh,
+ hairMesh: model.parts.hairMesh,
+ originals: model.originals,
+ });
+ const animator = new Animator({ state });
+
+ // ------------------------------------------------------------------- look
+ const look = createLook({
+ renderer,
+ scene,
+ styles,
+ model,
+ ground,
+ key,
+ character: mirrorGroup,
+ gradientEnvironment: environment,
+ });
+
+ // --------------------------------------------------------------- 見えない壁
+ // A clip plane that hides part of the character, so it can look half buried
+ // in a wall (see src/clip.js). Applied in `refresh`, with the render settings.
+ const clip = createClipper({ scene, renderer, model });
+ // A wall's position is stored relative to the character, so every time the
+ // walls are applied they are shifted by the body's own offset - that is what
+ // makes them travel with ぶるべー when it is moved.
+ const characterOffset = () => {
+ const root = state.pose?.root;
+ return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 };
+ };
+ const applyWalls = () => clip.apply(
+ [state.render.wall, state.render.wall2],
+ characterOffset(),
+ wallYaw(),
+ );
+ // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any
+ // body yaw the 見る先 mode added. The walls take this too, so turning the
+ // character with Shift+drag no longer slides the hidden region around.
+ const wallYaw = () => {
+ const masterY = state.pose?.bones?.master?.[1] ?? 0;
+ return (masterY + (container.rotation.y * 180) / Math.PI) * DEG;
+ };
+
+ // --------------------------------------------------------------- backdrop
+ const backdrop = createBackdrop({
+ stage,
+ resolveUrl: (name) => assetUrl(name, 'background'),
+ onNeedsRender: () => { app.needsRender = true; },
+ });
+ /** The bitmap the exports paint behind the model (an Image or the camera video). */
+ let backdropImage = null;
+
+ /**
+ * Story-panel previews (keyed by panel id) and the counter that hands out
+ * those ids.
+ *
+ * WHY the previews live outside the state: a thumbnail is an image, and the
+ * state is exactly what a shared link carries - a data URL per panel would
+ * push the link past what a URL can hold. They are keyed by id and rebuilt as
+ * panels are added, so a restored link simply shows panels without previews.
+ * (Declared up here because `buildPanel` runs - and syncs - before the panel
+ * functions below are reached.)
+ */
+ const storyThumbs = new Map();
+ let storySeq = 0;
+
+ // ------------------------------------------------------- 擬音 overlay
+ // The 擬音 stamps are drawn into their own 2D canvas laid over the WebGL one.
+ overlayCanvas = document.createElement('canvas');
+ overlayCanvas.id = 'overlay-layer';
+ overlayCanvas.style.cssText =
+ 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2';
+ stage.append(overlayCanvas);
+
+ function redrawOverlay() {
+ if (!overlayCanvas) return;
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ const ratio = Math.min(2, window.devicePixelRatio || 1);
+ const wantW = Math.round(width * ratio);
+ const wantH = Math.round(height * ratio);
+ if (overlayCanvas.width !== wantW || overlayCanvas.height !== wantH) {
+ overlayCanvas.width = wantW;
+ overlayCanvas.height = wantH;
+ }
+ const ctx = overlayCanvas.getContext('2d');
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ const gionItems = state.gion?.items ?? [];
+ for (const item of gionItems) ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 });
+ ensureGionHandles(gionItems);
+ positionGionHandles(gionItems);
+ }
+
+ // ----------------------------------------------------------------- 擬音
+ // A stamp is a crop of a sheet, drawn on the overlay canvas.
+ // The canvas is `pointer-events: none`, so each stamp gets its own transparent
+ // drag box, parked over it by `positionGionHandles`.
+
+ /** Load a sheet once, on first use, and redraw when it arrives. */
+ function ensureGionSheet(name) {
+ if (gionImages.has(name)) return Promise.resolve(gionImages.get(name));
+ if (gionLoading.has(name)) return gionLoading.get(name);
+ const image = new Image();
+ const promise = new Promise((resolve) => {
+ image.onload = () => resolve(image);
+ image.onerror = () => resolve(null);
+ }).then((loaded) => {
+ gionLoading.delete(name);
+ if (loaded) {
+ gionImages.set(name, loaded);
+ redrawOverlay();
+ }
+ return loaded;
+ });
+ gionLoading.set(name, promise);
+ image.src = gionSheetUrl(name);
+ return promise;
+ }
+
+ /** Create one drag target per stamp, and drop the ones whose stamp is gone. */
+ function ensureGionHandles(items) {
+ const live = new Set();
+ for (const item of items) {
+ live.add(item.id);
+ if (gionHandles.has(item.id)) continue;
+ const handle = document.createElement('div');
+ handle.className = 'gion-handle';
+ handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id));
+ handle.addEventListener('pointermove', moveGionDrag);
+ handle.addEventListener('pointerup', endGionDrag);
+ handle.addEventListener('pointercancel', endGionDrag);
+ handle.dataset.gionHandle = '1';
+ stage.append(handle);
+ gionHandles.set(item.id, handle);
+ }
+ for (const [id, handle] of gionHandles) {
+ if (live.has(id)) continue;
+ handle.remove();
+ gionHandles.delete(id);
+ }
+ }
+
+ /** Park each drag target over its stamp (or hide it while the sheet loads). */
+ function positionGionHandles(items) {
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ for (const item of items) {
+ const handle = gionHandles.get(item.id);
+ if (!handle) continue;
+ const image = gionImages.get(item.sheet);
+ if (!imageReady(image)) {
+ handle.style.display = 'none';
+ continue;
+ }
+ const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image));
+ handle.style.display = 'block';
+ handle.style.left = `${rect.x}px`;
+ handle.style.top = `${rect.y}px`;
+ handle.style.width = `${rect.w}px`;
+ handle.style.height = `${rect.h}px`;
+ // Match the drawn stamp, which is rotated about its centre.
+ handle.style.transformOrigin = 'center';
+ handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none';
+ handle.classList.toggle('selected', item.id === app.gionSelected);
+ }
+ }
+
+ function startGionDrag(event, id) {
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === id);
+ if (!item) return;
+ // Touching a stamp also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectGion?.(id);
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ gionHandles.get(id)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ gionDrag = {
+ id,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ x: item.x ?? 0.5,
+ y: item.y ?? 0.5,
+ };
+ }
+
+ function moveGionDrag(event) {
+ if (!gionDrag || event.pointerId !== gionDrag.pointerId) return;
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id);
+ if (!item) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width);
+ item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height);
+ refresh('gion');
+ }
+
+ function endGionDrag(event) {
+ if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return;
+ gionDrag = null;
+ app.actions.syncPanel?.();
+ }
+
+ /** Add a stamp from a picker crop, centred, and hand it to the panel. */
+ function addGionStamp({ sheet, sx, sy, sw, sh }) {
+ gionSeq += 1;
+ const item = {
+ id: `g${gionSeq}`,
+ sheet,
+ sx, sy, sw, sh,
+ x: 0.5,
+ y: 0.5,
+ w: DEFAULT_STAMP_WIDTH,
+ rot: 0,
+ flip: false,
+ };
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = [...(state.gion.items ?? []), item];
+ app.gionSelected = item.id;
+ void ensureGionSheet(sheet);
+ applyState({}, { scope: 'gion', sync: true });
+ toast('擬音を追加しました');
+ }
+
+ // ------------------------------------------------------------------ props
+ let propSignature = '';
+
+ function applyProps() {
+ const items = state.props?.items ?? [];
+ const signature = JSON.stringify([items, state.render.colors]);
+ if (signature === propSignature) return;
+ propSignature = signature;
+ for (const child of [...propRoot.children]) {
+ // Hand every mesh back to the style system before the prop is disposed.
+ // `removeMesh` restores each mesh's own material (a prop under 線画 is
+ // wearing the shared paper material, which must not be disposed). Collect
+ // the meshes first: `removeMesh` deletes the outline hull, a child of the
+ // prop group.
+ const meshes = [];
+ child.traverse((object) => {
+ if (styles.originals.has(object)) meshes.push(object);
+ });
+ for (const mesh of meshes) styles.removeMesh(mesh);
+ propRoot.remove(child);
+ disposeProp(child);
+ }
+ for (const item of items) {
+ const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ let group;
+ try {
+ group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 });
+ } catch (error) {
+ console.warn('[bluebey] unknown prop', item.kind, error);
+ continue;
+ }
+ group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0);
+ group.rotation.set(
+ (item.rotX ?? def.rotX ?? 0) * DEG,
+ (item.rotY ?? def.rotY ?? 0) * DEG,
+ (item.rotZ ?? def.rotZ ?? 0) * DEG,
+ );
+ propRoot.add(group);
+ // Register with the style system, so フラット and 線画 reach the props too
+ // and each part gets an outline hull like the body and the hats. `lineOnly`
+ // keeps that outline out of リアル/フラット, where the props read by shading.
+ group.traverse((object) => {
+ if (object.isMesh) styles.addMesh(object, { lineOnly: true });
+ });
+ // 看板 only: paint the saved text onto the freshly built face (no-op for
+ // every other prop, which has no writing surface).
+ applyPropText(group, item.text);
+ }
+ refreshOutlineExclusion?.();
+ app.needsRender = true;
+ }
+
+ // ------------------------------------------------------------ prop dragging
+ // A prop can be dragged with the mouse. The pointer picks one, then moves it in
+ // the plane that faces the camera, so it follows the cursor at whatever depth
+ // it already sits at. (The ground plane is the other obvious choice, but the
+ // camera sits almost level with the floor, so the floor is nearly edge-on and
+ // a prop dragged across it would fly off.)
+ //
+ // The listener is on `stage` in the CAPTURE phase: that runs before the canvas
+ // and `view`'s container, so stopping the event there keeps the orbit controls
+ // and the bone picking out of a prop drag.
+ const propRaycaster = new THREE.Raycaster();
+ const propPointer = new THREE.Vector2();
+ const propPlane = new THREE.Plane();
+ const propPoint = new THREE.Vector3();
+ const propNormal = new THREE.Vector3();
+ let propDrag = null;
+ // The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's
+ // offset from the wall's anchor so grabbing a corner does not make it jump.
+ const wallDrag = { pointerId: null, key: 'wall', offset: new THREE.Vector3() };
+
+ /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */
+ function pointerNdc(event) {
+ const rect = canvas.getBoundingClientRect();
+ return propPointer.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ }
+
+ /** The prop under the pointer as `{ index, group, point }`, or null. */
+ function propAt(event) {
+ if (!propRoot.children.length) return null;
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const hits = propRaycaster.intersectObjects(propRoot.children, true);
+ if (!hits.length) return null;
+ // The meshes hang off the group `propRoot` holds, so walk up to that group.
+ let node = hits[0].object;
+ while (node.parent && node.parent !== propRoot) node = node.parent;
+ const items = state.props?.items ?? [];
+ const index = propRoot.children.indexOf(node);
+ if (index < 0 || index >= items.length) return null;
+ return { index, group: node, point: hits[0].point.clone() };
+ }
+
+ /**
+ * The point on the wall's guide under the pointer, or null.
+ *
+ * Only while the wall is on AND its guide is switched on: the guide is both
+ * what there is to grab and the switch that says "I am placing the wall now",
+ * so with it off a huge invisible plane cannot swallow every orbit drag.
+ */
+ function wallAt(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ let best = null;
+ for (let index = 0; index < clip.guides.length; index += 1) {
+ const key = index === 0 ? 'wall' : 'wall2';
+ const wall = state.render[key];
+ if (wall?.on !== true || wall?.guide !== true) continue;
+ const hits = propRaycaster.intersectObject(clip.guides[index], false);
+ if (!hits.length) continue;
+ if (!best || hits[0].distance < best.distance) {
+ best = { index, key, point: hits[0].point.clone() };
+ }
+ }
+ return best;
+ }
+
+ function onPropPointerDown(event) {
+ if (event.button !== 0) return;
+ if (rig?.controls?.dragging) return; // the rotation gizmo is in charge
+ const hit = propAt(event);
+ if (hit) {
+ // Claim the pointer, so nothing underneath sees it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point);
+ propDrag = {
+ pointerId: event.pointerId,
+ index: hit.index,
+ group: hit.group,
+ // Grabbing a corner rather than the middle must not make it jump.
+ offset: hit.group.position.clone().sub(hit.point),
+ };
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ return;
+ }
+
+ const onWall = wallAt(event);
+ if (!onWall) {
+ // Nothing to grab. Remember the press, and settle on pointer-up whether it
+ // was a tap (set the 見る先 target) or the start of an orbit.
+ if (state.lookAt?.enabled === true && !rig?.controls?.dragging) {
+ lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ }
+ return;
+ }
+ // A prop wins if it is under the pointer; otherwise the wall takes it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point);
+ const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]);
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = onWall.point.x - offset.x;
+ const pz = onWall.point.z - offset.z;
+ wallDrag.pointerId = event.pointerId;
+ wallDrag.key = onWall.key;
+ // Keep the grab point's offset *in the wall's own (character-relative) frame*,
+ // so grabbing a corner does not make the wall jump.
+ wallDrag.offset.set(
+ (wall.x ?? 0) - (cos * px - sin * pz),
+ (wall.y ?? 0) - (onWall.point.y - offset.y),
+ (wall.z ?? 0) - (sin * px + cos * pz),
+ );
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ }
+
+ function onPropPointerMove(event) {
+ if (lookTap && event.pointerId === lookTap.pointerId
+ && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) {
+ lookTap = null; // that was an orbit drag, not a tap
+ }
+ if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) {
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = propPoint.x - offset.x;
+ const pz = propPoint.z - offset.z;
+ const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]);
+ // Back into the wall's character-relative frame, then the same ranges the
+ // panel's sliders offer, so a drag and a number always describe the same
+ // place.
+ wall.x = round2(clampNumber(cos * px - sin * pz + wallDrag.offset.x, -4, 4));
+ wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4));
+ wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4));
+ applyWalls();
+ app.needsRender = true;
+ return;
+ }
+ if (!propDrag || event.pointerId !== propDrag.pointerId) return;
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const next = propPoint.add(propDrag.offset);
+ const item = state.props?.items?.[propDrag.index];
+ if (!item) return;
+ // Clamp to the ranges the panel's sliders offer, so a drag can never put a
+ // prop somewhere the numbers cannot describe (and never through the floor).
+ item.x = round2(clampNumber(next.x, -4, 4));
+ item.y = round2(clampNumber(next.y, 0, 2));
+ item.z = round2(clampNumber(next.z, -4, 4));
+ propDrag.group.position.set(item.x, item.y, item.z);
+ // Keep `applyProps`' signature in step with what is on screen, so a refresh
+ // during the drag does not tear the group down and rebuild it from the state
+ // (which would re-create every geometry on every pointer move).
+ propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]);
+ app.needsRender = true;
+ }
+
+ function onPropPointerUp(event) {
+ if (lookTap && (!event || event.pointerId === lookTap.pointerId)) {
+ lookTap = null;
+ if (event) applyLookTap(event);
+ return;
+ }
+ if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) {
+ wallDrag.pointerId = null;
+ stage.style.cursor = '';
+ history.push(state, '見えない壁');
+ app.panel?.sync();
+ return;
+ }
+ if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return;
+ propDrag = null;
+ stage.style.cursor = '';
+ // One undo step for the whole drag, and the panel catches up with the value.
+ history.push(state, '小物');
+ app.panel?.sync();
+ }
+
+ // A tap (a press that does not turn into a drag) in the viewport sets the
+ // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves,
+ // so an orbit drag never moves the target by accident.
+ let lookTap = null;
+ const LOOK_TAP_SLOP = 5;
+
+ stage.addEventListener('pointerdown', onPropPointerDown, { capture: true });
+ window.addEventListener('pointermove', onPropPointerMove);
+ window.addEventListener('pointerup', onPropPointerUp);
+ window.addEventListener('pointercancel', onPropPointerUp);
+
+ // ---------------------------------------------------------------- look-at
+ const lookScratch = new THREE.Vector3();
+ const headScratch = new THREE.Vector3();
+ const lookPlane = new THREE.Plane();
+ const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+ const lookPlaneNormal = new THREE.Vector3();
+ const lookPivot = new THREE.Vector3();
+ const lookHit = new THREE.Vector3();
+ const lookFloor = new THREE.Vector3();
+ /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */
+ let aimOffset = { x: 0, y: 0 };
+
+ /**
+ * The world point under a viewport tap, for the 見る先 target.
+ *
+ * The tap is projected onto the first thing it can sensibly mean: a vertical
+ * plane through the character that faces the camera (so a tap on the body or
+ * the backdrop keeps the character's own depth), or the floor, for a tap that
+ * lands on the ground in front. The nearer of the two wins.
+ */
+ function lookAtPointFrom(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const ray = propRaycaster.ray;
+ const headY = container.position.y + model.size.y * 0.72;
+ lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z);
+ if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1);
+ lookPlane.setFromNormalAndCoplanarPoint(
+ lookPlaneNormal.normalize(),
+ lookPivot.set(container.position.x, headY, container.position.z),
+ );
+ const onPlane = ray.intersectPlane(lookPlane, lookHit);
+ const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null;
+ if (!onPlane) return onFloor;
+ if (!onFloor) return onPlane;
+ return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor;
+ }
+
+ /** A tap in the viewport: aim the eyes at whatever was under the pointer. */
+ function applyLookTap(event) {
+ if (state.lookAt?.enabled !== true) return;
+ const point = lookAtPointFrom(event);
+ if (!point) return;
+ const cfg = state.lookAt;
+ // The same ranges the panel's sliders offer, so a tap and a number describe
+ // the same place.
+ cfg.x = round2(clampNumber(point.x, -6, 6));
+ cfg.y = round2(clampNumber(point.y, 0, 5));
+ cfg.z = round2(clampNumber(point.z, -6, 6));
+ history.push(state, '見る先');
+ refresh('lookAt');
+ app.panel?.sync();
+ }
+
+ function applyLookAt() {
+ const cfg = state.lookAt;
+ if (!cfg?.enabled) {
+ // Switching the mode off leaves the character where it was: the body turn
+ // and the gaze are remembered, not re-derived, so nothing snaps back.
+ container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG;
+ const frozen = cfg?.freeze;
+ return frozen
+ ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) }
+ : { x: 0, y: 0 };
+ }
+
+ container.updateMatrixWorld(true);
+ lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0);
+ mirrorGroup.worldToLocal(lookScratch);
+ headScratch.set(0, model.size.y * 0.72, 0);
+ lookScratch.sub(headScratch);
+
+ const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6;
+ const yaw = Math.atan2(lookScratch.x, lookScratch.z);
+ const pitch = Math.atan2(lookScratch.y, flat);
+ const amount = clamp(cfg.amount ?? 1, 0, 1);
+ const aim = {
+ x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1),
+ y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1),
+ };
+
+ // Turning the whole body is what a person does to look behind themselves; a
+ // half-strength turn keeps the feet planted while the body leans round. The
+ // turn is *stored*, so switching 体も向ける off stops updating it and the body
+ // stays where it is instead of snapping back to the front.
+ if (cfg.turnBody) {
+ cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG);
+ }
+ container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG;
+
+ // Keep the gaze too, for the moment the mode is switched off.
+ cfg.freeze = aim;
+ return aim;
+ }
+
+ // ---------------------------------------------------- history, gacha
+ const history = new History({ limit: 120 });
+
+ let rig = null;
+ // The gizmo always belongs to whichever camera is currently active.
+ view.onCameraChange = (camera, controls) => {
+ if (!rig) return;
+ rig.controls.camera = camera;
+ rig.orbit = controls;
+ };
+
+ rig = new Rig({
+ bones: model.bones,
+ scene,
+ camera: view.camera,
+ domElement: canvas,
+ orbit: view.controls,
+ pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body],
+ onChange: () => {
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ },
+ });
+
+ // The ground, the contact shadow and the gizmo are scenery, not character, and
+ // the two face plates are the *front half* of the body: their open edge would be
+ // picked up as a silhouette and draw a line across the face, while contributing
+ // nothing, since they sit exactly on the body.
+ //
+ // The outline hulls must stay out too, and that one is easy to miss: they are
+ // extra copies of every mesh sitting in the scene graph, and this pass swaps the
+ // material of every visible mesh. A hull is an expanded *back-face* shell, so
+ // handing it a plain front-side label material puts an expanded copy of the
+ // whole character in front of itself - it then paints its own label over
+ // everything, and every leaf edge against the body is inked as if the body were
+ // empty paper. That is exactly the faint dotted line along the leaves' bases.
+ function outlineExclusion() {
+ return [
+ ground,
+ look.contact,
+ ...clip.guides,
+ rig.helper,
+ model.parts.eyeMesh,
+ model.parts.mouthMesh,
+ ...(model.parts.hairMesh ? [model.parts.hairMesh] : []),
+ ...snotMeshes,
+ ...hatMeshes,
+ ...styles.outlineMeshes,
+ ];
+ }
+ outline.exclude(outlineExclusion());
+ refreshOutlineExclusion = () => outline.exclude(outlineExclusion());
+ // The wall is *not* excluded: it is handed to the pass as an occluder, so the
+ // labels behind it are culled as well. Excluded, the leaves and the nose
+ // buried in the wall kept their outlines, because the labels are read before
+ // its depth is applied.
+ outline.occlude(clip.occluders);
+
+ /**
+ * The parts the screen-space pass draws, with the label each one writes.
+ *
+ * The leaves are the hard case: a hull cannot outline a shell that thin. They
+ * are drawn with the LEAF label, which means the pass inks them where they meet
+ * the paper and where they fold against each other, but *not* where they run
+ * into the body - a line there reads as the leaf sinking into the body, and the
+ * original artwork has none (the leaf simply passes behind the body).
+ *
+ * The nose joins them in the line-art styles, with the NOSE label, so its ring
+ * against the body *is* drawn: it is the only thing that shows the nose in a
+ * line drawing. In the shaded styles it is left out entirely, and its hull then
+ * keeps only the part that pokes out of the silhouette - which is what the
+ * original artwork does (the nose reads by its own colour there).
+ *
+ * @param {string} style
+ * @returns {Array<[import('three').Object3D, number]>}
+ */
+ function screenParts(style) {
+ const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]);
+ if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]);
+ return parts;
+ }
+
+ /**
+ * The label every part writes (see src/outline.js).
+ *
+ * The leaves and the nose are the outlined ones. The body's label is the one
+ * real choice here, and it is exposed as `render.leafBodyLine`:
+ *
+ * - `BEHIND` (the default) makes the body count as paper, so each leaf is
+ * outlined where it emerges from the body. In a line drawing the two are
+ * paper on paper, so without this the leaves and the body merge into one
+ * white shape with no way to tell them apart - which is the greater evil.
+ * - `SOLID` makes the body block that line, so a leaf simply passes behind it.
+ * Cleaner where it works, but the leaves lose their outline there.
+ *
+ * Everything else - the feet, the hands, the props - is *merely behind*, so it
+ * stays BEHIND either way: it still hides what is behind it, but a leaf lying
+ * across it keeps its outline.
+ */
+ function outlineLabels(parts) {
+ const labels = new Map(parts);
+ const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL;
+ for (const mesh of model.parts.body ?? []) {
+ if (labels.has(mesh)) continue;
+ labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL);
+ }
+ return [...labels];
+ }
+
+ /** Point both outline systems at the right parts for `method`. */
+ function applyOutlineMethod(style, method = state.render.outlineMethod) {
+ const hybrid = method === 'screen';
+ const parts = hybrid ? screenParts(style) : [];
+ styles.setHullHidden(parts.map(([mesh]) => mesh));
+ outline.only(parts.length ? outlineLabels(parts) : null);
+ }
+
+ /**
+ * Run `fn` with the outline pass set up for another style, then put the
+ * on-screen configuration back. Both the hull/screen split and which parts the
+ * screen pass may see are shared by the live view and every offscreen render,
+ * so a pass that draws another style has to say so - the SVG and the "lines
+ * only" PNG are ink through and through, so they always want the line-art
+ * arrangement (a hull cannot outline the leaves at all).
+ */
+ async function withOutlineFor({ style, method = 'screen' }, fn) {
+ applyOutlineMethod(style, method);
+ try {
+ return await fn();
+ } finally {
+ applyOutlineMethod(state.render.style);
+ }
+ }
+
+ Object.assign(app, {
+ renderer, scene, camera: view.camera, view, styles, face, rig, animator,
+ key, ambient, ground, environment, halfHeight, outline, clip,
+ look, backdrop, history, propRoot, mirrorGroup, container,
+ get backdropImage() { return backdropImage; },
+ });
+
+ const recorder = createRecorder(canvas);
+
+ // -------------------------------------------------------------- the panel
+ const panel = buildPanel(app, document.getElementById('panel'));
+ app.panel = panel;
+
+ // On a phone the panel is a bottom sheet. Its grab handle drags the sheet's top
+ // edge up and down, so the menu can be pulled out of the way of the character.
+ const panelEl = document.getElementById('panel');
+ const panelGrip = document.createElement('div');
+ panelGrip.className = 'panel-grip';
+ panelGrip.setAttribute('aria-hidden', 'true');
+ panelEl.prepend(panelGrip);
+ installSheetDrag(panelEl, panelGrip);
+
+ /** Drag on the phone sheet's handle; a no-op anywhere else. */
+ function installSheetDrag(sheet, grip) {
+ const phone = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ let drag = null;
+ const minHeight = () => Math.round(window.innerHeight * 0.16);
+ const maxHeight = () => Math.round(window.innerHeight * 0.94);
+
+ grip.addEventListener('pointerdown', (event) => {
+ if (!phone.matches) return;
+ const rect = sheet.getBoundingClientRect();
+ // Start from the height the sheet has now, so the first move does not jump.
+ drag = { id: event.pointerId, fromY: event.clientY, startH: rect.height };
+ sheet.style.maxHeight = 'none';
+ sheet.style.height = `${rect.height}px`;
+ try { grip.setPointerCapture(event.pointerId); } catch { /* capture is optional */ }
+ event.preventDefault();
+ });
+ grip.addEventListener('pointermove', (event) => {
+ if (!drag || event.pointerId !== drag.id) return;
+ // Dragging up grows the sheet (its top edge climbs); dragging down shrinks it.
+ const next = drag.startH + (drag.fromY - event.clientY);
+ sheet.style.height = `${Math.min(maxHeight(), Math.max(minHeight(), next))}px`;
+ event.preventDefault();
+ });
+ const end = (event) => {
+ if (!drag || (event && event.pointerId !== drag.id)) return;
+ drag = null;
+ };
+ grip.addEventListener('pointerup', end);
+ grip.addEventListener('pointercancel', end);
+
+ // A window grown to the desktop layout keeps the side column: drop the height
+ // the drag had fixed, so the panel goes back to filling the screen.
+ const clearOnDesktop = () => {
+ if (phone.matches) return;
+ sheet.style.height = '';
+ sheet.style.maxHeight = '';
+ };
+ window.addEventListener('resize', clearOnDesktop);
+ }
+
+ Object.assign(app.actions, {
+ capturePose,
+ applyState,
+ refresh,
+ loadHatModel,
+ patch: (scope, value) => applyState({ [scope]: value }, { scope }),
+ syncPanel: () => app.panel?.sync(),
+ imageCanvas: (options) => captureImage(options),
+ savePNG: (options = {}) => savePNG(options),
+ copyPNG: () => copyPNG(),
+ faceMapCanvas: (kind) => faceMapCanvas(kind),
+ saveFaceMap: (kind) => saveFaceMap(kind),
+ addGionStamp: (item) => addGionStamp(item),
+ saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`),
+ loadSettings: (text) => {
+ const data = JSON.parse(text);
+ applyState(data, { full: true, sync: true });
+ toast('設定を読み込みました');
+ },
+ toggleRecording: () => toggleRecording(recorder),
+ resetPose: () => {
+ rig.reset();
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyState({}, { scope: 'pose', sync: true });
+ toast('ポーズをリセットしました');
+ },
+ setCameraPreset: (id) => setCameraPreset(id),
+ undo: () => restoreFromHistory('undo'),
+ redo: () => restoreFromHistory('redo'),
+ rollGacha: (seed) => rollGacha(seed),
+ copyShareLink: () => copyShareLink(),
+ shareImage: (network) => shareImage(network),
+ storyThumbs: () => storyThumbs,
+ toggleCamera: (on) => toggleCamera(on),
+ addProp: (kind) => addProp(kind),
+ addStoryPanel: () => addStoryPanel(),
+ saveStory: (options) => saveStory(options),
+ loadFromHash: () => loadFromHash(),
+ toggleAnimation: () => {
+ state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off';
+ app.animator.reset();
+ panel.sync();
+ app.needsRender = true;
+ },
+ });
+
+ installKeys();
+ installTopbar();
+ window.addEventListener('focus', () => { app.needsRender = true; });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) app.needsRender = true;
+ });
+
+ {
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ console.log(
+ '[bluebey] ready'
+ + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}`
+ + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}`
+ + ` quality=${quality}`
+ + ` dpr=${window.devicePixelRatio}`
+ + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}`
+ + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`,
+ );
+ }
+ window.addEventListener('resize', () => resizeViewport());
+
+ applyState({}, {});
+ await loadFromHash();
+ history.reset(state);
+ rig.select('master', { silent: true });
+ panel.sync();
+
+ let last = performance.now();
+ let faceSignature = '';
+ let slowFrames = 0;
+ let framesSinceFps = 0;
+ let fpsAt = performance.now();
+ const warmupUntil = performance.now() + 2500;
+
+ let snotSignature = null;
+ const loop = (now) => {
+ const rawDelta = now - last;
+ const dt = Math.min(0.05, rawDelta / 1000);
+ last = now;
+
+ animator.update(dt);
+
+ const posed = animator.pose(state.pose);
+ if (!rig.controls.dragging) rig.applyPose(posed);
+ container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0);
+ updateKeyLight();
+ // The contact blob rides under the character, so it never lingers at the origin.
+ look.contact.position.x = container.position.x;
+ look.contact.position.z = container.position.z;
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ if (snotBubble) {
+ const snot = state.face.eyes.snot;
+ const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`;
+ if (sig !== snotSignature) {
+ snotSignature = sig;
+ applySnotBubble(snotBubble, snot);
+ app.needsRender = true;
+ }
+ // The sleeping breath swells the bubble; `snotScale` is 1 otherwise.
+ if (snotBubble.visible) {
+ const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1);
+ if (snotBubble.scale.x !== scale) {
+ snotBubble.scale.setScalar(scale);
+ app.needsRender = true;
+ }
+ }
+ }
+
+ const params = buildFaceParams();
+ const signature = JSON.stringify(params);
+ if (signature !== faceSignature) {
+ faceSignature = signature;
+ face.setParams(params, faceMode());
+ app.needsRender = true;
+ }
+ if (face.flush(now, 26)) app.needsRender = true;
+
+ view.controls.update(dt);
+
+ const animating = (state.anim.mode ?? 'off') !== 'off'
+ || state.anim.lookAround
+ || animator.blinkPhase >= 0
+ || recorder?.recorder?.state === 'recording';
+ // Always paint for the first couple of seconds, so a missed "needs render"
+ // can never leave an empty window staring back at the user.
+ if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) {
+ app.needsRender = false;
+
+ // The screen-space outline needs one extra pass *before* the scene, so it
+ // wraps the normal render instead of following it. `outline.render` paints
+ // the scene itself (ink and all), so there must be no second render after
+ // it - that would clear the canvas and wipe the ink straight back off.
+ const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false;
+ outline.render(() => renderer.render(scene, view.camera), {
+ enabled: wantsScreen,
+ camera: view.camera,
+ color: state.render.outlineColor,
+ // The label buffer is `quality`x the CSS size, so the radius has to carry
+ // that factor to keep the leaf/nose line a constant width in CSS pixels -
+ // otherwise it halves on a HiDPI display and jumps when the app lowers
+ // the quality on slow frames.
+ radius: outlineRadiusFor(view, state, quality),
+ });
+ view.controlsChanged = false;
+ framesSinceFps += 1;
+
+ // Hardware that cannot keep up gets a smaller drawing buffer instead of a
+ // slideshow (a very large buffer can look like "nothing ever appears").
+ if (rawDelta > 90) slowFrames += 1;
+ else slowFrames = 0;
+ if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) {
+ autoReductions += 1;
+ quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100);
+ renderer.setPixelRatio(quality);
+ slowFrames = 0;
+ console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality);
+ }
+ }
+
+ if (now - fpsAt > 1000) {
+ const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt));
+ framesSinceFps = 0;
+ fpsAt = now;
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ panel.setStats?.(
+ `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps`
+ + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''),
+ );
+ }
+
+ requestAnimationFrame(loop);
+ };
+ requestAnimationFrame(loop);
+
+ hideLoading();
+ app.ready = true;
+ window.__bluebeyReady = true;
+ window.__bluebey = {
+ app,
+ state,
+ set: (patch) => applyState(patch),
+ face: (patch) => applyState({ face: patch }),
+ pose: (patch) => applyState({ pose: patch }),
+ view: (patch) => applyState({ view: patch }),
+ render: (patch) => applyState({ render: patch }),
+ selectBone: (name) => rig.select(name),
+ preset: (id) => app.actions.applyFacePreset?.(id),
+ posePreset: (id) => app.actions.applyPosePreset?.(id),
+ png: (options) => captureImage(options),
+ };
+
+ /* ------------------------------------------------------------- internals */
+
+ /** The camera the outline pass should use (kept in step with the view rig). */
+ function outlineCamera() {
+ return view.camera;
+ }
+
+ /** The gizmo/frame loop re-reads the bones, so store them back into state. */
+ function capturePose() {
+ const pose = rig.getPose();
+ state.pose.bones = pose.bones;
+ }
+
+ function buildFaceParams() {
+ const eyes = state.face.eyes;
+ const openScale = animator.eyeOpen;
+ const drift = animator.look;
+
+ const eyeFor = (key) => {
+ const own = eyes[key];
+ // A shut artwork (1〜3線 / 3の目 / わらう) does not blink: its lid stays where
+ // the user put it, so the blink animation must not scale its `open`.
+ const shutArt = own.shape === 'three' || own.shape === 'arch'
+ || (typeof own.shape === 'string' && own.shape.startsWith('line'));
+ return {
+ // Which artwork this eye shows (ふつう / 1〜3線 / 3の目 / わらう / ハート).
+ // The renderer chooses the shut artwork from this, so it must travel.
+ shape: own.shape,
+ open: clamp01(own.open * (shutArt ? 1 : openScale)),
+ lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1),
+ lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1),
+ closed: own.closed,
+ closedLines: own.closedLines,
+ irisShape: own.irisShape,
+ threeFlip: own.threeFlip === true,
+ // 白目 / 光彩 are per eye and tri-state: `null` means "the usual" (open
+ // eyes show their white; the shared 光彩 switch decides the glint).
+ white: own.white ?? null,
+ highlight: own.highlight ?? null,
+ // 形の大きさ scales the drawn eye shape (the shut lines, the 3, the arch)
+ // and the heart.
+ shapeScale: own.shapeScale ?? 1,
+ // The tears are per eye, and only drawn when their own switch is on.
+ tearOn: own.tearOn === true,
+ tear: own.tear ?? 0.3,
+ tearY: own.tearY ?? 0,
+ // Where this eye sits, and where its teardrop hangs, can be nudged one
+ // side at a time (an asymmetric face).
+ eyeX: own.eyeX ?? 0,
+ tearX: own.tearX ?? 0,
+ tearTilt: own.tearTilt ?? 0,
+ // The lower lid can differ per eye (falling back to the shared value).
+ lowerLid: own.lowerLid ?? eyes.lowerLid ?? 0,
+ // The rest of the lids are per eye too now (null = use the shared value).
+ lidShape: own.lidShape ?? null,
+ lidWidth: own.lidWidth ?? null,
+ lidTilt: own.lidTilt ?? null,
+ lashes: own.lashes ?? null,
+ lashAngle: own.lashAngle ?? null,
+ lashPos: own.lashPos ?? null,
+ };
+ };
+
+ // A look-at target drives the eyes without being part of the saved
+ // expression; the mouth is used as-is.
+ const mouth = { ...state.face.mouth };
+
+ return {
+ eyes: {
+ // Everything shared by both eyes (colours, iris scale, glasses, ...) has
+ // to travel with the per-eye values, because the face renderer
+ // reads them from here. Spreading `...eyes` is also what makes the face
+ // redraw when one of them (e.g. `glasses`) changes: the loop compares the
+ // JSON of this object against the last one it drew.
+ ...eyes,
+ left: eyeFor('left'),
+ right: eyeFor('right'),
+ },
+ mouth,
+ };
+ }
+
+ function faceMode() {
+ return isLineStyle(state.render.style) ? 'line' : 'paint';
+ }
+
+ function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) {
+ if (full) {
+ const fresh = defaultState();
+ applyPatch(fresh, patch);
+ for (const key of Object.keys(fresh)) state[key] = fresh[key];
+ } else {
+ applyPatch(state, patch);
+ }
+ refresh(scope);
+ app.needsRender = true;
+ if (sync && !silent) app.panel?.sync();
+ }
+
+ /** Re-apply the parts of the state named by `scope` ('all' by default). */
+ function refresh(scope = 'all') {
+ app.needsRender = true;
+ // Where the eyes aim depends on where the character is, so it is recomputed
+ // whenever anything that moves could change.
+ if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') {
+ aimOffset = applyLookAt();
+ }
+ if (scope === 'all' || scope === 'render') {
+ styles.setStyle(state.render.style);
+ styles.setPaper(state.render.paper);
+ styles.setOutlineWidth(state.render.outlineWidth);
+ styles.setOutlineColor(state.render.outlineColor);
+ styles.setOutlineEnabled(state.render.outline);
+ // Which parts each outline method draws, and which hulls stand down for it
+ // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`.
+ applyOutlineMethod(state.render.style);
+
+ const line = isLineStyle(state.render.style);
+ // Shadows, reflections, body colours and mirroring are the look module's
+ // job, so that they are applied together and stay consistent.
+ look.apply(state.render, { line });
+ applyProps();
+ // 見えない壁: the one setting that changes what is drawn rather than how.
+ applyWalls();
+
+ const azimuth = state.render.lightAzimuth * DEG;
+ const elevation = state.render.lightElevation * DEG;
+ const distance = model.size.y * 3;
+ keyOffset.set(
+ Math.cos(elevation) * Math.sin(azimuth) * distance,
+ Math.sin(elevation) * distance,
+ Math.cos(elevation) * Math.cos(azimuth) * distance,
+ );
+ updateKeyLight();
+ key.intensity = state.render.lightIntensity;
+ ambient.intensity = state.render.ambient;
+ key.color.set(state.render.lightColor ?? '#ffffff');
+ ambient.color.set(state.render.ambientColor ?? '#ffffff');
+ renderer.toneMappingExposure = state.render.exposure;
+ applyBackground();
+ renderer.shadowMap.needsUpdate = true;
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ applyBackdrop();
+ // The renderer's *clear* depends on the background mode as well: a preset
+ // photo, a loaded picture and the camera all show through a transparent
+ // clear (see `backgroundOf`). Without this, switching the background left
+ // the canvas clearing to the old opaque colour and hid the layer that had
+ // just been set up behind it - which is why the background only appeared
+ // after some other change (adding a prop) re-ran this.
+ applyBackground();
+ }
+ if (scope === 'all' || scope === 'view' || scope === 'gion') redrawOverlay();
+
+ // A style change flips the face between paint and ink (`faceMode`), so the
+ // 'render' scope has to rebuild the face too - otherwise the old drawing
+ // stays on the plates until something else (a blink) happens to dirty them.
+ if (scope === 'all' || scope === 'face' || scope === 'render') {
+ face.setSource('eyes', state.face.eyes.source);
+ face.setSource('mouth', state.face.mouth.source);
+ face.setParams(buildFaceParams(), faceMode());
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ view.setProjection(state.view.projection);
+ view.apply(state.view, model.size);
+ view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed);
+ }
+
+ if (scope === 'all' || scope === 'pose') {
+ rig.applyPose(state.pose);
+ const root = state.pose.root ?? [0, 0, 0];
+ container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0);
+ }
+
+ // Every user-driven change funnels through here, so this is the one place
+ // that has to remember a step for undo. Undo itself sets `suspendHistory`.
+ if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope);
+ }
+
+ /** Paint the backdrop bitmap behind the model, for an export. */
+ function backdropSource() {
+ if (state.view.background === 'camera') {
+ const video = backdrop.el?.querySelector?.('video');
+ return video && video.readyState >= 2 ? video : null;
+ }
+ return backdropImage;
+ }
+
+ /**
+ * Freeze the current camera frame into a canvas.
+ *
+ * The AR preview is a live video, so by the time the PNG finishes encoding the
+ * feed has moved on and the saved picture no longer matches what was on screen
+ * when the shutter was pressed. Snapping the frame first keeps the two in step.
+ */
+ function snapshotBackdrop() {
+ if (state.view.background !== 'camera') return undefined;
+ const video = backdrop.el?.querySelector?.('video');
+ if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return undefined;
+ const frame = document.createElement('canvas');
+ frame.width = video.videoWidth;
+ frame.height = video.videoHeight;
+ try {
+ frame.getContext('2d').drawImage(video, 0, 0);
+ } catch {
+ return undefined;
+ }
+ return frame;
+ }
+
+ /**
+ * Paint the backdrop *behind* the rendered model.
+ *
+ * WHY the scratch canvas and `destination-over`: `ctx` already holds the
+ * character on a transparent field (see `exporter.renderStill`), so drawing the
+ * photo straight onto it with the default `source-over` painted OVER the
+ * character and the export came out as a bare photograph. Building the scene in
+ * a scratch canvas first also lets the dark veil sit on top of the photo, which
+ * it could not do once the photo had been slipped underneath.
+ */
+ function drawBackdropInto(ctx, width, height, scale, sourceOverride) {
+ const source = sourceOverride ?? backdropSource();
+ if (!source) return false;
+ const style = backdropStyle(state.view);
+ if (!style.visible) return false;
+ const iw = source.naturalWidth || source.videoWidth || source.width || 0;
+ const ih = source.naturalHeight || source.videoHeight || source.height || 0;
+ if (!iw || !ih) return false;
+
+ const back = document.createElement('canvas');
+ back.width = width;
+ back.height = height;
+ const paint = back.getContext('2d');
+
+ // The same CSS the live layer uses: blur (bleeding past the edges), the fit,
+ // the mirror and the scale/offset transform. The offset is in the element's
+ // own (pre-scale) space, so it is multiplied by `scale` here, exactly as
+ // `transform: scale(s) translate(...)` does in CSS.
+ const bleed = style.blur > 0 ? style.blur * 2 : 0;
+ const w = width + bleed * 2;
+ const h = height + bleed * 2;
+ paint.save();
+ if (style.blur > 0) paint.filter = `blur(${style.blur}px)`;
+ paint.translate(
+ width / 2 + style.scale * style.offset.x * width,
+ height / 2 + style.scale * style.offset.y * height,
+ );
+ paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale);
+ paint.translate(-width / 2, -height / 2);
+
+ if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') {
+ const fit = style.backgroundSize === 'cover'
+ ? Math.max(w / iw, h / ih)
+ : Math.min(w / iw, h / ih);
+ const dw = iw * fit;
+ const dh = ih * fit;
+ paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh);
+ } else if (style.backgroundSize === 'auto') {
+ // CSS repeats the picture at its own size and anchors the grid on the box
+ // centre (`background-position: center`). A canvas pattern instead repeats
+ // at the image's *intrinsic* size, which is `scale`x too small for a
+ // canvas that is `scale`x the CSS box - so the export tiled `scale`x more
+ // densely than the preview. Paint the tiles by hand at `scale`, on a grid
+ // centred on the box centre; the CTM above already carries the offset /
+ // backgroundScale / mirror, so the tiles must not re-apply them.
+ const tw = iw * scale;
+ const th = ih * scale;
+ // Cover only the user-space area the visible canvas needs (widened by the
+ // blur bleed), mapped back through the CTM. The CTM holds no rotation, so
+ // its `a`/`d` scale factors are enough to invert it for the bounds.
+ const matrix = paint.getTransform();
+ const [left, right] = [
+ (-bleed - matrix.e) / matrix.a,
+ (width + bleed - matrix.e) / matrix.a,
+ ].sort((a, b) => a - b);
+ const [top, bottom] = [
+ (-bleed - matrix.f) / matrix.d,
+ (height + bleed - matrix.f) / matrix.d,
+ ].sort((a, b) => a - b);
+ const cx = width / 2;
+ const cy = height / 2;
+ const firstCol = Math.floor((left - cx) / tw);
+ const lastCol = Math.ceil((right - cx) / tw);
+ const firstRow = Math.floor((top - cy) / th);
+ const lastRow = Math.ceil((bottom - cy) / th);
+ for (let row = firstRow; row <= lastRow; row++) {
+ for (let col = firstCol; col <= lastCol; col++) {
+ paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th);
+ }
+ }
+ } else {
+ paint.drawImage(source, -bleed, -bleed, w, h);
+ }
+ paint.restore();
+
+ if (style.darken > 0) {
+ paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`;
+ paint.fillRect(0, 0, width, height);
+ }
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.drawImage(back, 0, 0);
+ ctx.restore();
+ return true;
+ }
+
+ /** Load (or clear) the backdrop bitmap that the exports composite. */
+ function applyBackdrop() {
+ const viewState = state.view;
+ backdrop.apply(viewState);
+ // The AR shutter rides the viewport, not the panel, so it shows/hides here.
+ if (app.arShutter) app.arShutter.hidden = viewState.background !== 'camera';
+ // The touch hint would sit under the shutter, so it steps aside in AR mode.
+ const touchHint = document.getElementById('viewport-hint-touch');
+ if (touchHint) touchHint.style.visibility = viewState.background === 'camera' ? 'hidden' : '';
+ const url = viewState.background === 'preset'
+ ? assetUrl(viewState.backgroundPreset, 'background')
+ : viewState.background === 'image'
+ ? viewState.backgroundImage
+ : viewState.background === 'effect'
+ ? backdrop.effectUrl(viewState.backgroundEffect)
+ : null;
+ if (!url) {
+ if (viewState.background !== 'camera') backdropImage = null;
+ return;
+ }
+ if (backdropImage?.dataset?.url === url) return;
+ const image = new Image();
+ image.dataset.url = url;
+ image.onload = () => { backdropImage = image; app.needsRender = true; };
+ image.onerror = () => { backdropImage = null; };
+ image.src = url;
+ }
+
+ function applyBackground() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function captureImage({ scale = 2, transparent = null, lines = null } = {}) {
+ // Freeze the live camera frame up front, so the saved picture holds the frame
+ // that was on screen when the button was pressed (see `snapshotBackdrop`).
+ const frozenBackdrop = snapshotBackdrop();
+ return withHiddenHelpers(async () => {
+ // The face texture is redrawn on a throttle (every 26 ms) while the render
+ // loop runs, so a capture taken straight after a change could still hold the
+ // *previous* expression. That is what made a comic panel occasionally show
+ // its neighbour's face: force the pending redraw through first.
+ face.flush(performance.now(), 0);
+ const wantsTransparent = transparent === true
+ || (transparent == null && state.view.background === 'transparent');
+ // A transparent export of a line drawing drops the paper as well. The whole
+ // point of transparent line art is to lay the lines over another picture,
+ // and a white silhouette would just hide it - so this renders the `outline`
+ // style, which draws the ink and leaves the body out. A shaded style keeps
+ // its body, because there it is an ordinary cut-out of the character.
+ const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style));
+ const drawn = wantsLines ? 'outline' : state.render.style;
+ const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view);
+
+ const onScreen = styles.style;
+ if (drawn !== onScreen) styles.setStyle(drawn);
+ let result;
+ try {
+ result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({
+ renderer,
+ scene,
+ camera: view.camera,
+ outline,
+ outlineOptions: {
+ enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false,
+ color: state.render.outlineColor,
+ // The export's label buffer is `scale`x the one on screen (renderStill
+ // sets it to the output size), so its radius grows with it to keep the
+ // line's weight relative to the model the same as in the preview.
+ radius: outlineRadiusFor(view, state, scale),
+ },
+ width: canvas.clientWidth || window.innerWidth,
+ height: canvas.clientHeight || window.innerHeight,
+ restore: () => { resizeViewport(); applyStateBackgroundAgain(); },
+ }, { scale, background }));
+ } finally {
+ if (drawn !== onScreen) styles.setStyle(onScreen);
+ }
+
+ // The backdrop and the 擬音 stamps live in DOM layers behind the WebGL
+ // canvas, so an export has to paint them in itself - in the same order as
+ // the screen: backdrop, then stamps.
+ const ctx = result.canvas.getContext('2d');
+ // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps are
+ // drawn at below. The backdrop's tiled fit needs it too, so its tiles come
+ // out the size the preview's CSS paints them.
+ const base = canvas.clientWidth || 1280;
+ const outputScale = result.width / Math.max(1, base);
+ let changed = false;
+ if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true;
+ const gionItems = state.gion?.items ?? [];
+ if (gionItems.length) {
+ for (const item of gionItems) await ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ });
+ changed = true;
+ }
+ if (changed) result.blob = await exporter.canvasToBlob(result.canvas);
+ return result;
+ });
+ }
+
+ function applyStateBackgroundAgain() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function savePNG({
+ scale = state.render.pngScale ?? 2,
+ transparent = state.render.pngTransparent ? true : undefined,
+ } = {}) {
+ const result = await captureImage({ scale, transparent });
+ exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`);
+ toast(`PNGを書き出しました(${result.width}×${result.height})`);
+ }
+
+ /**
+ * The canvas the 下地 export paints on, *without* downloading it. Kept separate
+ * from `saveFaceMap` so the in-page round-trip check can look at exactly the
+ * pixels the button writes.
+ */
+ function faceMapCanvas(kind) {
+ // A redraw is throttled while the render loop runs (see the loop's
+ // `face.flush(now, 26)`), so force any pending one through first or the map
+ // would show the expression before last.
+ face.flush(performance.now(), 0);
+ return exporter.buildFaceMap(face, kind);
+ }
+
+ async function saveFaceMap(kind) {
+ const canvas = faceMapCanvas(kind);
+ if (!canvas) {
+ toast('この下地は書き出せませんでした');
+ return;
+ }
+ const blob = await exporter.canvasToBlob(canvas);
+ const label = kind === 'eyes' ? '目' : '口';
+ exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`);
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`);
+ }
+
+ /** Hide the gizmo and helpers so they never end up in an export. */
+ async function withHiddenHelpers(fn) {
+ const wasVisible = rig.helper.visible;
+ const guidesWereVisible = clip.guides.map((guide) => guide.visible);
+ rig.helper.visible = false;
+ clip.guides.forEach((guide) => { guide.visible = false; });
+ try {
+ return await fn();
+ } finally {
+ rig.setGizmoVisible(wasVisible);
+ clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; });
+ app.needsRender = true;
+ }
+ }
+
+ async function copyPNG() {
+ const result = await captureImage({ scale: 2 });
+ await exporter.copyCanvasToClipboard(result.canvas);
+ toast('画像をクリップボードにコピーしました');
+ }
+
+ /**
+ * Swap in another GLB.
+ *
+ * Kept as the implementation, but no longer wired to anything: the panel's
+ * 「GLBを差し替える」 button and the window-wide drop handler were removed on
+ * 2026-09-27. Putting the feature back is a button plus an action that calls
+ * this (the model returns on reload, so nothing is saved).
+ */
+ async function replaceModel(file) {
+ if (!file) return;
+ try {
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const next = await loadModel(buffer);
+ styles.dispose();
+ rig.dispose();
+ container.remove(model.root);
+ scene.remove(container);
+ container.add(next.root);
+ scene.add(container);
+ app.model = next;
+ toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`);
+ app.needsRender = true;
+ } catch (error) {
+ console.error(error);
+ toast('このファイルは読み込めませんでした');
+ }
+ }
+
+ let recording = false;
+ async function toggleRecording(session) {
+ if (!session) {
+ toast('この環境では録画できません');
+ return;
+ }
+ if (!recording) {
+ if (!state.anim.idle) {
+ state.anim.idle = true;
+ panel.sync();
+ }
+ recording = startRecording(session);
+ toast('録画を開始しました(もう一度押すと停止)');
+ } else {
+ const blob = await stopRecording(session);
+ recording = false;
+ if (blob) {
+ exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`);
+ toast('録画を保存しました(WebM)');
+ }
+ }
+ app.panel?.setRecording(recording);
+ }
+
+ function setCameraPreset(id) {
+ const presets = {
+ front: { azimuth: 0, polar: 82 },
+ threeQuarter: { azimuth: 34, polar: 78 },
+ side: { azimuth: 90, polar: 84 },
+ back: { azimuth: 180, polar: 82 },
+ top: { azimuth: 24, polar: 26 },
+ };
+ applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true });
+ }
+
+ function restoreFromHistory(kind) {
+ const snapshot = kind === 'undo' ? history.undo() : history.redo();
+ if (!snapshot) {
+ toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません');
+ return;
+ }
+ suspendHistory = true;
+ try {
+ applyState(snapshot, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました');
+ }
+
+ /** おまかせ: a seeded random expression, so it can be reproduced and shared. */
+ function rollGacha(seedText) {
+ const seed = seedText || String(Math.floor(Math.random() * 1e9));
+ const roll = rollAll(seed);
+ suspendHistory = true;
+ try {
+ applyState(roll.patch, { scope: 'all' });
+ } finally {
+ suspendHistory = false;
+ }
+ history.push(state, 'おまかせ');
+ app.panel?.sync();
+ const shown = decodeSeed(seed) ?? String(seed);
+ toast(`おまかせ表情(シード ${shown})`);
+ return shown;
+ }
+
+ async function copyShareLink() {
+ try {
+ const encoded = await encodeState(state);
+ const link = `${location.origin}${location.pathname}#s=${encoded}`;
+ await navigator.clipboard.writeText(link);
+ toast(`この見た目のリンクをコピーしました(${link.length}文字)`);
+ } catch (error) {
+ console.error(error);
+ toast('リンクを作れませんでした');
+ }
+ }
+
+ /** The tag every shared picture carries. */
+ const SHARE_TAG = '#ぶるべースタジオ';
+
+ /** The studio's own address - the landing page itself, not a state-reproducing
+ * `#s=` link. Shared alongside the tag so a viewer can find the studio. */
+ function studioUrl() {
+ return `${location.origin}${location.pathname}`;
+ }
+
+ /**
+ * Share the current look as a *picture*, not as a link.
+ *
+ * Neither X nor Facebook's web dialog can attach an image, so the picture goes
+ * through the OS share sheet (`navigator.share` with files) wherever the browser
+ * has one - a single tap on a phone, and it can go to any app. Where there is no
+ * share sheet (most desktops), the PNG is put on the clipboard and the network's
+ * compose window is opened with the tag already typed, so the picture only has to
+ * be pasted. The tag *and* the studio link always travel together: the user asks
+ * for both to be kept when the picture is shared.
+ */
+ async function shareImage(network) {
+ let shot;
+ try {
+ shot = await captureImage({ scale: 1.5, transparent: false });
+ } catch (error) {
+ console.error(error);
+ toast('画像を作れませんでした');
+ return;
+ }
+ const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' });
+
+ if (!network && navigator.canShare?.({ files: [file] })) {
+ try {
+ await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() });
+ return;
+ } catch (error) {
+ if (error?.name === 'AbortError') return;
+ console.error(error);
+ }
+ }
+
+ // No share sheet (or a named network): put the picture on the clipboard and
+ // open that network's compose window with the tag and the studio link filled in.
+ let copied = false;
+ try {
+ await exporter.copyCanvasToClipboard(shot.canvas);
+ copied = true;
+ } catch (error) {
+ console.error(error);
+ }
+ const studio = studioUrl();
+ const target = network === 'facebook'
+ ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}`
+ : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`;
+ window.open(target, '_blank');
+ toast(copied
+ ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)'
+ : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)');
+ }
+
+ async function toggleCamera(on) {
+ if (!on) {
+ backdrop.stopCamera();
+ applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true });
+ return;
+ }
+ const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment');
+ if (!result?.ok) {
+ toast(result?.reason ?? 'カメラを使えません');
+ return;
+ }
+ applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true });
+ toast('カメラの映像を背景にしました(そのまま写真に撮れます)');
+ }
+
+ function addProp(kind) {
+ const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ state.props.items = [...(state.props.items ?? []), {
+ kind,
+ x: def.x ?? 0,
+ y: def.y ?? 0,
+ z: def.z ?? 0,
+ rotX: def.rotX ?? 0,
+ rotY: def.rotY ?? 0,
+ rotZ: def.rotZ ?? 0,
+ scale: def.scale ?? 1,
+ }];
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ async function addStoryPanel() {
+ const index = (state.story.panels?.length ?? 0) + 1;
+ // A counter, not the array length: removing a panel and then adding another
+ // used to hand out an id that was already taken, which confused the list.
+ storySeq += 1;
+ const id = `p${storySeq}`;
+ state.story.panels = [...(state.story.panels ?? []), {
+ id,
+ label: `コマ${index}`,
+ pose: JSON.parse(JSON.stringify(state.pose)),
+ face: JSON.parse(JSON.stringify(state.face)),
+ // The 擬音 stamps as well, so a panel comes back with everything it showed.
+ gion: JSON.parse(JSON.stringify(state.gion)),
+ // The camera as well as the pose: recalling a panel should put you back
+ // where you were looking when you recorded it.
+ view: JSON.parse(JSON.stringify(state.view)),
+ }];
+ applyState({}, { scope: 'gion', sync: true });
+
+ // Then photograph what was just recorded, so the list shows the shot rather
+ // than only its name.
+ try {
+ const shot = await captureImage({ scale: 0.35, transparent: false });
+ storyThumbs.set(id, {
+ version: (storyThumbs.get(id)?.version ?? 0) + 1,
+ url: thumbDataUrl(shot.canvas),
+ });
+ toast(`コマ${index}を追加しました`);
+ } catch (error) {
+ console.warn('[bluebey] story thumbnail failed', error);
+ toast(`コマ${index}を追加しました(プレビューは作れませんでした)`);
+ }
+ app.panel?.sync();
+ }
+
+ /** Shrink a capture down to a thumbnail data URL for the panel list. */
+ function thumbDataUrl(source, width = 168) {
+ const scale = width / Math.max(1, source.width);
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.max(1, Math.round(source.width * scale));
+ canvas.height = Math.max(1, Math.round(source.height * scale));
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
+ return canvas.toDataURL('image/png');
+ }
+
+ /**
+ * A number in the corner of a comic panel, for sheets whose reading order is
+ * not obvious.
+ */
+ function drawPanelNumber(ctx, number, x, y, cellW, cellH) {
+ const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075));
+ const margin = radius * 0.7;
+ const cx = x + margin + radius;
+ const cy = y + margin + radius;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
+ ctx.fill();
+ ctx.lineWidth = Math.max(2, radius * 0.14);
+ ctx.strokeStyle = '#3f2b52';
+ ctx.stroke();
+ ctx.fillStyle = '#3f2b52';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`;
+ ctx.fillText(String(number), cx, cy + radius * 0.04);
+ ctx.restore();
+ }
+
+ /**
+ * まんが: replay the panels, capture each one, and hand back a framed sheet.
+ */
+ async function saveStory({ scale = 2 } = {}) {
+ const panels = state.story?.panels ?? [];
+ if (!panels.length) {
+ toast('コマがありません。「今の状態をコマに追加」で作ってください');
+ return;
+ }
+ const saved = JSON.parse(JSON.stringify(state));
+ const captures = [];
+ suspendHistory = true;
+ try {
+ for (const panel of panels) {
+ const pose = panel.pose ?? {};
+ state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] };
+ state.face = JSON.parse(JSON.stringify(saved.face));
+ applyPatch(state.face, panel.face ?? {});
+ // The camera the panel was framed with, so a comic keeps its angles.
+ if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view));
+ refresh('all');
+ // Two frames: one to apply the pose, one to draw it.
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ captures.push(await captureImage({ scale, transparent: false }));
+ }
+ } finally {
+ suspendHistory = false;
+ applyState(saved, { full: true, sync: true });
+ }
+
+ const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2));
+ const gap = state.story?.gap ?? 12;
+ const padding = state.story?.padding ?? 20;
+ const rows = Math.ceil(captures.length / columns);
+ const cellW = Math.max(...captures.map((item) => item.width));
+ const cellH = Math.max(...captures.map((item) => item.height));
+
+ // Once the sheet is more than one column wide, the reading order stops being
+ // obvious from the layout alone, so the panels are numbered. A single column
+ // (or two panels) reads top to bottom without help.
+ const numbered = columns >= 2 && captures.length >= 3;
+
+ // A manga page reads by its panel frames. They are drawn inside each cell so
+ // the artwork keeps its full size, and are thick enough to survive the 1×
+ // export.
+ const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012));
+
+ const sheet = document.createElement('canvas');
+ sheet.width = padding * 2 + cellW * columns + gap * (columns - 1);
+ sheet.height = padding * 2 + cellH * rows + gap * (rows - 1);
+ const ctx = sheet.getContext('2d');
+ ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff';
+ ctx.fillRect(0, 0, sheet.width, sheet.height);
+ captures.forEach((capture, index) => {
+ const x = padding + (index % columns) * (cellW + gap);
+ const y = padding + Math.floor(index / columns) * (cellH + gap);
+ ctx.drawImage(capture.canvas, x, y, cellW, cellH);
+ ctx.lineWidth = frame;
+ ctx.strokeStyle = '#2a1e33';
+ ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame);
+ if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH);
+ });
+
+ const stamp = exporter.timestamp();
+ exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`);
+ toast(`まんがを書き出しました(${captures.length}コマ)`);
+ }
+
+ /** A shared link restores the whole look; otherwise this is the start point. */
+ async function loadFromHash() {
+ // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。
+ // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。
+ const query = new URLSearchParams(location.search).get('s');
+ const hash = location.hash.replace(/^#/, '');
+ const encoded = query ?? new URLSearchParams(hash).get('s');
+ if (!encoded) return false;
+ const decoded = await decodeState(encoded);
+ if (!decoded?.state) return false;
+ suspendHistory = true;
+ try {
+ applyState(decoded.state, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ return true;
+ }
+
+ function installTopbar() {
+ // The panel itself is visible from the start, but each of its sections starts
+ // collapsed (see `section()` in src/ui.js): the panel reads as a short list of
+ // headings, and you open the one you want instead of scrolling past twelve.
+ resizeViewport();
+
+ // AR (カメラ=AR) get a camera-app style shutter over the viewport, so a photo
+ // is one tap away. It is a DOM layer over the canvas, so it never lands in the
+ // export; its visibility follows the background mode (see `applyBackdrop`).
+ const shutter = document.createElement('button');
+ shutter.type = 'button';
+ shutter.id = 'ar-shutter';
+ shutter.className = 'ar-shutter';
+ shutter.title = '写真を撮る';
+ shutter.setAttribute('aria-label', '写真を撮る');
+ shutter.hidden = true;
+ shutter.addEventListener('click', () => { savePNG(); });
+ stage.append(shutter);
+ app.arShutter = shutter;
+
+ document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG());
+ document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG());
+ document.getElementById('btn-help')?.addEventListener('click', toggleHelp);
+ document.getElementById('btn-panel')?.addEventListener('click', togglePanel);
+ document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); });
+ const help = document.getElementById('help');
+ help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); });
+
+ // --- the mouse on the character ----------------------------------------
+ // Dragging the model slides it across the view; holding Shift and dragging
+ // turns it around. Bone posing lives in the panel, so the mouse never grabs a
+ // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over
+ // the view, so a tap on the view first just dismisses the sheet.
+ const dragRay = new THREE.Raycaster();
+ const dragNdc = new THREE.Vector2();
+ const dragHitAt = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ dragNdc.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ dragRay.setFromCamera(dragNdc, view.camera);
+ const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body]
+ .filter((mesh) => mesh?.visible);
+ return dragRay.intersectObjects(targets, false)[0] ?? null;
+ };
+ // World units per screen pixel, so the model keeps up with the cursor.
+ const dragScale = () => {
+ const camera = view.camera;
+ const height = Math.max(1, canvas.clientHeight);
+ if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height;
+ const distance = camera.position.distanceTo(view.controls.target);
+ return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height;
+ };
+ let modelDrag = null;
+
+ canvas.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0 || event.target !== canvas) return;
+ if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) {
+ togglePanel();
+ return;
+ }
+ if (!dragHitAt(event)) return;
+ const root = state.pose.root ?? [0, 0, 0];
+ const master = state.pose.bones?.master ?? [0, 0, 0];
+ modelDrag = {
+ pointerId: event.pointerId,
+ spin: event.shiftKey,
+ x: event.clientX,
+ y: event.clientY,
+ root: [...root],
+ masterY: master[1] ?? 0,
+ };
+ view.controls.enabled = false;
+ stage.style.cursor = 'grabbing';
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ }
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (!modelDrag || event.pointerId !== modelDrag.pointerId) return;
+ const dx = event.clientX - modelDrag.x;
+ const dy = event.clientY - modelDrag.y;
+ if (modelDrag.spin) {
+ const bones = { ...(state.pose.bones ?? {}) };
+ const master = bones.master ?? [0, 0, 0];
+ bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]];
+ state.pose.bones = bones;
+ } else {
+ const k = dragScale();
+ state.pose.root = [
+ round2(modelDrag.root[0] + dx * k),
+ round2(modelDrag.root[1] - dy * k),
+ modelDrag.root[2],
+ ];
+ }
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ });
+
+ const endModelDrag = (event) => {
+ if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return;
+ modelDrag = null;
+ view.controls.enabled = true;
+ stage.style.cursor = '';
+ if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } }
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ };
+ canvas.addEventListener('pointerup', endModelDrag);
+ canvas.addEventListener('pointercancel', endModelDrag);
+ }
+
+ function togglePanel() {
+ document.body.classList.toggle('panel-hidden');
+ resizeViewport();
+ }
+
+ // The panel is a side column on a wide screen and a bottom sheet on a small
+ // one, where it starts closed so the 3D view fills the screen (see the
+ // small-screen rules in src/style.css). When the window is resized across that
+ // breakpoint, match the layout the other side expects instead of leaving the
+ // panel stuck in the previous state.
+ const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ narrowScreenLayout.addEventListener('change', (event) => {
+ document.body.classList.toggle('panel-hidden', event.matches);
+ resizeViewport();
+ });
+
+ function installKeys() {
+ window.addEventListener('keydown', (event) => {
+ // Undo/redo are the only shortcuts that need a modifier, so they are dealt
+ // with before the "no modifiers" rule below.
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
+ const combo = event.key.toLowerCase();
+ if (combo === 'z' || combo === 'y') {
+ event.preventDefault();
+ restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo');
+ return;
+ }
+ }
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
+ const target = event.target;
+ if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return;
+ switch (event.key) {
+ case 's': case 'S': savePNG(); break;
+ case 'c': case 'C': copyPNG(); break;
+ case 'r': case 'R': app.actions.resetPose(); break;
+ case 'g': case 'G': {
+ const visible = !rig.helper.visible;
+ rig.setGizmoVisible(visible);
+ toast(visible ? 'ギズモを表示' : 'ギズモを隠しました');
+ app.needsRender = true;
+ break;
+ }
+ case ' ': event.preventDefault(); app.actions.toggleAnimation(); break;
+ case '?': toggleHelp(); break;
+ case 'Tab': event.preventDefault(); togglePanel(); break;
+ case '1': setCameraPreset('front'); break;
+ case '2': setCameraPreset('threeQuarter'); break;
+ case '3': setCameraPreset('side'); break;
+ case '4': setCameraPreset('back'); break;
+ case '5': setCameraPreset('top'); break;
+ default: break;
+ }
+ });
+ }
+
+}
+
+function toggleHelp(force) {
+ const help = document.getElementById('help');
+ if (help) help.hidden = force === undefined ? !help.hidden : !force;
+}
+
+/** A persistent message for problems that need the user to do something. */
+function showNotice(text) {
+ let node = document.getElementById('notice');
+ if (!node) {
+ node = document.createElement('div');
+ node.id = 'notice';
+ document.body.append(node);
+ }
+ node.textContent = text;
+ node.hidden = false;
+}
+
+/* ------------------------------------------------------------------- camera */
+
+/**
+ * Orbiting camera with both a perspective and an orthographic projection.
+ * Only one of them is enabled at a time; switching copies the framing across so
+ * the model does not jump.
+ */
+class ViewRig {
+ constructor(domElement, onChange) {
+ this.domElement = domElement;
+ this.onChange = onChange;
+ this.projection = 'persp';
+ this.controlsChanged = false;
+
+ this.cameras = {
+ // A tight near/far keeps depth precision high, which matters because the
+ // outline hull is only a few hundredths of a unit away from the surface.
+ // The far plane has to sit well past the furthest the camera may go, or
+ // zooming all the way out slices the character on it - which is exactly
+ // what the old `far = 120` did, because the zoom limit was also 120.
+ persp: new THREE.PerspectiveCamera(30, 1, 1, 2000),
+ ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000),
+ };
+ this.controlsMap = {};
+ for (const [key, camera] of Object.entries(this.cameras)) {
+ const controls = new OrbitControls(camera, domElement);
+ controls.enableDamping = false;
+ controls.enablePan = true;
+ controls.minDistance = 1;
+ controls.maxDistance = 400;
+ controls.addEventListener('change', () => {
+ this.controlsChanged = true;
+ this.onChange?.();
+ });
+ this.controlsMap[key] = controls;
+ }
+ this.controlsMap.ortho.enabled = false;
+ this.camera = this.cameras.persp;
+ this.controls = this.controlsMap.persp;
+ this.target = new THREE.Vector3(0, 1, 0);
+ this.autoDistance = 12;
+ // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes
+ // the camera's actual height back into it - so the framing `frame()` chose
+ // needs its own field to measure the zoom against (see `zoomFactor`).
+ this.orthoHeight = 6;
+ this.autoOrthoHeight = 6;
+ this.resize();
+ }
+
+ /** Fit the model into view. */
+ frame(size, targetY) {
+ this.target.set(0, targetY, 0);
+ for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target);
+ const fov = this.cameras.persp.fov * Math.PI / 180;
+ const heightDistance = size.y / (2 * Math.tan(fov / 2));
+ const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect));
+ this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45;
+ this.orthoHeight = size.y * 1.5;
+ this.autoOrthoHeight = this.orthoHeight;
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance);
+ }
+ this.resize();
+ }
+
+ get aspect() {
+ const width = this.domElement.clientWidth || 1;
+ const height = this.domElement.clientHeight || 1;
+ return width / height;
+ }
+
+ resize() {
+ const width = this.domElement.clientWidth || window.innerWidth;
+ const height = this.domElement.clientHeight || window.innerHeight;
+ const aspect = width / height;
+ this.cameras.persp.aspect = aspect;
+ this.cameras.persp.updateProjectionMatrix();
+ this.setOrthoHeight(this.orthoHeight);
+ }
+
+ setOrthoHeight(height) {
+ const ortho = this.cameras.ortho;
+ const halfHeight = height / 2;
+ const halfWidth = halfHeight * this.aspect;
+ ortho.left = -halfWidth;
+ ortho.right = halfWidth;
+ ortho.top = halfHeight;
+ ortho.bottom = -halfHeight;
+ ortho.zoom = 1;
+ ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * How large the character looks now, relative to the auto-fit framing.
+ *
+ * 1 means "the default framing", which is where the panel's 大きさ sliders sit
+ * and so where the screen-space outline's pixel width is written against. The
+ * two projections scale differently - a perspective camera shows 1/distance as
+ * much per world unit, an orthographic one 1/height, and OrbitControls zooms
+ * the latter with `camera.zoom` rather than by moving it - so each is
+ * normalised by its own auto-fit value.
+ */
+ zoomFactor() {
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001);
+ return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1;
+ }
+ const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target);
+ return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1;
+ }
+
+ setProjection(kind) {
+ if (kind === this.projection) return;
+ const previous = this.camera;
+ const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp;
+ next.position.copy(previous.position);
+ next.quaternion.copy(previous.quaternion);
+ this.controlsMap[this.projection].enabled = false;
+ this.projection = kind;
+ this.camera = next;
+ this.controls = this.controlsMap[kind];
+ this.controls.enabled = true;
+ this.controls.target.copy(this.target);
+ this.resize();
+ this.onCameraChange?.(next, this.controls);
+ this.controlsChanged = true;
+ this.onChange?.();
+ }
+
+ /** Apply the serialisable view state (azimuth/polar/zoom). */
+ apply(viewState, size) {
+ const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance;
+ const targetY = viewState.targetY || this.target.y;
+ this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0);
+ const spherical = new THREE.Spherical(
+ distance,
+ clamp(viewState.polar, 1, 179) * Math.PI / 180,
+ viewState.azimuth * Math.PI / 180,
+ );
+ const offset = new THREE.Vector3().setFromSpherical(spherical);
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.copy(this.target).add(offset);
+ camera.lookAt(this.target);
+ }
+ if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight);
+ else if (size) this.setOrthoHeight(this.orthoHeight);
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.target.copy(this.target);
+ controls.update();
+ }
+ this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom;
+ this.cameras.persp.updateProjectionMatrix();
+ this.cameras.ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * Write where the camera actually is back into the serialisable view state.
+ *
+ * WHY: the mouse orbit and pan move the `controls`, not the state. Without this
+ * the state kept the last camera the *panel* set, so any refresh of the view
+ * snapped the camera back to it - which is what made picking a background jump
+ * the camera. Keeping the two in step also means a shared link carries the
+ * camera you framed.
+ */
+ captureInto(viewState) {
+ if (!viewState || typeof viewState !== 'object') return;
+ const target = this.controls?.target ?? this.target;
+ const spherical = new THREE.Spherical().setFromVector3(
+ new THREE.Vector3().subVectors(this.camera.position, target),
+ );
+ viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100;
+ viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100;
+ viewState.distance = Math.round(spherical.radius * 1000) / 1000;
+ viewState.targetX = Math.round(target.x * 1000) / 1000;
+ viewState.targetY = Math.round(target.y * 1000) / 1000;
+ viewState.targetZ = Math.round(target.z * 1000) / 1000;
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000;
+ }
+ }
+
+ setAutoRotate(enabled, speed) {
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.autoRotate = enabled;
+ controls.autoRotateSpeed = speed;
+ }
+ }
+}
+
+/**
+ * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`).
+ *
+ * A clamp keeps a very deep zoom from turning the line into either a smear or
+ * nothing at all, which is what the linear factor would do at the extremes of
+ * the 大きさ slider.
+ */
+const OUTLINE_ZOOM_MIN = 0.25;
+const OUTLINE_ZOOM_MAX = 3;
+
+/**
+ * The screen-space outline's radius, in pixels of the label buffer.
+ *
+ * The body's lines are inverted hulls, i.e. an offset in *world* units, so they
+ * thicken as the character grows on screen and thin as it shrinks. This pass
+ * works in pixels instead, so left alone its lines - the waist leaves and the
+ * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy
+ * beside the body the moment you pull back. Scaling the radius by the rig's
+ * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying
+ * the width at the auto-fit framing (so the slider keeps its meaning).
+ *
+ * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the
+ * preview's buffer is `quality`x the CSS size (and `quality` itself follows the
+ * display density and the automatic reductions), and an export renders the label
+ * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width,
+ * so `outlinePixels` reads as CSS pixels whichever buffer it lands in.
+ */
+function outlineRadiusFor(view, state, extra = 1) {
+ const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX);
+ return (state.render.outlinePixels ?? 2) * zoom * extra;
+}
+
+/* ----------------------------------------------------------------- scenery */
+
+function makeEnvironment(renderer) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 32;
+ const ctx = canvas.getContext('2d');
+ const gradient = ctx.createLinearGradient(0, 0, 0, 32);
+ gradient.addColorStop(0, '#ffffff');
+ gradient.addColorStop(0.5, '#ece7fa');
+ gradient.addColorStop(1, '#b9aade');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 64, 32);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.mapping = THREE.EquirectangularReflectionMapping;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const generator = new THREE.PMREMGenerator(renderer);
+ const environment = generator.fromEquirectangular(texture).texture;
+ texture.dispose();
+ generator.dispose();
+ return environment;
+}
+
+/** Round to 2 decimals: the precision the panel's prop sliders show. */
+const round2 = (value) => Math.round(value * 100) / 100;
+
+const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+
+export function backgroundOf(viewState) {
+ if (viewState.background === 'transparent') return { mode: 'transparent' };
+ // A preset, a loaded photo, a drawn effect line or the phone's camera all
+ // live in a DOM layer behind the WebGL canvas, so the renderer is left clear
+ // and the exports paint the bitmap into the picture themselves (see
+ // drawBackdropInto in main.js).
+ if (viewState.background === 'preset' || viewState.background === 'image'
+ || viewState.background === 'effect' || viewState.background === 'camera') {
+ return { mode: 'transparent' };
+ }
+ return { mode: 'solid', color: viewState.backgroundColor };
+}
+
+/* ------------------------------------------------------------------- errors */
+
+function setLoadingProgress(ratio) {
+ const node = document.getElementById('loading-text');
+ if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`;
+}
+
+function hideLoading() {
+ const node = document.getElementById('loading');
+ if (node) node.classList.add('done');
+}
+
+function showLoadError(error) {
+ const text = document.getElementById('loading-text');
+ const detail = document.getElementById('loading-error');
+ if (text) text.textContent = '読み込みに失敗しました。';
+ if (detail) {
+ detail.textContent = `${error?.message ?? error}\n\n`
+ + 'このページはローカルサーバー経由で開く必要があります。\n'
+ + 'bluebey-studio フォルダで python serve.py を実行し、\n'
+ + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。';
+ }
+}
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+const clamp01 = (value) => clamp(value, 0, 1);
diff --git a/public/bluebey-studio/src/model.js b/public/bluebey-studio/src/model.js
new file mode 100644
index 0000000..3925e85
--- /dev/null
+++ b/public/bluebey-studio/src/model.js
@@ -0,0 +1,283 @@
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+/**
+ * Loads bluebey.glb and works out everything the studio needs to know about it:
+ * the rig, which meshes are the face overlays, and the original hand-drawn
+ * textures that ship inside the file.
+ *
+ * The GLB layout (materials carry the reliable names; meshes are named after
+ * Blender objects):
+ * body parts Body, Foots, Leaf, Vein, LeftHand, RightHand, Nose
+ * overlays eyes-* (eight hand-drawn variants, one texture each) and Mouth
+ * face plates eyes-plate / mouth-plate, the big shells the studio draws on
+ * hair plate hair-plate, a shell that wraps right round the head (optional)
+ *
+ * The two face plates are what the artwork is painted onto. They are the front
+ * half of the body, so wherever the drawing lands there is a surface to carry
+ * it - the old hand-drawn planes only covered a patch of the head, which clipped
+ * the bottom of a deep smile and most of a teardrop. When a model has no plates
+ * (the 2022 file), the studio falls back to drawing on the hand-drawn planes.
+ */
+
+/** Hand-drawn texture variants inside the GLB, keyed by their material suffix. */
+export const EYE_VARIANTS = [
+ { key: 'opened', label: '開いた目' },
+ { key: 'closed', label: '閉じた目' },
+ { key: 'close-tight', label: 'ぎゅっと閉じ' },
+ { key: 'left-wink', label: '左ウインク' },
+ { key: 'look-up', label: '上を見る' },
+ { key: 'look-down', label: '下を見る' },
+ { key: 'look-left', label: '左を見る' },
+ { key: 'look-right', label: '右を見る' },
+];
+
+/**
+ * Friendly names for the eleven bones of the rig. Keys are lower-cased with
+ * dots removed, because three's GLTFLoader sanitises node names (`armsupport.l`
+ * arrives as `armsupportl`).
+ */
+const BONE_LABELS = {
+ master: '全身',
+ armsupportl: '左うでの付け根',
+ arml: '左うで',
+ handl: '左手先',
+ armr: '右うで',
+ handr: '右手先',
+ legsupportl: '左あしの付け根',
+ footl: '左足',
+ toel: '左つまさき',
+ legsupportr: '右あしの付け根',
+ footr: '右足',
+ toer: '右つまさき',
+};
+
+const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();
+
+/**
+ * The overlay planes hug the head, so nudge them outwards before anything else
+ * happens. They are pushed along their own normals (not away from the head
+ * centre): the mouth in particular needs to clear the head's lower surface and
+ * the ring of leaves around the base, otherwise a low or frowning mouth gets
+ * swallowed by them.
+ */
+const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004, hair: 0.008 };
+
+export async function loadModel(source, { onProgress } = {}) {
+ const loader = new GLTFLoader();
+ let gltf;
+ if (typeof source === 'string') {
+ gltf = await loader.loadAsync(source, (event) => {
+ if (onProgress && event.lengthComputable) onProgress(event.loaded / event.total);
+ });
+ } else {
+ // A File/ArrayBuffer, e.g. from drag and drop or the file picker.
+ gltf = await loader.parseAsync(source, '');
+ }
+
+ const root = gltf.scene;
+
+ // The file carries its own camera node; we drive our own.
+ const cameras = [];
+ root.traverse((object) => { if (object.isCamera) cameras.push(object); });
+ for (const camera of cameras) camera.removeFromParent();
+
+ const eyes = new Map();
+ const body = [];
+ /** The original material name of every body mesh (`blb`, `leaf`, `vein`, ...). */
+ const kinds = new Map();
+ /** The plane holding the hand-drawn mouth texture (a texture source only). */
+ let mouthOriginal = null;
+ /** The big shells the artwork is drawn onto, if the file has them. */
+ let eyePlate = null;
+ let mouthPlate = null;
+ /** The shell that wraps round the head, so hair shows from behind (optional). */
+ let hairPlate = null;
+
+ root.traverse((object) => {
+ if (!object.isMesh) return;
+ // Skinned bounds stop matching the pose, so culling would pop meshes away.
+ object.frustumCulled = false;
+ const material = Array.isArray(object.material) ? object.material[0] : object.material;
+ const materialName = (material?.name ?? '').toLowerCase();
+ if (materialName === 'eyes-plate') {
+ eyePlate = object;
+ return;
+ }
+ if (materialName === 'mouth-plate') {
+ mouthPlate = object;
+ return;
+ }
+ if (materialName === 'hair-plate') {
+ hairPlate = object;
+ return;
+ }
+ if (materialName.startsWith('eyes-')) {
+ eyes.set(materialName.slice('eyes-'.length), object);
+ return;
+ }
+ if (materialName === 'mouth') {
+ mouthOriginal = object;
+ return;
+ }
+ body.push(object);
+ kinds.set(object, materialName);
+ });
+
+ // The waist leaves - the `Leaf` blade and the `Vein` rim that shares its mesh.
+ // These are the thin, overlapping shells the inverted-hull outline cannot draw
+ // (see MODEL-GUIDE.md §5), so the screen-space pass takes them over on its own.
+ const leaves = body.filter((mesh) => kinds.get(mesh) === 'leaf' || kinds.get(mesh) === 'vein');
+
+ // The drawing surface: the big plate when present, otherwise the 2022 plane.
+ const eyeMesh = eyePlate ?? eyes.get('opened') ?? eyes.values().next().value;
+ const mouth = mouthPlate ?? mouthOriginal;
+ // The wrapping hair shell, if the model carries one (`hair-plate`).
+ const hairMesh = hairPlate;
+ // Its cylindrical UV has a seam at the back of the head. Triangles that cross
+ // it get one UV near 0 and the next near 1, so the whole texture is stretched
+ // across them - a visible streak down the back. Close the seam per triangle.
+ if (hairMesh) healCylinderSeam(hairMesh);
+ if (!eyeMesh) throw new Error('eye mesh not found in the GLB');
+ if (!mouth) throw new Error('mouth mesh not found in the GLB');
+
+ // The nose ball. It is a bump sitting on the body rather than a part with a
+ // silhouette of its own, so the outline pass treats it per style - see
+ // `outlineExclusion` in main.js.
+ const noseMesh = body.find((mesh) => (mesh.material?.name ?? '').toLowerCase() === 'nose') ?? null;
+
+ // Only the drawing surface stays visible; every other overlay is a texture
+ // source, so hide it. (`mouthOriginal` may be the drawing surface itself.)
+ for (const mesh of eyes.values()) mesh.visible = mesh === eyeMesh;
+ if (mouthOriginal && mouthOriginal !== mouth) mouthOriginal.visible = false;
+ if (mouthPlate && eyePlate) mouthPlate.visible = true;
+
+ /** @type {{ eyes: Record<string, THREE.Texture>, mouth: THREE.Texture|null }} */
+ const originals = { eyes: {}, mouth: mouthOriginal?.material?.map ?? mouth.material.map ?? null };
+ for (const [key, mesh] of eyes) {
+ if (mesh.material.map) originals.eyes[key] = mesh.material.map;
+ }
+
+ // Push the overlay planes a hair off the body surface, so the two do not
+ // z-fight. The plates are shells of the body, so the offset is tiny; a large
+ // one is what made the mouth look like it floated in profile.
+ const bodyMesh = body.find((mesh) => mesh.geometry?.attributes?.position?.count > 2000) ?? body[0];
+ const headCentre = new THREE.Box3()
+ .setFromBufferAttribute(bodyMesh.geometry.attributes.position)
+ .getCenter(new THREE.Vector3());
+ inflateOverlay(eyeMesh, headCentre, OVERLAY_INFLATE.eyes);
+ inflateOverlay(mouth, headCentre, OVERLAY_INFLATE.mouth);
+ if (hairMesh) inflateOverlay(hairMesh, headCentre, OVERLAY_INFLATE.hair);
+
+ // Recentre: put the character on the origin with its feet on the ground so the
+ // camera maths stays trivial.
+ const box = new THREE.Box3().setFromObject(root);
+ const centre = box.getCenter(new THREE.Vector3());
+ root.position.x -= centre.x;
+ root.position.z -= centre.z;
+ root.position.y -= box.min.y;
+ root.updateMatrixWorld(true);
+
+ const bounds = new THREE.Box3().setFromObject(root);
+ const size = bounds.getSize(new THREE.Vector3());
+ const middle = bounds.getCenter(new THREE.Vector3());
+
+ const skinned = [];
+ root.traverse((object) => { if (object.isSkinnedMesh && object.skeleton) skinned.push(object); });
+ const skeleton = skinned[0]?.skeleton ?? null;
+
+ const bones = [];
+ if (skeleton) {
+ for (const bone of skeleton.bones) {
+ bones.push({
+ name: bone.name,
+ label: BONE_LABELS[normalizeBoneName(bone.name)] ?? bone.name,
+ bone,
+ rest: bone.quaternion.clone(),
+ });
+ }
+ }
+
+ return {
+ gltf,
+ root,
+ size,
+ middle,
+ bounds,
+ skeleton,
+ bones,
+ parts: {
+ body,
+ kinds,
+ leaves,
+ overlays: [eyeMesh, mouth, ...(hairMesh ? [hairMesh] : [])],
+ eyeMesh,
+ mouthMesh: mouth,
+ hairMesh,
+ noseMesh,
+ hasFacePlates: Boolean(eyePlate && mouthPlate),
+ },
+ originals,
+ };
+}
+
+function inflateOverlay(mesh, centre, distance) {
+ const geometry = mesh.geometry;
+ const position = geometry?.attributes?.position;
+ if (!position) return;
+ const normal = geometry.attributes.normal;
+ const radial = new THREE.Vector3();
+ const offset = new THREE.Vector3();
+ const vertexNormal = new THREE.Vector3();
+ for (let i = 0; i < position.count; i++) {
+ radial.fromBufferAttribute(position, i).sub(centre);
+ if (radial.lengthSq() < 1e-12) continue;
+ offset.copy(radial).normalize();
+ if (normal) {
+ // The planes are double sided, so the stored normal may point either way.
+ vertexNormal.fromBufferAttribute(normal, i);
+ if (vertexNormal.lengthSq() > 1e-12) {
+ if (vertexNormal.dot(offset) < 0) vertexNormal.negate();
+ offset.copy(vertexNormal.normalize());
+ }
+ }
+ position.setXYZ(
+ i,
+ position.getX(i) + offset.x * distance,
+ position.getY(i) + offset.y * distance,
+ position.getZ(i) + offset.z * distance,
+ );
+ }
+ position.needsUpdate = true;
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+}
+
+/**
+ * Make every triangle of a cylindrical UV island sit on one side of the seam.
+ *
+ * A cylinder unwrap cuts the surface open along one line (here the back of the
+ * head). A triangle that spans the cut has its corners at u ~ 1 and u ~ 0, so
+ * interpolating them drags the whole texture across the triangle - the streak
+ * seen down the back. Wrapping the low corners up by one turn keeps each
+ * triangle local; the texture's two edges are identical (the covering is drawn
+ * right across), so the join is invisible.
+ */
+function healCylinderSeam(mesh) {
+ const source = mesh?.geometry;
+ if (!source?.attributes?.uv) return;
+ // Give every triangle its own corners first. The seam's low and high sides
+ // share vertices, so editing a shared one would drag its neighbours too; a
+ // non-indexed copy isolates each triangle and the fix cannot leak.
+ const geometry = source.index ? source.toNonIndexed() : source;
+ const uv = geometry.attributes.uv;
+ for (let i = 0; i < uv.count; i += 3) {
+ const top = Math.max(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2));
+ if (top - Math.min(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2)) <= 0.5) continue;
+ for (let k = 0; k < 3; k += 1) {
+ if (uv.getX(i + k) < top - 0.5) uv.setX(i + k, uv.getX(i + k) + 1);
+ }
+ }
+ uv.needsUpdate = true;
+ mesh.geometry = geometry;
+}
diff --git a/public/bluebey-studio/src/outline.js b/public/bluebey-studio/src/outline.js
new file mode 100644
index 0000000..127d576
--- /dev/null
+++ b/public/bluebey-studio/src/outline.js
@@ -0,0 +1,368 @@
+import * as THREE from 'three';
+
+/**
+ * A screen-space outline: the label version of "line art".
+ *
+ * The studio's other outline is an *inverted hull* - a copy of a mesh, expanded
+ * along its normals, drawn back-faces-only. That is cheap and its line is
+ * computed from the geometry, so it comes out smooth, but it cannot outline a
+ * thin closed solid: a leaf blade is 0.026 units thick and the hull expands by
+ * 0.022 in every direction, so the expanded front and back cross inside the leaf
+ * and the line breaks up. No amount of mesh fixing removes that; it is the
+ * technique.
+ *
+ * So the thin overlapping parts - the waist leaves, and the nose in the
+ * line-art styles - are handed to this pass instead, and everything else keeps
+ * its hull (see MODEL-GUIDE.md §5). This module renders the scene once into a
+ * buffer holding a *label* per pixel, then draws a full-screen pass that inks
+ * pixels where the labels disagree.
+ *
+ * ## Why labels, not coverage
+ *
+ * The parts want different lines:
+ *
+ * - a LEAF that runs into the body must NOT get a line along the intersection.
+ * Such a line reads as the leaf sinking into the body, and the original
+ * artwork does not draw one either - the leaf simply passes behind the body.
+ * - the NOSE is the opposite. It is a bump sitting on the body, so the ring
+ * where it meets the body *is* its outline. In a line drawing there is no
+ * colour to read the nose by, so that ring is the only thing that shows it.
+ *
+ * A single "coverage" cannot express that difference, so each part writes a
+ * label:
+ *
+ * 0 (paper) nothing is there
+ * BEHIND something that is merely behind: it still writes depth, so it
+ * hides what is behind *it*, but a leaf in front of it is still
+ * outlined - a foot behind the skirt does not swallow the leaf's
+ * edge. This is the default for meshes nobody claimed.
+ * SOLID the body: the leaves *emerge* from it, so a leaf must not be
+ * outlined where it meets it (that reads as the leaf sinking into
+ * the body, and the original artwork draws no line there either).
+ * LEAF a leaf
+ * NOSE the nose
+ *
+ * The label is read with `NearestFilter`, because it is an identity, not a
+ * colour: filtering it would blend two labels into a third value that means
+ * neither.
+ *
+ * ## What this pass deliberately does not do
+ *
+ * It does not look for folds, and so it needs no normals at all - only the
+ * labels. Surface shape was tried (a second buffer of filtered normals, with the
+ * fold test gated to the leaves) and taken out again: it did bring back the
+ * lines where one leaf lies over the next, but it cost a whole extra scene pass
+ * and it read as a grainy speckle across the skirt, because a leaf is thin
+ * enough (0.026) that its own rim is a 145-degree crease and the mesh arrives
+ * with those edges split into separate vertices. The recipe is written up in
+ * MODEL-GUIDE.md §5-2c if it is ever wanted back - for instance to make raised
+ * leaf veins show.
+ *
+ * Callers must keep the outline hulls out of this pass: `exclude()` takes them
+ * (see `outlineExclusion` in main.js). A hull is an expanded copy of a mesh, and
+ * this pass swaps a front-side material onto everything it sees, so a hull would
+ * paint an enlarged copy of the character's own label over all of it.
+ *
+ * A mesh that should *hide* parts of the character without being outlined itself
+ * (the invisible wall) is handed to `occlude()` instead: it is drawn for its
+ * depth alone, with a paper label, so the leaves and the nose buried behind it
+ * get no label - and therefore no line.
+ */
+
+// Taps around the pixel, averaged into a coverage. 16 gives a smooth ramp.
+const TAPS = 16;
+
+/** Written into the alpha channel; see the note above. */
+export const BEHIND_LABEL = 0.1;
+export const SOLID_LABEL = 0.4;
+export const LEAF_LABEL = 0.7;
+export const NOSE_LABEL = 1;
+
+const VERTEX = /* glsl */`
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ // The quad is already in clip space, so no camera maths is involved.
+ gl_Position = vec4(position.xy, 0.0, 1.0);
+ }
+`;
+
+const FRAGMENT = /* glsl */`
+ uniform sampler2D uLabels;
+ uniform vec2 uTexel;
+ uniform vec3 uColor;
+ uniform float uRadius;
+ varying vec2 vUv;
+
+ const float TAU = 6.28318530718;
+
+ // The labels, read back out of the alpha channel (see the top of this file).
+ // They are exact values, not a gradient, so the tests are simple comparisons.
+ //
+ // "paper" means "does not block a line": the untouched background (0) and the
+ // parts that are merely behind (0.1). Only the body (0.4) blocks one.
+ float paper(float a) { return 1.0 - step(0.2, a); }
+ float drawn(float a) { return step(0.6, a); }
+ float nose(float a) { return step(0.85, a); }
+
+ void main() {
+ vec4 here = texture2D(uLabels, vUv);
+ float ink = 0.0;
+
+ for (int i = 0; i < ${TAPS}; i++) {
+ float angle = (float(i) / float(${TAPS})) * TAU;
+ vec2 offset = vec2(cos(angle), sin(angle)) * uTexel * uRadius;
+ vec4 there = texture2D(uLabels, vUv + offset);
+
+ float edge = 0.0;
+ // 1. a drawn part against the paper. A leaf against the *body* fires
+ // nothing here, which is the point: the leaf simply passes behind it.
+ edge = max(edge, max(drawn(here.a) * paper(there.a),
+ drawn(there.a) * paper(here.a)));
+ // 2. the nose against anything that is not the nose, so its whole ring -
+ // including the part against the body - is inked.
+ edge = max(edge, abs(nose(here.a) - nose(there.a)));
+
+ ink += edge;
+ }
+
+ // Averaging the taps turns the flag into a coverage, and that is what takes
+ // the steps out of the line: a pixel half over an edge gets half the ink, so
+ // the line gets soft edges instead of landing on the pixel grid. It also
+ // reads lighter than a hard band of the same width, which is what makes it
+ // sit next to the hull's line without looking heavier.
+ ink /= float(${TAPS});
+ // The ramp starts at about three taps out of sixteen rather than at one, so an
+ // isolated tap is not enough to ink a pixel; a real edge has half the taps
+ // crossing it, so asking for a few costs nothing there.
+ float line = smoothstep(0.19, 0.45, ink);
+ if (line < 0.02) discard;
+ gl_FragColor = vec4(uColor, line);
+ }
+`;
+
+export function createScreenOutline({ renderer, scene, camera, width = 1280, height = 800 }) {
+ const target = new THREE.WebGLRenderTarget(width, height, {
+ // The label is an identity, not a shade: filtering would blend two labels into
+ // a third value that means neither of them. The softness of the line comes
+ // from spreading the taps in the shader, not from blurring this buffer.
+ minFilter: THREE.NearestFilter,
+ magFilter: THREE.NearestFilter,
+ depthBuffer: true,
+ // The alpha channel carries the label, so it must not be filled in.
+ format: THREE.RGBAFormat,
+ });
+ target.texture.generateMipmaps = false;
+
+ /**
+ * The label each part writes into the alpha channel.
+ *
+ * `transparent: true` keeps three.js from defining `OPAQUE`, which would force
+ * the alpha to 1, and `blending: NoBlending` makes the fragment *replace* the
+ * pixel instead of blending into it, so the labels stay exact. `depthWrite`
+ * stays on, which is what hides the parts that are behind something else.
+ */
+ const labelMaterials = new Map();
+ function labelMaterialFor(label) {
+ let material = labelMaterials.get(label);
+ if (material) return material;
+ material = new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: label,
+ blending: THREE.NoBlending,
+ depthWrite: true,
+ });
+ material.customProgramCacheKey = () => `bluebey-outline-label-${label}`;
+ labelMaterials.set(label, material);
+ return material;
+ }
+
+ const uniforms = {
+ uLabels: { value: target.texture },
+ uTexel: { value: new THREE.Vector2(1 / width, 1 / height) },
+ uColor: { value: new THREE.Color('#2a1e33') },
+ uRadius: { value: 1.4 },
+ };
+ const material = new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: VERTEX,
+ fragmentShader: FRAGMENT,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ toneMapped: false,
+ });
+
+ // A single quad in clip space, with the camera taken out of the equation.
+ const quadScene = new THREE.Scene();
+ const quadCamera = new THREE.Camera();
+ quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
+
+ /** Objects that must not appear in the outline (ground, shadows, gizmos). */
+ const hidden = [];
+ /** Meshes whose material (and render order) is borrowed for the label pass. */
+ const swapped = [];
+ const exclusion = new Set();
+
+ /**
+ * Meshes that only *occlude* in the label pass (the 見えない壁).
+ *
+ * They are drawn with a paper label and both sides, so their depth hides
+ * whatever is behind them: a leaf or the nose buried in the wall then gets no
+ * label at all, and so no line. Their own silhouette inks nothing either,
+ * because paper against paper is not an edge - which is what keeps the wall
+ * itself invisible instead of drawing a rectangle.
+ */
+ const occluders = new Set();
+ let occluderMaterial = null;
+
+ function occluderMaterialFor() {
+ if (!occluderMaterial) {
+ occluderMaterial = new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: 0, // paper: it never reads as a drawn part
+ blending: THREE.NoBlending,
+ depthWrite: true,
+ side: THREE.DoubleSide, // the wall can be seen from either side
+ });
+ }
+ return occluderMaterial;
+ }
+
+ /** Objects to draw in the label pass for their depth alone (see `occluders`). */
+ function occlude(objects) {
+ occluders.clear();
+ for (const object of objects) if (object) occluders.add(object);
+ }
+
+ /**
+ * When set, these meshes write the given label and everything else writes
+ * BEHIND. Without it the whole scene shares one label.
+ *
+ * @type {Map<THREE.Object3D, number>|null}
+ */
+ let focus = null;
+
+ function setSize(nextWidth, nextHeight) {
+ const w = Math.max(1, Math.round(nextWidth));
+ const h = Math.max(1, Math.round(nextHeight));
+ if (target.width === w && target.height === h) return;
+ target.setSize(w, h);
+ uniforms.uTexel.value.set(1 / w, 1 / h);
+ }
+
+ /** Layers/objects to leave out of the label pass (ground, shadow, gizmo, hulls). */
+ function exclude(objects) {
+ exclusion.clear();
+ for (const object of objects) if (object) exclusion.add(object);
+ }
+
+ /**
+ * Hand the pass a split: these meshes get this label, and everything else is
+ * drawn as BEHIND - present, so it still hides what is behind it, but not
+ * blocking a leaf's outline. Callers should therefore name every part that a
+ * leaf must not be outlined against (the body) as well as the outlined ones
+ * (the leaves, the nose).
+ *
+ * `null` puts the whole scene back on one label.
+ *
+ * @param {Array<[THREE.Object3D, number]>|null} parts
+ */
+ function only(parts) {
+ focus = parts ? new Map(parts) : null;
+ }
+
+ /**
+ * Draw one frame. `baseRender` renders the scene the normal way; it is called
+ * between the label pass and the ink so the ink lands on top of it.
+ */
+ function render(baseRender, options = {}) {
+ const enabled = options.enabled !== false;
+ if (!enabled) {
+ baseRender();
+ return;
+ }
+
+ uniforms.uColor.value.set(options.color ?? '#2a1e33');
+ uniforms.uRadius.value = Math.max(0.6, options.radius ?? 1.4);
+
+ // --- 1. the labels pass --------------------------------------------
+ const previousOverride = scene.overrideMaterial;
+ const previousClear = renderer.getClearColor(new THREE.Color());
+ const previousAlpha = renderer.getClearAlpha();
+ hidden.length = 0;
+ swapped.length = 0;
+ const hide = (object) => {
+ if (object.visible) {
+ hidden.push(object);
+ object.visible = false;
+ }
+ };
+ for (const object of exclusion) hide(object);
+
+ if (focus) {
+ // A per-mesh material, so the meshes outside `focus` can occlude without
+ // contributing a line. (`overrideMaterial` would put one label on all of
+ // them, and a leaf ending on a foot would then be read the same way as a
+ // leaf ending on the body.)
+ //
+ // The occluders are also pushed to the front of the draw order. Every body
+ // mesh sits at the same origin, so three.js would otherwise fall back to
+ // insertion order and could draw a far-side leaf *before* the body that
+ // hides it - and writing a label cannot erase what is already in the
+ // buffer, only stop it being drawn. Drawn first, the depth test does it.
+ scene.traverse((object) => {
+ if (!object.isMesh || !object.visible) return;
+ swapped.push([object, object.material, object.renderOrder]);
+ if (occluders.has(object)) {
+ // Depth only, drawn first: the wall hides the labels behind it.
+ object.material = occluderMaterialFor();
+ object.renderOrder = -1;
+ return;
+ }
+ const label = focus.get(object) ?? BEHIND_LABEL;
+ object.material = labelMaterialFor(label);
+ if (label < 0.6) object.renderOrder = -1;
+ });
+ scene.overrideMaterial = null;
+ } else {
+ // No split given: nothing is outlined, so no label may read as "drawn".
+ scene.overrideMaterial = labelMaterialFor(SOLID_LABEL);
+ }
+
+ renderer.setRenderTarget(target);
+ renderer.setClearColor(0x000000, 0);
+ renderer.clear(true, true, false);
+ renderer.render(scene, options.camera ?? camera);
+
+ renderer.setRenderTarget(null);
+ scene.overrideMaterial = previousOverride;
+ for (const object of hidden) object.visible = true;
+ for (const [object, swappedMaterial, renderOrder] of swapped) {
+ object.material = swappedMaterial;
+ object.renderOrder = renderOrder;
+ }
+ renderer.setClearColor(previousClear, previousAlpha);
+
+ // --- 2. the scene itself -------------------------------------------
+ baseRender();
+
+ // --- 3. the ink, straight over the top -----------------------------
+ renderer.autoClear = false;
+ renderer.render(quadScene, quadCamera);
+ renderer.autoClear = true;
+ }
+
+ function dispose() {
+ target.dispose();
+ for (const material of labelMaterials.values()) material.dispose();
+ labelMaterials.clear();
+ material.dispose();
+ occluderMaterial?.dispose();
+ for (const child of quadScene.children) child.geometry.dispose();
+ }
+
+ return { render, setSize, exclude, occlude, only, uniforms, target, material, dispose };
+}
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
new file mode 100644
index 0000000..a6e971d
--- /dev/null
+++ b/public/bluebey-studio/src/panel.js
@@ -0,0 +1,2506 @@
+import {
+ h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
+ colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon,
+} from './ui.js';
+import {
+ FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch,
+} from './presets.js';
+import { STYLE_DEFS } from './styles.js';
+import { ENVIRONMENTS } from './look.js';
+import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
+import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js';
+import { HAT_LIBRARY } from './hats.js';
+import {
+ GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
+} from './gion.js';
+
+/**
+ * A small icon for each section heading, so the list a tab shows can be scanned
+ * at a glance. Keyed by the section title; a title with no icon just shows text.
+ */
+const SECTION_ICONS = {
+ 'ポーズ例': 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ '全身とボーンのスライダー': 'M4 7h16M4 12h16M4 17h16M8 7h.01M15 12h.01M11 17h.01',
+ '操作': 'M7 4l10 6-4.3 1.3L10.8 16 7 4Z',
+ 'うごき': 'M8 5l10 7-10 7V5Z',
+ 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z',
+ '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3',
+ '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4',
+ '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0',
+ '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z',
+ 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z',
+ '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01',
+ '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5',
+ '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8',
+ '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01',
+ '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01',
+ 'まんが': 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
+ '共有': 'M8.2 10.6a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 6.4a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 20.8a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM10 10.4l5-2.6M10 13.6l5 2.6',
+ 'テクスチャの下地を書き出す': 'M4 4h16v16H4zM4 9.3h16M4 14.7h16M9.3 4v16M14.7 4v16',
+ '設定': 'M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8ZM12 2.6v2.4M12 19v2.4M2.6 12H5M19 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7',
+};
+
+/** `section`, with the heading icon looked up from its title. */
+const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options });
+
+/** Fallbacks for the fields an older settings file may predate. */
+const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 };
+
+/**
+ * The licence reminder the three image-import buttons show before they open the
+ * file picker. Kept in one place so the wording can change without hunting for
+ * each button.
+ */
+const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。';
+
+/** True when the user confirmed they have the right to use the image. */
+function confirmImageLicense() {
+ return window.confirm(IMAGE_LICENSE_NOTICE);
+}
+
+/**
+ * The panel's multi-line field. The 看板 prop's lettering is the only caller, but
+ * the shape (a control row wrapping a textarea) is worth keeping in one place.
+ */
+function textArea({ label, value = '', rows = 3, onChange }) {
+ const input = h('textarea', {
+ rows,
+ style: {
+ width: '100%',
+ boxSizing: 'border-box',
+ resize: 'vertical',
+ font: 'inherit',
+ fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace',
+ fontSize: '11.5px',
+ lineHeight: '1.5',
+ padding: '6px 8px',
+ border: '1px solid #e4dff0',
+ borderRadius: '8px',
+ background: '#fff',
+ color: '#2b2433',
+ },
+ });
+ input.value = value ?? '';
+ input.addEventListener('input', () => onChange?.(input.value));
+ return {
+ el: controlRow(label, input, { wide: true }),
+ // Assigning the same text again would jump the caret to the end, so only
+ // write when the value actually differs.
+ set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; },
+ get: () => input.value,
+ };
+}
+
+/**
+ * The placed prop groups, in state order.
+ *
+ * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in
+ * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's
+ * per-item `faceScale` has to be re-applied to the freshly built mesh by the one
+ * place that knows that value - this panel. There is no direct scene reference
+ * here, so this walks up from the model's container and collects the props: they
+ * are the only objects tagged `userData.propId`, and the app adds them in order.
+ */
+function placedPropGroups(app) {
+ const scene = app.container?.parent;
+ if (!scene) return [];
+ const groups = [];
+ scene.traverse((object) => {
+ if (object.userData?.propId) groups.push(object);
+ });
+ return groups;
+}
+
+/**
+ * Builds the whole control panel and wires it to the app state.
+ *
+ * Widgets never call the heavy "apply everything" path while they are being
+ * dragged; they patch one scope and ask the app to refresh just that part.
+ */
+export function buildPanel(app, root) {
+ const state = app.state;
+ const model = app.model;
+ const rig = app.rig;
+
+ const syncers = [];
+ const boneSyncers = [];
+ const sync = () => { for (const fn of syncers) fn(); };
+ const syncBones = () => { for (const fn of boneSyncers) fn(); };
+
+ /** Replace the state and re-read every widget (used by presets and loads). */
+ const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true });
+
+ root.replaceChildren();
+
+ // --- randomising ----------------------------------------------------------
+ // Shared by 「顔をランダムに」 (face tab) and 「すべてをランダムに」 (the top bar).
+ const rnd = (min, max) => min + (max - min) * Math.random();
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
+ const randomEye = () => ({
+ open: +rnd(0.4, 1).toFixed(2),
+ lookX: +rnd(-0.35, 0.35).toFixed(2),
+ lookY: +rnd(-0.25, 0.25).toFixed(2),
+ eyeX: Math.round(rnd(-16, 16)),
+ });
+ const randomGlasses = () => {
+ const kind = pick(['glasses', 'sunglasses']);
+ const dark = kind === 'sunglasses';
+ return {
+ enabled: true,
+ kind,
+ frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']),
+ lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']),
+ lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2),
+ lensGap: Math.round(rnd(-24, 56)),
+ frameWidth: +rnd(0.6, 1.8).toFixed(2),
+ scale: +rnd(0.9, 1.15).toFixed(2),
+ offsetY: Math.round(rnd(-18, 18)),
+ tilt: Math.round(rnd(-12, 12)),
+ };
+ };
+ const randomFacePatch = () => {
+ return {
+ eyes: {
+ left: randomEye(),
+ right: randomEye(),
+ // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。
+ glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(),
+ },
+ // The mouth takes the height and the tilt too, so the face really changes.
+ mouth: {
+ smile: +rnd(-1, 1.2).toFixed(2),
+ // ときどき丸く開く口(O)にする。
+ round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0,
+ open: +rnd(0, 0.5).toFixed(2),
+ width: +rnd(0.7, 1.3).toFixed(2),
+ thickness: +rnd(0.7, 1.6).toFixed(2),
+ tilt: Math.round(rnd(-12, 12)),
+ offsetY: Math.round(rnd(-16, 16)),
+ },
+ };
+ };
+ const randomFace = () => replace({ face: randomFacePatch() }, 'face');
+ const randomAll = () => {
+ // 後ろを向く(180°)や大きく傾くポーズは避ける。見える顔が変わる範囲だけ。
+ const poses = POSE_PRESETS.filter((preset) => {
+ const m = preset.pose?.bones?.master ?? [0, 0, 0];
+ return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40;
+ });
+ const pose = pick(poses.length ? poses : POSE_PRESETS);
+ app.actions.applyPosePreset?.(pose.id);
+ // The viewer style (実写/フラット/線画), the body colours, and 左右反転 are
+ // deliberately left alone.
+ replace({ face: randomFacePatch() }, 'face');
+ sync();
+ };
+
+ // A small action bar above the tabs: undo/redo apply to every tab, so they must
+ // not be buried inside one of them. 「すべてをランダムに」 sits here too.
+ const topBar = h('div', { class: 'panel-topbar' });
+ const iconButton = (label, d, onClick) => {
+ const button = h('button', { type: 'button', class: 'icon-btn', title: label, 'aria-label': label });
+ button.append(icon(d));
+ button.addEventListener('click', onClick);
+ return button;
+ };
+ topBar.append(
+ iconButton('元に戻す', 'M4.8 11.5h9.7a5.2 5.2 0 0 1 0 10.4h-3.6M4.8 11.5 9.6 6.7M4.8 11.5 9.6 16.3', () => app.actions.undo()),
+ iconButton('やり直す', 'M19.2 11.5h-9.7a5.2 5.2 0 0 0 0 10.4h3.6M19.2 11.5 14.4 6.7M19.2 11.5 14.4 16.3', () => app.actions.redo()),
+ iconButton('すべてをランダムに', 'M7.2 9.4a2.6 2.6 0 1 1 3.9 2.3c-.9.5-1.3 1.2-1.3 2.1v.4M9.2 17.4h.01M16.8 6.8v6.1M16.8 15.6h.01', () => randomAll()),
+ );
+ root.append(topBar);
+
+ // Tabs instead of one long scroll: the panel used to be a wall of controls,
+ // and everything is easier to find this way. Each tab is a small stroke icon
+ // (its name is the tooltip and the accessible label) sitting on the top edge
+ // of the content, so the row reads as tabs rather than buttons.
+ const tabBar = tabs(root, [
+ {
+ id: 'pose',
+ label: 'ポーズ',
+ // A standing figure: head, body, arms and legs.
+ icon: 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ },
+ {
+ id: 'face',
+ label: '顔',
+ // A smiley face: an outline, two eyes and a smile.
+ icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ },
+ {
+ id: 'view',
+ label: '見た目',
+ // A painter's palette: the outline, the thumb hole and four colour dots.
+ icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01',
+ },
+ {
+ id: 'scene',
+ label: 'シーン',
+ // A framed picture: background, stage and light all sit inside the frame.
+ icon: 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ },
+ {
+ id: 'export',
+ label: '書き出し',
+ // A download arrow dropping into a tray.
+ icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6',
+ },
+ ]);
+ const tabPose = tabBar.panels.pose;
+ const tabFace = tabBar.panels.face;
+ const tabView = tabBar.panels.view;
+ const tabScene = tabBar.panels.scene;
+ const tabExport = tabBar.panels.export;
+
+ /* ------------------------------------------------------------------ pose */
+
+ // ポーズ例: ready-made poses, because most people start by picking one.
+ const posePresetSection = section(tabPose, 'ポーズ例');
+ posePresetSection.add(buttons({
+ items: POSE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyPosePreset(preset),
+ })),
+ }));
+
+ // 全身とボーンのスライダー: pick a bone, then turn it on the three axes.
+ const poseSection = section(tabPose, '全身とボーンのスライダー');
+
+ const boneList = h('div', { class: 'bone-list' });
+ const boneButtons = new Map();
+ for (const bone of model.bones) {
+ const button = h('button', { type: 'button', class: 'bone-item' },
+ h('span', { text: bone.label }),
+ h('small', { text: bone.name }));
+ button.addEventListener('click', () => rig.select(bone.name));
+ boneButtons.set(bone.name, button);
+ boneList.append(button);
+ }
+ poseSection.add(controlRow(null, boneList, { wide: true }));
+
+ const axisSliders = {
+ x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }),
+ y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }),
+ z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }),
+ };
+ for (const widget of Object.values(axisSliders)) poseSection.add(widget.el);
+
+ function setAxis(axis, value) {
+ const name = rig.selected;
+ if (!name) return;
+ const delta = rig.getDelta(name);
+ delta[axis] = value;
+ rig.setDelta(name, delta);
+ app.actions.capturePose();
+ app.needsRender = true;
+ }
+
+ poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。'
+ + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。'));
+
+ // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the
+ // main way in, so they stay folded away inside the bone section.
+ const rootSliders = [
+ { label: 'よこ移動', min: -20, max: 20 },
+ { label: 'たて移動', min: -10, max: 20 },
+ { label: 'おくゆき', min: -20, max: 20 },
+ ].map(({ label, min, max }, index) => slider({
+ label, min, max, step: 0.01, value: 0,
+ format: (v) => v.toFixed(2),
+ onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; },
+ }));
+ const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)');
+ for (const widget of rootSliders) poseBox.add(widget.el);
+
+ const gizmoToggle = check({
+ label: '回転ギズモを表示',
+ // Off by default: the rings belong to the model's surface, so having them
+ // drawn on top of the character is not what you want while composing a
+ // picture. The 'g' key toggles it too.
+ value: false,
+ onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; },
+ });
+ poseBox.add(controlRow(null, gizmoToggle.el, { wide: true }));
+
+ // --- 操作 -----------------------------------------------------------------
+ // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the
+ // undo/redo pair are used often enough that they should be on screen without
+ // opening anything first.
+ tabPose.append(buttons({
+ items: [
+ { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } },
+ { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() },
+ { id: 'random', label: '少しランダムに', onClick: () => randomPose() },
+ ],
+ }).el);
+ tabPose.append(hint('「元に戻す」「やり直す」は、パネル上部のアイコンにあります(どのタブでも使えます)。'));
+
+ /** Accepts a preset object (from the buttons) or a preset id (from tools). */
+ function applyPosePreset(presetOrId) {
+ const preset = typeof presetOrId === 'string'
+ ? POSE_PRESETS.find((item) => item.id === presetOrId)
+ : presetOrId;
+ if (!preset) return;
+ // Some poses already raise the arms; leaving the てをふる motion running would
+ // add to the arm the pose just set and fold the flipper over the face, so
+ // switching to any pose stops that motion.
+ if (state.anim.mode === 'wave') {
+ state.anim.mode = 'off';
+ app.animator.reset();
+ app.needsRender = true;
+ }
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyPatch(state.pose, preset.pose ?? {});
+ if (!state.pose.root) state.pose.root = [0, 0, 0];
+ replace({}, 'pose');
+ sync();
+ }
+
+ function randomPose() {
+ const names = model.bones.map((bone) => bone.name);
+ for (const name of names) {
+ if (name === 'master') continue;
+ const scale = name.startsWith('arm') ? 22 : 12;
+ rig.setDelta(name, {
+ x: (Math.random() - 0.5) * scale,
+ y: (Math.random() - 0.5) * scale,
+ z: (Math.random() - 0.5) * scale,
+ });
+ }
+ app.actions.capturePose();
+ syncBones();
+ app.needsRender = true;
+ }
+
+ /* ------------------------------------------------------------------ face */
+
+ let faceSection = section(tabFace, 'プリセット');
+
+ // The face tab's sections are created here in the order they should read on
+ // screen (プリセット -> 目 -> 見る先 -> 口 -> 眼鏡 -> 鼻ちょうちん -> 帽子),
+ // but filled further down where the widgets are built: the DOM order is fixed
+ // at creation.
+ const faceTabSections = {
+ eye: section(tabFace, '目'),
+ look: section(tabFace, '見る先'),
+ mouth: section(tabFace, '口'),
+ glasses: section(tabFace, '眼鏡・サングラス'),
+ snot: section(tabFace, '鼻ちょうちん'),
+ hat: section(tabFace, '帽子'),
+ };
+
+ faceSection.add(buttons({
+ items: FACE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyFacePreset(preset.id),
+ })),
+ }));
+
+ function applyFacePreset(id) {
+ const preset = FACE_PRESETS.find((item) => item.id === id);
+ if (!preset) return;
+ state.face = defaultState().face;
+ applyPatch(state.face, preset.face);
+ app.actions.refresh('face');
+ sync();
+ }
+
+ // --- eyes
+ faceSection = faceTabSections.eye;
+
+ /** Tear sliders, which are added to the こまかい設定 box once it exists. */
+ const tearWidgets = [];
+ // The link switch sits directly above the *left* eye's open slider, because that
+ // is the slider it drives - it was previously below both eyes, where it looked
+ // like it belonged to whatever came next.
+ const linkedToggle = check({
+ label: '左右を連動させる',
+ value: true,
+ onChange: (value) => {
+ state.face.eyes.linked = value;
+ if (value) {
+ // Catch the two eyes up with each other, so switching the link on does
+ // something visible even if they had drifted apart.
+ state.face.eyes.right.open = state.face.eyes.left.open;
+ state.face.eyes.right.closed = state.face.eyes.left.closed;
+ state.face.eyes.right.closedLines = state.face.eyes.left.closedLines;
+ state.face.eyes.right.irisShape = state.face.eyes.left.irisShape;
+ state.face.eyes.right.shape = state.face.eyes.left.shape;
+ // The lids are per eye now too, so link them along with the rest.
+ state.face.eyes.right.lowerLid = state.face.eyes.left.lowerLid;
+ state.face.eyes.right.lidShape = state.face.eyes.left.lidShape;
+ state.face.eyes.right.lidWidth = state.face.eyes.left.lidWidth;
+ state.face.eyes.right.lidTilt = state.face.eyes.left.lidTilt;
+ state.face.eyes.right.lashes = state.face.eyes.left.lashes;
+ state.face.eyes.right.lashAngle = state.face.eyes.left.lashAngle;
+ state.face.eyes.right.lashPos = state.face.eyes.left.lashPos;
+ state.face.eyes.right.white = state.face.eyes.left.white ?? null;
+ state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null;
+ state.face.eyes.right.lookX = state.face.eyes.left.lookX;
+ state.face.eyes.right.lookY = state.face.eyes.left.lookY;
+ // The tears mirror as a pair: the size and the height match, while the
+ // sideways position and the tilt flip sign.
+ state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0;
+ state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0;
+ state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0);
+ state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0);
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into
+ // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and
+ // writes them together so the menu always shows the current look.
+ const eyeShapeOf = (eye) => {
+ const shape = eye.shape;
+ const explicitShut = shape === 'three' || shape === 'arch'
+ || (typeof shape === 'string' && shape.startsWith('line'));
+ if (explicitShut) return shape;
+ if ((eye.open ?? 1) <= 0.02) {
+ if (!shape) {
+ if (eye.closed === 'three') return 'three';
+ if (eye.closed === 'arch') return 'arch';
+ return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`;
+ }
+ // An open/heart eye whose lid is all the way down is just a blink.
+ return 'line1';
+ }
+ if (shape) return shape;
+ return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+ };
+ const applyEyeShape = (eye, value) => {
+ eye.shape = value;
+ eye.irisShape = value === 'heart' ? 'heart' : 'circle';
+ if (value === 'open' || value === 'heart') {
+ eye.open = 1;
+ // Clear any stale shut fields so a blink (open -> 0) shows a plain line and
+ // never the 3 or the arch that was picked before.
+ eye.closed = 'line';
+ eye.closedLines = 1;
+ return;
+ }
+ // A shut artwork is drawn as the eye's content with the lids open; the lid
+ // height is its own slider (上まぶたの高さ), so selecting one opens the lid.
+ eye.open = 1;
+ if (value.startsWith('line')) {
+ eye.closed = 'line';
+ eye.closedLines = Number(value.slice(4)) || 1;
+ return;
+ }
+ eye.closed = value;
+ };
+ const eyeWhiteOf = (eye) => {
+ if (eye.white != null) return eye.white === true;
+ // The usual: an open eye shows its white; a shut eye or a heart does not.
+ return eyeShapeOf(eye) === 'open';
+ };
+ const eyeHighlightOf = (eye) => (eye.highlight != null
+ ? eye.highlight === true
+ : state.face.eyes.highlight !== false);
+
+ const EYE_ICONS = {
+ both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>',
+ left: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>',
+ right: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>',
+ lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>',
+ };
+ // Each eye group carries the same controls. Linked, one group ("両目") drives
+ // both eyes; unlinked, a group per eye appears and the gaze pad doubles.
+ const eyeGroup = (title, icon = null) => {
+ const body = h('div', { class: 'eye-group-body' });
+ const head = h('div', { class: 'eye-group-head' },
+ icon ? svgIcon(EYE_ICONS[icon] ?? '') : null,
+ h('span', { text: title }));
+ const el = h('div', { class: 'eye-group' }, head, body);
+ return {
+ el,
+ add: (child) => (body.append(child?.el ?? child), child),
+ setVisible: (visible) => { el.style.display = visible ? '' : 'none'; },
+ };
+ };
+
+ const makeEyeControls = (mode) => {
+ const keys = mode === 'both' ? ['left', 'right'] : [mode];
+ const primary = keys[0];
+ const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); };
+ const touched = () => { app.actions.refresh('face'); sync(); };
+ const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左目' : '右目'), mode);
+
+ // --- まぶた (per eye) ------------------------------------------------
+ // 上まぶたの高さ and 下まぶたの高さ share one opening, so each is held to the
+ // other: the lower lid cannot rise past the upper, and the upper cannot drop
+ // below the lower. The sliders' own limits are kept in step in `syncOne`.
+ const lidSub = eyeGroup('まぶた', 'lid');
+ const open = slider({
+ label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`),
+ onInput: (v) => {
+ const floor = state.face.eyes[primary].lowerLid ?? state.face.eyes.lowerLid ?? 0;
+ const vv = Math.max(v, floor);
+ write((e) => { e.open = vv; });
+ if (vv !== v) open.set(vv);
+ touched();
+ },
+ });
+ const lowerLid = slider({
+ label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => {
+ const ceil = state.face.eyes[primary].open ?? 1;
+ const vv = Math.min(v, ceil);
+ write((e) => { e.lowerLid = vv; });
+ if (vv !== v) lowerLid.set(vv);
+ touched();
+ },
+ });
+ const lidShape = segmented({
+ label: 'まぶたの形',
+ options: [
+ { value: 'curve', label: '曲線' },
+ { value: 'flat', label: '直線(平ら)' },
+ ],
+ value: 'curve',
+ onChange: (v) => { write((e) => { e.lidShape = v; }); touched(); },
+ });
+ const lidWidth = slider({
+ label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { write((e) => { e.lidWidth = v; }); touched(); },
+ });
+ const lidTilt = slider({
+ label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { write((e) => { e.lidTilt = v; }); touched(); },
+ });
+ const lashes = check({
+ label: 'まつげをつける',
+ value: false,
+ onChange: (v) => { write((e) => { e.lashes = v; }); touched(); },
+ });
+ const lashAngle = slider({
+ label: 'まつげの角度', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { write((e) => { e.lashAngle = v; }); touched(); },
+ });
+ const lashPos = slider({
+ label: 'まつげの位置', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { write((e) => { e.lashPos = v; }); touched(); },
+ });
+ lidSub.add(lidShape);
+ lidSub.add(open);
+ lidSub.add(lowerLid);
+ lidSub.add(lidWidth);
+ lidSub.add(lidTilt);
+ lidSub.add(controlRow(null, lashes.el, { wide: true }));
+ lidSub.add(lashAngle);
+ lidSub.add(lashPos);
+ group.add(lidSub);
+ const openInput = open.el.querySelector('input');
+ const lowerLidInput = lowerLid.el.querySelector('input');
+
+ // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open,
+ // so 上まぶたの高さ can then be lowered onto it.
+ const shape = segmented({
+ label: '目の形',
+ options: [
+ { value: 'open', label: 'ふつう' },
+ { value: 'line1', label: '1線' },
+ { value: 'line2', label: '2線' },
+ { value: 'line3', label: '3線' },
+ { value: 'three', label: '3の目' },
+ { value: 'arch', label: 'わらう' },
+ { value: 'heart', label: 'ハート' },
+ ],
+ value: 'open',
+ onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); },
+ });
+ // The heart colour only matters for the heart, so it sits right under 目の形
+ // and shows only then.
+ const heartColor = colorField({
+ label: 'ハートの色', value: '#e0344f',
+ swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'],
+ onChange: (v) => { state.face.eyes.heartColor = v; touched(); },
+ });
+ // 大きさ: one knob for the iris, the heart and the shut artwork.
+ const size = slider({
+ label: '大きさ', min: 0.4, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.irisScale = v; touched(); },
+ });
+ const threeFlip = segmented({
+ label: '3の向き',
+ options: [
+ { value: 'normal', label: '3' },
+ { value: 'flip', label: '3(逆)' },
+ ],
+ value: 'normal',
+ onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); },
+ });
+ const white = check({
+ label: '白目を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.white = v; }); touched(); },
+ });
+ const highlight = check({
+ label: '光彩(瞳の白い丸)を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.highlight = v; }); touched(); },
+ });
+ const pad = xyPad({
+ value: { x: 0, y: 0 },
+ center: 'eye',
+ onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); },
+ });
+ // 涙: per eye. Linked, one set drives both - size and height match, while the
+ // sideways position and the tilt mirror so the pair stays symmetric.
+ const tearOn = check({
+ label: '涙を出す',
+ value: false,
+ onChange: (v) => { write((e) => { e.tearOn = v; }); touched(); },
+ });
+ const writeTear = (patch) => {
+ Object.assign(state.face.eyes[primary], patch);
+ if (mode === 'both') {
+ const mirrored = { ...patch };
+ if (patch.tearX != null) mirrored.tearX = -patch.tearX;
+ if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt;
+ Object.assign(state.face.eyes[primary === 'left' ? 'right' : 'left'], mirrored);
+ }
+ touched();
+ };
+ const tear = slider({
+ label: '涙の大きさ', min: 0.3, max: 1.6, step: 0.01, value: 0.3, format: (v) => v.toFixed(2),
+ onInput: (v) => writeTear({ tear: v }),
+ });
+ const tearY = slider({
+ label: '涙の高さ', min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => writeTear({ tearY: v }),
+ });
+ const tearX = slider({
+ label: '涙のよこ位置', min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => writeTear({ tearX: v }),
+ });
+ const tearTilt = slider({
+ label: '涙の傾き', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => writeTear({ tearTilt: v }),
+ });
+
+ group.add(shape);
+ group.add(heartColor);
+ group.add(size);
+ group.add(threeFlip);
+ group.add(controlRow(null, white.el, { wide: true }));
+ group.add(controlRow(null, highlight.el, { wide: true }));
+ group.add(controlRow(null, tearOn.el, { wide: true }));
+ group.add(tear);
+ group.add(tearY);
+ group.add(tearX);
+ group.add(tearTilt);
+ group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el,
+ h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' }))));
+
+ const syncOne = () => {
+ const e = state.face.eyes[primary];
+ const shapeValue = eyeShapeOf(e);
+ const lower = e.lowerLid ?? state.face.eyes.lowerLid ?? 0;
+ const upper = e.open ?? 1;
+ // Keep each lid's slider range from crossing the other before applying the
+ // values, so the displayed thumb cannot sit past the other lid either.
+ if (openInput) openInput.min = String(Math.min(lower, upper));
+ if (lowerLidInput) lowerLidInput.max = String(Math.max(lower, upper));
+ open.set(upper);
+ lowerLid.set(lower);
+ lidShape.set(e.lidShape ?? state.face.eyes.lidShape ?? 'curve');
+ lidWidth.set(e.lidWidth ?? state.face.eyes.lidWidth ?? 14);
+ lidTilt.set(e.lidTilt ?? state.face.eyes.lidTilt ?? 0);
+ lashes.set((e.lashes ?? state.face.eyes.lashes) === true);
+ const lashesOn = (e.lashes ?? state.face.eyes.lashes) === true;
+ lashAngle.set(e.lashAngle ?? state.face.eyes.lashAngle ?? 0);
+ lashPos.set(e.lashPos ?? state.face.eyes.lashPos ?? 0);
+ lashAngle.el.style.display = lashesOn ? '' : 'none';
+ lashPos.el.style.display = lashesOn ? '' : 'none';
+ shape.set(shapeValue);
+ heartColor.set(state.face.eyes.heartColor ?? '#e0344f');
+ heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none';
+ size.set(state.face.eyes.irisScale ?? 1);
+ threeFlip.set(e.threeFlip ? 'flip' : 'normal');
+ threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none';
+ white.set(eyeWhiteOf(e));
+ highlight.set(eyeHighlightOf(e));
+ const tearShown = e.tearOn === true;
+ tearOn.set(tearShown);
+ for (const widget of [tear, tearY, tearX, tearTilt]) {
+ widget.el.style.display = tearShown ? '' : 'none';
+ }
+ tear.set(e.tear ?? 0.3);
+ tearY.set(e.tearY ?? 0);
+ tearX.set(e.tearX ?? 0);
+ tearTilt.set(e.tearTilt ?? 0);
+ pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 });
+ };
+ return { group, sync: syncOne };
+ };
+
+ const bothControls = makeEyeControls('both');
+ const leftControls = makeEyeControls('left');
+ const rightControls = makeEyeControls('right');
+
+ faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
+ faceSection.add(bothControls.group);
+ faceSection.add(leftControls.group);
+ faceSection.add(rightControls.group);
+ faceSection.add(hint('「目の形」の「2線」「3線」は端点がつながった形になります。'
+ + '閉じ目の形も、上まぶた・下まぶたで上下から隠せます。'));
+
+ // 横位置 (per eye) rarely changes, so it gets its own small box.
+ const eyeAdvancedWidgets = {};
+ for (const key of ['left', 'right']) {
+ const label = key === 'left' ? '左目' : '右目';
+ const eyeX = slider({
+ label: `${label}の横位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); },
+ });
+ eyeAdvancedWidgets[key] = { eyeX };
+ tearWidgets.push(eyeX.el);
+ }
+
+ // One button to put every eye setting back to the artwork's defaults. The
+ // artwork *source* is kept: resetting the numbers should not throw away a
+ // hand-drawn image the user loaded.
+ faceSection.add(buttons({
+ items: [{
+ id: 'eyesReset',
+ label: '目の設定をリセット',
+ onClick: () => {
+ const source = state.face.eyes.source;
+ state.face.eyes = { ...defaultState().face.eyes, source };
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、'
+ + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。'
+ + 'はずすと「左目」「右目」の設定がそれぞれに出て、目線のパッドも2つになります。'
+ + '「左目」「右目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。'));
+
+ const eyeColors = {
+ white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }),
+ iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }),
+ line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }),
+ };
+ const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)');
+ for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el);
+
+ // 横位置 (per eye) is rarely used, so it stays in its own small box.
+ const tearBox = details(faceSection.body, '横位置(左右べつ)');
+ for (const el of tearWidgets) tearBox.add(el);
+
+ // --- glasses / sunglasses (drawn into the same texture as the eyes)
+ faceSection = faceTabSections.glasses;
+ faceSection.add(hint('眼鏡やサングラスを、目と同じ絵にかけます。'
+ + '線画のときはレンズを塗らず、フレームの線だけになります。'));
+
+ // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明
+ // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の
+ // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を
+ // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても
+ // 失われない。
+ const GLASSES_KINDS = {
+ glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 },
+ sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 },
+ };
+ const wearSunglasses = () => {
+ state.face.eyes.glasses.lensOpacity = 1;
+ state.face.eyes.left.open = 0;
+ state.face.eyes.right.open = 0;
+ };
+ const setGlassesKind = (kind) => {
+ Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] });
+ // サングラス hides the eye behind an opaque lens, so both eyes shut with it;
+ // 眼鏡's lens is clear, so switching back opens them again.
+ const open = kind === 'sunglasses' ? 0 : 1;
+ state.face.eyes.left.open = open;
+ state.face.eyes.right.open = open;
+ };
+
+ const glassesToggle = check({
+ label: '眼鏡をかける',
+ value: false,
+ onChange: (value) => {
+ state.face.eyes.glasses.enabled = value;
+ // A loaded state can already be on サングラス, so enabling the shades also
+ // shuts the eyes rather than showing them through the opaque lens.
+ if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses();
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
+
+ const glassesKindSegment = segmented({
+ label: '種類',
+ options: [
+ { value: 'glasses', label: '眼鏡' },
+ { value: 'sunglasses', label: 'サングラス' },
+ ],
+ value: 'glasses',
+ onChange: (value) => {
+ setGlassesKind(value);
+ // Picking a kind means you mean to wear it, so put the glasses on rather
+ // than leaving the checkbox off and nothing showing on the face.
+ state.face.eyes.glasses.enabled = true;
+ glassesToggle.set(true);
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(glassesKindSegment.el);
+
+ const glassesSliders = {
+ scale: slider({
+ label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); },
+ }),
+ lensOpacity: slider({
+ label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22,
+ format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); },
+ }),
+ lensGap: slider({
+ label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); },
+ }),
+ frameWidth: slider({
+ label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.offsetY = v; app.actions.refresh('face'); },
+ }),
+ tilt: slider({
+ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.eyes.glasses.tilt = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(glassesSliders.scale.el);
+ faceSection.add(glassesSliders.lensOpacity.el);
+ const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)');
+ glassesBox.add(glassesSliders.frameWidth.el);
+ glassesBox.add(glassesSliders.offsetY.el);
+ glassesBox.add(glassesSliders.tilt.el);
+ glassesBox.add(glassesSliders.lensGap.el);
+ const glassesFrameColor = colorField({
+ label: 'フレームの色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'],
+ onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); },
+ });
+ const glassesLensColor = colorField({
+ label: 'レンズの色', value: '#2b2433',
+ swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'],
+ onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); },
+ });
+ glassesBox.add(glassesFrameColor.el);
+ glassesBox.add(glassesLensColor.el);
+ glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。'
+ + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。'
+ + '「傾き」は左右のレンズが一緒に傾きます。'));
+ glassesBox.add(buttons({
+ items: [
+ {
+ id: 'glasses',
+ label: '眼鏡',
+ onClick: () => {
+ setGlassesKind('glasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'sunglasses',
+ label: 'サングラス',
+ onClick: () => {
+ setGlassesKind('sunglasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ // --- 鼻ちょうちん: the snot bubble of a sleeping face.
+ faceSection = faceTabSections.snot;
+ const snotToggle = check({
+ label: '鼻ちょうちんを出す',
+ value: false,
+ onChange: (value) => { state.face.eyes.snot.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, snotToggle.el, { wide: true }));
+ const snotSize = slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.snot.size = v; app.actions.refresh('face'); },
+ });
+ faceSection.add(snotSize.el);
+ const snotBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ const snotSliders = {
+ offsetX: slider({
+ label: 'よこの位置', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetY = v; app.actions.refresh('face'); },
+ }),
+ offsetZ: slider({
+ label: 'おくゆき', min: -10, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); },
+ }),
+ };
+ for (const widget of Object.values(snotSliders)) snotBox.add(widget.el);
+ const snotColor = colorField({
+ label: '泡の色', value: '#e3ecff',
+ swatches: ['#e3ecff', '#ffffff', '#cfe0ff', '#bfe8d0'],
+ onChange: (v) => { state.face.eyes.snot.color = v; app.actions.refresh('face'); },
+ });
+ snotBox.add(snotColor.el);
+ snotBox.add(hint('寝ている顔の横に、鼻から出る泡を描きます。'
+ + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。'
+ + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
+
+ // --- mouth
+ faceSection = faceTabSections.mouth;
+
+ const mouthToggle = check({
+ label: '口を表示',
+ value: true,
+ onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, mouthToggle.el, { wide: true }));
+
+ // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、
+ // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために
+ // スライダー自体は残してある。
+ const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile)));
+
+ const mouthSliders = {
+ smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }),
+ open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }),
+ round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }),
+ width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }),
+ thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }),
+ tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }),
+ offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
+ tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }),
+ tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }),
+ corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }),
+ cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }),
+ };
+ // 口には「ふつうの口」と「丸く開く(O)」の2つの形があり、使うスライダーが
+ // まったく違う。形を切り替えると、その形で使うものだけを出す。
+ const mouthModeOf = (mouth) => (mouth.round > 0.004 ? 'round' : 'curve');
+ const mouthRowKeys = {
+ curve: ['smile', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'],
+ round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'],
+ };
+ // DOM order is fixed here; the mode only shows or hides rows.
+ const mouthRowOrder = ['round', 'smile', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle'];
+ const mouthMode = segmented({
+ label: '口の形',
+ options: [
+ { value: 'curve', label: 'ふつうの口' },
+ { value: 'round', label: '丸く開く(O)' },
+ ],
+ value: 'curve',
+ onChange: (shape) => {
+ if (shape === 'round') {
+ if (!(state.face.mouth.round > 0.004)) state.face.mouth.round = 0.6;
+ } else {
+ state.face.mouth.round = 0;
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(mouthMode);
+ for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el);
+ faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。'
+ + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。'));
+ faceSection.add(buttons({
+ items: [{
+ id: 'mouthreset',
+ label: '口を初期値に戻す',
+ onClick: () => {
+ state.face.mouth = defaultState().face.mouth;
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ const mouthColors = {
+ color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }),
+ innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }),
+ tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }),
+ cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }),
+ };
+ const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)');
+ for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el);
+
+ /* ------------------------------------------------------ movement (pose tab) */
+
+ // This is the pose tab's うごき section; the widgets sit here because they are
+ // driven by the animator alongside the pose state.
+ const moveSection = section(tabPose, 'うごき');
+
+ const animWidgets = {
+ blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }),
+ lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }),
+ };
+
+ const motionSegment = segmented({
+ label: 'からだのうごき',
+ options: [
+ { value: 'off', label: 'とまる' },
+ { value: 'idle', label: 'ゆれる' },
+ { value: 'walk', label: '歩く' },
+ { value: 'wave', label: 'てをふる' },
+ { value: 'sleep', label: 'ねている' },
+ ],
+ value: 'off',
+ onChange: (value) => {
+ state.anim.mode = value;
+ // Sleeping always shows the snot bubble, so the motion reads without having
+ // to switch the bubble on as well.
+ if (value === 'sleep') {
+ state.face.eyes.snot.enabled = true;
+ snotToggle.set(true);
+ app.actions.refresh('face');
+ }
+ app.animator.reset();
+ app.needsRender = true;
+ },
+ });
+ moveSection.add(motionSegment.el);
+ for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true }));
+
+ moveSection.add(slider({
+ label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`,
+ onInput: (v) => { state.anim.blinkInterval = v; },
+ }));
+ moveSection.add(slider({
+ label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.anim.speed = v; },
+ }));
+
+ const recordButton = buttons({
+ items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }],
+ });
+ moveSection.add(recordButton.el);
+ moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。'));
+
+ // --- 見る先 (moved here, next to the face controls, from the pose tab) ----
+ const lookAtSection = faceTabSections.look;
+ // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
+ const refreshLookAt = () => {
+ app.actions.refresh('lookAt');
+ app.actions.refresh('face');
+ sync();
+ };
+ const lookAtToggle = check({
+ label: '指定した場所を見る',
+ value: false,
+ onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true }));
+ const lookAtSliders = {
+ x: slider({
+ label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.x = v; refreshLookAt(); },
+ }),
+ y: slider({
+ label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.y = v; refreshLookAt(); },
+ }),
+ z: slider({
+ label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.z = v; refreshLookAt(); },
+ }),
+ amount: slider({
+ label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); },
+ }),
+ };
+ for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el);
+ const turnBodyToggle = check({
+ label: '体も向ける',
+ value: false,
+ onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true }));
+ lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。'));
+
+ /* ---------------------------------------------------------------- display */
+
+ // The face tab's bottom line: a quick way out of whatever has been built up.
+ tabFace.append(buttons({
+ items: [
+ { id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') },
+ { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() },
+ ],
+ }).el);
+
+ const displaySection = section(tabView, 'スタイル');
+
+ const styleSegment = segmented({
+ label: 'スタイル',
+ options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })),
+ value: 'real',
+ onChange: (value) => replace({ render: { style: value } }, 'render'),
+ });
+ displaySection.add(styleSegment.el);
+ displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。'));
+
+ const outlineToggle = check({
+ label: '輪郭線を出す', value: true,
+ onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); },
+ });
+ displaySection.add(controlRow(null, outlineToggle.el, { wide: true }));
+ const outlineWidthSlider = slider({
+ label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3),
+ onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlineWidthSlider.el);
+ const outlinePixelsSlider = slider({
+ label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2,
+ format: (v) => `${v.toFixed(1)} px`,
+ onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlinePixelsSlider.el);
+ displaySection.add(colorField({
+ label: '線の色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'],
+ onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); },
+ }));
+ displaySection.add(colorField({
+ label: '紙の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'],
+ onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); },
+ }));
+ const leafBodyLineCheck = check({
+ label: '葉っぱと体の境目に線を出す', value: true,
+ onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); },
+ title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。'
+ + '「葉が体にめり込んで見える」のが気になるときに切ってください',
+ });
+ displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true }));
+ displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。'));
+ displaySection.add(buttons({
+ items: [{
+ id: 'resetStyle',
+ label: 'プリセットのスタイルに戻す',
+ onClick: () => replace({
+ render: {
+ style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2,
+ outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true,
+ },
+ }, 'render'),
+ }],
+ }));
+
+ /* ------------------------------------------------------------- 帽子 (顔タブ) */
+
+ const hatSection = faceTabSections.hat;
+ hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。'));
+ const hatSegment = segmented({
+ label: '帽子',
+ options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })),
+ value: 'none',
+ onChange: (value) => {
+ state.face.hat.kind = value;
+ // Picking one of the built-in hats (including なし) also clears any GLB hat
+ // the user imported, so なし really removes the hat.
+ state.face.hat.custom = false;
+ app.actions.refresh('all');
+ },
+ });
+ hatSection.add(hatSegment.el);
+ syncers.push(() => hatSegment.set(state.face.hat?.custom === true ? 'none' : (state.face.hat?.kind ?? 'none')));
+
+ // 帽子モデル(GLB)の取り込み。頭に乗るよう自動で位置と大きさを合わせる。
+ const hatFilePicker = h('input', { type: 'file', accept: '.glb,.gltf,model/gltf-binary,model/gltf+json', style: { display: 'none' } });
+ hatFilePicker.addEventListener('change', async () => {
+ const file = hatFilePicker.files?.[0];
+ hatFilePicker.value = '';
+ if (!file) return;
+ try {
+ await app.actions.loadHatModel(file);
+ toast(`帽子「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('帽子モデルを読み込めませんでした(GLBをお使いください)');
+ }
+ });
+ hatSection.add(hatFilePicker);
+ hatSection.add(buttons({
+ items: [{ id: 'hatfile', label: '帽子モデルを読み込む(GLB)', onClick: () => hatFilePicker.click() }],
+ }));
+ hatSection.add(hint('GLBの帽子を頭に乗せます。大きさと位置は自動で合わせます。'));
+
+ const hatBox = details(hatSection.body, '位置と傾き');
+ const hatSliders = {
+ height: slider({
+ label: '高さ', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); },
+ }),
+ x: slider({
+ label: 'よこ位置', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.x = v; app.actions.refresh('all'); },
+ }),
+ z: slider({
+ label: 'おくゆき', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.z = v; app.actions.refresh('all'); },
+ }),
+ tiltX: slider({
+ label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); },
+ }),
+ tiltZ: slider({
+ label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); },
+ }),
+ };
+ for (const widget of Object.values(hatSliders)) hatBox.add(widget.el);
+ hatBox.add(hint('「高さ」「よこ位置」「おくゆき」で帽子を動かし、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
+ syncers.push(() => {
+ hatSliders.height.set(state.face.hat?.height ?? 0);
+ hatSliders.x.set(state.face.hat?.x ?? 0);
+ hatSliders.z.set(state.face.hat?.z ?? 0);
+ hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0);
+ hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
+ });
+
+ /* ---------------------------------------------------------- 環境の映りこみ */
+
+ // The body colours are fixed (see `render.colors` in presets.js), so the 体の色
+ // section is gone. The strength of the environment reflection is still a look
+ // setting, so it lives in 見た目の調整 below.
+ const envIntensitySlider = slider({
+ label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); },
+ });
+
+ /* -------------------------------------------------------------------- look */
+
+ const lookSection = section(tabView, '見た目の調整');
+ const mirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); },
+ });
+ lookSection.add(controlRow(null, mirrorToggle.el, { wide: true }));
+ lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。'));
+
+ const shadowCheck = check({
+ label: '影を落とす',
+ value: true,
+ onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, shadowCheck.el, { wide: true }));
+ const shadowOpacitySlider = slider({
+ label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); },
+ });
+ const shadowSoftnessSlider = slider({
+ label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); },
+ });
+ lookSection.add(shadowOpacitySlider.el);
+ lookSection.add(shadowSoftnessSlider.el);
+ const contactShadowToggle = check({
+ label: '接地影だけ',
+ value: false,
+ onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true }));
+ const contactBlobToggle = check({
+ label: '足もとの丸い影',
+ value: false,
+ onChange: (value) => { state.render.contactBlob = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactBlobToggle.el, { wide: true }));
+ lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。'
+ + '「接地影だけ」は足もとの丸い影だけにします。'
+ + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。'));
+ // 環境の映りこみ: the strength of the environment reflection, moved here from
+ // the old 体の色 section (the body colours themselves are fixed now).
+ lookSection.add(envIntensitySlider.el);
+
+ const environmentSegment = segmented({
+ label: '照明の種類',
+ options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })),
+ value: 'gradient',
+ onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); },
+ });
+ // 照明の種類は「シーン」タブの「画面・光」に置く(環境の映りこみの元を選ぶものなので)。
+ lookSection.add(buttons({
+ items: [{
+ id: 'resetLook',
+ label: 'プリセットに戻す',
+ onClick: () => replace({
+ render: {
+ mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6,
+ contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1,
+ },
+ }, 'render'),
+ }],
+ }));
+
+ /* -------------------------------------------------------------- background */
+
+ const backdropSection = section(tabScene, '背景');
+ // 素材 is one button for two state values (a photo or an effect line). This
+ // remembers which of the two was picked last, so leaving 素材 and coming back
+ // returns to it instead of always jumping to the photos.
+ let lastMaterial = 'preset';
+ const backgroundModeSegment = segmented({
+ label: '背景',
+ options: [
+ { value: 'solid', label: '単色' },
+ { value: 'transparent', label: '透明' },
+ { value: 'material', label: '素材' },
+ { value: 'image', label: '自分の画像' },
+ { value: 'camera', label: 'カメラ=AR' },
+ ],
+ value: 'solid',
+ onChange: (value) => {
+ if (value === 'camera') {
+ // The action owns the stream, so let it decide and re-read the state after.
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ if (state.view.background === 'camera') app.actions.toggleCamera(false);
+ state.view.background = value === 'material' ? lastMaterial : value;
+ app.actions.refresh('view');
+ sync();
+ },
+ });
+ backdropSection.add(backgroundModeSegment.el);
+ // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section.
+ const backgroundColour = colorField({
+ label: '背景の色', value: '#ffffff',
+ swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'],
+ onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundColour.el);
+
+ const photoButtons = buttons({
+ label: '写真',
+ items: BACKGROUND_PRESETS.map((preset) => ({
+ id: preset.name,
+ label: preset.label,
+ onClick: () => {
+ state.view.backgroundPreset = preset.name;
+ lastMaterial = 'preset';
+ state.view.background = 'preset';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ const effectButtons = buttons({
+ label: '効果線(まんが)',
+ items: EFFECT_PRESETS.map((effect) => ({
+ id: `effect-${effect.name}`,
+ label: effect.label,
+ onClick: () => {
+ state.view.backgroundEffect = effect.name;
+ lastMaterial = 'effect';
+ state.view.background = 'effect';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ // The two rows are shown only while 素材 is the active mode (see the syncer
+ // below); the border groups them so they read as one choice, not two.
+ const materialGroup = h('div', {
+ class: 'material-group',
+ style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' },
+ }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。'));
+ backdropSection.add(materialGroup);
+
+ const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ backgroundImagePicker.addEventListener('change', async () => {
+ const file = backgroundImagePicker.files?.[0];
+ backgroundImagePicker.value = '';
+ if (!file) return;
+ try {
+ const dataUrl = await app.backdrop.loadImageFile(file);
+ state.view.backgroundImage = dataUrl;
+ state.view.background = 'image';
+ app.actions.refresh('view');
+ sync();
+ toast(`背景の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ backdropSection.add(backgroundImagePicker);
+ backdropSection.add(buttons({
+ items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }],
+ }));
+
+ const backgroundFitSegment = segmented({
+ label: '合わせ方',
+ options: [
+ { value: 'cover', label: '全体' },
+ { value: 'contain', label: '収める' },
+ { value: 'stretch', label: '伸ばす' },
+ { value: 'tile', label: '並べる' },
+ ],
+ value: 'cover',
+ onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); },
+ });
+ const backgroundBlurSlider = slider({
+ label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); },
+ });
+ const backgroundDarkenSlider = slider({
+ label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); },
+ });
+ const backgroundScaleSlider = slider({
+ label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundFitSegment.el);
+ backdropSection.add(backgroundBlurSlider.el);
+ backdropSection.add(backgroundDarkenSlider.el);
+ backdropSection.add(backgroundScaleSlider.el);
+
+ const cameraFacingSegment = segmented({
+ label: 'カメラ',
+ options: [
+ { value: 'user', label: '前' },
+ { value: 'environment', label: '後ろ' },
+ ],
+ value: 'environment',
+ onChange: (value) => {
+ state.view.cameraFacing = value;
+ // Only a live stream has to be restarted on the other lens.
+ if (state.view.background === 'camera') {
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ app.actions.refresh('view');
+ },
+ });
+ const cameraMirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); },
+ });
+ backdropSection.add(cameraFacingSegment.el);
+ backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true }));
+
+ // A gyro reading is a delta from the pose the device held when the switch was
+ // turned on, so the camera does not jump when it starts listening.
+ let gyroBase = null;
+ let gyroOff = null;
+ const gyroToggle = check({
+ label: '端末を傾けて見る',
+ value: false,
+ onChange: (value) => {
+ state.view.gyro = value;
+ if (!value) {
+ gyroBase = null;
+ if (gyroOff) { gyroOff(); gyroOff = null; }
+ return;
+ }
+ gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 };
+ app.backdrop?.requestGyro?.();
+ gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => {
+ if (!gyroBase) return;
+ const yaw = reading?.yaw ?? 0;
+ const pitch = reading?.pitch ?? 0;
+ state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw));
+ state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch));
+ app.actions.refresh('view');
+ }) ?? null;
+ },
+ });
+ backdropSection.add(controlRow(null, gyroToggle.el, { wide: true }));
+ backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。'));
+ backdropSection.add(buttons({
+ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }],
+ }));
+
+ /* ------------------------------------------------------------------- props */
+
+ // 小物: the things you put the character *among*. Each stage feature gets its
+ // own top-level section rather than one shared 舞台 drawer, so they are easier
+ // to find and open independently.
+ const propSection = section(tabScene, '小物');
+ propSection.add(buttons({
+ label: '追加',
+ items: PROP_LIBRARY.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ onClick: () => { app.actions.addProp(entry.id); sync(); },
+ })),
+ }));
+ propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。'
+ + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。'));
+ const propListBox = h('div', { class: 'prop-list' });
+ propSection.add(controlRow(null, propListBox, { wide: true }));
+
+ const propWidgets = [];
+ let propShape = '';
+
+ /**
+ * Rebuild the list only when the *set* of props changes: a rebuild mid-drag
+ * would replace the slider the user is holding. The handlers read the item by
+ * index rather than keeping a reference, so a state swap (undo, load) is safe.
+ */
+ const renderProps = () => {
+ const items = state.props?.items ?? [];
+ const shape = items.map((item) => item.kind).join('|');
+ if (shape === propShape && propWidgets.length === items.length) return;
+ propShape = shape;
+ propWidgets.length = 0;
+ propListBox.replaceChildren();
+ items.forEach((item, index) => {
+ const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind;
+ const block = h('div', {
+ class: 'prop-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ }, subhead(items.length > 1 ? `${label} ${index + 1}` : label));
+ const patch = (key) => (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('render');
+ sync();
+ };
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }),
+ y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }),
+ rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }),
+ rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }),
+ scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }),
+ };
+ // The numbers are only a fallback for the drag, so they stay folded away.
+ const numbers = details(block, '数値で調整');
+ for (const widget of Object.values(sliders)) numbers.add(widget.el);
+ // 看板 only: the writing area (face *and* frame) can be enlarged on its
+ // own, so the posts and base stay put. The app rebuilds the props from the
+ // state and carries only the whole-prop scale, so the props syncer
+ // re-applies this to the fresh mesh (see `applyPropFaceScale`).
+ let faceScale = null;
+ let signText = null;
+ if (item.kind === 'sign') {
+ signText = textArea({
+ label: '看板の文字(改行できます)',
+ value: '',
+ onChange: (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target.text = value;
+ // Paint straight onto the live face rather than rebuilding every prop
+ // on each keystroke; a later rebuild re-applies it (see main.js).
+ applyPropText(placedPropGroups(app)[index], value);
+ app.needsRender = true;
+ },
+ });
+ block.insertBefore(signText.el, numbers.el);
+ faceScale = slider({
+ label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1,
+ format: (v) => v.toFixed(2), onInput: patch('faceScale'),
+ });
+ block.insertBefore(faceScale.el, numbers.el);
+ }
+ block.append(buttons({
+ items: [{
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.props = state.props ?? { items: [] };
+ state.props.items = (state.props.items ?? []).filter((_, i) => i !== index);
+ app.actions.refresh('render');
+ renderProps();
+ sync();
+ },
+ }],
+ }).el);
+ propListBox.append(block);
+ propWidgets.push({ index, sliders, faceScale, signText });
+ });
+ };
+ renderProps();
+
+ /* ------------------------------------------------------------- 擬音 */
+
+ // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense,
+ // so the user picks the crop with a marquee instead of the app slicing them
+ // (see src/gion.js for why). Only the sheet name and the crop rectangle live in
+ // the state - never the bitmap - so a shared link stays small.
+ const gionSection = section(tabScene, '擬音(マンガのオノマトペ)');
+ gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。'
+ + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。'
+ + '貼ったあとはビューポートでドラッグして動かせます。'));
+
+ const gionSheetSelect = selectField({
+ label: 'シート',
+ options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })),
+ value: GION_SHEETS[0]?.name ?? '',
+ onChange: () => {},
+ });
+ if (GION_SHEETS.length) {
+ gionSection.add(gionSheetSelect.el);
+ gionSection.add(buttons({
+ items: [{
+ id: 'pick',
+ label: '擬音をえらぶ',
+ primary: true,
+ onClick: () => openGionPicker(gionSheetSelect.get()),
+ }],
+ }));
+ } else {
+ // No sheet ships with the studio (see src/gion.js), so there is nothing to
+ // pick yet - the section stays, but only with a note on how to add one.
+ gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、'
+ + 'src/gion.js の GION_SHEETS に登録すると使えます。'));
+ }
+
+ const gionListBox = h('div', { class: 'gion-list' });
+ gionSection.add(controlRow(null, gionListBox, { wide: true }));
+
+ let gionShape = '';
+ const gionWidgets = [];
+
+ /** Select a stamp: the viewport outlines it and the list highlights it. */
+ const selectGion = (id) => {
+ app.gionSelected = id ?? null;
+ app.actions.refresh('gion');
+ sync();
+ };
+
+ /**
+ * A little canvas preview of a stamp's crop, drawn from the sheet once it has
+ * loaded. The sheet itself is only fetched when a stamp first needs it.
+ */
+ const gionPreviews = new Map();
+ function gionPreview(item) {
+ const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 });
+ const draw = (image) => {
+ if (!image?.naturalWidth) return;
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh);
+ const w = item.sw * scale;
+ const h = item.sh * scale;
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh,
+ (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
+ };
+ const known = gionPreviews.get(item.sheet);
+ if (known) {
+ draw(known);
+ } else {
+ const image = new Image();
+ image.onload = () => draw(image);
+ image.src = gionSheetUrl(item.sheet);
+ gionPreviews.set(item.sheet, image);
+ }
+ return canvas;
+ }
+
+ /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */
+ const renderGion = () => {
+ const items = state.gion?.items ?? [];
+ const shape = items
+ .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`)
+ .join('|');
+ if (shape === gionShape && gionWidgets.length === items.length) return;
+ gionShape = shape;
+ gionWidgets.length = 0;
+ gionListBox.replaceChildren();
+ if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null;
+ if (items.length === 0) {
+ gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' }));
+ return;
+ }
+ items.forEach((item, index) => {
+ const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet;
+ const block = h('div', {
+ class: 'gion-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ });
+ const preview = gionPreview(item);
+ preview.title = 'クリックで選択';
+ preview.addEventListener('click', () => selectGion(item.id));
+ const head = h('div', { class: 'gion-item-head' }, preview,
+ subhead(`擬音 ${index + 1}(${label})`));
+ block.append(head);
+
+ const patch = (key) => (value) => {
+ const target = state.gion?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('gion');
+ sync();
+ };
+ const sliders = {
+ w: slider({
+ label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH,
+ format: (v) => `${Math.round(v)}px`, onInput: patch('w'),
+ }),
+ rot: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0,
+ format: (v) => `${Math.round(v)}°`, onInput: patch('rot'),
+ }),
+ };
+ const flip = check({ label: '左右反転', value: false, onChange: patch('flip') });
+ block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true }));
+ block.append(buttons({
+ items: [
+ { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) },
+ {
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index);
+ if (app.gionSelected === item.id) app.gionSelected = null;
+ app.actions.refresh('gion');
+ renderGion();
+ sync();
+ },
+ },
+ ],
+ }).el);
+ gionListBox.append(block);
+ gionWidgets.push({ id: item.id, sliders, flip, block });
+ });
+ };
+ renderGion();
+
+ /**
+ * The picker: a full-screen overlay showing the whole sheet, on which the user
+ * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of
+ * the picture; Esc or キャンセル closes without adding anything.
+ */
+ const openGionPicker = (sheetName) => {
+ const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0];
+ if (!sheet) { toast('擬音のシートがありません'); return; }
+
+ const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) });
+ const marquee = h('div', { class: 'gion-picker-marquee' });
+ const board = h('div', { class: 'gion-picker-board' }, image, marquee);
+ const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true });
+ const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' });
+ const overlay = h('div', { class: 'gion-picker' },
+ h('div', { class: 'gion-picker-bar' },
+ h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }),
+ h('div', { class: 'gion-picker-actions' },
+ h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }),
+ addButton,
+ cancelButton)),
+ board);
+
+ /** Where the sheet is currently displayed, in board pixels. */
+ let sheetRect = null;
+ /** The marquee the user is drawing, or the last one drawn. */
+ let current = null;
+ let start = null;
+
+ const layoutSheet = () => {
+ if (!image.naturalWidth) return;
+ const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 });
+ image.style.left = `${rect.x}px`;
+ image.style.top = `${rect.y}px`;
+ image.style.width = `${rect.w}px`;
+ image.style.height = `${rect.h}px`;
+ sheetRect = rect;
+ };
+
+ const updateAdd = () => {
+ addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect);
+ };
+
+ const drawMarquee = () => {
+ if (!current) { marquee.style.display = 'none'; return; }
+ marquee.style.display = 'block';
+ marquee.style.left = `${current.x}px`;
+ marquee.style.top = `${current.y}px`;
+ marquee.style.width = `${current.w}px`;
+ marquee.style.height = `${current.h}px`;
+ };
+
+ const pointIn = (event, bounds) => ({
+ x: clamp(event.clientX - bounds.left, 0, bounds.width),
+ y: clamp(event.clientY - bounds.top, 0, bounds.height),
+ });
+
+ board.addEventListener('pointerdown', (event) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
+ start = pointIn(event, board.getBoundingClientRect());
+ current = null;
+ drawMarquee();
+ updateAdd();
+ try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ }
+ });
+ board.addEventListener('pointermove', (event) => {
+ if (!start) return;
+ current = normalizeRect(start, pointIn(event, board.getBoundingClientRect()));
+ drawMarquee();
+ updateAdd();
+ });
+ const endMarquee = (event) => {
+ if (!start) return;
+ start = null;
+ try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ }
+ updateAdd();
+ };
+ board.addEventListener('pointerup', endMarquee);
+ board.addEventListener('pointercancel', endMarquee);
+ image.addEventListener('load', () => { layoutSheet(); updateAdd(); });
+
+ addButton.addEventListener('click', () => {
+ if (!current || !sheetRect) return;
+ const crop = cropFromMarquee(current, sheetRect, image);
+ app.actions.addGionStamp({ sheet: sheet.name, ...crop });
+ close();
+ });
+ cancelButton.addEventListener('click', () => close());
+ overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
+
+ const onKey = (event) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ close();
+ };
+ const onResize = () => layoutSheet();
+
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ document.removeEventListener('keydown', onKey);
+ window.removeEventListener('resize', onResize);
+ overlay.remove();
+ };
+
+ document.addEventListener('keydown', onKey);
+ window.addEventListener('resize', onResize);
+ document.body.append(overlay);
+ // A cached sheet is already loaded, so the `load` event never fires for it.
+ if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); }
+ };
+
+ /* ------------------------------------------------------------- 見えない壁 */
+
+ // 見えない壁: place up to two finite "boards" and bury the character in them, so
+ // only the part in front still shows. A wall's position is kept *relative to the
+ // character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ const wallSection = section(tabScene, '見えない壁');
+ wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ + '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。'
+ + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。'));
+
+ const WALL_SLOTS = [
+ { key: 'wall', label: '壁 1' },
+ { key: 'wall2', label: '壁 2' },
+ ];
+
+ const wallGroups = WALL_SLOTS.map(({ key, label }) => {
+ const box = details(wallSection.body, label);
+ const cfg = () => (state.render[key] = state.render[key] ?? defaultState().render[key]);
+ const toggle = check({
+ label: `${label}で一部を隠す`,
+ value: false,
+ onChange: (value) => { cfg().on = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, toggle.el, { wide: true }));
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().x = v; app.actions.refresh('render'); sync(); } }),
+ y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().y = v; app.actions.refresh('render'); sync(); } }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().z = v; app.actions.refresh('render'); sync(); } }),
+ yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().yaw = v; app.actions.refresh('render'); sync(); } }),
+ tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().tilt = v; app.actions.refresh('render'); sync(); } }),
+ size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { cfg().size = v; app.actions.refresh('render'); sync(); } }),
+ };
+ for (const widget of Object.values(sliders)) box.add(widget.el);
+
+ // The wall writes depth but no colour, so it hides what is behind it without
+ // being visible. The guide is the tinted copy of that same rectangle, shown
+ // only while placing - and it is also the switch that says "I am placing the
+ // wall now", so with it off the wall cannot swallow an orbit drag.
+ const guide = check({
+ label: '板を表示(ビューポートでドラッグして動かせます)',
+ value: false,
+ onChange: (value) => { cfg().guide = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, guide.el, { wide: true }));
+ return { key, label, toggle, sliders, guide };
+ });
+
+ wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**'
+ + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。'
+ + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'
+ + '「板を表示」した板は、ビューポートでドラッグしても動かせます。'));
+ wallSection.add(buttons({
+ items: [{
+ id: 'wallReset',
+ label: '見えない壁をリセット',
+ onClick: () => {
+ state.render.wall = defaultState().render.wall;
+ state.render.wall2 = defaultState().render.wall2;
+ app.actions.refresh('render');
+ sync();
+ },
+ }],
+ }));
+
+ /* ------------------------------------------------------------------ light */
+
+ // 画面・光: the light rig and the camera, which together decide how the picture
+ // is lit and framed.
+ const screenLightSection = section(tabScene, '画面・光');
+ const lightSection = details(screenLightSection.body, 'ひかり');
+ const lightPad = xyPad({
+ value: { x: -0.2, y: 0.45 },
+ onChange: ({ x, y }) => {
+ state.render.lightAzimuth = x * 180;
+ state.render.lightElevation = 5 + Math.max(0, y) * 80;
+ app.actions.refresh('render');
+ },
+ });
+ lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' },
+ lightPad.el,
+ h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' }))));
+ const lightIntensitySlider = slider({
+ label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); },
+ });
+ const ambientSlider = slider({
+ label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); },
+ });
+ const exposureSlider = slider({
+ label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); },
+ });
+ const lightColorField = colorField({
+ label: '光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'],
+ onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); },
+ });
+ const ambientColorField = colorField({
+ label: '環境光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'],
+ onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); },
+ });
+ lightSection.add(environmentSegment.el);
+ lightSection.add(lightIntensitySlider.el);
+ lightSection.add(ambientSlider.el);
+ lightSection.add(lightColorField.el);
+ lightSection.add(ambientColorField.el);
+ lightSection.add(exposureSlider.el);
+ lightSection.add(hint('「照明の種類」は環境の映りこみの元(グラデーション/部屋/なし)です。'
+ + '「光の色」でライトの色、「環境光の色」で影側の色を変えられます。'
+ + 'テクスチャ(環境)の映りこみの強さは「見た目」タブの「環境の映りこみ」で調整できます。'));
+
+ /* ----------------------------------------------------------------- camera */
+
+ const cameraSection = details(screenLightSection.body, 'カメラ');
+
+ const projectionSegment = segmented({
+ label: '映し方',
+ options: [
+ { value: 'persp', label: '遠近あり' },
+ { value: 'ortho', label: '正投影' },
+ ],
+ value: 'persp',
+ onChange: (value) => replace({ view: { projection: value } }, 'view'),
+ });
+ cameraSection.add(projectionSegment.el);
+ cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。'));
+
+ cameraSection.add(buttons({
+ label: '向き',
+ items: [
+ { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') },
+ { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') },
+ { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') },
+ { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') },
+ { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') },
+ ],
+ }));
+
+ const cameraSliders = {
+ azimuth: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); },
+ }),
+ polar: slider({
+ label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); },
+ }),
+ size: slider({
+ label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1),
+ onInput: (v) => {
+ if (state.view.projection === 'ortho') state.view.orthoHeight = v;
+ else state.view.distance = v;
+ app.actions.refresh('view');
+ },
+ }),
+ targetY: slider({
+ label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); },
+ }),
+ };
+ for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el);
+
+ const autoRotateToggle = check({
+ label: '自動で回す', value: false,
+ onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); },
+ });
+ cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true }));
+ cameraSection.add(slider({
+ label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1),
+ onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); },
+ }));
+
+ /* ------------------------------------------------------------------ export */
+
+ const exportSection = section(tabExport, '画像');
+
+ exportSection.add(segmented({
+ label: 'PNGの大きさ',
+ options: [
+ { value: '1', label: '1倍' },
+ { value: '2', label: '2倍' },
+ { value: '3', label: '3倍' },
+ { value: '4', label: '4倍' },
+ ],
+ value: '2',
+ onChange: (value) => { state.render.pngScale = Number(value); },
+ }));
+ exportSection.add(buttons({
+ items: [
+ { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() },
+ { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() },
+ ],
+ }));
+ const transparentCheck = check({
+ label: 'PNGの背景を透明にする', value: false,
+ onChange: (v) => { state.render.pngTransparent = v; },
+ });
+ exportSection.add(transparentCheck.el);
+ exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。'));
+
+ // まんが(コマ)gets its own section: it is a different kind of output.
+ const comicSection = section(tabExport, 'まんが');
+ const storyScaleSegment = segmented({
+ label: '書き出しの大きさ',
+ options: [
+ { value: '1', label: '1×(軽い)' },
+ { value: '1.5', label: '1.5×' },
+ { value: '2', label: '2×(きれい)' },
+ ],
+ value: '1.5',
+ hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。'
+ + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)',
+ onChange: (value) => { state.story.scale = Number(value); },
+ });
+ comicSection.add(storyScaleSegment.el);
+ const storyColumnsSlider = slider({
+ label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`,
+ onInput: (v) => { state.story.columns = Math.round(v); },
+ });
+ const storyGapSlider = slider({
+ label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { state.story.gap = v; },
+ });
+ const storyBackgroundField = colorField({
+ label: '台紙の色', value: '#ffffff',
+ onChange: (v) => { state.story.sheetBackground = v; },
+ });
+ comicSection.add(storyColumnsSlider.el);
+ comicSection.add(storyGapSlider.el);
+ comicSection.add(storyBackgroundField.el);
+ comicSection.add(buttons({
+ items: [
+ { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } },
+ { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) },
+ ],
+ }));
+ comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。'));
+ const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } });
+ comicSection.add(controlRow(null, storyPanelBox, { wide: true }));
+ let storyShape = '';
+
+ /** Bring back everything a panel saved: pose, face, 擬音 and the camera. */
+ const recallStoryPanel = (index) => {
+ const panel = state.story?.panels?.[index];
+ if (!panel) return;
+ // Replace the bones outright: a merged pose would keep bones the panel never had.
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ app.actions.applyState({
+ pose: panel.pose ?? {},
+ face: panel.face ?? {},
+ // The 擬音 on the shot. A panel saved before this existed clears them, so
+ // recalling it shows what it actually recorded rather than a leftover.
+ gion: panel.gion ?? { items: [] },
+ // The camera it was recorded with, so the view comes back too. A panel
+ // saved before this existed simply keeps the current camera.
+ ...(panel.view ? { view: panel.view } : {}),
+ }, { scope: 'all', sync: true });
+ };
+
+ const removeStoryPanel = (index) => {
+ state.story = state.story ?? {};
+ state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index);
+ // The empty patch is only here to route the edit through the app's own apply
+ // path: that is what records an undo step and re-reads the panel.
+ app.actions.applyState({}, { scope: 'all', sync: true });
+ };
+
+ /**
+ * Same rebuild policy as the prop list: only when the set of panels changes.
+ *
+ * A preview arrives a moment *after* its panel does (it needs a render), so its
+ * version is part of the identity: when one turns up, the row is drawn again
+ * with it.
+ */
+ const renderStoryPanels = () => {
+ const panels = state.story?.panels ?? [];
+ const thumbs = app.actions.storyThumbs?.() ?? null;
+ const shape = `${panels.length}:${panels
+ .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`)
+ .join('|')}`;
+ if (shape === storyShape) return;
+ storyShape = shape;
+ storyPanelBox.replaceChildren();
+ panels.forEach((panel, index) => {
+ const thumb = thumbs?.get?.(panel.id)?.url;
+ const preview = thumb
+ ? h('img', {
+ class: 'story-thumb',
+ alt: panel.label ?? `コマ${index + 1}`,
+ src: thumb,
+ title: 'クリックでこのコマを呼び出す',
+ style: {
+ width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer',
+ background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px',
+ },
+ onClick: () => recallStoryPanel(index),
+ })
+ : h('div', {
+ text: '(プレビューなし)',
+ style: {
+ padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd',
+ color: '#8a80a0', fontSize: '11px',
+ },
+ });
+ const block = h('div', {
+ class: 'story-panel',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' },
+ }, subhead(panel.label ?? `コマ${index + 1}`), preview);
+ block.append(buttons({
+ items: [
+ { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) },
+ { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) },
+ ],
+ }).el);
+ storyPanelBox.append(block);
+ });
+ };
+ renderStoryPanels();
+
+ // The share section comes after まんが, so the reading order is 画像 → まんが →
+ // 共有 (the order the outputs are usually wanted in).
+ /* ------------------------------------------------------------ extra export */
+
+ const extraExportSection = section(tabExport, '共有');
+ extraExportSection.add(buttons({
+ items: [
+ { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() },
+ { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') },
+ { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') },
+ { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() },
+ ],
+ }));
+ extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。'
+ + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。'));
+
+ // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは
+ // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして
+ // 共有のあとに置く(素材を作る → 見せる、の順)。
+ const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す');
+ faceMapSection.add(buttons({
+ items: [
+ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
+ { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
+ ],
+ }));
+ faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
+ + 'そのまま絵を描いて「目の画像を読み込む(PNG)」や「口の画像を読み込む(PNG)」で読み込むと、'
+ + '描いた場所にそのまま入ります。青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'));
+
+ /* --------------------------------------------------------------- settings */
+
+ const settingsSection = section(tabExport, '設定');
+
+ settingsSection.add(buttons({
+ items: [
+ { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() },
+ { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() },
+ ],
+ }));
+
+ const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } });
+ settingsPicker.addEventListener('change', async () => {
+ const file = settingsPicker.files?.[0];
+ settingsPicker.value = '';
+ if (!file) return;
+ try {
+ const text = await file.text();
+ app.actions.loadSettings(text);
+ } catch (error) {
+ console.error(error);
+ toast('設定ファイルを読み込めませんでした');
+ }
+ });
+ settingsSection.add(settingsPicker);
+
+ /* -------------------------------------------------------------------- sync */
+
+ // A tiny read-out of what the renderer is actually doing: if the picture ever
+ // looks wrong or never appears, this says why (buffer size, quality, fps).
+ const infoSection = section(tabScene, '画面の情報');
+ const statsLine = h('p', { class: 'hint', text: '計測中…' });
+ infoSection.add(statsLine);
+ infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
+
+ // シーン: the scene controls are built wherever they belong in the code, so put
+ // them on the tab in the order the user meets them: what is behind the
+ // character, what is beside it, then the light and the read-out.
+ tabScene.append(
+ backdropSection.el, propSection.el, gionSection.el, wallSection.el,
+ screenLightSection.el, infoSection.el,
+ );
+
+ boneSyncers.push(() => {
+ const name = rig.selected;
+ for (const [key, button] of boneButtons) button.classList.toggle('active', key === name);
+ const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 };
+ axisSliders.x.set(delta.x);
+ axisSliders.y.set(delta.y);
+ axisSliders.z.set(delta.z);
+ const root = state.pose.root ?? [0, 0, 0];
+ rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0));
+ });
+
+ syncers.push(...boneSyncers);
+
+ syncers.push(() => {
+ const linked = state.face.eyes.linked !== false;
+ bothControls.group.setVisible(linked);
+ leftControls.group.setVisible(!linked);
+ rightControls.group.setVisible(!linked);
+ bothControls.sync();
+ leftControls.sync();
+ rightControls.sync();
+
+ for (const key of ['left', 'right']) {
+ const eye = state.face.eyes[key];
+ const w = eyeAdvancedWidgets[key];
+ w.eyeX.set(eye.eyeX ?? 0);
+ }
+
+ glassesToggle.set(state.face.eyes.glasses.enabled === true);
+ glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');
+ for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]);
+ glassesFrameColor.set(state.face.eyes.glasses.frameColor);
+ glassesLensColor.set(state.face.eyes.glasses.lensColor);
+ eyeColors.white.set(state.face.eyes.white);
+ eyeColors.iris.set(state.face.eyes.iris);
+ eyeColors.line.set(state.face.eyes.line);
+ });
+
+ syncers.push(() => {
+ snotToggle.set(state.face.eyes.snot.enabled === true);
+ snotSize.set(state.face.eyes.snot.size);
+ for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]);
+ snotColor.set(state.face.eyes.snot.color);
+ });
+
+ syncers.push(() => {
+ mouthToggle.set(state.face.mouth.visible);
+ for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]);
+ for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]);
+ const mouthShape = mouthModeOf(state.face.mouth);
+ mouthMode.set(mouthShape);
+ const shown = new Set(mouthRowKeys[mouthShape]);
+ for (const key of mouthRowOrder) mouthSliders[key].el.style.display = shown.has(key) ? '' : 'none';
+ });
+
+ syncers.push(() => {
+ motionSegment.set(state.anim.mode ?? 'off');
+ animWidgets.blink.set(state.anim.blink);
+ animWidgets.lookAround.set(state.anim.lookAround);
+ });
+
+ syncers.push(() => {
+ styleSegment.set(state.render.style);
+ outlineToggle.set(state.render.outline);
+ outlinePixelsSlider.set(state.render.outlinePixels ?? 2);
+ leafBodyLineCheck.set(state.render.leafBodyLine !== false);
+ transparentCheck.set(state.render.pngTransparent);
+ lightPad.set({
+ x: state.render.lightAzimuth / 180,
+ y: Math.max(0, (state.render.lightElevation - 5) / 80),
+ });
+ lightIntensitySlider.set(state.render.lightIntensity ?? 2.1);
+ ambientSlider.set(state.render.ambient ?? 0.9);
+ lightColorField.set(state.render.lightColor ?? '#ffffff');
+ ambientColorField.set(state.render.ambientColor ?? '#ffffff');
+ exposureSlider.set(state.render.exposure ?? 1);
+ const line = ['lineart', 'outline'].includes(state.render.style);
+ outlineToggle.el.querySelector('input').disabled = line;
+ });
+
+ syncers.push(() => {
+ projectionSegment.set(state.view.projection);
+ cameraSliders.azimuth.set(state.view.azimuth);
+ cameraSliders.polar.set(state.view.polar);
+ const ortho = state.view.projection === 'ortho';
+ cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12));
+ cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5);
+ autoRotateToggle.set(state.view.autoRotate);
+ });
+
+ syncers.push(() => {
+ lookAtToggle.set(state.lookAt?.enabled === true);
+ for (const [key, widget] of Object.entries(lookAtSliders)) {
+ widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]);
+ }
+ turnBodyToggle.set(state.lookAt?.turnBody === true);
+ });
+
+ syncers.push(() => {
+ envIntensitySlider.set(state.render?.envIntensity ?? 1);
+ });
+
+ syncers.push(() => {
+ mirrorToggle.set(state.render?.mirror === true);
+ shadowCheck.set(state.render?.shadow !== false);
+ shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22);
+ shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6);
+ contactShadowToggle.set(state.render?.contactShadow === true);
+ contactBlobToggle.set(state.render?.contactBlob === true);
+ environmentSegment.set(state.render?.environment ?? 'gradient');
+ });
+
+ syncers.push(() => {
+ const background = state.view?.background ?? 'solid';
+ // 素材 is active for either half of it, and its rows show only then.
+ const isMaterial = background === 'preset' || background === 'effect';
+ backgroundModeSegment.set(isMaterial ? 'material' : background);
+ materialGroup.style.display = isMaterial ? '' : 'none';
+ backgroundColour.set(state.view?.backgroundColor ?? '#ffffff');
+ backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover');
+ backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0);
+ backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0);
+ backgroundScaleSlider.set(state.view?.backgroundScale ?? 1);
+ cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment');
+ cameraMirrorToggle.set(state.view?.cameraMirror === true);
+ gyroToggle.set(state.view?.gyro === true);
+ });
+
+ syncers.push(() => {
+ renderProps();
+ const items = state.props?.items ?? [];
+ // A rebuild only carries the whole-prop scale, so re-apply each sign's face
+ // size to the freshly built mesh here (see `placedPropGroups`).
+ const groups = placedPropGroups(app);
+ for (const entry of propWidgets) {
+ const item = items[entry.index] ?? {};
+ entry.sliders.x.set(item.x ?? 0);
+ entry.sliders.y.set(item.y ?? 0);
+ entry.sliders.z.set(item.z ?? 0);
+ entry.sliders.rotX.set(item.rotX ?? 0);
+ entry.sliders.rotY.set(item.rotY ?? 0);
+ entry.sliders.rotZ.set(item.rotZ ?? 0);
+ entry.sliders.scale.set(item.scale ?? 1);
+ entry.faceScale?.set(item.faceScale ?? 1);
+ applyPropFaceScale(groups[entry.index], item.faceScale);
+ entry.signText?.set(item.text ?? '');
+ }
+ });
+
+ syncers.push(() => {
+ renderGion();
+ const items = state.gion?.items ?? [];
+ for (const entry of gionWidgets) {
+ const item = items.find((candidate) => candidate.id === entry.id);
+ if (!item) continue;
+ entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH);
+ entry.sliders.rot.set(item.rot ?? 0);
+ entry.flip.set(item.flip === true);
+ entry.block.classList.toggle('active', item.id === app.gionSelected);
+ }
+ });
+
+ syncers.push(() => {
+ for (const group of wallGroups) {
+ const wall = state.render?.[group.key] ?? defaultState().render[group.key];
+ group.toggle.set(wall.on === true);
+ for (const [key, widget] of Object.entries(group.sliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
+ group.guide.set(wall.guide === true);
+ }
+ });
+
+ syncers.push(() => {
+ storyScaleSegment.set(String(state.story?.scale ?? 1.5));
+ storyColumnsSlider.set(state.story?.columns ?? 1);
+ storyGapSlider.set(state.story?.gap ?? 12);
+ storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff');
+ renderStoryPanels();
+ });
+
+ app.actions.applyFacePreset = applyFacePreset;
+ app.actions.applyPosePreset = applyPosePreset;
+
+ sync();
+
+ // The 'g' key toggles the gizmo behind the panel's back, so the checkbox
+ // follows the rig rather than the other way round.
+ syncers.push(() => gizmoToggle.set(rig.helper.visible === true));
+
+ return {
+ sync,
+ onRigChanged: syncBones,
+ /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */
+ selectGion(id) {
+ app.gionSelected = id ?? null;
+ sync();
+ },
+ /** Open the crop picker for a sheet (the 擬音 section's button calls this). */
+ openGionPicker(sheet) {
+ openGionPicker(sheet ?? gionSheetSelect.get());
+ },
+ setStats(text) { statsLine.textContent = text; },
+ setRecording(recording) {
+ recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始');
+ },
+ };
+}
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
new file mode 100644
index 0000000..a31c952
--- /dev/null
+++ b/public/bluebey-studio/src/presets.js
@@ -0,0 +1,668 @@
+/**
+ * The complete, serialisable studio state plus the preset libraries.
+ *
+ * Everything here is plain JSON: "設定を保存" writes exactly this object, and
+ * "設定を読み込む" merges it back in, so a look can be archived and shared.
+ *
+ * Eye parameters are named after the character's own left/right, NOT the
+ * viewer's: `left` is the eye on the model's +x side, which is the half of the
+ * eye texture with u > 0.5. In a front view that eye appears on the right of
+ * the screen.
+ */
+
+/**
+ * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
+ * two opposite cuts) can be made. A wall's position is kept *relative to the
+ * character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ */
+function wallDefaults() {
+ return {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character
+ yaw: 0, // turn about Y, degrees
+ tilt: 0, // lean about X, degrees
+ // The guide is the only part of the wall that is ever *drawn* (the wall
+ // itself writes depth but no colour, so it hides what is behind it
+ // without being visible). `guide` shows that rectangle while placing,
+ // which is also when it can be dragged in the viewport.
+ guide: false,
+ size: 1, // the wall's size (a real width and height, not a clip)
+ };
+}
+
+export function defaultState() {
+ return {
+ version: 1,
+ view: {
+ projection: 'persp', // persp | ortho
+ fov: 30,
+ azimuth: 0, // degrees, 0 = looking at the face
+ polar: 76, // degrees from +y, 90 = level with the target
+ distance: 0, // 0 = fit to the model automatically
+ orthoHeight: 0, // 0 = fit to the model automatically
+ targetY: 0, // 0 = centre of the model
+ // The mouse orbit and pan write their own target back here (see
+ // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps
+ // the camera the user actually framed.
+ targetX: 0,
+ targetZ: 0,
+ autoRotate: false,
+ autoRotateSpeed: 0.8,
+ // solid | transparent | preset | image | effect | camera
+ // preset = one of the CC0 backdrops in assets/backgrounds
+ // image = something the user loaded (data URL)
+ // effect = a procedurally drawn manga effect line (no file at all)
+ // camera = the phone's camera, for the AR mode
+ background: 'solid',
+ backgroundColor: '#ffffff',
+ backgroundPreset: 'autumn_park',
+ backgroundEffect: 'focus', // focus | fall | speed | ellipse
+ backgroundImage: null, // data URL, set by 「画像を読み込む」
+ backgroundFit: 'cover', // cover | contain | stretch | tile
+ backgroundBlur: 0, // px
+ backgroundDarken: 0, // 0..1 dark veil, helps the character read
+ backgroundScale: 1,
+ backgroundOffset: { x: 0, y: 0 },
+ // AR: which camera, and whether to mirror (front camera feels mirrored)
+ cameraFacing: 'environment', // environment | user
+ cameraMirror: false,
+ gyro: false, // look around by turning the phone
+ },
+ render: {
+ style: 'real', // real | flat | lineart | outline
+ outline: true,
+ outlineWidth: 0.022,
+ outlineColor: '#2a1e33',
+ paper: '#ffffff',
+ shadow: true,
+ // The soft round blob at the character's feet. Off by default: it sat at the
+ // origin and stayed behind when the character was moved. See look.js.
+ contactBlob: false,
+ pngScale: 2,
+ pngTransparent: false,
+ svgWidth: 2048,
+ lightAzimuth: -38,
+ lightElevation: 40,
+ lightIntensity: 2.1,
+ ambient: 0.9,
+ // The colour of the main light and of the ambient (sky) light.
+ lightColor: '#ffffff',
+ ambientColor: '#ffffff',
+ exposure: 1,
+ // The fixed body palette. ぶるべー keeps its colours, so there is no theme
+ // picker and no per-part editor; the parts live here so the styles and the
+ // model have one place to read them from.
+ colors: {
+ body: '#c8b0f0',
+ accent: '#8a4fe0',
+ nose: '#7a4fb0',
+ leaf: '#a6dd6a',
+ vein: '#7fbf3f',
+ feet: '#7a4fb0',
+ },
+ // Mirror the whole character, for laying out a panel that faces its text.
+ mirror: false,
+ // Shadows: the soft blob under the feet, plus the cast shadow.
+ shadowOpacity: 0.22,
+ shadowSoftness: 1.6,
+ shadowOffset: 0,
+ contactShadow: false, // blob only, no cast shadow
+ // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is
+ // the tiny procedural sky the studio always had.
+ environment: 'gradient', // gradient | room | none
+ envIntensity: 1,
+ // Hand-drawn line work (SVG export and the outline pass).
+ handDrawn: 0, // 0 = off, 1 = a lot
+ handDrawnSeed: 7,
+ handDrawnScale: 40,
+ handDrawnPasses: 1,
+ // How the outline is drawn - the two methods take the parts they are good
+ // at. See MODEL-GUIDE.md §5 and src/outline.js.
+ //
+ // 'screen' (the default) hands the leaves to the screen-space edge pass,
+ // because a hull cannot outline a shell that thin: the expanded
+ // front and back cross inside it and the line breaks up. Every
+ // other part keeps its hull, whose line is computed from the
+ // geometry and so comes out smooth, where the pixel grid would
+ // make it stepped.
+ // 'hull' the inverted-hull copy everywhere - cheap, and the way it was
+ // done before, but the leaves then come out as a tangle.
+ outlineMethod: 'screen',
+ outlinePixels: 2.4, // 'screen' only: line width in screen pixels
+ // Whether a leaf gets a line where it meets the *body*. There is no right
+ // answer here, so it is a setting:
+ // on - the body counts as paper, so the leaves are outlined where they
+ // emerge from it. Without this a line drawing cannot tell the
+ // leaves and the body apart at all (both are paper).
+ // off - the body blocks the line, so the leaf simply passes behind it and
+ // nothing is drawn along the meeting. Cleaner, but the leaves and
+ // the body merge into one white shape.
+ leafBodyLine: true,
+ // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide
+ // whatever is behind them, so the character can be buried in a wall and
+ // only the rest of the body shows. See src/clip.js.
+ wall: wallDefaults(),
+ wall2: wallDefaults(),
+ },
+ pose: {
+ bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0
+ root: [0, 0, 0], // whole-body offset in model units
+ },
+ anim: {
+ mode: 'off', // off | idle | walk
+ blink: true,
+ blinkInterval: 3.4, // seconds between blinks
+ lookAround: false,
+ speed: 1,
+ },
+ face: {
+ // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
+ // `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
+ // `tiltX` leans it forward/back and `tiltZ` leans it sideways.
+ hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 },
+ eyes: {
+ source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
+ linked: true, // move both eyes together
+ left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ irisScale: 1,
+ lookMax: 1.2,
+ highlight: true,
+ lidWidth: 14,
+ lowerLid: 0,
+ lashes: false,
+ lashAngle: 0,
+ lashPos: 0,
+ // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat`
+ // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the
+ // narrowed eye. See faceArt.js.
+ lidShape: 'curve',
+ lidTilt: 0,
+ heartScale: 2,
+ heartColor: '#e0344f',
+ // The tears live on each eye (see `eyes.left.tear`), so one eye can cry
+ // on its own; only the colour is shared.
+ tearColor: '#8fd8ff',
+ // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same
+ // plate as the eyes, out to the side of the nose. Starts off.
+ snot: {
+ enabled: false,
+ size: 1.23,
+ offsetX: -23,
+ offsetY: 0,
+ offsetZ: 16,
+ color: '#e3ecff',
+ },
+ // 眼鏡 / サングラス are drawn into the same texture as the eyes, so they
+ // travel with the head instead of being a separate 3D mesh. Off by
+ // default. `kind` only picks which lens defaults the panel offers; the
+ // drawing itself reads the colours and opacities below.
+ glasses: {
+ enabled: false,
+ kind: 'glasses', // glasses | sunglasses
+ frameColor: '#2a1e33',
+ frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke)
+ lensColor: '#2b2433',
+ lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark
+ lensGap: 0, // px, + moves the two lenses further apart
+ scale: 1, // lens size multiplier
+ offsetY: 0, // px, artwork units (positive = down)
+ tilt: 0, // degrees, both lenses turn together
+ },
+ white: '#ffffff',
+ iris: '#150e1b',
+ line: '#55386e',
+ },
+ mouth: {
+ source: 'parametric', // parametric | original
+ visible: true,
+ smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown
+ width: 1, // the smile line's length (the chord); also the O's width
+ thickness: 1,
+ open: 0, // 0 = closed lips, 1 = wide open
+ tilt: 0, // degrees
+ offsetY: 0, // texture pixels
+ tongue: 0.96, // 0 hides it; scales the whole tongue
+ tonguePos: 0.84, // 0..1 along the smile line
+ corners: 0.71, // length of the corner marks, 0 hides them
+ cornerAngle: -8, // degrees, tilts the corner marks
+ round: 0, // >0 turns the mouth into a round "O"
+ color: '#ff1a44',
+ innerColor: '#4a0f1e',
+ tongueColor: '#ff2d2d',
+ cornerColor: '#725497',
+ line: '#55386e',
+ },
+ },
+ // ----------------------------------------------------------------- look at
+ // Where the character should look (world units).
+ lookAt: {
+ enabled: false,
+ x: 0,
+ y: 2.6,
+ z: 3,
+ turnBody: false, // also rotate the whole body a little
+ amount: 1,
+ // The turn and the gaze are *remembered* rather than derived, so switching
+ // the mode (or just 体も向ける) off leaves the character where it is instead
+ // of snapping it back to the front. See `applyLookAt` in main.js.
+ bodyYawDeg: 0,
+ freeze: null,
+ },
+ // ------------------------------------------------------------------- props
+ // 小物. Each entry is a procedural prop from src/props.js.
+ props: {
+ items: [],
+ },
+ // -------------------------------------------------------------------- 擬音
+ // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop
+ // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only
+ // the sheet *name* is stored, never the bitmap, so a shared link stays small
+ // (see src/gion.js for the geometry).
+ gion: {
+ items: [],
+ },
+ // ------------------------------------------------------------------- story
+ // 4コマ / 紙芝居. Each panel is a small state patch (pose, face).
+ story: {
+ columns: 1,
+ gap: 12,
+ padding: 20,
+ sheetBackground: '#ffffff',
+ // How big each captured panel is, as a multiple of the viewport. 2x doubles
+ // each side, so the sheet PNG gets four times the pixels per panel.
+ scale: 1.5,
+ panels: [],
+ },
+ };
+}
+
+/** Expression presets. Each patches a copy of the default state. */
+export const FACE_PRESETS = [
+ {
+ id: 'normal',
+ label: 'ふつう',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // Depth 1 = the hand-drawn original; the corners and tongue below are also
+ // the settings that reproduce it (measured against the artwork).
+ mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 },
+ },
+ },
+ {
+ id: 'laugh',
+ label: 'にっこり',
+ face: {
+ eyes: {
+ left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ highlight: false,
+ },
+ // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a
+ // round mascot reads as a shout, so only びっくり keeps the round O.
+ mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 },
+ },
+ },
+ {
+ id: 'eating',
+ label: 'たべている',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // A small, cute round mouth (the other round mouth, with びっくり).
+ mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 },
+ },
+ },
+ {
+ id: 'wink',
+ label: 'ウインク',
+ face: {
+ eyes: {
+ // The shut eye is the original `eyes-close-tight` artwork: three strokes
+ // sharing one vertex, pointing at the nose.
+ left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 },
+ },
+ },
+ {
+ id: 'surprised',
+ label: 'びっくり',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 0.74,
+ lookMax: 0.35,
+ highlight: true,
+ },
+ mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 },
+ },
+ },
+ {
+ id: 'sleepy',
+ label: 'ねぼけ',
+ face: {
+ eyes: {
+ left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ // A sleeper's snot bubble, with the defaults from the panel.
+ snot: { enabled: true },
+ },
+ mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 },
+ },
+ },
+ {
+ id: 'shy',
+ label: 'てれ',
+ face: {
+ eyes: {
+ // Blushing, eyes half shut and looking away.
+ left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 0.5, width: 0.82, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0.5 },
+ },
+ },
+ {
+ id: 'sidelong',
+ label: 'じと目',
+ face: {
+ eyes: {
+ // Flat, narrowed lids tilted inwards - a sideways, unimpressed look.
+ // 上まぶたの高さ is now independent of the lower lid, so `open` carries the
+ // whole slit (it used to be measured from the raised lower lid).
+ left: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ right: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ lowerLid: 0.42,
+ lidShape: 'flat',
+ lidTilt: 12,
+ irisScale: 0.92,
+ highlight: true,
+ },
+ mouth: { smile: 0.12, width: 0.78, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'love',
+ label: 'すき',
+ face: {
+ eyes: {
+ left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ irisScale: 1,
+ // The white behind a heart would poke out around its lobes, so hide it.
+ heartColor: '#e0344f',
+ highlight: false,
+ },
+ mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 },
+ },
+ },
+ {
+ id: 'grumpy',
+ label: 'むっと',
+ face: {
+ eyes: {
+ left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ irisScale: 0.86,
+ highlight: true,
+ },
+ mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'cry',
+ label: 'ないてる',
+ face: {
+ eyes: {
+ // Big tears that hang low and lean outwards - the classic "crying hard"
+ // look. The sideways position and the tilt are mirrored between the eyes,
+ // so the pair stays symmetric.
+ left: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: -20 },
+ right: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: 20 },
+ irisScale: 1.02,
+ lowerLid: 0.42,
+ highlight: true,
+ },
+ mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+];
+
+/**
+ * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose.
+ *
+ * The axes were measured (turn one bone 40° and watch the hand's centre move),
+ * and the two sides are NOT the same. That is what used to send the left hand
+ * behind while the right one came forward:
+ *
+ * arm / hand
+ * X lift. Up on both sides, so the SAME value is symmetric.
+ * Y roll about the flipper - which way the palm faces.
+ * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a
+ * symmetric pose writes the right-hand Z with the opposite sign, while
+ * a walk (opposite arms) writes the SAME sign on both.
+ * legsupport
+ * X out to the side and up (same value on both sides is symmetric)
+ * Y twist (which way the toe points)
+ * Z forward/back, mirrored like `arm`
+ * It pivots at the middle of the body, so the feet travel a long way for
+ * a small angle - keep it under about 15 degrees.
+ * master
+ * X lean forward Y spin around Z roll sideways
+ *
+ * `armsupport` still exists in the rig but it is a control bone that pivots at
+ * the body's centre, so it swings a hand in a wide arc across the face. Every
+ * preset below drives `arm`/`hand` instead.
+ */
+export const POSE_PRESETS = [
+ { id: 'stand', label: '立ち', pose: { bones: {} } },
+ {
+ id: 'attention',
+ label: 'きおつけ',
+ pose: {
+ bones: {
+ // Both flippers dropped to the sides. A negative X lowers the arm, and
+ // the value is the same on both sides (only Z mirrors between them).
+ 'arm.l': [-62, 0, 0],
+ 'arm.r': [-62, 0, 0],
+ },
+ },
+ },
+ {
+ id: 'bow',
+ label: 'おじぎ',
+ pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'deep-bow',
+ label: 'ふかくおじぎ',
+ pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'tilt',
+ label: 'くびかしげ',
+ pose: {
+ bones: {
+ master: [0, 0, 16],
+ // The roll turns the whole body about its base, which swings the feet up
+ // and down, so the legs take the difference. The right-hand leg (on the
+ // left of the screen) is the one that is planted: it opens outwards a
+ // little and keeps the sole down. The left-hand leg bends back behind it.
+ 'legsupport.r': [8, 0, 0],
+ 'legsupport.l': [-6, 0, 14],
+ },
+ },
+ },
+ {
+ id: 'wave',
+ label: 'てをふる',
+ pose: {
+ bones: {
+ master: [0, 0, -4],
+ // The left flipper goes up and slightly forward. Y is the roll about the
+ // flipper's own length, which is what turns the palm to face outwards.
+ 'arm.l': [84, 0, -6],
+ 'hand.l': [0, -30, 0],
+ },
+ },
+ },
+ {
+ id: 'banzai',
+ label: 'ばんざい',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ // Both flippers as far up as the shoulder allows (the pivot sits at the
+ // middle of the body, so they cannot reach above the head).
+ 'arm.l': [106, 0, -12],
+ 'arm.r': [106, 0, 12],
+ },
+ root: [0, 0.2, 0],
+ },
+ },
+ {
+ id: 'stretch',
+ label: 'のびをする',
+ pose: {
+ bones: {
+ master: [-16, 0, 0],
+ 'arm.l': [112, 0, -8],
+ 'arm.r': [112, 0, 8],
+ },
+ root: [0, 0.08, 0],
+ },
+ },
+ {
+ id: 'jump',
+ label: 'ジャンプ',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ 'arm.l': [68, 0, -10],
+ 'arm.r': [68, 0, 10],
+ 'legsupport.l': [10, 0, 0],
+ 'legsupport.r': [10, 0, 0],
+ },
+ root: [0, 0.62, 0],
+ },
+ },
+ {
+ id: 'leap',
+ label: '大ジャンプ',
+ pose: {
+ bones: {
+ master: [-18, 0, 0],
+ 'arm.l': [92, 0, -12],
+ 'arm.r': [92, 0, 12],
+ 'legsupport.l': [14, 0, 0],
+ 'legsupport.r': [14, 0, 0],
+ },
+ root: [0, 1.0, 0],
+ },
+ },
+ {
+ id: 'dance',
+ label: 'おどる',
+ pose: {
+ bones: {
+ master: [-4, 0, -12],
+ // Deliberately uneven: the left flipper is up, the right one is out in
+ // front, and the feet step the other way.
+ 'arm.l': [78, 0, -14],
+ 'arm.r': [14, 0, 34],
+ 'legsupport.l': [0, 0, 8],
+ 'legsupport.r': [0, 0, 8],
+ },
+ root: [0, 0.14, 0],
+ },
+ },
+ {
+ id: 'tip-over',
+ label: 'こてん',
+ pose: {
+ bones: {
+ master: [0, 0, 68],
+ 'arm.l': [26, 0, -18],
+ 'arm.r': [10, 0, 18],
+ },
+ root: [0, 0.25, 0],
+ },
+ },
+ {
+ id: 'lie',
+ label: 'ねている',
+ pose: {
+ bones: {
+ // Flat on its back: X tips the body backwards all the way over, so the
+ // face looks up. The root lift keeps the back resting on the ground
+ // rather than sinking through it (the body's depth becomes its height).
+ master: [-88, 0, 0],
+ // Both flippers rest against the body, loose.
+ 'arm.l': [-18, 0, 0],
+ 'arm.r': [-18, 0, 0],
+ },
+ root: [0, 1.5, 0],
+ },
+ },
+ { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } },
+ { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } },
+ {
+ id: 'present',
+ label: '右を紹介',
+ pose: {
+ bones: {
+ master: [6, -26, 0],
+ // The right flipper sweeps forward to point at whatever is on its right
+ // (+Z is forwards on the right); the left one just steps aside.
+ 'arm.r': [16, 0, 52],
+ 'arm.l': [10, 0, -6],
+ },
+ },
+ },
+ {
+ id: 'present-left',
+ label: '左を紹介',
+ pose: {
+ bones: {
+ master: [6, 26, 0],
+ 'arm.l': [16, 0, -52],
+ 'arm.r': [10, 0, 6],
+ },
+ },
+ },
+];
+
+/** Deep-merge `patch` into `target` (plain objects only). */
+export function applyPatch(target, patch) {
+ for (const [key, value] of Object.entries(patch ?? {})) {
+ if (value && typeof value === 'object' && !Array.isArray(value)
+ && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
+ applyPatch(target[key], value);
+ } else {
+ target[key] = Array.isArray(value) ? value.slice() : value;
+ }
+ }
+ return target;
+}
+
+export function cloneState(state) {
+ return JSON.parse(JSON.stringify(state));
+}
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
new file mode 100644
index 0000000..78525bd
--- /dev/null
+++ b/public/bluebey-studio/src/props.js
@@ -0,0 +1,646 @@
+import * as THREE from 'three';
+
+/**
+ * Procedural 小物 (props) that can be placed on the stage next to the character:
+ * a lectern, a desk, a microphone stand, a signboard, a potted plant and a
+ * cardboard box.
+ *
+ * Everything is built from three's primitives instead of loaded from a file, for
+ * three reasons. The character is about 4.15 units tall with its feet on y = 0
+ * and its body a sphere of radius ~1.6 centred near y = 2.4, so every generator
+ * below works from that scale, and every group's origin is its *base centre* -
+ * dropping one at a ground position and rotating it about y is all the app has
+ * to do. The studio also re-skins props with the same toon/flat materials and
+ * the same inverted-hull outline as the body, so the geometry stays chunky: no
+ * plate thinner than ~0.05 units (a thin plate's offset hull turns into a smear)
+ * and no textures anywhere. And props must be identical on every run, so every
+ * size, position and angle here is a literal - no Math.random().
+ *
+ * Colours are never hard-coded on a mesh. Each material is tagged with
+ * `userData.part`, and `applyPropColors` fills the colour in from the app's
+ * render.colors palette, which is what lets the colour-theme feature recolour a
+ * prop that was built minutes ago. The mapping keeps lightness apart from hue,
+ * so a prop still reads in the near-monochrome すみ theme:
+ *
+ * wood the body colour, darkened - painted wood, cardboard, soil
+ * paper the body colour, nearly white - the blank sign face
+ * accent the accent colour - legs, frames, pots, mic bodies
+ * leaf the leaf colour - foliage
+ * metal a fixed neutral grey - stands, booms, microphone heads
+ *
+ * `userData.shade` multiplies a part's colour, so one prop can use two tones of
+ * the same part - a cardboard box and its darker inner flaps - without adding a
+ * sixth palette entry.
+ */
+
+/** Reused when something has to be aimed along a direction vector. */
+const UP = new THREE.Vector3(0, 1, 0);
+const WHITE = new THREE.Color(0xffffff);
+/** A fixed grey: a metal stand has to read in every theme, warm or grey. */
+const METAL_GREY = '#b9bec7';
+/** Enough of the palette to build with when the app has not sent one yet. */
+const FALLBACK_COLORS = { body: '#c8b0f0', accent: '#8a4fe0', leaf: '#a6dd6a' };
+
+const PART_NAMES = ['wood', 'metal', 'paper', 'accent', 'leaf'];
+
+const clamp01 = (value) => Math.min(1, Math.max(0, value));
+
+/** The lectern's reading surface tilts up towards the audience, i.e. towards +z. */
+const SLAB_TILT = -0.32;
+
+// ---------------------------------------------------------------------------
+// Building blocks
+// ---------------------------------------------------------------------------
+
+/**
+ * A material per (part, shade) inside one prop. Sharing them keeps the draw
+ * calls down and, more importantly, lets `disposeProp` release each one once.
+ */
+function materialCache() {
+ const cache = new Map();
+ return (part, shade = 1) => {
+ if (!PART_NAMES.includes(part)) throw new Error(`unknown prop material part: ${part}`);
+ const key = `${part}:${shade}`;
+ let material = cache.get(key);
+ if (!material) {
+ const metal = part === 'metal';
+ material = new THREE.MeshStandardMaterial({
+ color: 0xffffff, // filled in by applyPropColors
+ roughness: 0.75,
+ metalness: metal ? 0.6 : 0,
+ });
+ material.userData.part = part;
+ material.userData.shade = shade;
+ cache.set(key, material);
+ }
+ return material;
+ };
+}
+
+/**
+ * Wraps a generator so each prop gets its own material cache. The generator
+ * receives `(group, material, options)`, fills the group and returns nothing.
+ */
+function prop(generator) {
+ return (options = {}) => {
+ const group = new THREE.Group();
+ generator(group, materialCache(), options);
+ return group;
+ };
+}
+
+/** Adds a mesh, matching the body's shadow flags, and returns it. */
+function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) {
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ parent.add(mesh);
+ return mesh;
+}
+
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop
+ * that meet flush - the pencil's lead in its wood, a can's label on its body - come
+ * out as one white shape. Tagging one of them with a tone puts the seam back; it
+ * has no effect on リアル / フラット, where the part's own colour draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
+/** A cuboid, sized as [width, height, depth] and centred on `at`. */
+function addBox(parent, material, size, at = [0, 0, 0]) {
+ const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A cylinder (or cone, if the two radii differ) centred on `at`. */
+function addCylinder(parent, material, radiusTop, radiusBottom, height, at, segments = 12) {
+ const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A rounded blob. The caller usually scales it into a leaf or an ellipsoid. */
+function addSphere(parent, material, radius, at, segments = [10, 8]) {
+ const geometry = new THREE.SphereGeometry(radius, segments[0], segments[1]);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A rod from `from` to `to`: stems, booms and struts are all just this. */
+function addStrut(parent, material, from, to, radius, segments = 8) {
+ const start = new THREE.Vector3(from[0], from[1], from[2]);
+ const end = new THREE.Vector3(to[0], to[1], to[2]);
+ const axis = end.clone().sub(start);
+ const length = axis.length();
+ const mesh = addMesh(
+ parent,
+ new THREE.CylinderGeometry(radius, radius, length, segments),
+ material,
+ (start.x + end.x) / 2,
+ (start.y + end.y) / 2,
+ (start.z + end.z) / 2,
+ );
+ mesh.quaternion.setFromUnitVectors(UP, axis.normalize());
+ return mesh;
+}
+
+// ---------------------------------------------------------------------------
+// The props
+// ---------------------------------------------------------------------------
+
+/**
+ * 演台. A foot plate, a pedestal, a slab that tilts up towards the audience and
+ * a gooseneck microphone on the corner nearest the camera. The mic is on the
+ * audience edge on purpose: on the far edge the slab would hide it from a
+ * slightly-above camera.
+ */
+const buildPodium = prop((group, material) => {
+ addBox(group, material('accent'), [1.30, 0.10, 0.98], [0, 0.05, 0]);
+ addBox(group, material('wood'), [1.10, 1.30, 0.78], [0, 0.75, 0]);
+
+ const slab = addBox(group, material('wood'), [1.42, 0.12, 0.86], [0, 1.42, 0.10]);
+ slab.rotation.x = SLAB_TILT;
+ // A lip along the slab's low edge, so the silhouette reads as a lectern top.
+ const lip = addBox(group, material('accent'), [1.02, 0.10, 0.10], [0, 1.498, 0.527]);
+ lip.rotation.x = SLAB_TILT;
+
+ const stem = addCylinder(group, material('metal'), 0.032, 0.032, 0.60, [-0.48, 1.80, 0.26], 8);
+ stem.rotation.x = SLAB_TILT;
+ const head = addSphere(group, material('metal'), 0.09, [-0.48, 2.11, 0.15], [10, 8]);
+ head.scale.set(1, 1.25, 1);
+});
+
+/**
+ * 机. A top, four legs and a pair of side rails. The legs and rails take the
+ * darker `accent` tone so the table keeps its shape in a flat or greyscale
+ * render, where a single flat colour would turn it into a silhouette.
+ */
+const buildDesk = prop((group, material) => {
+ addBox(group, material('wood'), [2.30, 0.12, 1.20], [0, 1.44, 0]);
+ for (const x of [-1.00, 1.00]) {
+ for (const z of [-0.46, 0.46]) {
+ addBox(group, material('accent'), [0.14, 1.38, 0.14], [x, 0.69, z]);
+ }
+ addBox(group, material('accent'), [0.12, 0.10, 1.00], [x, 0.42, 0]);
+ }
+});
+
+/**
+ * マイク. A round weighted base, a pole, a short boom and a body with a grille
+ * head, standing about as high as the character's hands.
+ */
+const buildMic = prop((group, material) => {
+ addCylinder(group, material('metal'), 0.40, 0.42, 0.10, [0, 0.05, 0], 16);
+ addCylinder(group, material('metal'), 0.048, 0.058, 1.35, [0, 0.775, 0], 10);
+ addStrut(group, material('metal'), [0, 1.42, 0], [0, 1.62, 0.28], 0.038);
+
+ const body = addCylinder(group, material('accent'), 0.13, 0.13, 0.44, [0, 1.80, 0.36], 12);
+ body.rotation.x = 0.30;
+ const head = addSphere(group, material('metal'), 0.145, [0, 2.05, 0.42], [12, 8]);
+ head.scale.set(1, 0.9, 1);
+});
+
+/**
+ * 看板. A post, a frame and a blank face. The face is a named child tagged
+ * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it
+ * without hunting for the writable mesh. The face *and* its frame are grouped,
+ * so `applyPropFaceScale` can grow the whole board about the face's centre while
+ * the post and base stay put. Both are thin boxes rather than planes: the outline
+ * pass offsets geometry along its normals, and a zero-thickness plane gets a
+ * visibly doubled rim.
+ */
+const buildSign = prop((group, material) => {
+ addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]);
+ addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]);
+
+ // The board is one group so the face and its frame scale together, about the
+ // board's own centre - which is also the face's centre.
+ const board = new THREE.Group();
+ board.name = 'sign-board';
+ board.userData.faceScaleGroup = true;
+ board.position.set(0, 2.21, 0);
+ group.add(board);
+
+ addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]);
+ const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]);
+ face.name = 'sign-face';
+ face.userData.canvasTexture = true;
+});
+
+/** The branches of 観葉植物: where a stem ends, which way its leaf points. */
+const PLANT_BRANCHES = [
+ { end: [0.05, 1.12, 0.04], dir: [0.10, 0.96, 0.14] },
+ { end: [-0.30, 0.98, -0.06], dir: [-0.52, 0.76, -0.30] },
+ { end: [0.28, 1.00, 0.14], dir: [0.48, 0.74, 0.40] },
+ { end: [-0.40, 0.80, -0.16], dir: [-0.86, 0.36, -0.28] },
+ { end: [0.38, 0.82, 0.16], dir: [0.88, 0.32, 0.26] },
+ { end: [-0.04, 1.06, -0.20], dir: [-0.14, 0.95, -0.28] },
+];
+
+/**
+ * 観葉植物. A tapered pot with a rim, dark soil, and leaves that are just
+ * squashed spheres aimed along each branch's direction - the cheapest way to get
+ * a soft, rounded leaf that still takes a clean outline.
+ */
+const buildPlant = prop((group, material) => {
+ addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14);
+ tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18);
+ tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28);
+
+ for (const branch of PLANT_BRANCHES) {
+ addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035);
+
+ const direction = new THREE.Vector3(branch.dir[0], branch.dir[1], branch.dir[2]).normalize();
+ const leaf = addSphere(group, material('leaf'), 0.30, [0, 0, 0], [8, 6]);
+ leaf.scale.set(0.46, 1, 0.22);
+ leaf.position.set(branch.end[0], branch.end[1], branch.end[2]).addScaledVector(direction, 0.26);
+ leaf.quaternion.setFromUnitVectors(UP, direction);
+ }
+});
+
+/**
+ * 段ボール箱. The body and two lid flaps, plus a tape strip when the lid is
+ * shut. The flaps are children of pivot groups sitting on the box's top edges,
+ * so `open: true` is a single rotation each. The flaps are a darker shade of the
+ * same wood part, which is what makes the box read as cardboard rather than a
+ * solid block.
+ */
+const buildBox = prop((group, material, options) => {
+ const flapsDown = options.open !== true;
+ addBox(group, material('wood'), [1.24, 1.00, 1.24], [0, 0.50, 0]);
+
+ const back = new THREE.Group();
+ back.position.set(0, 1.00, -0.62);
+ back.rotation.x = flapsDown ? 0 : -1.05;
+ group.add(back);
+ tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20);
+
+ const front = new THREE.Group();
+ front.position.set(0, 1.00, 0.62);
+ front.rotation.x = flapsDown ? 0 : 1.05;
+ group.add(front);
+ tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20);
+
+ if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]);
+});
+
+/**
+ * 空いた缶詰. A short metal can, opened: a foot bead, the body, a label band and
+ * a mouth rim, plus the single lid peeled back on a hinge at the back of the
+ * mouth so it stands up behind the opening. It is deliberately
+ * small next to the 4-unit-tall character - something to be pointed at rather
+ * than stood behind - so every size here stays under a unit, and the whole can
+ * leans a little so it does not read as a diagram. The label is the one coloured
+ * part; everything else is the neutral `metal` grey, so it still reads as a tin.
+ */
+const buildCan = prop((group, material) => {
+ addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16);
+ addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16);
+ tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20);
+ addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16);
+ // A darker disc just inside the rim so the mouth reads as empty, not capped.
+ tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30);
+
+ // The one lid, peeled back on a hinge at the back of the mouth so its
+ // underside shows. The hinge sits *on* the can's back rim and the disc is
+ // placed so its own rim passes through that same point (its centre is one
+ // radius in front of the hinge), so opening the hinge rotates the lid about
+ // its edge - the two rims stay joined instead of the lid floating in mid-air.
+ // It is twisted slightly sideways so it does not read as a knob sitting
+ // straight up on the can.
+ const lidRadius = 0.132;
+ const hinge = new THREE.Group();
+ hinge.position.set(0, 0.42, -lidRadius);
+ hinge.rotation.x = -1.05;
+ hinge.rotation.z = 0.4;
+ group.add(hinge);
+ addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18);
+
+ group.rotation.z = 0.05;
+});
+
+/**
+ * ベッド. A wooden frame on four legs with a mattress, a headboard at the far end
+ * and a pillow, sized so the character can be posed lying on it.
+ */
+const buildBed = prop((group, material) => {
+ // Sized to take the character lying down: the body is a sphere of radius ~1.6,
+ // so the frame is a little over 3 units across and 4 units long.
+ for (const x of [-1.55, 1.55]) {
+ for (const z of [-1.85, 1.85]) {
+ addBox(group, material('wood'), [0.22, 0.5, 0.22], [x, 0.25, z]);
+ }
+ }
+ addBox(group, material('wood'), [3.5, 0.26, 4.3], [0, 0.63, 0]);
+ // The headboard sits at the far (-z) end, away from the camera.
+ addBox(group, material('wood'), [3.5, 1.5, 0.2], [0, 1.25, -2.15]);
+ addBox(group, material('paper'), [3.3, 0.46, 4.1], [0, 0.99, 0]);
+
+ // The pillow, slightly tipped up against the headboard.
+ const pillow = addBox(group, material('paper', 0.9), [2.2, 0.34, 0.9], [0, 1.39, -1.5]);
+ pillow.rotation.x = -0.08;
+});
+
+/**
+ * 布団. A mattress laid on the floor, a pillow and a quilt folded over the lower
+ * end.
+ */
+const buildFuton = prop((group, material) => {
+ addBox(group, material('paper'), [3.6, 0.3, 4.4], [0, 0.15, 0]);
+ const pillow = addBox(group, material('paper', 0.9), [2.2, 0.3, 0.9], [0, 0.45, -1.6]);
+ pillow.rotation.x = -0.06;
+ addBox(group, material('accent', 0.8), [3.6, 0.24, 2.7], [0, 0.42, 0.85]);
+});
+
+// ---------------------------------------------------------------------------
+// The library
+// ---------------------------------------------------------------------------
+
+/**
+ * `height` is the approximate height in units, measured from the base, so the UI
+ * can offer a sensible scale. Where a prop has options, it is the height of the
+ * default variant: an open cardboard box stands about a third taller again, so
+ * the offered scale stays sensible either way.
+ */
+/** 鉛筆: an oversized pencil standing on its eraser, point up. */
+const buildPencil = prop((group, material) => {
+ const r = 0.13;
+ // Eraser, ferrule and lead are each a band of the same width against the body,
+ // so without a tone of their own they vanish into it in 線画.
+ tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20);
+ tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24);
+ addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
+ addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
+ // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the
+ // two look joined, while its point still pokes out past the wood.
+ tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45);
+});
+
+/** コップ: a mug with a rim, a handle and a little drink inside. */
+const buildCup = prop((group, material) => {
+ addCylinder(group, material('paper'), 0.34, 0.30, 0.66, [0, 0.33, 0], 18);
+ addCylinder(group, material('paper', 0.86), 0.30, 0.30, 0.05, [0, 0.645, 0], 18);
+ addCylinder(group, material('wood', 0.5), 0.28, 0.28, 0.02, [0, 0.61, 0], 18);
+ addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0);
+});
+
+/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */
+const buildNotebook = prop((group, material) => {
+ const width = 1.5;
+ const depth = 2.0;
+ const pages = 0.18;
+ const cover = 0.04;
+ // The cover pokes out past the pages, so the notebook reads from above.
+ addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]);
+ addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]);
+ addBox(
+ group,
+ material('accent'),
+ [0.16, pages + 0.04, depth + 0.02],
+ [-width / 2 + 0.08, cover + pages / 2, 0],
+ );
+});
+
+/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */
+const buildEraser = prop((group, material) => {
+ const width = 0.9;
+ const depth = 0.5;
+ const half = 0.15;
+ addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]);
+ tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22);
+});
+
+export const PROP_LIBRARY = [
+ { id: 'podium', label: '演台', height: 2.2, build: buildPodium },
+ { id: 'desk', label: '机', height: 1.5, build: buildDesk },
+ { id: 'mic', label: 'マイク', height: 2.2, build: buildMic },
+ { id: 'sign', label: '看板', height: 2.7, build: buildSign },
+ { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant },
+ { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox },
+ { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan },
+ { id: 'bed', label: 'ベッド', height: 2.0, build: buildBed },
+ { id: 'futon', label: '布団', height: 0.65, build: buildFuton },
+ { id: 'cup', label: 'コップ', height: 0.66, build: buildCup },
+ { id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil },
+ { id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook },
+ { id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser },
+];
+
+/**
+ * Where a freshly added prop goes. The character's body is a sphere of radius
+ * ~1.6 around x = 0, z = 0, so anything closer than about 2.2 units would push
+ * into it; each prop therefore sits 2.6-3.0 units out, in the quadrant that
+ * suits its height, turned only slightly - a prop square to the camera reads as
+ * a diagram, and one turned all the way shows its side.
+ */
+export const PROP_DEFAULTS = {
+ podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 },
+ desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 },
+ mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 },
+ // `faceScale` only means something for a 看板: it grows the writing area (the
+ // face and its frame) together, so the post and base stay put. The panel owns
+ // that control.
+ sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1, text: '' },
+ plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 },
+ box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 },
+ can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 },
+ bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 },
+ futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 },
+ pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 },
+ cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 },
+ notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 },
+ eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, scale: 1 },
+};
+
+/** The palette entry each part reads, derived from the app's colours. */
+function derivePalette(colors = {}) {
+ const body = new THREE.Color(colors.body ?? FALLBACK_COLORS.body);
+ const accent = new THREE.Color(colors.accent ?? FALLBACK_COLORS.accent);
+ const leaf = new THREE.Color(colors.leaf ?? FALLBACK_COLORS.leaf);
+ return {
+ // Darkened rather than mixed towards black, so the surface keeps a hint of
+ // the theme's hue instead of turning into a grey.
+ wood: body.clone().multiplyScalar(0.72),
+ paper: body.clone().lerp(WHITE, 0.85),
+ accent,
+ leaf,
+ metal: new THREE.Color(METAL_GREY),
+ };
+}
+
+/**
+ * Builds one prop.
+ *
+ * @param {string} id one of PROP_LIBRARY's ids
+ * @param {object} [options]
+ * @param {object} [options.colors] the app's render.colors palette
+ * @param {string} [options.outlineColor] kept on the group for the outline pass
+ * @param {number} [options.scale] uniform scale for the whole prop
+ * @returns {THREE.Group} the prop, origin at its base centre, facing +z
+ */
+export function buildProp(id, options = {}) {
+ const spec = PROP_LIBRARY.find((entry) => entry.id === id);
+ if (!spec) {
+ const known = PROP_LIBRARY.map((entry) => entry.id).join(', ');
+ throw new Error(`unknown prop id ${JSON.stringify(id)}; known props: ${known}`);
+ }
+
+ const group = spec.build(options);
+ group.name = `prop:${id}`;
+ group.userData.propId = id;
+ group.userData.outlineColor = options.outlineColor ?? null;
+ if (Number.isFinite(options.scale) && options.scale !== 1) {
+ group.scale.setScalar(options.scale);
+ }
+ return applyPropColors(group, options.colors);
+}
+
+/**
+ * Recolours a built prop in place, for the colour-theme feature. Every mesh
+ * material this module creates carries `userData.part`, so the walk only has to
+ * look at those; anything else (a future prop built by hand) is left alone.
+ */
+export function applyPropColors(group, colors = {}) {
+ const palette = derivePalette(colors);
+ const seen = new Set();
+
+ group.traverse((object) => {
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) {
+ if (!material || seen.has(material)) continue;
+ seen.add(material);
+ const part = material.userData?.part;
+ if (!part) continue;
+ const shade = Number.isFinite(material.userData.shade) ? material.userData.shade : 1;
+ material.color.copy(palette[part] ?? palette.wood).multiplyScalar(shade);
+ material.color.r = clamp01(material.color.r);
+ material.color.g = clamp01(material.color.g);
+ material.color.b = clamp01(material.color.b);
+ }
+ });
+
+ return group;
+}
+
+/**
+ * The writing surface of a prop: the mesh tagged `userData.canvasTexture`, i.e.
+ * the 看板's face. A prop without one returns `null`, so callers can treat every
+ * prop the same.
+ */
+export function propFace(group) {
+ let face = null;
+ group?.traverse?.((object) => {
+ if (!face && object.userData?.canvasTexture) face = object;
+ });
+ return face;
+}
+
+/**
+ * Scale the writing area - the 看板's face *and* the frame around it - about the
+ * board's own centre, so the post and base stay where they are. A prop that tags
+ * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the
+ * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the
+ * 看板's board group sits at the face centre, which is what makes this a scale
+ * about the centre rather than about the prop's base.
+ *
+ * WHY this is applied from the panel: the app rebuilds every prop from the state
+ * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale,
+ * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards.
+ */
+export function applyPropFaceScale(group, faceScale) {
+ let target = null;
+ group?.traverse?.((object) => {
+ if (!target && object.userData?.faceScaleGroup) target = object;
+ });
+ target ??= propFace(group);
+ if (!target) return;
+ const value = Number(faceScale);
+ const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
+ target.scale.setScalar(scale);
+}
+
+/**
+ * Paint text onto a prop's writing surface (the 看板's face), or clear it.
+ *
+ * The face's material gets a `CanvasTexture`, drawn here with a system font stack
+ * (no font file is needed, and Japanese falls back to whatever the device has).
+ * Long lines and long blocks are shrunk to fit the board, and explicit newlines
+ * are honoured. The material is per-prop (see `materialCache`), so the map never
+ * leaks to another sign.
+ */
+export function applyPropText(group, text) {
+ if (typeof document === 'undefined') return; // Node (tests): no canvas
+ const face = propFace(group);
+ const material = face?.material;
+ if (!material) return;
+ const value = typeof text === 'string' ? text : '';
+
+ if (!value.trim()) {
+ if (material.map) {
+ material.map.dispose();
+ material.map = null;
+ material.needsUpdate = true;
+ }
+ material.userData.signText = '';
+ return;
+ }
+ if (material.userData.signText === value) return; // nothing changed
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 330;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#f4efe2';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#241a30';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ const family = '"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif';
+ const lines = value.split(/\r\n|\r|\n/);
+ const maxWidth = canvas.width * 0.86;
+ let size = 150;
+ const fits = () => {
+ ctx.font = `700 ${size}px ${family}`;
+ return lines.every((line) => ctx.measureText(line).width <= maxWidth)
+ && lines.length * size * 1.15 <= canvas.height * 0.86;
+ };
+ while (size > 18 && !fits()) size -= 2;
+ ctx.font = `700 ${size}px ${family}`;
+ const step = size * 1.15;
+ const startY = canvas.height / 2 - ((lines.length - 1) * step) / 2;
+ lines.forEach((line, index) => ctx.fillText(line, canvas.width / 2, startY + index * step));
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 4;
+ if (material.map) material.map.dispose();
+ material.map = texture;
+ material.userData.signText = value;
+ material.needsUpdate = true;
+}
+
+/** The world-space box the prop occupies; the app drops its contact shadow from it. */
+export function propBounds(group) {
+ group.updateMatrixWorld(true);
+ return new THREE.Box3().setFromObject(group);
+}
+
+/** Releases every geometry and material in the prop and empties the group. */
+export function disposeProp(group) {
+ const geometries = new Set();
+ const materials = new Set();
+
+ group.traverse((object) => {
+ if (object.geometry) geometries.add(object.geometry);
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) if (material) materials.add(material);
+ });
+
+ for (const geometry of geometries) geometry.dispose();
+ for (const material of materials) material.dispose();
+ group.clear();
+}
diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js
new file mode 100644
index 0000000..e3c8a8e
--- /dev/null
+++ b/public/bluebey-studio/src/rig.js
@@ -0,0 +1,212 @@
+import * as THREE from 'three';
+import { TransformControls } from 'three/addons/controls/TransformControls.js';
+
+/**
+ * Posing. Each bone is driven as a rotation *relative to its rest pose*, so
+ * every bone reads 0/0/0 in the original stance and a saved pose stays valid
+ * even if the model is re-exported with different bone orientations.
+ *
+ * `bone.quaternion = rest * delta` and `delta = rest⁻¹ * quaternion`, with the
+ * delta expressed as XYZ Euler angles in degrees for the UI.
+ */
+
+const RAD = Math.PI / 180;
+const DEG = 180 / Math.PI;
+
+/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */
+const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();
+
+/**
+ * The same angle shifted by whole turns to sit next to `reference`. On an exact
+ * half-turn tie the lower (smaller-magnitude) value wins, which keeps the
+ * canonical -180..180 reading when two are equally close.
+ */
+const nearestAngle = (angle, reference) => {
+ const turns = (reference - angle) / 360;
+ const low = angle + 360 * Math.floor(turns);
+ const high = angle + 360 * Math.ceil(turns);
+ return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high;
+};
+
+/** True when two quaternions describe the same rotation (double cover included). */
+const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6;
+
+export class Rig {
+ constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) {
+ this.bones = bones;
+ this.byName = new Map();
+ for (const entry of bones) {
+ this.byName.set(entry.name, entry);
+ const alias = normalizeName(entry.name);
+ if (!this.byName.has(alias)) this.byName.set(alias, entry);
+ }
+ this.scene = scene;
+ this.domElement = domElement;
+ this.orbit = orbit;
+ this.pickTargets = pickTargets;
+ this.onChange = onChange;
+ this.selected = null;
+ // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler
+ // has several equivalent triples for one rotation, so getDelta returns the
+ // one closest to this. That keeps a single-axis drag smooth through the
+ // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped
+ // twin, which made the sliders and the next setDelta spin the character.
+ this.lastDelta = new Map();
+
+ this.controls = new TransformControls(camera, domElement);
+ this.controls.setMode('rotate');
+ this.controls.setSpace('local');
+ this.controls.setSize(0.8);
+ // A *hidden* TransformControls still grabs the pointer in three: its picker
+ // stays raycastable even when the helper is not drawn, so grabbing the body
+ // (the rings sit on the `master` bone, right where you grab) silently turned
+ // the character. Keep the controls disabled until the gizmo is actually
+ // shown, so the mouse can only move the character (see setGizmoVisible).
+ this.controls.enabled = false;
+ this.helper = this.controls.getHelper();
+ this.helper.visible = false;
+ // Whether the user wants the gizmo at all. It starts OFF: the model is the
+ // point, and the rings sat on top of it until you went looking for them.
+ // Selecting a bone must not switch it back on by itself, so `select()`
+ // consults this instead of always showing the helper.
+ this.gizmoWanted = false;
+ scene.add(this.helper);
+
+ this.controls.addEventListener('dragging-changed', (event) => {
+ this.orbit.enabled = !event.value;
+ });
+ this.controls.addEventListener('objectChange', () => {
+ if (this.selected) this.onChange?.(this.selected);
+ });
+ }
+
+ /* ------------------------------------------------------------ selection */
+
+ select(name, { silent = false } = {}) {
+ if (name && !this.byName.has(name)) return;
+ this.selected = name ?? null;
+ if (name) {
+ this.controls.attach(this.byName.get(name).bone);
+ this.helper.visible = this.gizmoWanted;
+ } else {
+ this.controls.detach();
+ this.helper.visible = false;
+ }
+ // Only an actually-shown gizmo may take the pointer (see the constructor).
+ this.controls.enabled = this.helper.visible;
+ if (!silent) this.onChange?.(this.selected);
+ }
+
+ // Clicking the model no longer reaches for a bone: the mouse is for composing
+ // (see the drag handling in src/main.js), and bones are picked from the panel's
+ // bone list, which calls `select()` directly. So there is no pointer handler on
+ // the canvas here at all.
+
+ /* ----------------------------------------------------------------- pose */
+
+ getDelta(name) {
+ const entry = this.byName.get(name);
+ if (!entry) return { x: 0, y: 0, z: 0 };
+ const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion);
+ const base = new THREE.Euler().setFromQuaternion(delta, 'XYZ');
+ const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 };
+
+ // For one rotation an XYZ Euler has a second solution - its "flipped" twin
+ // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle
+ // stays within +/-90, so past 90 the twin is what continues the drag; offer
+ // both and keep whichever is closest to the last value. Near gimbal lock the
+ // twin no longer reproduces the rotation, so it is dropped by the check.
+ const candidates = [{ x: base.x, y: base.y, z: base.z }];
+ const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ');
+ if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) {
+ candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z });
+ }
+
+ let best = { x: 0, y: 0, z: 0 };
+ let bestDistance = Infinity;
+ for (const candidate of candidates) {
+ const x = nearestAngle(candidate.x * DEG, reference.x);
+ const y = nearestAngle(candidate.y * DEG, reference.y);
+ const z = nearestAngle(candidate.z * DEG, reference.z);
+ const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2;
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = { x, y, z };
+ }
+ }
+
+ this.lastDelta.set(entry, best);
+ return { ...best };
+ }
+
+ setDelta(name, { x = 0, y = 0, z = 0 }) {
+ const entry = this.byName.get(name);
+ if (!entry) return;
+ const delta = new THREE.Quaternion().setFromEuler(
+ new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'),
+ );
+ entry.bone.quaternion.copy(entry.rest).multiply(delta);
+ // Remember exactly what the UI asked for so getDelta can hand it straight
+ // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone
+ // entry, not the name, so a raw name and its normalised alias agree.
+ this.lastDelta.set(entry, { x, y, z });
+ }
+
+ /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */
+ applyPose(pose = {}) {
+ // Presets and saved files use the glTF bone names (`armsupport.l`) while
+ // three sanitises them to `armsupportl`, so compare on the normalised form -
+ // otherwise every preset that touches an arm or leg is silently ignored.
+ const rotations = new Map();
+ for (const [key, value] of Object.entries(pose.bones ?? {})) {
+ rotations.set(normalizeName(key), value);
+ }
+ for (const entry of this.bones) {
+ const value = rotations.get(normalizeName(entry.name));
+ if (Array.isArray(value)) this.setDelta(entry.name, { x: value[0], y: value[1], z: value[2] });
+ else this.setDelta(entry.name, { x: 0, y: 0, z: 0 });
+ }
+ }
+
+ getPose({ onlyMoved = true } = {}) {
+ const rotations = {};
+ for (const entry of this.bones) {
+ const { x, y, z } = this.getDelta(entry.name);
+ const rounded = [round1(x), round1(y), round1(z)];
+ if (onlyMoved && rounded.every((value) => value === 0)) continue;
+ rotations[entry.name] = rounded;
+ }
+ return { bones: rotations };
+ }
+
+ reset(name) {
+ if (name) {
+ this.setDelta(name, { x: 0, y: 0, z: 0 });
+ return;
+ }
+ for (const entry of this.bones) this.setDelta(entry.name, { x: 0, y: 0, z: 0 });
+ }
+
+ /** True when every bone sits at its rest rotation. */
+ isRestPose() {
+ return this.bones.every((entry) => {
+ const { x, y, z } = this.getDelta(entry.name);
+ return Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && Math.abs(z) < 0.01;
+ });
+ }
+
+ setGizmoVisible(visible) {
+ this.gizmoWanted = visible !== false;
+ this.helper.visible = this.gizmoWanted && this.selected != null;
+ // A hidden gizmo must not grab the pointer (see the constructor).
+ this.controls.enabled = this.helper.visible;
+ }
+
+ dispose() {
+ this.controls.detach();
+ this.controls.dispose();
+ this.helper.removeFromParent();
+ }
+}
+
+const round1 = (value) => Math.round(value * 10) / 10;
diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css
new file mode 100644
index 0000000..a147b1f
--- /dev/null
+++ b/public/bluebey-studio/src/style.css
@@ -0,0 +1,880 @@
+:root {
+ --accent: #7b53d1;
+ --accent-soft: #efe8fd;
+ --accent-2: #b498ff;
+ --text: #2b2433;
+ --muted: #6f6682;
+ --border: #e4dff0;
+ --card: rgba(255, 255, 255, 0.94);
+ --radius: 12px;
+ --panel-width: 344px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ height: 100vh;
+ /* `dvh` tracks the space mobile browser chrome leaves; the lines above are
+ the fallbacks for browsers that do not know it yet. */
+ height: 100dvh;
+ overflow: hidden;
+}
+
+body {
+ font-family: system-ui, -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Noto Sans JP", Meiryo, sans-serif;
+ color: var(--text);
+ background: radial-gradient(120% 120% at 30% 0%, #f7f5fc 0%, #ece8f4 60%, #e4e0ee 100%);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ------------------------------------------------------------------ viewport */
+
+/*
+ * The canvas gets its size from #stage, never from its own width/height
+ * attributes: a canvas is a replaced element, so `width: auto` would make its
+ * layout follow those attributes - and since the renderer writes them, that
+ * becomes a resize feedback loop that leaves the picture blank.
+ */
+#stage {
+ position: fixed;
+ inset: 0;
+}
+
+body:not(.panel-hidden) #stage {
+ right: var(--panel-width);
+}
+
+#view {
+ display: block;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+ outline: none;
+}
+
+/* ------------------------------------------------------------------- topbar */
+
+#topbar {
+ position: fixed;
+ top: 14px;
+ left: 16px;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ max-width: calc(100vw - var(--panel-width) - 48px);
+}
+
+#topbar h1 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #4b3a72;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
+}
+
+.topbar-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+
+#topbar button {
+ font: inherit;
+ font-size: 12px;
+ padding: 6px 11px;
+ border-radius: 999px;
+ border: 1px solid rgba(123, 83, 209, 0.22);
+ background: rgba(255, 255, 255, 0.86);
+ color: #5a4a7d;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+ transition: background 0.15s, transform 0.1s;
+}
+
+#topbar button:hover { background: #fff; transform: translateY(-1px); }
+#topbar button:active { transform: translateY(0); }
+#topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
+
+/* Shared by the mouse hint and the touch hint (see #viewport-hint-touch). */
+.viewport-hint {
+ position: fixed;
+ left: 18px;
+ bottom: 14px;
+ z-index: 10;
+ margin: 0;
+ font-size: 11.5px;
+ color: #8a80a0;
+ pointer-events: none;
+}
+
+/* Phones get a touch-specific hint instead of this mouse one; swapped in the
+ small-screen media query at the end of the viewport/panel section below. */
+#viewport-hint-touch { display: none; }
+
+/* AR (camera) mode's shutter: a camera-app button over the viewport. It is a DOM
+ layer, so it never appears in the exported PNG. */
+.ar-shutter {
+ position: absolute;
+ left: 50%;
+ bottom: calc(22px + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ z-index: 4;
+ width: 64px;
+ height: 64px;
+ padding: 0;
+ border-radius: 50%;
+ border: 4px solid #ffffff;
+ background: rgba(255, 255, 255, 0.35);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
+ cursor: pointer;
+}
+.ar-shutter::after {
+ content: "";
+ position: absolute;
+ inset: 6px;
+ border-radius: 50%;
+ background: #ffffff;
+}
+.ar-shutter:active::after { background: #d9d2e8; }
+.ar-shutter[hidden] { display: none; }
+
+/* -------------------------------------------------------------------- panel */
+
+#panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: var(--panel-width);
+ padding: 14px 14px 40px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--card);
+ border-left: 1px solid var(--border);
+ backdrop-filter: blur(10px);
+ z-index: 30;
+ scrollbar-width: thin;
+}
+
+#panel::-webkit-scrollbar { width: 9px; }
+#panel::-webkit-scrollbar-thumb { background: #d8d1e8; border-radius: 9px; }
+
+/* The phone sheet's grab handle is a real element (built in the app) so it can
+ carry a drag; it is hidden everywhere else. */
+.panel-grip { display: none; }
+
+body.panel-hidden #panel { display: none; }
+
+body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
+
+@media (max-width: 780px) {
+ :root { --panel-width: min(88vw, 344px); }
+ #topbar { max-width: calc(100vw - 60px); }
+}
+
+/*
+ * Phones and other short touch screens. The panel becomes a bottom sheet over
+ * the 3D view instead of a side column: the canvas keeps the full screen behind
+ * it, so opening the sheet does not resize the render (and the ≡ button, which
+ * already calls resizeViewport, is all the control needed).
+ */
+@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) {
+ /* Full-screen canvas: the sheet floats over it, so its height never feeds
+ back into the renderer. */
+ #stage,
+ body:not(.panel-hidden) #stage {
+ inset: 0;
+ right: 0;
+ }
+
+ #topbar {
+ top: calc(8px + env(safe-area-inset-top));
+ left: calc(10px + env(safe-area-inset-left));
+ right: calc(10px + env(safe-area-inset-right));
+ max-width: none;
+ gap: 8px;
+ }
+
+ /* Let the title give way first (it can ellipsize); the buttons keep their
+ labels and stay on one line. */
+ #topbar h1 {
+ font-size: 13px;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .topbar-actions {
+ flex: 1;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+ gap: 6px;
+ }
+
+ /* Comfortable tap targets (>= 40px) in a row that still fits 360px. */
+ #topbar button {
+ font-size: 11px;
+ padding: 9px 10px;
+ min-height: 40px;
+ }
+
+ #btn-panel { min-width: 44px; }
+
+ #viewport-hint { display: none; }
+ #viewport-hint-touch {
+ display: block;
+ bottom: calc(14px + env(safe-area-inset-bottom));
+ }
+
+ #panel {
+ top: auto;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ max-height: 65dvh;
+ padding: 0 14px calc(20px + env(safe-area-inset-bottom));
+ border-left: 0;
+ border-top: 1px solid var(--border);
+ border-radius: 18px 18px 0 0;
+ box-shadow: 0 -6px 24px rgba(40, 20, 80, 0.18);
+ }
+
+ /* The grab handle: sticky at the top of the scroll area, so it stays grabbable
+ however far the menu is scrolled, and `touch-action: none` so a drag on it
+ resizes the sheet instead of scrolling it. */
+ .panel-grip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: sticky;
+ top: 0;
+ z-index: 6;
+ height: 24px;
+ margin: 0 -14px;
+ background: var(--card);
+ touch-action: none;
+ cursor: grab;
+ }
+ .panel-grip::after {
+ content: "";
+ width: 44px;
+ height: 5px;
+ border-radius: 999px;
+ background: #cfc6e4;
+ }
+ .panel-grip:active { cursor: grabbing; }
+
+ body.panel-hidden #topbar { max-width: none; }
+
+ /* The grab handle is sticky at the top of the sheet, so the action bar and the
+ tabs start below it. */
+ .panel-topbar { top: 24px; }
+ .tabs { top: 58px; }
+}
+
+/* ----------------------------------------------------------------- sections */
+
+.tabs {
+ position: sticky;
+ /* Sits just below the sticky .panel-topbar (which is 34px tall). */
+ top: 34px;
+ z-index: 3;
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ padding: 2px 0 0;
+ margin: -2px 0 8px;
+ border-bottom: 2px solid var(--border);
+ background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0));
+}
+
+/* Each tab is a rounded-top tab sitting on the strip's baseline; only the icon
+ shows, and the active one fills with the accent and covers the baseline. */
+.tab {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font: inherit;
+ padding: 9px 2px 7px;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 9px 9px 0 0;
+ background: var(--accent-soft);
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.tab svg { display: block; width: 20px; height: 20px; }
+.tab:hover { color: var(--accent-2); border-color: var(--accent-2); }
+.tab.active {
+ margin-bottom: -2px;
+ padding-bottom: 9px;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.tab-panel[hidden] { display: none; }
+
+.details {
+ border: 1px dashed var(--border);
+ border-radius: 9px;
+ padding: 0 9px;
+ background: #fdfcff;
+}
+.details > summary {
+ font-size: 11px;
+ color: var(--muted);
+ cursor: pointer;
+ padding: 6px 0;
+ list-style-position: inside;
+}
+.details[open] > summary { color: var(--accent); }
+.details-body { display: grid; gap: 8px; padding: 2px 0 9px; }
+
+.sec {
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 10px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgba(53, 38, 90, 0.04);
+}
+
+.sec-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 12px;
+ margin: 0;
+ border: 0;
+ background: #fff;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #4b3a72;
+ text-align: left;
+ cursor: pointer;
+}
+
+.sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; }
+.sec.closed .chev { transform: rotate(-90deg); }
+.sec-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.sec-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sec-icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); }
+.sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; }
+.sec.closed .sec-body { display: none; }
+
+/* -------------------------------------------------------------------- rows */
+
+.row { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; align-items: center; }
+.row.wide { grid-template-columns: 1fr; gap: 5px; }
+.row > .label { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.control { display: flex; align-items: center; gap: 7px; min-width: 0; }
+.hint { font-size: 11px; line-height: 1.55; color: var(--muted); }
+.hint b { color: #5a4a7d; }
+.row.is-off .control, .row.is-off > .label { opacity: 0.4; }
+.row.is-off .control input { cursor: default; }
+.subhead {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: #8d80ab;
+ text-transform: uppercase;
+ margin-top: 3px;
+}
+.eye-group {
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+ padding-top: 8px;
+ display: grid;
+ gap: 8px;
+}
+.eye-group-head {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: #6b5c92;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.eye-group-head .grp-icon { color: #8a7ab8; }
+.eye-group-body { display: grid; gap: 8px; }
+.select {
+ flex: 1;
+ min-width: 0;
+ font: inherit;
+ font-size: 11.5px;
+ padding: 5px 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--text);
+}
+.stack { display: grid; gap: 6px; }
+.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+
+input[type="range"] {
+ flex: 1;
+ min-width: 0;
+ height: 18px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.num {
+ flex: 0 0 auto;
+ min-width: 40px;
+ font-size: 11px;
+ color: #5a4a7d;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.seg { display: inline-flex; flex-wrap: wrap; padding: 2px; gap: 2px; background: #f2eff9; border-radius: 999px; }
+.seg button {
+ font: inherit;
+ font-size: 11px;
+ padding: 4px 9px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.seg button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(53, 38, 90, 0.16); }
+
+.btn {
+ font: inherit;
+ font-size: 11.5px;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--text);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.btn:hover { border-color: var(--accent-2); }
+.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn.primary:hover { background: #6b45c2; }
+.btn:disabled { opacity: 0.45; cursor: default; }
+.buttons { display: flex; flex-wrap: wrap; gap: 6px; }
+
+.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #4b3a72; cursor: pointer; }
+.chk input { accent-color: var(--accent); width: 14px; height: 14px; }
+
+.pad-wrap { display: flex; gap: 10px; align-items: center; }
+.pad {
+ position: relative;
+ flex: 0 0 auto;
+ width: 118px;
+ height: 118px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background:
+ linear-gradient(#f0ecfa, #f0ecfa) 50% 0 / 1px 100% no-repeat,
+ linear-gradient(#f0ecfa, #f0ecfa) 0 50% / 100% 1px no-repeat,
+ #faf8ff;
+ cursor: crosshair;
+ touch-action: none;
+}
+.pad-dot {
+ position: absolute;
+ width: 13px;
+ height: 13px;
+ margin: -7px 0 0 -7px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35);
+ pointer-events: none;
+}
+.pad-eye {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ border: 1px dashed #d9d1ec;
+ pointer-events: none;
+}
+
+/* Tail picker: a 3x3 grid where a dot's place is the direction it means, so
+ the whole pad fits next to an 82px label even in a narrow panel. */
+.tail-pad {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ place-items: center;
+ flex: 0 0 auto;
+ width: 128px;
+ height: 128px;
+ padding: 10px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: #faf8ff;
+}
+.tail-dot {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: #ded6f0;
+ cursor: pointer;
+ transition: background 0.12s, transform 0.1s;
+}
+.tail-dot:hover { background: var(--accent-2); transform: scale(1.1); }
+.tail-dot.active {
+ background: var(--accent);
+ box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35);
+}
+
+.swatches { display: flex; gap: 5px; flex-wrap: wrap; }
+.swatch { width: 17px; height: 17px; border-radius: 5px; border: 1px solid rgba(43, 36, 51, 0.18); cursor: pointer; }
+input[type="color"] {
+ width: 30px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.bone-list {
+ max-height: 168px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ padding: 4px;
+ display: grid;
+ gap: 2px;
+ background: #fcfbff;
+}
+.bone-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 4px 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ font: inherit;
+ font-size: 11.5px;
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+}
+.bone-item:hover { background: #f4f0fd; }
+.bone-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
+.bone-item small { color: var(--muted); font-size: 10.5px; }
+
+/* --------------------------------------------------------------------- 擬音 */
+
+/*
+ * The stamps are painted onto the caption canvas, which is `pointer-events:
+ * none`, so each one gets its own transparent drag box on top - the same trick
+ * the bubbles use. The box follows the stamp's rotation. Its z-index sits below
+ * the caption handles (3), because the bubbles are drawn over the stamps and so
+ * must stay grabbable where the two overlap.
+ */
+.gion-handle {
+ position: absolute;
+ display: none;
+ cursor: move;
+ touch-action: none;
+ pointer-events: auto;
+ z-index: 2;
+ border: 1px dashed transparent;
+ border-radius: 4px;
+}
+.gion-handle.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(123, 83, 209, 0.18);
+}
+
+.gion-list { display: grid; gap: 10px; }
+.gion-item-head { display: flex; align-items: center; gap: 8px; }
+.gion-thumb {
+ flex: 0 0 auto;
+ width: 84px;
+ height: 56px;
+ background: #f3f0fa;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+}
+.gion-item.active .gion-thumb { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
+
+/* The picker covers the whole window, so even a big sheet has room to draw on. */
+.gion-picker {
+ position: fixed;
+ inset: 0;
+ z-index: 95;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px;
+ background: rgba(24, 16, 44, 0.86);
+ backdrop-filter: blur(4px);
+}
+.gion-picker-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ flex-wrap: wrap;
+ color: #fff;
+}
+.gion-picker-title { font-size: 13.5px; font-weight: 700; }
+.gion-picker-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.gion-picker-actions .hint { color: #cfc6e6; }
+.gion-picker-board {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: 12px;
+ /* A checkerboard reads as "transparent", so a PNG sheet's holes are obvious. */
+ background:
+ linear-gradient(45deg, #2b2344 25%, transparent 25%) 0 0 / 22px 22px,
+ linear-gradient(-45deg, #2b2344 25%, transparent 25%) 0 11px / 22px 22px,
+ linear-gradient(45deg, transparent 75%, #2b2344 75%) 11px -11px / 22px 22px,
+ linear-gradient(-45deg, transparent 75%, #2b2344 75%) -11px 0 / 22px 22px,
+ #241d3a;
+ cursor: crosshair;
+ touch-action: none;
+}
+.gion-picker-sheet {
+ position: absolute;
+ display: block;
+ user-select: none;
+ -webkit-user-drag: none;
+ pointer-events: none;
+}
+.gion-picker-marquee {
+ position: absolute;
+ display: none;
+ border: 1.5px solid #fff;
+ background: rgba(180, 152, 255, 0.28);
+ box-shadow: 0 0 0 9999px rgba(20, 12, 40, 0.42);
+ pointer-events: none;
+}
+
+/* -------------------------------------------------------------------- toast */
+
+#notice {
+ position: fixed;
+ left: 50%;
+ top: 14px;
+ transform: translateX(-50%);
+ z-index: 95;
+ max-width: min(560px, 90vw);
+ padding: 10px 16px;
+ border-radius: 12px;
+ background: #fff4e5;
+ border: 1px solid #f0c48a;
+ color: #7a4a12;
+ font-size: 12px;
+ line-height: 1.65;
+ white-space: pre-line;
+ box-shadow: 0 6px 20px rgba(43, 36, 51, 0.15);
+}
+
+#notice[hidden] { display: none; }
+
+/* ------------------------------------------------ 起動時のご利用について */
+#start-notice {
+ position: fixed;
+ inset: 0;
+ z-index: 96;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(43, 36, 51, 0.5);
+ backdrop-filter: blur(3px);
+}
+#start-notice[hidden] { display: none; }
+
+.start-sheet {
+ width: min(560px, 100%);
+ max-height: 86vh;
+ overflow: auto;
+ padding: 22px 26px;
+ border-radius: 16px;
+ background: #fff;
+ color: #2b2433;
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
+ font-size: 13.5px;
+ line-height: 1.75;
+}
+.start-sheet h2 { margin: 0 0 12px; font-size: 17px; }
+.start-sheet p { margin: 0 0 10px; }
+.start-sheet ul { margin: 0 0 12px; padding-left: 1.3em; }
+.start-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+.start-skip { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #6b5d7a; }
+.start-ok {
+ border: 0;
+ border-radius: 8px;
+ padding: 9px 22px;
+ background: #6b4ea8;
+ color: #fff;
+ font-size: 13.5px;
+ cursor: pointer;
+}
+
+#toast {
+ position: fixed;
+ left: 50%;
+ bottom: 26px;
+ transform: translate(-50%, 14px);
+ z-index: 70;
+ padding: 8px 16px;
+ border-radius: 999px;
+ background: rgba(43, 36, 51, 0.9);
+ color: #fff;
+ font-size: 12px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s, transform 0.2s;
+}
+#toast.show { opacity: 1; transform: translate(-50%, 0); }
+
+/* ------------------------------------------------------------------ loading */
+
+#loading {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ display: grid;
+ place-items: center;
+ background: rgba(244, 242, 250, 0.94);
+ transition: opacity 0.35s;
+}
+#loading.done { opacity: 0; pointer-events: none; }
+
+.loading-box { text-align: center; max-width: 520px; padding: 24px; }
+.loading-spinner {
+ width: 34px;
+ height: 34px;
+ margin: 0 auto 14px;
+ border-radius: 50%;
+ border: 3px solid #e0d8f2;
+ border-top-color: var(--accent);
+ animation: spin 0.9s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+#loading-text { margin: 0; font-size: 13px; color: #5a4a7d; }
+#loading-error {
+ margin: 12px 0 0;
+ font-size: 12px;
+ line-height: 1.7;
+ color: #b3261e;
+ white-space: pre-wrap;
+ text-align: left;
+}
+
+/* --------------------------------------------------------------------- help */
+
+#help {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(43, 36, 51, 0.42);
+ backdrop-filter: blur(3px);
+}
+#help[hidden] { display: none; }
+
+.help-sheet {
+ position: relative;
+ width: min(760px, 100%);
+ max-height: min(84vh, 900px);
+ overflow-y: auto;
+ padding: 26px 30px 32px;
+ background: #fff;
+ border-radius: 18px;
+ box-shadow: 0 24px 60px rgba(20, 12, 40, 0.3);
+ font-size: 13px;
+ line-height: 1.75;
+}
+.help-sheet h2 { margin: 0 0 14px; font-size: 17px; color: #4b3a72; }
+.help-sheet h3 { margin: 20px 0 6px; font-size: 13.5px; color: var(--accent); }
+.help-sheet ul { margin: 0; padding-left: 1.25em; }
+.help-sheet li { margin: 3px 0; }
+.help-sheet code { background: #f3f0fa; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
+.help-sheet .keys { list-style: none; padding: 0; display: grid; gap: 4px; }
+.help-close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: 50%;
+ background: #f3f0fa;
+ color: #5a4a7d;
+ font-size: 19px;
+ line-height: 1;
+ cursor: pointer;
+}
+.help-close:hover { background: #e8e2f7; }
+
+kbd {
+ display: inline-block;
+ min-width: 20px;
+ padding: 1px 6px;
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: 5px;
+ background: #faf9fe;
+ font: inherit;
+ font-size: 11px;
+ text-align: center;
+}
+
+/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム.
+ Sticky, so it (and the tabs below it) stay on screen while the panel scrolls. */
+.panel-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: #fdfcff;
+ display: flex;
+ gap: 4px;
+ justify-content: flex-end;
+ padding: 4px 8px 2px;
+}
+.panel-topbar .icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: #2b2433;
+ cursor: pointer;
+}
+.panel-topbar .icon-btn:hover { background: #f4f0fb; }
+.panel-topbar .icon-btn:active { transform: translateY(1px); }
diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js
new file mode 100644
index 0000000..2e4f62d
--- /dev/null
+++ b/public/bluebey-studio/src/styles.js
@@ -0,0 +1,457 @@
+import * as THREE from 'three';
+
+/**
+ * Render styles for the body meshes, plus the outline pass.
+ *
+ * real the PBR materials exactly as authored in the GLB
+ * flat two-tone toon shading, the classic mascot look
+ * lineart white paper + lines, the face artwork drawn as strokes
+ * outline nothing but the lines, kept invisible via colorWrite = false so the
+ * result composites on top of anything (used by the SVG export)
+ *
+ * The lines come from two systems, each used where it is strong (see
+ * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin
+ * overlapping leaves are handed to the screen-space pass instead (outline.js),
+ * which the caller arranges with `setHullHidden`.
+ */
+
+export const STYLE_DEFS = [
+ { value: 'real', label: 'リアル' },
+ { value: 'flat', label: 'フラット' },
+ { value: 'lineart', label: '線画' },
+];
+
+// 'outline' is still used internally by the SVG export (lines on a transparent
+// background), it is just no longer offered as a viewing style.
+const LINE_STYLES = new Set(['lineart', 'outline']);
+
+export const isLineStyle = (value) => LINE_STYLES.has(value);
+
+/** Parts that need no outline at all. */
+const OUTLINE_SKIP = new Set();
+
+/**
+ * Parts whose outline is only useful in the line-art styles.
+ *
+ * Empty now: the leaves used to be listed here, but the line styles get their
+ * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the
+ * hull is the wrong tool for them in either direction.
+ */
+const OUTLINE_LINE_ONLY = new Set();
+
+/**
+ * Parts that should be outlined in the SHADED styles only.
+ *
+ * The inverted hull draws an outline by expanding a copy of the mesh along its
+ * normals and rendering the back faces. That works on one big round body, but
+ * the leaf skirt is a dozen thin shells lying on top of each other: each hull's
+ * far side shows through its neighbours, which is what produced the faint
+ * overlapping hairlines. In the SVG pass the leaves get their line from their
+ * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped
+ * there and kept for the cartoon styles, where it does read as an edge.
+ */
+const OUTLINE_SHADED_ONLY = new Set(['vein']);
+
+/**
+ * Per-part outline thickness, as a fraction of the global width.
+ *
+ * The hull is offset by a fixed distance in world units, so the right factor
+ * depends on how fine a part's mesh is compared with that offset:
+ *
+ * - the nose is only ~0.24 units across, and the offset that looks right on the
+ * body reads as a thick ring on it, hence the small factor. In the line-art
+ * styles it is the other way round (a hairline looks like dust on paper), so
+ * the nose gets a fuller line there - see OUTLINE_SCALE_LINE.
+ * - the leaves need a *larger* factor, which only shows up in the "hull only"
+ * style: the 13 blades overlap and interpenetrate, so each one's expanded
+ * shell cuts across its neighbours and the line breaks up. A thicker hull
+ * merges those scratches back into a band. Making the leaves thicker does NOT
+ * fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the
+ * blocker, not the leaf's own thickness.
+ */
+const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 };
+
+/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */
+const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 };
+
+/**
+ * Parts whose outline should only survive where the part itself sticks out past
+ * the body. Their hull is drawn first with depth testing off, so everything the
+ * body covers paints over it: the nose then only gets a line in the views where
+ * it actually pokes out of the silhouette, instead of a ring around it - which is
+ * what the original artwork does, the nose reads by its own colour there.
+ *
+ * In the line-art styles there is no colour to read it by (the nose is paper on
+ * paper), so this is switched off and the nose goes to the screen-space pass
+ * instead - see `screenParts` in main.js.
+ */
+const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']);
+
+export class Styles {
+ constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) {
+ this.meshes = meshes;
+ this.paper = paper;
+ this.style = 'real';
+ this.outlineEnabled = true;
+ /**
+ * Source meshes whose hull outline must stay off, because another method
+ * (the screen-space pass) is drawing them. Empty = every hull is available.
+ * Only the leaves ever land here: see MODEL-GUIDE.md §5.
+ */
+ this.hullHidden = new Set();
+
+ this.originals = new Map();
+ for (const mesh of meshes) {
+ this.originals.set(mesh, mesh.material);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ }
+
+ this.gradientMap = makeGradientMap();
+ this.toonMaterials = new Map();
+ this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false });
+ // A hat is not flat: in a line drawing a white hat on a white head merges
+ // into one shape, so hats get a light tone instead of paper (see addMesh).
+ // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it
+ // steps from the paper towards the ink is cached by strength in
+ // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a
+ // pencil's lead) can ask for a darker fill and its seam then reads.
+ this.toneMaterials = new Map();
+ /** Runtime meshes that want a tone fill rather than paper in 線画. */
+ this.toneMeshes = new Map();
+ this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true });
+
+ this.outlineWidth = outlineWidth;
+ this.outlineColor = outlineColor;
+ /** scale -> { material, uniform }, so the width slider updates all of them. */
+ this.hullMaterials = new Map();
+ this.outlineMeshes = this.createOutlines();
+ this.setStyle('real');
+ }
+
+ /**
+ * The part kind, taken from the material the GLB shipped with.
+ *
+ * It must be read from `originals`, not from `mesh.material`: `setStyle`
+ * replaces the materials, and the replacements (paper, ink, toon) carry no
+ * name, so after the first swap every part would look like an unknown one and
+ * the leaf/nose special cases would silently stop applying.
+ */
+ kindOf(mesh) {
+ return (this.originals.get(mesh)?.name ?? '').toLowerCase();
+ }
+
+ hullMaterialFor(scale, overlay = false) {
+ const key = `${scale}:${overlay ? 'overlay' : 'solid'}`;
+ let entry = this.hullMaterials.get(key);
+ if (!entry) {
+ const uniform = { value: this.outlineWidth * scale };
+ const material = makeHullMaterial(this.outlineColor, uniform);
+ if (overlay) {
+ // Depth-*tested*, but writing none: the hull is drawn before the body
+ // (renderOrder -1), so the body still paints over its interior and the
+ // nose only gets a line where it pokes out of the silhouette. Testing
+ // (rather than ignoring) depth is what lets the 見えない壁 hide it too -
+ // with the test off, a nose buried in the wall left a filled blob,
+ // because nothing was left to paint over the hull's inside.
+ material.depthTest = true;
+ material.depthWrite = false;
+ }
+ entry = { uniform, material, scale };
+ this.hullMaterials.set(key, entry);
+ }
+ return entry.material;
+ }
+
+ /**
+ * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far
+ * it steps from the paper towards the ink. `userData.tone` on the mesh (set by
+ * the builder) carries the amount; the hat default comes from `options.tone`.
+ */
+ toneMaterialFor(strength = DEFAULT_TONE) {
+ const key = String(strength);
+ let material = this.toneMaterials.get(key);
+ if (!material) {
+ material = new THREE.MeshBasicMaterial({
+ color: toneOf(this.paper, strength),
+ toneMapped: false,
+ });
+ material.userData.tone = strength;
+ this.toneMaterials.set(key, material);
+ }
+ return material;
+ }
+
+ createOutlines() {
+ const hulls = [];
+ for (const mesh of this.meshes) {
+ const kind = this.kindOf(mesh);
+ if (OUTLINE_SKIP.has(kind)) continue;
+ const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
+ const material = this.hullMaterialFor(this.scaleFor(kind), overlay);
+ const hull = mesh.isSkinnedMesh
+ ? new THREE.SkinnedMesh(mesh.geometry, material)
+ : new THREE.Mesh(mesh.geometry, material);
+ hull.name = `${mesh.name || 'mesh'}:outline`;
+ hull.position.copy(mesh.position);
+ hull.quaternion.copy(mesh.quaternion);
+ hull.scale.copy(mesh.scale);
+ hull.frustumCulled = false;
+ hull.castShadow = false;
+ hull.receiveShadow = false;
+ if (overlay) hull.renderOrder = -1;
+ hull.userData.kind = kind;
+ hull.userData.source = mesh;
+ hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
+ if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix);
+ mesh.parent.add(hull);
+ hulls.push(hull);
+ }
+ return hulls;
+ }
+
+ /**
+ * Register a mesh that is built at runtime (a hat), so it follows the render
+ * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
+ *
+ * `options.tone` gives the mesh the light tone fill in 線画 instead of paper,
+ * for parts whose shape would otherwise merge with the body (the hats). A
+ * number instead of `true` picks how far to step towards the ink, and the
+ * builder can override it per mesh with `userData.tone` - which is how a part
+ * draws its seam against a same-coloured neighbour.
+ *
+ * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh
+ * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット.
+ */
+ addMesh(mesh, options = {}) {
+ if (!mesh || this.originals.has(mesh)) return;
+ this.originals.set(mesh, mesh.material);
+ this.meshes.push(mesh);
+ const tone = mesh.userData.tone ?? options.tone;
+ if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone));
+ const kind = this.kindOf(mesh);
+ if (!OUTLINE_SKIP.has(kind)) {
+ const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
+ const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay));
+ hull.name = `${mesh.name || 'mesh'}:outline`;
+ hull.position.copy(mesh.position);
+ hull.quaternion.copy(mesh.quaternion);
+ hull.scale.copy(mesh.scale);
+ hull.frustumCulled = false;
+ hull.castShadow = false;
+ hull.receiveShadow = false;
+ if (overlay) hull.renderOrder = -1;
+ hull.userData.kind = kind;
+ hull.userData.source = mesh;
+ hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
+ mesh.parent?.add(hull);
+ this.outlineMeshes.push(hull);
+ mesh.userData.hull = hull;
+ }
+ this.setStyle(this.style);
+ }
+
+ /** Take a runtime mesh (and its outline hull) back out. */
+ removeMesh(mesh) {
+ if (!mesh || !this.originals.has(mesh)) return;
+ const original = this.originals.get(mesh);
+ const hull = mesh.userData.hull;
+ if (hull) {
+ hull.removeFromParent();
+ const i = this.outlineMeshes.indexOf(hull);
+ if (i >= 0) this.outlineMeshes.splice(i, 1);
+ delete mesh.userData.hull;
+ }
+ // Put the mesh's own material back before dropping it: while a line style is
+ // on it is wearing the *shared* paper/tone material, and the caller is about
+ // to dispose it - which would blank every part using it.
+ if (original) mesh.material = original;
+ this.originals.delete(mesh);
+ this.toneMeshes.delete(mesh);
+ const j = this.meshes.indexOf(mesh);
+ if (j >= 0) this.meshes.splice(j, 1);
+ }
+
+ /** The outline factor for a part in the current style (line art wants more). */
+ scaleFor(kind) {
+ const line = LINE_STYLES.has(this.style);
+ const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE;
+ return table[kind] ?? OUTLINE_SCALE[kind] ?? 1;
+ }
+
+ setStyle(value) {
+ this.style = value;
+ const line = LINE_STYLES.has(value);
+ for (const mesh of this.meshes) {
+ const original = this.originals.get(mesh);
+ let material;
+ if (line) {
+ // Paper on paper in `lineart`; a light tone for hats, which would
+ // otherwise merge into the head; invisible in `outline`, which is the pass
+ // the SVG trace reads. Either way every edge - the body, the nose, and
+ // the leaves - is drawn by the screen-space outline, so no part needs a
+ // material trick of its own any more.
+ material = value === 'lineart'
+ ? (this.toneMeshes.get(mesh) ?? this.paperMaterial)
+ : this.invisibleMaterial;
+ } else if (value === 'flat') {
+ material = this.toonFor(original);
+ } else {
+ material = original;
+ }
+ if (mesh.material !== material) mesh.material = material;
+ }
+
+ // The nose only keeps a line where it pokes out of the body in the shaded
+ // styles; in a line drawing it is handed to the screen-space pass instead
+ // (see `screenParts` in main.js), because a hull cannot ring a bump that sits
+ // flush on the surface it is drawn on - the expanded shell lands *inside* the
+ // body and loses the depth test.
+ for (const hull of this.outlineMeshes) {
+ const kind = hull.userData.kind;
+ const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line;
+ hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly);
+ hull.renderOrder = silhouetteOnly ? -1 : 0;
+ }
+
+ // Lines are the whole point of the line-art styles.
+ this.setOutlineVisible(line ? true : this.outlineEnabled);
+ }
+
+ /**
+ * Hand a set of parts to the screen-space outline, or take them back.
+ *
+ * The hull and the screen-space pass each have a shape they cannot draw: a hull
+ * cannot outline a thin closed shell (the leaves), and the screen-space pass
+ * draws a stepped line because it works on the pixel grid. So the leaves go to
+ * the screen-space pass and everything else keeps its hull, which is drawn from
+ * the geometry and therefore comes out smooth.
+ */
+ setHullHidden(meshes) {
+ this.hullHidden = new Set(meshes ?? []);
+ this.setStyle(this.style);
+ }
+
+ toonFor(original) {
+ let material = this.toonMaterials.get(original);
+ if (!material) {
+ material = new THREE.MeshToonMaterial({
+ color: original.color ? original.color.clone() : new THREE.Color(0xffffff),
+ map: original.map ?? null,
+ vertexColors: original.vertexColors === true,
+ gradientMap: this.gradientMap,
+ side: original.side,
+ transparent: original.transparent === true,
+ alphaTest: original.alphaTest ?? 0,
+ depthWrite: original.depthWrite !== false,
+ });
+ this.toonMaterials.set(original, material);
+ }
+ return material;
+ }
+
+ setPaper(color) {
+ this.paper = color;
+ this.paperMaterial.color.set(color);
+ for (const material of this.toneMaterials.values()) {
+ material.color.copy(toneOf(color, material.userData.tone));
+ }
+ }
+
+ setOutlineVisible(visible) {
+ const lineMode = LINE_STYLES.has(this.style);
+ for (const hull of this.outlineMeshes) {
+ const lineOnly = hull.userData.lineOnly === true;
+ const shadedOnly = hull.userData.shadedOnly === true;
+ hull.visible = visible
+ && (!lineOnly || lineMode)
+ && !(shadedOnly && lineMode)
+ && !this.hullHidden.has(hull.userData.source);
+ }
+ }
+
+ setOutlineEnabled(enabled) {
+ this.outlineEnabled = enabled;
+ if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled);
+ }
+
+ setOutlineWidth(width) {
+ this.outlineWidth = width;
+ for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale;
+ }
+
+ setOutlineColor(color) {
+ this.outlineColor = color;
+ for (const entry of this.hullMaterials.values()) entry.material.color.set(color);
+ }
+
+ /**
+ * The toon materials are cached copies of the originals, so a colour theme that
+ * edits `original.color` has to be copied across or the flat style keeps
+ * showing the old colour. See src/look.js.
+ */
+ refreshColors() {
+ for (const [mesh, original] of this.originals) {
+ const toon = this.toonMaterials.get(original);
+ if (toon && original.color) toon.color.copy(original.color);
+ // The outline hull is a colour of its own, so it is left alone.
+ void mesh;
+ }
+ return this;
+ }
+
+ dispose() {
+ for (const hull of this.outlineMeshes) {
+ hull.removeFromParent();
+ hull.skeleton = null;
+ }
+ this.outlineMeshes = [];
+ for (const entry of this.hullMaterials.values()) entry.material.dispose();
+ this.hullMaterials.clear();
+ this.paperMaterial.dispose();
+ for (const material of this.toneMaterials.values()) material.dispose();
+ this.toneMaterials.clear();
+ this.invisibleMaterial.dispose();
+ this.gradientMap.dispose();
+ for (const material of this.toonMaterials.values()) material.dispose();
+ this.toonMaterials.clear();
+ }
+}
+
+/** How far the hat default steps from the paper towards the ink in 線画. */
+const DEFAULT_TONE = 0.14;
+
+/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */
+function toneOf(paper, strength = DEFAULT_TONE) {
+ return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength);
+}
+
+/** A 3-step ramp gives crisper cartoon bands than the shader's default. */
+function makeGradientMap() {
+ const steps = new Uint8Array([90, 165, 255]);
+ const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat);
+ texture.minFilter = THREE.NearestFilter;
+ texture.magFilter = THREE.NearestFilter;
+ texture.generateMipmaps = false;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+function makeHullMaterial(color, uniform) {
+ const material = new THREE.MeshBasicMaterial({
+ color,
+ side: THREE.BackSide,
+ toneMapped: false,
+ });
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.uOutline = uniform;
+ shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace(
+ '#include <begin_vertex>',
+ '#include <begin_vertex>\n\ttransformed += normal * uOutline;',
+ );
+ };
+ material.customProgramCacheKey = () => 'bluebey-outline-hull';
+ return material;
+}
diff --git a/public/bluebey-studio/src/textOutlines.js b/public/bluebey-studio/src/textOutlines.js
new file mode 100644
index 0000000..334a92d
--- /dev/null
+++ b/public/bluebey-studio/src/textOutlines.js
@@ -0,0 +1,432 @@
+import opentype from 'opentype.js';
+
+/**
+ * Caption text as outlines.
+ *
+ * A caption drawn with <text> changes shape in every viewer, because the glyphs
+ * come from whatever font that viewer happens to have. The studio ships one
+ * subset font (M PLUS Rounded 1c) and this module turns the caption into plain
+ * SVG paths instead, so an exported SVG looks the same everywhere and stays
+ * editable as vector art.
+ *
+ * The module runs unchanged in the browser and in Node: it touches no DOM and no
+ * Node built-in at module scope, so the tests can parse the font straight from
+ * disk. `loadFont` is the only asynchronous export; everything else is a pure
+ * function of the font, the text and the options.
+ *
+ * Widths are summed one character at a time, without kerning between them. That
+ * costs a fraction of a pixel on Latin pairs but keeps measuring and wrapping in
+ * exact agreement, which matters more for a short caption.
+ */
+
+/** Families tried in order when a caption falls back to live <text>. */
+const FALLBACK_FAMILIES = [
+ 'M PLUS Rounded 1c',
+ 'Hiragino Maru Gothic ProN',
+ 'Yu Gothic',
+ 'Meiryo',
+ 'sans-serif',
+];
+
+/** CSS keywords that must stay unquoted inside a font stack. */
+const GENERIC_FAMILIES = new Set([
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji',
+ 'fangsong',
+]);
+
+/**
+ * Closing marks and brackets that should not start a line.
+ *
+ * A full kinsoku table needs per-font metrics; this short list covers the marks
+ * a caption actually uses, and dragging the preceding character down is enough
+ * to fix the rest.
+ */
+const CLOSING_PUNCTUATION = new Set([
+ '、', '。', ',', '.', ':', ';', '!', '?',
+ ')', ']', '}', '〕', '〉', '》', '」', '』', '】', '〗', '〙', '〟',
+ '”', '’', '⦆', '»',
+]);
+
+const SPACE = /\s/;
+const LINE_BREAK = /\r\n|\r|\n/;
+
+const DEFAULT_FONT_SIZE = 16;
+const DEFAULT_LINE_HEIGHT = 1.4;
+
+/**
+ * Parsed fonts, keyed by URL. The stored value is the in-flight promise, so two
+ * callers asking for the same URL share one request instead of loading twice.
+ */
+const fontCache = new Map();
+
+/** Family name of the most recent font from `loadFont`, read by `captionFontStack`. */
+let loadedFamily = null;
+
+/**
+ * Load a font and remember it under `url`.
+ *
+ * Resolves to an `opentype.Font`; rejects if the font cannot be fetched or
+ * parsed, and does not cache that failure, so a later call can retry.
+ *
+ * @param {string} url URL (browser) or file path (Node) of the font
+ * @returns {Promise<import('opentype.js').Font>}
+ */
+export async function loadFont(url) {
+ if (!url) throw new Error('loadFont: a font URL is required');
+ if (fontCache.has(url)) return fontCache.get(url);
+
+ const pending = opentype.load(url).then((font) => {
+ loadedFamily = familyNameOf(font) ?? loadedFamily;
+ return font;
+ });
+ // A rejected promise left in the cache would make every retry fail forever.
+ pending.catch(() => fontCache.delete(url));
+ fontCache.set(url, pending);
+ return pending;
+}
+
+/**
+ * Synchronous twin of `loadFont` for tests and offline use.
+ *
+ * @param {ArrayBuffer} arrayBuffer font bytes; a typed-array view is accepted too
+ * @returns {import('opentype.js').Font}
+ */
+export function parseFont(arrayBuffer) {
+ // `opentype.parse` needs a real ArrayBuffer, so unwrap a view (for example a
+ // Node Buffer) before handing it over.
+ const view = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : null;
+ const buffer = view
+ ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)
+ : arrayBuffer;
+ return opentype.parse(buffer);
+}
+
+/**
+ * Does the font really cover every character of `text`?
+ *
+ * Callers use this to decide between outlines and live text: a single missing
+ * glyph means the caption would come out with a hole in it, so the answer is
+ * then `false`. Newlines carry no glyph and are ignored.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @returns {boolean}
+ */
+export function hasGlyphs(font, text) {
+ if (!font || typeof text !== 'string') return false;
+ for (const ch of text) {
+ if (ch === '\n' || ch === '\r') continue;
+ if (font.charToGlyphIndex(ch) === 0) return false; // 0 is .notdef
+ }
+ return true;
+}
+
+/**
+ * Size one string in pixels.
+ *
+ * The width is the widest line, so the same call works for a single line and for
+ * text that already contains breaks. `ascent` is above the baseline and
+ * `descent` below it, both in the font's own sign convention.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @param {number} [fontSize]
+ * @returns {{width: number, ascent: number, descent: number}}
+ */
+export function measureText(font, text, fontSize = DEFAULT_FONT_SIZE) {
+ const size = Number.isFinite(fontSize) ? fontSize : DEFAULT_FONT_SIZE;
+ const scale = size / (font.unitsPerEm || 1000);
+
+ let width = 0;
+ for (const line of String(text ?? '').split(LINE_BREAK)) {
+ width = Math.max(width, measureLine(font, line, size));
+ }
+ return { width, ascent: font.ascender * scale, descent: font.descender * scale };
+}
+
+/**
+ * Wrap `text` into lines and report the block's size.
+ *
+ * Rules, in the order they apply:
+ * - an explicit `\n` always ends a line;
+ * - a Latin word breaks only at a space, never in the middle;
+ * - CJK characters break anywhere, one break opportunity per character;
+ * - a piece that is too wide on its own still gets a line (nothing is dropped);
+ * - a line is not allowed to start with closing punctuation when dragging the
+ * previous character down would avoid it.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @param {object} [options]
+ * @param {number} [options.fontSize=16]
+ * @param {number|null} [options.maxWidth=0] 0/null/undefined means "no wrapping"
+ * @param {number} [options.lineHeight=1.4] multiplier of `fontSize`
+ * @param {'left'|'center'|'right'} [options.align='left']
+ * @returns {{
+ * lines: Array<{text: string, width: number}>,
+ * width: number, height: number, lineHeight: number,
+ * fontSize: number, align: string,
+ * }}
+ */
+export function layoutText(font, text, options = {}) {
+ const opts = options ?? {};
+ const fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : DEFAULT_FONT_SIZE;
+ const lineHeight = Number.isFinite(opts.lineHeight) ? opts.lineHeight : DEFAULT_LINE_HEIGHT;
+ const align = opts.align === 'center' || opts.align === 'right' ? opts.align : 'left';
+ const maxWidth = opts.maxWidth;
+ const wrapWidth = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
+
+ const lines = [];
+ for (const paragraph of String(text ?? '').split(LINE_BREAK)) {
+ const atoms = tokenise(font, paragraph, fontSize);
+ for (const wrapped of wrapAtoms(atoms, wrapWidth)) {
+ lines.push({
+ text: wrapped.map((atom) => atom.text).join(''),
+ width: wrapped.reduce((sum, atom) => sum + atom.width, 0),
+ });
+ }
+ }
+
+ let width = 0;
+ for (const line of lines) width = Math.max(width, line.width);
+
+ const height = lines.length * fontSize * lineHeight;
+ return { lines, width, height, lineHeight, fontSize, align };
+}
+
+/**
+ * Convert a layout to one SVG path `d` string.
+ *
+ * `x`/`y` is the top-left corner of the text block. Each line sits on its own
+ * baseline, placed with half-leading so a line box of `fontSize * lineHeight`
+ * surrounds the glyphs evenly, and shifted sideways by `layout.align`.
+ *
+ * Characters the font does not cover are skipped rather than drawn as .notdef,
+ * and a line whose outline cannot be built cleanly is dropped, so the result
+ * never carries `NaN` into the document.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {object} layout value returned by `layoutText`
+ * @param {object} [options]
+ * @param {number} [options.x=0]
+ * @param {number} [options.y=0]
+ * @param {number} [options.round=2] decimal places in the output
+ * @returns {string}
+ */
+export function textToPathData(font, layout, options = {}) {
+ const opts = options ?? {};
+ const x = Number.isFinite(opts.x) ? opts.x : 0;
+ const y = Number.isFinite(opts.y) ? opts.y : 0;
+ const round = Number.isFinite(opts.round) ? Math.max(0, Math.floor(opts.round)) : 2;
+
+ if (!font || !layout || !Array.isArray(layout.lines) || layout.lines.length === 0) return '';
+
+ const fontSize = Number.isFinite(layout.fontSize) ? layout.fontSize : DEFAULT_FONT_SIZE;
+ const lineHeight = Number.isFinite(layout.lineHeight) ? layout.lineHeight : DEFAULT_LINE_HEIGHT;
+ const blockWidth = Number.isFinite(layout.width) ? layout.width : 0;
+
+ const step = fontSize * lineHeight;
+ const scale = fontSize / (font.unitsPerEm || 1000);
+ const ascent = font.ascender * scale;
+ const descent = -font.descender * scale; // depth below the baseline, positive
+ const halfLeading = (step - (ascent + descent)) / 2;
+
+ const parts = [];
+ for (let i = 0; i < layout.lines.length; i++) {
+ const line = layout.lines[i];
+ const drawable = drawableText(font, line.text);
+ if (!drawable) continue;
+
+ const baseline = y + i * step + halfLeading + ascent;
+ const lineX = x + alignOffset(layout.align, blockWidth, line.width);
+ const d = font.getPath(drawable, lineX, baseline, fontSize).toPathData(round);
+ if (!d || d.includes('NaN') || d.includes('Infinity')) continue;
+ parts.push(d);
+ }
+ return parts.join(' ');
+}
+
+/**
+ * The CSS `font-family` the app should use for live `<text>` or canvas captions.
+ *
+ * Starts with the family of the most recently loaded font, so the fallback text
+ * looks as close as possible to the outlines, and ends with Japanese-safe
+ * families that exist on the machines the studio runs on.
+ *
+ * @returns {string}
+ */
+export function captionFontStack() {
+ const families = [loadedFamily ?? FALLBACK_FAMILIES[0]];
+ const seen = new Set(families.map((name) => name.toLowerCase()));
+ for (const name of FALLBACK_FAMILIES) {
+ if (seen.has(name.toLowerCase())) continue;
+ seen.add(name.toLowerCase());
+ families.push(name);
+ }
+ return families.map(cssFamily).join(', ');
+}
+
+// --- internals ---------------------------------------------------------------
+
+/** Width of one line of text, in pixels. */
+function measureLine(font, line, fontSize) {
+ let width = 0;
+ for (const ch of line) width += font.getAdvanceWidth(ch, fontSize);
+ return width;
+}
+
+/** Family name of a font, or `null` when it does not carry one. */
+function familyNameOf(font) {
+ const names = font?.names?.fontFamily;
+ if (!names) return null;
+ // A parsed font stores one entry per language tag; a font built from scratch
+ // stores a plain string. Accept both.
+ const value = typeof names === 'string' ? names : names.en ?? Object.values(names)[0];
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
+}
+
+/** Only the characters the font can actually draw, newlines removed. */
+function drawableText(font, text) {
+ let out = '';
+ for (const ch of String(text ?? '')) {
+ if (ch === '\n' || ch === '\r') continue;
+ if (font.charToGlyphIndex(ch) === 0) continue; // no outline to draw
+ out += ch;
+ }
+ return out;
+}
+
+/** Sideways shift of a line inside the block, for the block's alignment. */
+function alignOffset(align, blockWidth, lineWidth) {
+ const slack = blockWidth - lineWidth;
+ if (align === 'center') return slack / 2;
+ if (align === 'right') return slack;
+ return 0;
+}
+
+/** Quote a family for CSS unless it is a generic keyword. */
+function cssFamily(name) {
+ if (GENERIC_FAMILIES.has(name.toLowerCase())) return name;
+ return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+}
+
+/** `true` for scripts that may break between any two characters. */
+function isCjk(ch) {
+ const c = ch.codePointAt(0);
+ return (
+ (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo
+ (c >= 0x2e80 && c <= 0x303f) || // radicals, CJK punctuation
+ (c >= 0x3040 && c <= 0x30ff) || // hiragana, katakana
+ (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo
+ (c >= 0x3400 && c <= 0x4dbf) || // CJK extension A
+ (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs
+ (c >= 0xa960 && c <= 0xa97f) || // Hangul Jamo extended A
+ (c >= 0xac00 && c <= 0xd7ff) || // Hangul syllables
+ (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs
+ (c >= 0xfe10 && c <= 0xfe4f) || // vertical and compatibility forms
+ (c >= 0xff00 && c <= 0xffef) || // fullwidth and halfwidth forms
+ (c >= 0x1f200 && c <= 0x1f2ff) || // enclosed ideographic supplement
+ (c >= 0x20000 && c <= 0x2fa1f) // CJK extensions B onwards
+ );
+}
+
+/** One breakable piece of text together with its advance width. */
+function makeAtom(font, text, fontSize, space, closing) {
+ return { text, width: font.getAdvanceWidth(text, fontSize), space, closing };
+}
+
+/**
+ * Split a paragraph into the smallest pieces a line may break between.
+ *
+ * A Latin run stays one atom so it can never be split; every CJK character is
+ * its own atom so a break may fall on either side of it.
+ *
+ * @returns {Array<{text: string, width: number, space: boolean, closing: boolean}>}
+ */
+function tokenise(font, paragraph, fontSize) {
+ const atoms = [];
+ let word = null;
+
+ const endWord = () => {
+ if (word !== null) {
+ atoms.push(word);
+ word = null;
+ }
+ };
+
+ for (const ch of paragraph) {
+ if (SPACE.test(ch)) {
+ endWord();
+ atoms.push(makeAtom(font, ch, fontSize, true, false));
+ } else if (isCjk(ch)) {
+ endWord();
+ atoms.push(makeAtom(font, ch, fontSize, false, CLOSING_PUNCTUATION.has(ch)));
+ } else {
+ if (word === null) word = makeAtom(font, '', fontSize, false, false);
+ word.text += ch;
+ word.width += font.getAdvanceWidth(ch, fontSize);
+ }
+ }
+ endWord();
+ return atoms;
+}
+
+/**
+ * Greedy line breaker over atoms. Always returns at least one line, so an empty
+ * paragraph comes back as one empty line and explicit breaks are preserved.
+ *
+ * @param {Array<object>} atoms
+ * @param {number} maxWidth `Infinity` disables wrapping
+ * @returns {Array<Array<object>>}
+ */
+function wrapAtoms(atoms, maxWidth) {
+ const lines = [];
+ let current = [];
+
+ const currentWidth = () => current.reduce((sum, atom) => sum + atom.width, 0);
+
+ const finish = () => {
+ // A break swallows the spaces next to it, so no line ends or starts blank.
+ while (current.length > 0 && current[current.length - 1].space) current.pop();
+ while (current.length > 0 && current[0].space) current.shift();
+ lines.push(current);
+ current = [];
+ };
+
+ for (const atom of atoms) {
+ if (current.length === 0) {
+ if (atom.space) continue; // never start a line with a space
+ current.push(atom);
+ continue;
+ }
+ if (currentWidth() + atom.width <= maxWidth) {
+ current.push(atom);
+ continue;
+ }
+
+ // The atom does not fit. Keep closing punctuation off the start of the next
+ // line by dragging the previous character down, but only when that leaves
+ // something behind and the pair still respects the maximum width.
+ const carried = current[current.length - 1];
+ const visible = current.filter((a) => !a.space);
+ if (
+ atom.closing &&
+ visible.length > 1 &&
+ !carried.space &&
+ carried.width + atom.width <= maxWidth
+ ) {
+ current.pop();
+ finish();
+ current.push(carried);
+ } else {
+ finish();
+ }
+ // The break already stands in for a space, so it does not start the new line.
+ if (atom.space) continue;
+ current.push(atom);
+ }
+
+ finish();
+ return lines;
+}
diff --git a/public/bluebey-studio/src/trace.js b/public/bluebey-studio/src/trace.js
new file mode 100644
index 0000000..9b61d6d
--- /dev/null
+++ b/public/bluebey-studio/src/trace.js
@@ -0,0 +1,410 @@
+/**
+ * Zero-dependency marching-squares contour tracer.
+ *
+ * Runs unchanged in the browser and in Node: the module has no imports at all,
+ * never touches the DOM and never writes to the console.
+ *
+ * Coordinate system
+ * -----------------
+ * Pixel (x, y) is the unit square whose top-left corner sits at (x, y), so the
+ * centre of pixel (x, y) is at (x + 0.5, y + 0.5) and y grows downwards
+ * (row 0 is the top row of the input).
+ *
+ * Contours
+ * --------
+ * Every returned contour is a *closed* polyline of sub-pixel points; the first
+ * point is not repeated at the end. Contours are oriented so that the inside of
+ * the shape (the region where the sample is at or above the threshold) lies to
+ * the left of the direction of travel. See `contourArea` for the sign this
+ * implies.
+ */
+
+const EDGE_TOP = 0;
+const EDGE_RIGHT = 1;
+const EDGE_BOTTOM = 2;
+const EDGE_LEFT = 3;
+
+/**
+ * Directed segments emitted by each unambiguous marching-squares case.
+ *
+ * The case index is built from the cell corners as
+ * `tl | tr << 1 | br << 2 | bl << 3`. Entries are flat `[from, to, from, to]`
+ * pairs of edge ids; the direction keeps the inside region on the left.
+ */
+const CELL_SEGMENTS = [
+ null, // 0 - nothing inside
+ [EDGE_LEFT, EDGE_TOP, -1, -1], // 1 - top-left only
+ [EDGE_TOP, EDGE_RIGHT, -1, -1], // 2 - top-right only
+ [EDGE_LEFT, EDGE_RIGHT, -1, -1], // 3 - top row
+ [EDGE_RIGHT, EDGE_BOTTOM, -1, -1], // 4 - bottom-right only
+ null, // 5 - saddle (top-left + bottom-right)
+ [EDGE_TOP, EDGE_BOTTOM, -1, -1], // 6 - right column
+ [EDGE_LEFT, EDGE_BOTTOM, -1, -1], // 7 - everything but bottom-left
+ [EDGE_BOTTOM, EDGE_LEFT, -1, -1], // 8 - bottom-left only
+ [EDGE_BOTTOM, EDGE_TOP, -1, -1], // 9 - left column
+ null, // 10 - saddle (top-right + bottom-left)
+ [EDGE_BOTTOM, EDGE_RIGHT, -1, -1], // 11 - everything but bottom-right
+ [EDGE_RIGHT, EDGE_LEFT, -1, -1], // 12 - bottom row
+ [EDGE_RIGHT, EDGE_TOP, -1, -1], // 13 - everything but top-right
+ [EDGE_TOP, EDGE_LEFT, -1, -1], // 14 - everything but top-left
+ null, // 15 - everything inside
+];
+
+// Case 5 (top-left + bottom-right inside). When the centre of the cell is
+// inside, the two inside corners are joined through the middle and the two
+// outside corners are separated; otherwise the inside corners are separated.
+const SADDLE_5_CONNECTED = [EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT, EDGE_TOP];
+const SADDLE_5_SPLIT = [EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM];
+
+// Case 10 (top-right + bottom-left inside): the mirror image of case 5.
+const SADDLE_10_CONNECTED = [EDGE_TOP, EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT];
+const SADDLE_10_SPLIT = [EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM, EDGE_LEFT];
+
+const DUPLICATE_EPS = 1e-9;
+
+/**
+ * Signed area of a closed polyline, via the shoelace formula. The polyline is
+ * implicitly closed (the last point is joined back to the first), so an open
+ * ring is fine.
+ *
+ * Sign convention: this is the plain shoelace sum `Σ (x_i·y_{i+1} − x_{i+1}·y_i) / 2`
+ * evaluated in the tracer's y-down pixel coordinates. A ring that runs
+ * clockwise *as seen on screen* is therefore positive, and a counter-clockwise
+ * one is negative. Because `traceAlphaContours` keeps the inside on the left,
+ * the outer boundary of a filled region comes out negative and a hole in it
+ * comes out positive.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @returns {number} signed area in square pixels
+ */
+export function contourArea(points) {
+ const n = points.length;
+ if (!points || n < 3) return 0;
+ let sum = 0;
+ for (let i = 0; i < n; i++) {
+ const a = points[i];
+ const b = i + 1 === n ? points[0] : points[i + 1];
+ sum += a.x * b.y - b.x * a.y;
+ }
+ return sum / 2;
+}
+
+/**
+ * Build an SVG path `d` attribute with one closed subpath per contour.
+ *
+ * `mapPoint(x, y)` returns the `[X, Y]` pair written to the output, which is
+ * what makes it possible to flip the y axis or apply a scale without touching
+ * the tracer. Coordinates are rounded to `decimals` places.
+ *
+ * @param {Array<Array<{x: number, y: number}>>} contours
+ * @param {(x: number, y: number) => [number, number]} mapPoint
+ * @param {number} [decimals]
+ * @returns {string}
+ */
+export function contoursToPathData(contours, mapPoint, decimals = 2) {
+ const places = Math.max(0, Math.floor(decimals));
+ const factor = Math.pow(10, places);
+ const parts = [];
+ for (let c = 0; c < contours.length; c++) {
+ const points = contours[c];
+ if (!points || points.length < 2) continue;
+ for (let i = 0; i < points.length; i++) {
+ const mapped = mapPoint(points[i].x, points[i].y);
+ // Rounding before formatting keeps `-0.00` out of the output.
+ const rx = Math.round(mapped[0] * factor) / factor;
+ const ry = Math.round(mapped[1] * factor) / factor;
+ parts.push((i === 0 ? 'M' : 'L') + rx.toFixed(places) + ' ' + ry.toFixed(places));
+ }
+ parts.push('Z');
+ }
+ return parts.join(' ');
+}
+
+// --- closed-ring simplification helpers ------------------------------------
+
+/** Drop points that repeat their predecessor (including across the wrap). */
+function removeConsecutiveDuplicates(points) {
+ const out = [];
+ for (let i = 0; i < points.length; i++) {
+ const p = points[i];
+ const last = out[out.length - 1];
+ if (last && Math.abs(last.x - p.x) <= DUPLICATE_EPS && Math.abs(last.y - p.y) <= DUPLICATE_EPS) {
+ continue;
+ }
+ out.push(p);
+ }
+ while (out.length > 1) {
+ const first = out[0];
+ const last = out[out.length - 1];
+ if (Math.abs(first.x - last.x) <= DUPLICATE_EPS && Math.abs(first.y - last.y) <= DUPLICATE_EPS) {
+ out.pop();
+ } else {
+ break;
+ }
+ }
+ return out;
+}
+
+/** Drop points that sit on the straight segment between their two neighbours. */
+function removeCollinear(points) {
+ let list = points;
+ let changed = true;
+ while (changed && list.length > 3) {
+ changed = false;
+ const n = list.length;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const a = list[i === 0 ? n - 1 : i - 1];
+ const b = list[i];
+ const c = list[i + 1 === n ? 0 : i + 1];
+ const abx = b.x - a.x;
+ const aby = b.y - a.y;
+ const bcx = c.x - b.x;
+ const bcy = c.y - b.y;
+ const cross = abx * bcy - aby * bcx;
+ // |cross| / (|ab| * |bc|) is sin(turn angle); a small value means a
+ // straight-through point, which carries no shape information.
+ const scale = Math.sqrt((abx * abx + aby * aby) * (bcx * bcx + bcy * bcy));
+ const straight = scale <= DUPLICATE_EPS || Math.abs(cross) <= 1e-9 * scale;
+ if (straight && abx * bcx + aby * bcy >= 0) {
+ changed = true;
+ } else {
+ out.push(b);
+ }
+ }
+ list = out;
+ }
+ return list;
+}
+
+/**
+ * Iterative Douglas–Peucker for an *open* polyline. The two end points are
+ * always kept; the recursion uses an explicit stack so long contours cannot
+ * overflow the call stack.
+ */
+function douglasPeucker(points, tolerance) {
+ const n = points.length;
+ if (n <= 2) return points.slice();
+ const keep = new Uint8Array(n);
+ keep[0] = 1;
+ keep[n - 1] = 1;
+ const stack = [0, n - 1];
+ while (stack.length > 0) {
+ const i1 = stack.pop();
+ const i0 = stack.pop();
+ if (i1 <= i0 + 1) continue;
+ const a = points[i0];
+ const b = points[i1];
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const len = Math.sqrt(dx * dx + dy * dy);
+ let maxDistance = -1;
+ let maxIndex = -1;
+ if (len <= DUPLICATE_EPS) {
+ // Degenerate segment: fall back to the distance from the anchor point.
+ for (let i = i0 + 1; i < i1; i++) {
+ const px = points[i].x - a.x;
+ const py = points[i].y - a.y;
+ const d = Math.sqrt(px * px + py * py);
+ if (d > maxDistance) {
+ maxDistance = d;
+ maxIndex = i;
+ }
+ }
+ } else {
+ for (let i = i0 + 1; i < i1; i++) {
+ const p = points[i];
+ const d = Math.abs(dy * (p.x - a.x) - dx * (p.y - a.y)) / len;
+ if (d > maxDistance) {
+ maxDistance = d;
+ maxIndex = i;
+ }
+ }
+ }
+ if (maxDistance > tolerance && maxIndex > i0) {
+ keep[maxIndex] = 1;
+ stack.push(i0, maxIndex, maxIndex, i1);
+ }
+ }
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ if (keep[i]) out.push(points[i]);
+ }
+ return out;
+}
+
+/**
+ * Simplify a closed ring, wrap-around segment included.
+ *
+ * The ring is cut at the point farthest from `points[0]`, which gives two open
+ * polylines that together cover every segment of the loop exactly once; each
+ * half is then simplified with Douglas–Peucker and the halves are stitched
+ * back together (without duplicating the shared anchors).
+ */
+function simplifyClosedRing(points, tolerance) {
+ const n = points.length;
+ if (n <= 3) return points.slice();
+ let far = 0;
+ let farDistance = -1;
+ const first = points[0];
+ for (let i = 1; i < n; i++) {
+ const dx = points[i].x - first.x;
+ const dy = points[i].y - first.y;
+ const d = dx * dx + dy * dy;
+ if (d > farDistance) {
+ farDistance = d;
+ far = i;
+ }
+ }
+ // A ring whose points all coincide carries no shape; leave it to minArea.
+ if (far <= 0 || farDistance <= DUPLICATE_EPS) return points.slice();
+
+ const head = douglasPeucker(points.slice(0, far + 1), tolerance);
+ const tail = douglasPeucker(points.slice(far).concat([first]), tolerance);
+
+ // `head` ends and `tail` starts on the same anchor, and both end on
+ // `points[0]`; drop the duplicated join so every point appears once.
+ return head.slice(0, -1).concat(tail.slice(0, -1));
+}
+
+/**
+ * Trace the iso-contour of a scalar field at `options.threshold`.
+ *
+ * @param {ArrayLike<number>} alpha width*height samples, row-major, row 0 on top
+ * @param {number} width
+ * @param {number} height
+ * @param {object} [options]
+ * @param {number} [options.threshold=0.5] inside when `alpha/255 >= threshold`
+ * @param {number} [options.simplifyTolerance=0.35] Douglas-Peucker tolerance, px
+ * @param {number} [options.minArea=2] drop rings smaller than this, px²
+ * @returns {Array<Array<{x: number, y: number}>>}
+ */
+export function traceAlphaContours(alpha, width, height, options = {}) {
+ const threshold = options.threshold ?? 0.5;
+ const tolerance = options.simplifyTolerance ?? 0.35;
+ const minArea = options.minArea ?? 2;
+
+ const w = Math.floor(width);
+ const h = Math.floor(height);
+ if (!alpha || w < 1 || h < 1 || alpha.length < w * h) return [];
+ if (w < 2 && h < 2) return [];
+
+ // Work on a signed field, padded with a 1px outside border: `s >= 0` is
+ // inside. The padding guarantees every crossing is strictly interior, so the
+ // marching always yields closed rings and never touches the array edges.
+ // The border holds the same value a fully transparent pixel maps to, which
+ // makes shapes that run off the image close exactly on the image edge.
+ const pw = w + 2;
+ const ph = h + 2;
+ const s = new Float32Array(pw * ph);
+ s.fill(-threshold);
+ for (let y = 0; y < h; y++) {
+ const src = y * w;
+ const dst = (y + 1) * pw + 1;
+ for (let x = 0; x < w; x++) s[dst + x] = alpha[src + x] / 255 - threshold;
+ }
+ const sample = (x, y) => s[y * pw + x];
+
+ // Crossing points, keyed by the grid edge they sit on. Both cells sharing an
+ // edge call these with the same sample pair in the same order (top→bottom,
+ // left→right), so the coordinates come out bit-identical and can be matched
+ // by key alone.
+ const points = new Map();
+ function crossing(key, a, b, x0, y0, dx, dy) {
+ let p = points.get(key);
+ if (p === undefined) {
+ const t = a / (a - b);
+ p = { x: x0 + dx * t, y: y0 + dy * t };
+ points.set(key, p);
+ }
+ return p;
+ }
+ // Horizontal edge of the padded grid at row `py`, spanning columns px..px+1.
+ const hKey = (px, py) => `h:${px}:${py}`;
+ const hPoint = (px, py) => crossing(hKey(px, py), sample(px, py), sample(px + 1, py), px - 0.5, py - 0.5, 1, 0);
+ // Vertical edge of the padded grid at column `px`, spanning rows py..py+1.
+ const vKey = (px, py) => `v:${px}:${py}`;
+ const vPoint = (px, py) => crossing(vKey(px, py), sample(px, py), sample(px, py + 1), px - 0.5, py - 0.5, 0, 1);
+
+ const edgeKey = [
+ (px, py) => hKey(px, py), // EDGE_TOP
+ (px, py) => vKey(px + 1, py), // EDGE_RIGHT
+ (px, py) => hKey(px, py + 1), // EDGE_BOTTOM
+ (px, py) => vKey(px, py), // EDGE_LEFT
+ ];
+ const edgePoint = [
+ (px, py) => hPoint(px, py), // EDGE_TOP
+ (px, py) => vPoint(px + 1, py), // EDGE_RIGHT
+ (px, py) => hPoint(px, py + 1), // EDGE_BOTTOM
+ (px, py) => vPoint(px, py), // EDGE_LEFT
+ ];
+
+ // Directed segments: `from` -> `to`, inside region on the left of travel.
+ const fromKeys = [];
+ const toKeys = [];
+ const fromPoints = [];
+
+ for (let py = 0; py < ph - 1; py++) {
+ for (let px = 0; px < pw - 1; px++) {
+ const tl = sample(px, py);
+ const tr = sample(px + 1, py);
+ const br = sample(px + 1, py + 1);
+ const bl = sample(px, py + 1);
+ const inside = (v) => (v >= 0 ? 1 : 0);
+ const code = inside(tl) | (inside(tr) << 1) | (inside(br) << 2) | (inside(bl) << 3);
+ let segments = CELL_SEGMENTS[code];
+ if (code === 5) {
+ // With the cell centre inside, the two inside corners join through the
+ // middle; otherwise each is cut off on its own.
+ segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_5_CONNECTED : SADDLE_5_SPLIT;
+ } else if (code === 10) {
+ segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_10_CONNECTED : SADDLE_10_SPLIT;
+ }
+ if (!segments) continue;
+ for (let i = 0; i < segments.length; i += 2) {
+ const a = segments[i];
+ const b = segments[i + 1];
+ if (a < 0 || b < 0) continue; // padding of the single-segment cases
+ fromKeys.push(edgeKey[a](px, py));
+ fromPoints.push(edgePoint[a](px, py));
+ toKeys.push(edgeKey[b](px, py));
+ edgePoint[b](px, py); // make sure the shared crossing exists
+ }
+ }
+ }
+
+ // Every crossing has exactly one incoming and one outgoing segment, so the
+ // segments can be walked into closed rings without any ambiguity.
+ const outgoing = new Map();
+ for (let i = 0; i < fromKeys.length; i++) outgoing.set(fromKeys[i], i);
+
+ const segmentCount = fromKeys.length;
+ const used = new Uint8Array(segmentCount);
+ const contours = [];
+
+ for (let start = 0; start < segmentCount; start++) {
+ if (used[start]) continue;
+ const ring = [];
+ let current = start;
+ for (let guard = 0; guard <= segmentCount; guard++) {
+ used[current] = 1;
+ ring.push(fromPoints[current]);
+ const next = outgoing.get(toKeys[current]);
+ if (next === undefined || next === start) break;
+ if (used[next]) break;
+ current = next;
+ }
+ if (ring.length >= 3) contours.push(ring);
+ }
+
+ const result = [];
+ for (let i = 0; i < contours.length; i++) {
+ let ring = removeConsecutiveDuplicates(contours[i]);
+ ring = simplifyClosedRing(ring, tolerance);
+ ring = removeCollinear(ring);
+ if (ring.length < 3) continue;
+ if (Math.abs(contourArea(ring)) < minArea) continue;
+ result.push(ring);
+ }
+ return result;
+} \ No newline at end of file
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js
new file mode 100644
index 0000000..91e852b
--- /dev/null
+++ b/public/bluebey-studio/src/ui.js
@@ -0,0 +1,367 @@
+/**
+ * Small DOM widget kit for the control panel: hyperscript, collapsible sections
+ * and a handful of labelled controls. Everything returns `{ el, set, get }` so
+ * callers can push state back into the widgets when presets are applied.
+ */
+
+export function h(tag, props = {}, ...children) {
+ const node = document.createElement(tag);
+ for (const [key, value] of Object.entries(props ?? {})) {
+ if (value == null || value === false) continue;
+ if (key === 'class') node.className = value;
+ else if (key === 'text') node.textContent = value;
+ else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
+ else if (key === 'dataset' && typeof value === 'object') Object.assign(node.dataset, value);
+ else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value);
+ else if (value === true) node.setAttribute(key, '');
+ else node.setAttribute(key, String(value));
+ }
+ for (const child of children.flat()) {
+ if (child == null || child === false) continue;
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ }
+ return node;
+}
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+let toastTimer = 0;
+
+export function toast(message) {
+ const node = document.getElementById('toast');
+ if (!node) return;
+ node.textContent = message;
+ node.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => node.classList.remove('show'), 2400);
+}
+
+export function section(parent, title, { open = false, icon = null } = {}) {
+ const body = h('div', { class: 'sec-body' });
+ const head = h('button', { type: 'button', class: 'sec-head' },
+ h('span', { class: 'sec-title' },
+ icon ? secIcon(icon) : null,
+ h('span', { text: title })),
+ h('span', { class: 'chev', text: '▾' }));
+ const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body);
+ head.addEventListener('click', () => el.classList.toggle('closed'));
+ parent.append(el);
+ // `add` accepts either an element or a widget object (`{ el }`), so callers
+ // cannot accidentally append `[object Object]`.
+ return { el, body, add: (child) => (body.append(child?.el ?? child), child), setOpen: (open) => el.classList.toggle('closed', !open) };
+}
+
+/** A label + control row. Pass `label` as null for a full-width control. */
+export function controlRow(label, control, { wide = false } = {}) {
+ if (label == null || wide) return h('div', { class: 'row wide' }, label ? h('span', { class: 'label', text: label }) : null, control);
+ return h('div', { class: 'row' },
+ h('span', { class: 'label', text: label, title: label }),
+ control);
+}
+
+/**
+ * A tab's optional icon: one 24x24 stroke path, drawn in `currentColor` so it
+ * follows the tab's text colour (and turns white when the tab is active).
+ */
+function tabIcon(d) {
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('width', '18');
+ svg.setAttribute('height', '18');
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.8');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.style.verticalAlign = 'baseline';
+ svg.style.flex = '0 0 auto';
+ const path = document.createElementNS(SVG_NS, 'path');
+ path.setAttribute('d', d);
+ svg.append(path);
+ return svg;
+}
+
+/** A small stroke icon built from raw SVG markup (see the icons in panel.js). */
+export function svgIcon(inner, { size = 16, viewBox = '0 0 24 24', className = 'grp-icon' } = {}) {
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', viewBox);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.8');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.setAttribute('class', className);
+ svg.innerHTML = inner;
+ return svg;
+}
+
+function secIcon(d) {
+ const svg = tabIcon(d);
+ svg.setAttribute('class', 'sec-icon');
+ svg.setAttribute('width', '16');
+ svg.setAttribute('height', '16');
+ svg.style.verticalAlign = 'middle';
+ return svg;
+}
+
+/**
+ * A standalone 24x24 stroke icon (the same drawing as a tab's), for a plain
+ * button that has no label of its own.
+ */
+export function icon(d, size = 18) {
+ const svg = tabIcon(d);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ return svg;
+}
+
+/**
+ * Tabbed container: returns the per-tab panels to fill in. When a definition
+ * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the
+ * label becomes its tooltip and accessible name; without one the label shows.
+ */
+export function tabs(parent, definitions) {
+ const bar = h('div', { class: 'tabs' });
+ const buttons = new Map();
+ const panels = {};
+ let active = definitions[0]?.id ?? null;
+
+ const select = (id) => {
+ if (!panels[id]) return;
+ active = id;
+ for (const [key, button] of buttons) button.classList.toggle('active', key === id);
+ for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== id;
+ };
+
+ for (const definition of definitions) {
+ const button = h('button', { type: 'button', class: 'tab' });
+ if (definition.icon) {
+ button.append(tabIcon(definition.icon));
+ button.title = definition.label;
+ button.setAttribute('aria-label', definition.label);
+ } else {
+ button.append(h('span', { class: 'tab-label', text: definition.label }));
+ }
+ button.addEventListener('click', () => select(definition.id));
+ buttons.set(definition.id, button);
+ bar.append(button);
+ panels[definition.id] = h('div', { class: 'tab-panel' });
+ }
+
+ parent.append(bar);
+ for (const definition of definitions) parent.append(panels[definition.id]);
+ select(active);
+ return { select, panels, get active() { return active; } };
+}
+
+/** A collapsed "more options" block, using the native details element. */
+export function details(parent, summary, { open = false } = {}) {
+ const body = h('div', { class: 'details-body' });
+ const el = h('details', { class: 'details' }, h('summary', { text: summary }), body);
+ if (open) el.open = true;
+ parent.append(el);
+ return { el, body, add: (child) => (body.append(child?.el ?? child), child) };
+}
+
+export function subhead(text) {
+ return h('div', { class: 'subhead', text });
+}
+
+export function hint(...lines) {
+ return h('p', { class: 'hint' }, ...lines.flat().map((line) => (line instanceof Node ? line : line)));
+}
+
+export function slider({ label, min = 0, max = 1, step = 0.01, value = 0, format, onInput, onCommit, wide = false }) {
+ const input = h('input', { type: 'range', min, max, step, value });
+ const num = h('span', { class: 'num' });
+ const fmt = format ?? ((v) => (step >= 1 ? String(Math.round(v)) : v.toFixed(2)));
+ const sync = () => { num.textContent = fmt(Number(input.value)); };
+ input.addEventListener('input', () => { sync(); onInput?.(Number(input.value)); });
+ input.addEventListener('change', () => onCommit?.(Number(input.value)));
+ sync();
+ const control = h('div', { class: 'control' }, input, num);
+ return {
+ el: controlRow(label, control, { wide }),
+ set(v) { input.value = String(v); sync(); },
+ get: () => Number(input.value),
+ };
+}
+
+export function check({ label, value = false, onChange, title }) {
+ const input = h('input', { type: 'checkbox' });
+ input.checked = !!value;
+ input.addEventListener('change', () => onChange?.(input.checked));
+ const el = h('label', { class: 'chk', title: title ?? '' }, input, h('span', { text: label }));
+ return { el, set: (v) => { input.checked = !!v; }, get: () => input.checked };
+}
+
+export function segmented({ label, options, value, onChange, wide = false }) {
+ const el = h('div', { class: 'seg' });
+ const nodes = new Map();
+ let current = value;
+ const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); };
+ const build = (list) => {
+ el.replaceChildren();
+ nodes.clear();
+ for (const option of list) {
+ const node = h('button', { type: 'button', title: option.title ?? option.label, text: option.label });
+ node.addEventListener('click', () => {
+ if (current === option.value) return;
+ current = option.value;
+ sync();
+ onChange?.(option.value);
+ });
+ nodes.set(option.value, node);
+ el.append(node);
+ }
+ sync();
+ };
+ build(options);
+ return {
+ el: controlRow(label, el, { wide }),
+ set(v) { current = v; sync(); },
+ get: () => current,
+ setOptions: build,
+ };
+}
+
+export function buttons({ label, items, wide = true }) {
+ const el = h('div', { class: 'buttons' });
+ const nodes = new Map();
+ for (const item of items) {
+ const node = h('button', {
+ type: 'button',
+ class: `btn${item.primary ? ' primary' : ''}`,
+ title: item.title ?? '',
+ text: item.label,
+ });
+ node.addEventListener('click', () => item.onClick?.());
+ const key = item.id ?? item.label;
+ nodes.set(key, node);
+ el.append(node);
+ }
+ return {
+ el: controlRow(label, el, { wide }),
+ button: (key) => nodes.get(key)?.el,
+ setDisabled(key, disabled) { const node = nodes.get(key); if (node) node.disabled = disabled; },
+ setLabel(key, text) { const node = nodes.get(key); if (node) node.textContent = text; },
+ };
+}
+
+export function colorField({ label, value = '#ffffff', onChange, swatches = [], wide = false }) {
+ const input = h('input', { type: 'color', value });
+ input.addEventListener('input', () => onChange?.(input.value));
+ const control = h('div', { class: 'control' }, input);
+ if (swatches.length) {
+ const strip = h('div', { class: 'swatches' });
+ for (const color of swatches) {
+ const chip = h('button', { type: 'button', class: 'swatch', title: color, style: { background: color } });
+ chip.addEventListener('click', () => { input.value = color; onChange?.(color); });
+ strip.append(chip);
+ }
+ control.append(strip);
+ }
+ return {
+ el: controlRow(label, control, { wide }),
+ set(v) { input.value = v; },
+ get: () => input.value,
+ };
+}
+
+/**
+ * Two-axis drag pad. `value` is `{ x, y }` with both components in -1..1 and
+ * `y` positive upwards (screen-like, but flipped so up = up).
+ */
+export function xyPad({ value = { x: 0, y: 0 }, onChange, onCommit, center = null }) {
+ const dot = h('span', { class: 'pad-dot' });
+ const el = h('div', { class: 'pad' }, center === 'eye' ? h('span', { class: 'pad-eye' }) : null, dot);
+ let current = { x: value.x ?? 0, y: value.y ?? 0 };
+ let dragging = false;
+
+ const place = () => {
+ dot.style.left = `${((current.x + 1) / 2) * 100}%`;
+ dot.style.top = `${((1 - (current.y + 1) / 2)) * 100}%`;
+ };
+ const fromEvent = (event) => {
+ const rect = el.getBoundingClientRect();
+ const x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
+ const y = clamp(1 - ((event.clientY - rect.top) / rect.height) * 2, -1, 1);
+ current = { x, y };
+ place();
+ onChange?.({ ...current });
+ };
+
+ el.addEventListener('pointerdown', (event) => {
+ dragging = true;
+ el.setPointerCapture(event.pointerId);
+ fromEvent(event);
+ });
+ el.addEventListener('pointermove', (event) => { if (dragging) fromEvent(event); });
+ const end = (event) => {
+ if (!dragging) return;
+ dragging = false;
+ if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId);
+ onCommit?.({ ...current });
+ };
+ el.addEventListener('pointerup', end);
+ el.addEventListener('pointercancel', end);
+ el.addEventListener('dblclick', () => { current = { x: 0, y: 0 }; place(); onChange?.({ ...current }); onCommit?.({ ...current }); });
+
+ place();
+ return {
+ el,
+ set(v) { current = { x: v.x ?? 0, y: v.y ?? 0 }; place(); },
+ get: () => ({ ...current }),
+ };
+}
+
+/**
+ * Tail-direction picker: a 3x3 grid of dots whose *position* is the direction,
+ * which reads at a glance in a way a flat row of "左上/右上" buttons does not.
+ * The centre dot means no tail. Same `{ el, set, get }` contract as the rest.
+ */
+export function tailPad({ label, value = 'left', onChange }) {
+ // Row-major, so the array order is what the user sees on screen.
+ const CELLS = [
+ ['topLeft', '左上'], ['top', '上'], ['topRight', '右上'],
+ ['left', '左'], ['none', 'なし'], ['right', '右'],
+ ['bottomLeft', '左下'], ['bottom', '下'], ['bottomRight', '右下'],
+ ];
+ const el = h('div', { class: 'tail-pad' });
+ const nodes = new Map();
+ let current = value;
+ const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); };
+ for (const [cell, text] of CELLS) {
+ const node = h('button', { type: 'button', class: 'tail-dot', title: text, 'aria-label': text });
+ node.addEventListener('click', () => {
+ if (current === cell) return;
+ current = cell;
+ sync();
+ onChange?.(cell);
+ });
+ nodes.set(cell, node);
+ el.append(node);
+ }
+ sync();
+ return {
+ el: controlRow(label, el),
+ set(v) { current = v; sync(); },
+ get: () => current,
+ };
+}
+
+export function selectField({ label, options, value, onChange, wide = false }) {
+ const select = h('select', { class: 'select' });
+ for (const option of options) select.append(h('option', { value: option.value, text: option.label }));
+ select.value = value;
+ select.addEventListener('change', () => onChange?.(select.value));
+ return {
+ el: controlRow(label, select, { wide }),
+ set(v) { select.value = v; },
+ get: () => select.value,
+ };
+}
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js
new file mode 100644
index 0000000..d6fb6bd
--- /dev/null
+++ b/public/bluebey-studio/src/urlState.js
@@ -0,0 +1,237 @@
+/**
+ * Shareable links: the whole look, packed into the URL fragment.
+ *
+ * A studio session is a lot of state, and "save a JSON file and send it" is a
+ * poor answer to "how do I show you what I made" - one link you can paste into
+ * chat is much better. So the state is JSON-encoded, deflated and
+ * base64url-encoded into something short enough to sit in a `#` fragment.
+ *
+ * Three details make it survive the trip:
+ *
+ * - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user
+ * loaded). No link can carry that, so `stripForUrl` replaces just that one
+ * field with `null` and keeps everything else - caption text, story panels,
+ * props. Nothing else is ever dropped.
+ *
+ * - The payload starts with a version tag ('1' = raw deflate, '0' = plain
+ * bytes) so the decoder knows whether to inflate. A browser without
+ * `CompressionStream` falls back to the uncompressed form rather than
+ * failing to produce a link at all.
+ *
+ * - base64url uses `-` and `_` and carries no `=` padding, so the fragment is
+ * safe to paste into chat, Markdown or HTML without being mangled or escaped.
+ *
+ * `decodeState` is deliberately total: any malformed, truncated or hostile input
+ * returns `null` rather than throwing, because it is fed whatever came out of
+ * the address bar.
+ *
+ * Most of a session is still at its default, and a default costs bytes on every
+ * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and
+ * `restoreDefaults` merges what is left back onto a fresh default, so the link
+ * carries only what the user actually changed. The pruning is lossless and the
+ * decoder still accepts the older, full-state links.
+ */
+
+import { applyPatch, defaultState } from './presets.js';
+
+/** Above this length a `data:` background image is a photo, not a link. */
+const MAX_INLINE_IMAGE = 2048;
+
+/** Version tags. '1' is the deflated body, '0' the fallback plain body. */
+const TAG_DEFLATE = '1';
+const TAG_PLAIN = '0';
+
+const BASE64URL = /^[A-Za-z0-9_-]+$/;
+
+/**
+ * A copy of `state` that is safe to put in a URL. Only the inline background
+ * image is dropped (replaced with `null`), and only when it is a `data:` URL
+ * long enough to blow up the link.
+ */
+export function stripForUrl(state) {
+ // A JSON round-trip gives the copy for free and drops anything that could not
+ // be serialised anyway, so the link and the live state cannot diverge.
+ const copy = JSON.parse(JSON.stringify(state ?? null));
+ if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy;
+
+ const image = copy.view && copy.view.backgroundImage;
+ if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) {
+ copy.view.backgroundImage = null;
+ }
+ return copy;
+}
+
+/** Plain objects only: not `null`, not an array. */
+function isPlainObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** Structural equality for the JSON-shaped values this module deals in. */
+function deepEqual(a, b) {
+ if (a === b) return true;
+ if (typeof a !== typeof b || a === null || b === null) return false;
+ if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b);
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ if (Array.isArray(a)) {
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
+ }
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
+}
+
+/** A detached copy of a JSON-shaped value. */
+function cloneValue(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+}
+
+/** Marks a value that equals its default, so its key can be left out entirely. */
+const OMIT = Symbol('urlState.omit');
+
+/**
+ * `value` with every sub-tree that deep-equals the matching `def` removed.
+ * Arrays are kept whole - never pruned element by element - because that is how
+ * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the
+ * value matched its default and can be dropped from the parent.
+ */
+function pruneValue(value, def) {
+ if (deepEqual(value, def)) return OMIT;
+ if (isPlainObject(value)) {
+ if (!isPlainObject(def)) return cloneValue(value);
+ const out = {};
+ for (const [key, child] of Object.entries(value)) {
+ const pruned = pruneValue(child, def[key]);
+ if (pruned !== OMIT) out[key] = pruned;
+ }
+ return Object.keys(out).length === 0 ? OMIT : out;
+ }
+ return Array.isArray(value) ? cloneValue(value) : value;
+}
+
+/**
+ * A copy of `state` with every value equal to `defaultState()` omitted. Lossless:
+ * `restoreDefaults` merges the result onto a fresh default. Arrays that differ
+ * from the default are carried whole so `applyPatch` can replace them.
+ */
+export function pruneDefaults(state) {
+ const pruned = pruneValue(state, defaultState());
+ return pruned === OMIT ? {} : pruned;
+}
+
+/**
+ * The inverse of `pruneDefaults`: a full state, with every field the partial does
+ * not mention left at its default. Merging a previously full state is idempotent,
+ * so links made by the older encoder still decode.
+ */
+export function restoreDefaults(partial) {
+ return applyPatch(defaultState(), partial);
+}
+
+/** True when the encoded fragment is bigger than a URL can comfortably hold. */
+export function isTooLong(text, limit = 1800) {
+ return typeof text === 'string' && text.length > limit;
+}
+
+/**
+ * base64url, without padding. Chunked so a large payload does not blow the
+ * argument limit of `String.fromCharCode`.
+ */
+function toBase64Url(bytes) {
+ let binary = '';
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */
+function fromBase64Url(text) {
+ const base64 = text.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = (4 - (base64.length % 4)) % 4;
+ const binary = atob(base64 + '='.repeat(pad));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+/** Read a whole ReadableStream into one Uint8Array. */
+async function drain(stream) {
+ const chunks = [];
+ let length = 0;
+ for await (const chunk of stream) {
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
+ chunks.push(bytes);
+ length += bytes.length;
+ }
+ const out = new Uint8Array(length);
+ let offset = 0;
+ for (const bytes of chunks) {
+ out.set(bytes, offset);
+ offset += bytes.length;
+ }
+ return out;
+}
+
+async function deflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
+ return drain(stream);
+}
+
+async function inflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ return drain(stream);
+}
+
+/**
+ * Serialise, deflate and base64url-encode a state. The result is a fragment
+ * payload: it survives a URL, a chat message and an HTML attribute.
+ *
+ * @param {object} state the studio state
+ * @returns {Promise<string>} a URL-safe string (tag + base64url)
+ */
+export async function encodeState(state) {
+ const json = JSON.stringify(pruneDefaults(stripForUrl(state)));
+ const bytes = new TextEncoder().encode(json);
+
+ if (typeof CompressionStream === 'function') {
+ try {
+ return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes));
+ } catch {
+ // Fall through: an unusable compressing stream should not cost the link.
+ }
+ }
+ return TAG_PLAIN + toBase64Url(bytes);
+}
+
+/**
+ * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed
+ * inflate or JSON that is not a plain object all come back as `null`.
+ *
+ * @param {string} text the fragment payload
+ * @returns {Promise<{ state: object } | null>}
+ */
+export async function decodeState(text) {
+ try {
+ if (typeof text !== 'string' || text.length < 2) return null;
+
+ const tag = text[0];
+ if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null;
+
+ const body = text.slice(1);
+ if (!BASE64URL.test(body)) return null;
+
+ let bytes = fromBase64Url(body);
+ if (tag === TAG_DEFLATE) {
+ if (typeof DecompressionStream !== 'function') return null;
+ bytes = await inflateRaw(bytes);
+ }
+
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
+ return { state: restoreDefaults(parsed) };
+ } catch {
+ return null;
+ }
+}
diff --git a/public/bluebey-studio/src/zip.js b/public/bluebey-studio/src/zip.js
new file mode 100644
index 0000000..21a1955
--- /dev/null
+++ b/public/bluebey-studio/src/zip.js
@@ -0,0 +1,218 @@
+/**
+ * Store-only ZIP writer.
+ *
+ * The studio exports a whole batch of images at once (one PNG per comic panel
+ * or sticker), and a single download is far nicer to handle than a dozen files,
+ * so the batch is packed into one archive here.
+ *
+ * PNG payloads are already DEFLATE-compressed, so running deflate over them a
+ * second time costs time and saves nothing measurable: every entry is stored
+ * verbatim (compression method 0, `compressedSize === uncompressedSize`) and no
+ * data descriptor is needed.
+ *
+ * The module has no imports, never touches the DOM beyond `Blob`, and stamps
+ * entries with a fixed DOS date unless one is passed in, so the same input
+ * always produces a byte-for-byte identical archive. That keeps the output safe
+ * to cache, diff, hash and test.
+ *
+ * Archive layout (every integer little-endian, no extra fields, no comments, no
+ * directory entries):
+ *
+ * [local file header + stored data] one per file
+ * [central directory entry] one per file
+ * [end of central directory record]
+ */
+
+/** Signature of a local file header ("PK\x03\x04"). */
+const LOCAL_SIGNATURE = 0x04034b50;
+/** Signature of a central directory entry ("PK\x01\x02"). */
+const CENTRAL_SIGNATURE = 0x02014b50;
+/** Signature of the end of central directory record ("PK\x05\x06"). */
+const EOCD_SIGNATURE = 0x06054b50;
+
+/** ZIP 2.0 is the oldest version that covers everything this writer emits. */
+const VERSION_NEEDED = 20;
+/** General purpose bit 11: the entry name is UTF-8, not CP437. */
+const FLAG_UTF8 = 0x0800;
+/** Compression method 0: stored. */
+const METHOD_STORE = 0;
+
+/** Largest value a 32-bit ZIP field can hold. Nothing may reach 4 GiB. */
+const MAX_FIELD = 0xffffffff;
+/** Largest value a 16-bit ZIP field can hold (entry count, name length). */
+const MAX_SHORT = 0xffff;
+
+const LOCAL_HEADER_SIZE = 30;
+const CENTRAL_HEADER_SIZE = 46;
+const EOCD_SIZE = 22;
+
+/** 1980-01-01 00:00 in DOS form: year offset 0, month 1, day 1, midnight. */
+const DEFAULT_TIME = 0;
+const DEFAULT_DATE = (1 << 5) | 1;
+
+/**
+ * CRC-32 (polynomial 0xEDB88320, reflected), the checksum every ZIP entry must
+ * carry. The table is built once at module load; eight table-driven bits per
+ * byte is fast enough that a several-megabyte PNG batch stays imperceptible.
+ */
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[i] = c >>> 0;
+ }
+ return table;
+})();
+
+/**
+ * @param {Uint8Array} bytes
+ * @returns {number} unsigned 32-bit CRC-32
+ */
+export function crc32(bytes) {
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+/** Clip a Date into the DOS date/time pair stored in the headers. */
+function toDosDateTime(date) {
+ const year = date.getFullYear();
+ // The DOS epoch starts in 1980; anything older is clamped to the epoch.
+ if (year < 1980) return { time: DEFAULT_TIME, date: DEFAULT_DATE };
+ const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
+ const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
+ return { time: time & MAX_SHORT, date: day & MAX_SHORT };
+}
+
+/**
+ * Entry names live inside the archive, where the separator is always "/": a
+ * backslash (what Windows paths use) would be taken as part of the name, and a
+ * leading slash would look like an absolute path to some extractors.
+ */
+function normalizeName(name) {
+ return String(name ?? '')
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '');
+}
+
+/** Accept any byte source, plus plain strings meaning UTF-8 text. */
+function toBytes(data, encoder) {
+ if (typeof data === 'string') return encoder.encode(data);
+ if (data instanceof Uint8Array) return data;
+ if (ArrayBuffer.isView(data)) {
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ }
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ throw new TypeError('ZIP: file data must be a Uint8Array, an ArrayBuffer or a string');
+}
+
+function writeU16(view, offset, value) {
+ view.setUint16(offset, value & MAX_SHORT, true);
+}
+
+function writeU32(view, offset, value) {
+ view.setUint32(offset, value >>> 0, true);
+}
+
+/**
+ * Pack `files` into a stored (uncompressed) ZIP archive.
+ *
+ * @param {Array<{name: string, data: Uint8Array | string}>} files
+ * a string `data` is encoded as UTF-8 text
+ * @param {object} [options]
+ * @param {Date} [options.date]
+ * timestamp for every entry; defaults to 1980-01-01 00:00 so that the same
+ * input always yields the same bytes
+ * @returns {Blob} an `application/zip` blob
+ */
+export function createZip(files, { date } = {}) {
+ const encoder = new TextEncoder();
+ const stamp = date ? toDosDateTime(date) : { time: DEFAULT_TIME, date: DEFAULT_DATE };
+
+ // First pass: normalise the input and measure it, so the output buffer can be
+ // allocated exactly once instead of being grown and copied.
+ const entries = [];
+ let localTotal = 0;
+ let centralTotal = 0;
+
+ for (const file of files ?? []) {
+ const nameBytes = encoder.encode(normalizeName(file.name));
+ const data = toBytes(file.data, encoder);
+
+ if (data.length > MAX_FIELD) {
+ throw new Error('ZIP: a file is 4 GiB or larger; zip64 is not supported');
+ }
+ if (nameBytes.length > MAX_SHORT) {
+ throw new Error('ZIP: a file name is longer than 65535 bytes');
+ }
+
+ const length = LOCAL_HEADER_SIZE + nameBytes.length + data.length;
+ localTotal += length;
+ centralTotal += CENTRAL_HEADER_SIZE + nameBytes.length;
+ entries.push({ nameBytes, data, crc: crc32(data), length, offset: 0 });
+ }
+
+ if (entries.length > MAX_SHORT) {
+ throw new Error('ZIP: more than 65535 files; zip64 is not supported');
+ }
+ if (localTotal + centralTotal + EOCD_SIZE > MAX_FIELD) {
+ throw new Error('ZIP: the archive is 4 GiB or larger; zip64 is not supported');
+ }
+
+ const bytes = new Uint8Array(localTotal + centralTotal + EOCD_SIZE);
+ const view = new DataView(bytes.buffer);
+
+ let offset = 0;
+ for (const entry of entries) {
+ entry.offset = offset;
+ writeU32(view, offset + 0, LOCAL_SIGNATURE);
+ writeU16(view, offset + 4, VERSION_NEEDED);
+ writeU16(view, offset + 6, FLAG_UTF8);
+ writeU16(view, offset + 8, METHOD_STORE);
+ writeU16(view, offset + 10, stamp.time);
+ writeU16(view, offset + 12, stamp.date);
+ writeU32(view, offset + 14, entry.crc);
+ writeU32(view, offset + 18, entry.data.length);
+ writeU32(view, offset + 22, entry.data.length);
+ writeU16(view, offset + 26, entry.nameBytes.length);
+ writeU16(view, offset + 28, 0); // extra field length
+ bytes.set(entry.nameBytes, offset + LOCAL_HEADER_SIZE);
+ bytes.set(entry.data, offset + LOCAL_HEADER_SIZE + entry.nameBytes.length);
+ offset += entry.length;
+ }
+
+ const centralOffset = offset;
+ for (const entry of entries) {
+ writeU32(view, offset + 0, CENTRAL_SIGNATURE);
+ writeU16(view, offset + 4, VERSION_NEEDED); // version made by (host 0 = MS-DOS)
+ writeU16(view, offset + 6, VERSION_NEEDED);
+ writeU16(view, offset + 8, FLAG_UTF8);
+ writeU16(view, offset + 10, METHOD_STORE);
+ writeU16(view, offset + 12, stamp.time);
+ writeU16(view, offset + 14, stamp.date);
+ writeU32(view, offset + 16, entry.crc);
+ writeU32(view, offset + 20, entry.data.length);
+ writeU32(view, offset + 24, entry.data.length);
+ writeU16(view, offset + 28, entry.nameBytes.length);
+ writeU16(view, offset + 30, 0); // extra field length
+ writeU16(view, offset + 32, 0); // file comment length
+ writeU16(view, offset + 34, 0); // disk number start
+ writeU16(view, offset + 36, 0); // internal attributes
+ writeU32(view, offset + 38, 0); // external attributes
+ writeU32(view, offset + 42, entry.offset);
+ bytes.set(entry.nameBytes, offset + CENTRAL_HEADER_SIZE);
+ offset += CENTRAL_HEADER_SIZE + entry.nameBytes.length;
+ }
+
+ writeU32(view, offset + 0, EOCD_SIGNATURE);
+ writeU16(view, offset + 4, 0); // number of this disk
+ writeU16(view, offset + 6, 0); // disk holding the central directory
+ writeU16(view, offset + 8, entries.length);
+ writeU16(view, offset + 10, entries.length);
+ writeU32(view, offset + 12, offset - centralOffset);
+ writeU32(view, offset + 16, centralOffset);
+ writeU16(view, offset + 20, 0); // archive comment length
+
+ return new Blob([bytes], { type: 'application/zip' });
+}