aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
commite332019acb312ec64893c26cf3d797d5ce472f26 (patch)
treed97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src
parent99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff)
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/animation.js161
-rw-r--r--public/bluebey-studio/src/background.js644
-rw-r--r--public/bluebey-studio/src/caption.js896
-rw-r--r--public/bluebey-studio/src/clip.js174
-rw-r--r--public/bluebey-studio/src/exporter.js323
-rw-r--r--public/bluebey-studio/src/face.js362
-rw-r--r--public/bluebey-studio/src/faceArt.js1415
-rw-r--r--public/bluebey-studio/src/gacha.js466
-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/history.js105
-rw-r--r--public/bluebey-studio/src/look.js180
-rw-r--r--public/bluebey-studio/src/main.js2426
-rw-r--r--public/bluebey-studio/src/model.js239
-rw-r--r--public/bluebey-studio/src/mouthFlap.js267
-rw-r--r--public/bluebey-studio/src/outline.js368
-rw-r--r--public/bluebey-studio/src/panel.js2508
-rw-r--r--public/bluebey-studio/src/presets.js698
-rw-r--r--public/bluebey-studio/src/props.js459
-rw-r--r--public/bluebey-studio/src/rig.js196
-rw-r--r--public/bluebey-studio/src/style.css673
-rw-r--r--public/bluebey-studio/src/styles.js350
-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.js326
-rw-r--r--public/bluebey-studio/src/urlState.js162
-rw-r--r--public/bluebey-studio/src/zip.js218
28 files changed, 15223 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..4446878
--- /dev/null
+++ b/public/bluebey-studio/src/animation.js
@@ -0,0 +1,161 @@
+/**
+ * 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;
+ }
+
+ reset() {
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.blinkPhase = -1;
+ this.blinkTimer = 2;
+ this.time = 0;
+ }
+
+ 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);
+
+ this.updateBlink(step);
+ this.updateLook(step);
+ }
+
+ updateBlink(dt) {
+ 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.
+ 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 };
+ }
+
+ // 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..72bc396
--- /dev/null
+++ b/public/bluebey-studio/src/background.js
@@ -0,0 +1,644 @@
+/**
+ * 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` is the exception: an original
+ * illustration drawn for this studio by the author, not a third-party CC0 photo. */
+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: '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;
+ }
+
+ /** Japanese, actionable text for the ways `getUserMedia` usually fails. */
+ function cameraFailureReason(error) {
+ switch (error?.name) {
+ case 'NotAllowedError':
+ case 'PermissionDeniedError':
+ return 'カメラの使用が許可されていません';
+ case 'NotFoundError':
+ case 'DevicesNotFoundError':
+ return '使えるカメラが見つかりませんでした';
+ case 'NotReadableError':
+ case 'TrackStartError':
+ return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)';
+ case 'OverconstrainedError':
+ case 'ConstraintNotSatisfiedError':
+ return '指定したカメラを使用できません';
+ case 'SecurityError':
+ return 'カメラを使うには https か localhost が必要です';
+ default:
+ return 'カメラを起動できませんでした';
+ }
+ }
+
+ async function startCamera(facing = 'environment') {
+ const mediaDevices = globalThis.navigator?.mediaDevices;
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
+ return { ok: false, 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/caption.js b/public/bluebey-studio/src/caption.js
new file mode 100644
index 0000000..15f5bf3
--- /dev/null
+++ b/public/bluebey-studio/src/caption.js
@@ -0,0 +1,896 @@
+import {
+ captionFontStack,
+ hasGlyphs,
+ layoutText,
+ textToPathData,
+} from './textOutlines.js';
+
+/**
+ * The speech bubble that sits next to the character.
+ *
+ * The same caption is drawn three times: into the 2D canvas that is overlaid on
+ * the 3D view, into the canvas that gets composited into an exported PNG, and
+ * into the vector SVG export. Drawing a shape three times by hand is how a
+ * preview starts to disagree with the file it produced, so the outline is built
+ * exactly once, here, as a short list of path commands. `drawCaption` replays
+ * that list through a 2D context and `captionToSvg` prints the same list as SVG
+ * path data, which is what keeps the two renderers together.
+ *
+ * The module never measures or wraps text itself: `textOutlines.js` owns line
+ * breaking and glyph outlines. This file only decides how large the bubble has
+ * to be for the block it is handed, padding included, and guarantees that block
+ * fits inside.
+ *
+ * It is pure in the same sense as `trace.js`: no DOM, no globals and no
+ * asynchronous work, so the tests can drive it in Node with a stub context.
+ */
+
+/** Defaults mirroring the `caption` slice of `presets.js`. */
+const DEFAULT_FONT_SIZE = 34;
+const DEFAULT_LINE_HEIGHT = 1.42;
+const DEFAULT_PADDING = 18;
+const DEFAULT_RADIUS = 24;
+const DEFAULT_BORDER_WIDTH = 4;
+const DEFAULT_MAX_WIDTH = 0.36;
+const DEFAULT_TEXT_COLOR = '#3f2b52';
+const DEFAULT_BUBBLE_COLOR = '#ffffff';
+const DEFAULT_BORDER_COLOR = '#55386e';
+
+/** Advance widths used before the vendored font has loaded, as a fraction of em. */
+const FALLBACK_WIDE_EM = 1;
+const FALLBACK_NARROW_EM = 0.55;
+
+/**
+ * Vertical metrics of the fallback stack, as a fraction of the font size.
+ *
+ * A typical Japanese family sits close to these numbers, so the baseline of the
+ * pre-load preview barely moves when the real font arrives.
+ */
+const FALLBACK_ASCENT = 0.88;
+const FALLBACK_DESCENT = 0.12;
+
+/** Control-point distance that turns a corner into a quarter circle. */
+const KAPPA = 0.5522847498307936;
+
+/** Code-point ranges that deserve a full em in the fallback measurement. */
+const WIDE_RANGES = [
+ [0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf],
+ [0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef],
+];
+
+/**
+ * The `ctx.font` string for a caption.
+ *
+ * Canvas wants size, weight and family in one string, and the size has to carry
+ * the export scale because the same caption is drawn at 1x for the preview and
+ * at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored
+ * subset ready?": until it is, the vendored family is dropped from the stack so
+ * the preview does not ask for a font that is still downloading.
+ *
+ * @param {object} caption the `caption` slice of the state
+ * @param {number} [scale=1]
+ * @param {boolean} [loaded=false]
+ * @returns {string}
+ */
+export function fontSpec(caption, scale = 1, loaded = false) {
+ const state = caption ?? {};
+ const size = resolveFontSize(state, positiveScale(scale));
+ const weight = state.bold ? 'bold ' : '';
+ return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`;
+}
+
+/**
+ * Resolve a caption to pixel geometry at the output size.
+ *
+ * `x`/`y` are fractions of the image and land on the bubble's top-left corner;
+ * `maxWidth` is a fraction of the image width and caps the text column. The box
+ * is always the bubble itself, padding included and the tail excluded, and it
+ * always fits inside the image: padding gives way first, then the position is
+ * clamped (leaving room for the tail), and only a block larger than the image
+ * is clipped. Every returned number is finite, even for an empty caption or a
+ * zero-sized image.
+ *
+ * `font` is an opentype font from `textOutlines.js`. Without it the text is
+ * wrapped by a rough character count, so the preview still shows a bubble while
+ * the font is loading.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{
+ * box: {x: number, y: number, w: number, h: number},
+ * lines: Array<{text: string, width: number}>,
+ * fontSize: number, lineHeight: number, padding: number, scale: number,
+ * usedOutlines: boolean,
+ * }}
+ */
+export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const resolvedScale = positiveScale(scale);
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+
+ const fontSize = resolveFontSize(state, resolvedScale);
+ const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT);
+ const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale;
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ const block = layoutBlock(font, text, {
+ fontSize,
+ maxWidth: resolveColumn(state, outWidth, requested),
+ lineHeight,
+ align: alignOf(state),
+ });
+
+ // Padding gives way before the bubble does, so a caption that cannot fit with
+ // its usual breathing room still fits inside the image.
+ const padding = Math.max(
+ 0,
+ Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2),
+ );
+ const w = Math.min(block.width + 2 * padding, outWidth);
+ const h = Math.min(block.height + 2 * padding, outHeight);
+
+ // The tail sticks out of the box, so the box has to keep that much clear of
+ // the edge it points at.
+ const tail = tailOf(state);
+ const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5));
+ const roomX = Math.max(0, outWidth - w);
+ const roomY = Math.max(0, outHeight - h);
+
+ // Which way the tail leaves the box, as a direction where each axis is -1,
+ // 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is
+ // driven by the direction rather than by which name the tail has.
+ const dir = tailDirection(tail);
+ let lowX = 0;
+ let highX = roomX;
+ if (dir.x < 0) lowX = Math.min(tailLength, roomX);
+ else if (dir.x > 0) highX = Math.max(0, roomX - tailLength);
+
+ let lowY = 0;
+ let highY = roomY;
+ if (dir.y < 0) lowY = Math.min(tailLength, roomY);
+ else if (dir.y > 0) highY = Math.max(0, roomY - tailLength);
+
+ const box = {
+ x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX),
+ y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY),
+ w,
+ h,
+ };
+
+ return {
+ box,
+ lines: block.lines,
+ fontSize,
+ lineHeight,
+ padding,
+ scale: resolvedScale,
+ usedOutlines: Boolean(font),
+ };
+}
+
+/**
+ * Lay the bubble down in a 2D context, ready to be filled and stroked.
+ *
+ * The tail is part of the same path as the bubble, so the border runs along it
+ * and joins the outline cleanly instead of meeting it at a seam. The caller
+ * owns the colours; this only traces the shape.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded
+ * @param {object} caption
+ * @param {number} [scale=1]
+ * @returns {boolean} `true` when there was a shape to trace
+ */
+export function bubblePath(ctx, box, caption, scale = 1) {
+ const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale));
+ applyCommands(ctx, commands);
+ return commands.length > 0;
+}
+
+/**
+ * Draw a caption: bubble first, then the text line by line.
+ *
+ * The 2D canvas is overlaid exactly on the 3D view for the preview and is
+ * composited into the PNG on export, which is why the very same function runs
+ * in both places: what the user sees is what the file contains.
+ *
+ * The bubble box comes back even when the caption is switched off, so the app
+ * can show a placeholder and hit-test a drag without a second layout.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box } = layout;
+ if (!state.enabled || !ctx) return box;
+
+ ctx.save();
+
+ const commands = bubbleCommands(box, state, layout.scale);
+ if (commands.length > 0) {
+ applyCommands(ctx, commands);
+ ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR);
+ ctx.fill();
+
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ if (borderWidth > 0) {
+ ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR);
+ ctx.lineWidth = borderWidth;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ }
+ }
+
+ if (layout.lines.length > 0) drawText(ctx, layout, state, font);
+
+ ctx.restore();
+ return box;
+}
+
+/**
+ * The caption as an SVG fragment, plus whether the text is outlined.
+ *
+ * Text becomes glyph outlines whenever the font covers it, because a `<text>`
+ * element is redrawn with whatever font the viewer happens to have and a
+ * caption that reflows is worse than one that cannot be selected. When a glyph
+ * is missing the whole caption falls back to `<text>` and `usedOutlines` is
+ * `false`, which is the caller's signal to warn that the file now depends on
+ * the viewer's fonts.
+ *
+ * The bubble and its tail are one `<path>`, traced from the same command list
+ * the canvas uses. `enabled` is not consulted: the export path only runs for an
+ * enabled caption, and drawing whatever is handed over keeps this usable for a
+ * thumbnail.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @param {number} [options.round=2] decimal places in the output
+ * @returns {{svg: string, usedOutlines: boolean}}
+ */
+export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) {
+ const state = caption ?? {};
+ const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2)));
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text);
+
+ const parts = [
+ '<svg xmlns="http://www.w3.org/2000/svg"'
+ + ` width="${formatNumber(outWidth, places)}"`
+ + ` height="${formatNumber(outHeight, places)}"`
+ + ` viewBox="0 0 ${formatNumber(outWidth, places)} ${formatNumber(outHeight, places)}">`,
+ ];
+
+ const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places);
+ if (bubble) {
+ const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR));
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ const stroke = borderWidth > 0
+ ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"`
+ + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"`
+ : '';
+ parts.push(`<path d="${bubble}" fill="${fill}"${stroke}/>`);
+ }
+
+ if (lines.length > 0) {
+ const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ let outlined = '';
+ if (usedOutlines) {
+ // `textToPathData` re-applies the alignment against the widest line, so
+ // the block it needs is the one `layoutText` measured.
+ outlined = textToPathData(font, {
+ lines,
+ width: blockWidthOf(lines),
+ height: lines.length * fontSize * lineHeight,
+ lineHeight,
+ fontSize,
+ align: alignOf(state),
+ }, { x: box.x + padding, y: box.y + padding, round: places });
+ usedOutlines = outlined !== '';
+ }
+ if (usedOutlines) {
+ parts.push(`<path d="${outlined}" fill="${escapeAttribute(textColor)}"/>`);
+ } else {
+ parts.push(liveText(state, layout, font, textColor, places));
+ }
+ }
+
+ parts.push('</svg>');
+ return { svg: parts.join('\n') + '\n', usedOutlines };
+}
+
+// --- text --------------------------------------------------------------------
+
+/** Draw every line at its own baseline, shifted sideways by the alignment. */
+function drawText(ctx, layout, state, font) {
+ const { box, lines, fontSize, lineHeight, padding, scale } = layout;
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const blockWidth = blockWidthOf(lines);
+ const left = box.x + padding;
+ const top = box.y + padding;
+ const align = alignOf(state);
+
+ ctx.font = fontSpec(state, scale, Boolean(font));
+ ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const x = left + alignOffset(align, blockWidth, line.width);
+ const y = top + i * step + halfLeading + metrics.ascent;
+ ctx.fillText(line.text, x, y);
+ }
+}
+
+/**
+ * The `<text>` fallback.
+ *
+ * The family comes from the font stack alone, not from `fontSpec`: size and
+ * weight have attributes of their own, and a `font-family` that carried them
+ * would be ignored by every viewer.
+ */
+function liveText(state, layout, font, fill, places) {
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const blockWidth = blockWidthOf(lines);
+ const align = alignOf(state);
+ const left = box.x + padding;
+ const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
+ const x = align === 'center'
+ ? left + blockWidth / 2
+ : align === 'right'
+ ? left + blockWidth
+ : left;
+
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const top = box.y + padding;
+
+ const attributes = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ `text-anchor="${anchor}"`,
+ ].filter(Boolean).join(' ');
+
+ const tspans = lines.map((line, i) => {
+ const y = top + i * step + halfLeading + metrics.ascent;
+ return `<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">`
+ + `${escapeText(line.text)}</tspan>`;
+ }).join('');
+
+ return `<text ${attributes}>${tspans}</text>`;
+}
+
+/**
+ * Wrap text without a font, from a character count.
+ *
+ * The preview has to show something while the vendored font downloads, and this
+ * is a deliberately crude stand-in: wide (CJK) characters count as one em and
+ * everything else as half an em. Once `layoutText` can run its metrics replace
+ * these numbers, so the rough version only ever decides a preview, never a file.
+ */
+function wrapByCount(text, { fontSize, maxWidth, lineHeight }) {
+ const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
+ const lines = [];
+
+ for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
+ let current = '';
+ for (const ch of paragraph) {
+ const candidate = current + ch;
+ if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) {
+ lines.push(current.trimEnd());
+ current = /\s/.test(ch) ? '' : ch; // the break swallows the space
+ } else {
+ current = candidate;
+ }
+ }
+ lines.push(current.trimEnd());
+ }
+
+ const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) }));
+ let width = 0;
+ for (const line of measured) width = Math.max(width, line.width);
+ return { lines: measured, width, height: measured.length * fontSize * lineHeight };
+}
+
+/** Wrap with `layoutText`, or with the character count when there is no font. */
+function layoutBlock(font, text, options) {
+ if (text === '') return { lines: [], width: 0, height: 0 };
+ if (font) {
+ const layout = layoutText(font, text, options);
+ return { lines: layout.lines, width: layout.width, height: layout.height };
+ }
+ return wrapByCount(text, options);
+}
+
+/** Text column in pixels: a fraction of the image, never wider than the image. */
+function resolveColumn(state, outWidth, padding) {
+ const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH);
+ if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText`
+ return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding));
+}
+
+/** Ascent above and descent below the baseline, in pixels. */
+function baselineMetrics(font, fontSize) {
+ const unitsPerEm = font?.unitsPerEm;
+ if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) {
+ return {
+ ascent: (font.ascender * fontSize) / unitsPerEm,
+ descent: (-font.descender * fontSize) / unitsPerEm,
+ };
+ }
+ return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT };
+}
+
+/** Widest line of the block, which is the width the alignment works against. */
+function blockWidthOf(lines) {
+ let width = 0;
+ for (const line of lines) width = Math.max(width, toFinite(line?.width, 0));
+ return width;
+}
+
+/** Sideways shift of one line inside the block. */
+function alignOffset(align, blockWidth, lineWidth) {
+ const slack = Math.max(0, blockWidth - lineWidth);
+ if (align === 'center') return slack / 2;
+ if (align === 'right') return slack;
+ return 0;
+}
+
+/** Width of a string by character count, in pixels. */
+function measureFallback(text, fontSize) {
+ let width = 0;
+ for (const ch of String(text ?? '')) {
+ width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM;
+ }
+ return width;
+}
+
+/** Characters that usually take a full em in a Japanese font. */
+function isWideChar(ch) {
+ const code = ch.codePointAt(0);
+ for (const [start, end] of WIDE_RANGES) {
+ if (code >= start && code <= end) return true;
+ }
+ return false;
+}
+
+// --- geometry ----------------------------------------------------------------
+
+/**
+ * The bubble outline as path commands.
+ *
+ * Both renderers go through here, which is the point: the canvas preview, the
+ * PNG and the SVG are three views of one geometry rather than three
+ * implementations of it.
+ *
+ * @returns {Array<object>} empty when there is nothing to draw
+ */
+function bubbleCommands(box, state, scale) {
+ const shape = bubbleOf(state);
+ const rect = {
+ x: toFinite(box?.x, 0),
+ y: toFinite(box?.y, 0),
+ w: toNonNegative(box?.w, 0),
+ h: toNonNegative(box?.h, 0),
+ };
+ if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return [];
+
+ const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale;
+ const edges = shape === 'shout'
+ ? shoutEdges(rect, scale)
+ : shape === 'rect'
+ ? rectEdges(rect)
+ : roundEdges(rect, radius);
+
+ return edgesToCommands(withTail(edges, rect, state, scale));
+}
+
+/** Clockwise rectangle, starting at the top-left corner. */
+function rectEdges(box) {
+ const { x, y, w, h } = box;
+ const tl = { x, y };
+ const tr = { x: x + w, y };
+ const br = { x: x + w, y: y + h };
+ const bl = { x, y: y + h };
+ return [
+ lineEdge(tl, tr),
+ lineEdge(tr, br),
+ lineEdge(br, bl),
+ lineEdge(bl, tl),
+ ];
+}
+
+/**
+ * Rounded rectangle as straight sides plus quarter-circle corners.
+ *
+ * Keeping the sides straight (rather than approximating the whole outline with
+ * a polyline) is what lets the tail attach to a real straight edge; the radius
+ * is clamped so the corners can never cross each other.
+ */
+function roundEdges(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ if (!(r > 0.5)) return rectEdges(box);
+
+ const k = r * KAPPA;
+ const p = (px, py) => ({ x: px, y: py });
+ const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to));
+ return [
+ lineEdge(p(x + r, y), p(x + w - r, y)),
+ corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]),
+ lineEdge(p(x + w, y + r), p(x + w, y + h - r)),
+ corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]),
+ lineEdge(p(x + w - r, y + h), p(x + r, y + h)),
+ corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]),
+ lineEdge(p(x, y + h - r), p(x, y + r)),
+ corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]),
+ ];
+}
+
+/**
+ * Star-burst: a rectangle whose four sides zig-zag outwards.
+ *
+ * The tooth depth and pitch are fractions of the bubble, so a big "shout" and a
+ * small one have the same number of spikes instead of the big one looking like
+ * a saw.
+ */
+function shoutEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const amp = Math.min(w, h) * 0.07;
+ const pitch = Math.max(scale, Math.min(w, h) * 0.22);
+ const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];
+ const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }];
+
+ const points = [];
+ for (let side = 0; side < 4; side++) {
+ const a = corners[side];
+ const b = corners[(side + 1) % 4];
+ const normal = normals[side];
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
+ const teeth = Math.max(2, Math.round(length / pitch));
+ points.push(a);
+ for (let i = 0; i < teeth; i++) {
+ if (i > 0) points.push(lerp(a, b, i / teeth));
+ const out = lerp(a, b, (i + 0.5) / teeth);
+ points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp });
+ }
+ }
+
+ return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length]));
+}
+
+/**
+ * Replace part of the edge the tail points at with a spike.
+ *
+ * The spike is inserted *into* the outline, not appended to it: the two sides
+ * of the tail and the bubble become one continuous border. The straight edge
+ * nearest the tail's anchor is used, which for every shape except the shout is
+ * the obvious side and for the shout is the nearest zig-zag segment.
+ */
+function withTail(edges, box, state, scale) {
+ const tail = tailOf(state);
+ if (tail === 'none' || edges.length === 0) return edges;
+
+ const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5);
+ if (!(tailLength > 0)) return edges;
+
+ const dir = tailDirection(tail);
+ // A diagonal tail leaves from a corner; an axis-aligned one leaves from the
+ // middle of the side it points at.
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const normal = {
+ x: dir.x * (diagonal ? Math.SQRT1_2 : 1),
+ y: dir.y * (diagonal ? Math.SQRT1_2 : 1),
+ };
+
+ const index = nearestEdge(edges, anchor, box, tail);
+ if (index < 0) return edges;
+
+ const edge = edges[index];
+ const length = distance(edge.from, edge.to);
+ const base = Math.min(tailLength * 0.7, length * 0.45);
+ if (!(base > 0) || !(length > 0)) return edges;
+
+ // The spike sits at the point of the edge nearest the anchor - the middle for
+ // a side, the corner for a diagonal - kept far enough in that both feet land
+ // on the edge itself.
+ const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length);
+ const middle = lerp(edge.from, edge.to, t);
+ // A diagonal spike advances `tailLength` on *each* axis, so it reaches as far
+ // as a side tail does and reads as a proper corner.
+ const reach = tailLength * (diagonal ? Math.SQRT2 : 1);
+ const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach };
+ const leftFoot = lerp(edge.from, edge.to, t - base / length);
+ const rightFoot = lerp(edge.from, edge.to, t + base / length);
+ const replacement = [
+ lineEdge(edge.from, leftFoot),
+ lineEdge(leftFoot, tip),
+ lineEdge(tip, rightFoot),
+ lineEdge(rightFoot, edge.to),
+ ];
+ return edges.slice(0, index).concat(replacement, edges.slice(index + 1));
+}
+
+/** Index of the straight edge closest to the tail's anchor, or `-1`. */
+function nearestEdge(edges, anchor, box, tail) {
+ let best = -1;
+ let bestDistance = Infinity;
+ for (let i = 0; i < edges.length; i++) {
+ const edge = edges[i];
+ if (edge.kind !== 'line') continue;
+ const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 };
+ if (!onSide(middle, box, tail)) continue;
+ const d = distance(middle, anchor);
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/** Is `point` on the side of the box the tail points at? */
+function onSide(point, box, tail) {
+ const dir = tailDirection(tail);
+ const midX = box.x + box.w * 0.5;
+ const midY = box.y + box.h * 0.5;
+ if (dir.x < 0 && point.x > midX) return false;
+ if (dir.x > 0 && point.x < midX) return false;
+ if (dir.y < 0 && point.y > midY) return false;
+ if (dir.y > 0 && point.y < midY) return false;
+ return true;
+}
+
+/** How far along `edge` (0..1) the point nearest `point` falls. */
+function projectionT(edge, point) {
+ const dx = edge.to.x - edge.from.x;
+ const dy = edge.to.y - edge.from.y;
+ const lengthSq = dx * dx + dy * dy;
+ if (!(lengthSq > 0)) return 0.5;
+ return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq;
+}
+
+/** One straight edge of a closed outline. */
+function lineEdge(from, to) {
+ return { kind: 'line', from, to };
+}
+
+/** One cubic edge of a closed outline. */
+function cubicEdge(from, c1, c2, to) {
+ return { kind: 'cubic', from, c1, c2, to };
+}
+
+/** Walk the edges into `M`/`L`/`C`/`Z` commands. */
+function edgesToCommands(edges) {
+ if (edges.length === 0) return [];
+ const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }];
+ for (const edge of edges) {
+ if (edge.kind === 'line') {
+ commands.push({ type: 'L', x: edge.to.x, y: edge.to.y });
+ } else {
+ commands.push({
+ type: 'C',
+ x1: edge.c1.x, y1: edge.c1.y,
+ x2: edge.c2.x, y2: edge.c2.y,
+ x: edge.to.x, y: edge.to.y,
+ });
+ }
+ }
+ commands.push({ type: 'Z' });
+ return commands;
+}
+
+/** Replay commands into a 2D context. */
+function applyCommands(ctx, commands) {
+ ctx.beginPath();
+ for (const command of commands) {
+ if (command.type === 'M') ctx.moveTo(command.x, command.y);
+ else if (command.type === 'L') ctx.lineTo(command.x, command.y);
+ else if (command.type === 'C') {
+ ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y);
+ } else if (command.type === 'Z') {
+ ctx.closePath();
+ }
+ }
+}
+
+/** Print commands as SVG path data. */
+function commandsToPathData(commands, places) {
+ const parts = [];
+ for (const command of commands) {
+ if (command.type === 'M') {
+ parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'L') {
+ parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'C') {
+ parts.push(
+ `C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}`
+ + ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}`
+ + ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`,
+ );
+ } else if (command.type === 'Z') {
+ parts.push('Z');
+ }
+ }
+ return parts.join(' ');
+}
+
+// --- shared helpers ----------------------------------------------------------
+
+/** The family list for a caption, or the system-only list before the font loads. */
+function fontFamilyStack(caption, loaded) {
+ const stack = captionFontStack();
+ if (loaded && caption?.font !== 'system') return stack;
+ return dropLeadingFamily(stack);
+}
+
+/** `captionFontStack()` minus its first family, for a plain system fallback. */
+function dropLeadingFamily(stack) {
+ let quote = null;
+ for (let i = 0; i < stack.length; i++) {
+ const ch = stack[i];
+ if (quote) {
+ if (ch === '\\') i++;
+ else if (ch === quote) quote = null;
+ } else if (ch === "'" || ch === '"') {
+ quote = ch;
+ } else if (ch === ',') {
+ const rest = stack.slice(i + 1).trim();
+ return rest === '' ? 'sans-serif' : rest;
+ }
+ }
+ return 'sans-serif';
+}
+
+/** Font size in output pixels, always positive and finite. */
+function resolveFontSize(caption, scale) {
+ const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE);
+ return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE);
+}
+
+function bubbleOf(caption) {
+ const value = caption?.bubble;
+ if (value === 'round' || value === 'rect' || value === 'shout' || value === 'none') return value;
+ return 'round';
+}
+
+function tailOf(caption) {
+ const value = caption?.tail;
+ return TAIL_VALUES.has(value) ? value : 'left';
+}
+
+const TAIL_VALUES = new Set([
+ 'left', 'right', 'top', 'bottom',
+ 'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
+ 'none',
+]);
+
+/**
+ * Which way a tail leaves the box, as a direction where each axis is -1, 0 or
+ * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both
+ * axes, so `bottomLeft` is `{ x: -1, y: 1 }`.
+ */
+function tailDirection(tail) {
+ switch (tail) {
+ case 'left': return { x: -1, y: 0 };
+ case 'right': return { x: 1, y: 0 };
+ case 'top': return { x: 0, y: -1 };
+ case 'bottom': return { x: 0, y: 1 };
+ case 'topLeft': return { x: -1, y: -1 };
+ case 'topRight': return { x: 1, y: -1 };
+ case 'bottomLeft': return { x: -1, y: 1 };
+ case 'bottomRight': return { x: 1, y: 1 };
+ default: return { x: 0, y: 0 };
+ }
+}
+
+function alignOf(caption) {
+ const value = caption?.align;
+ return value === 'center' || value === 'right' ? value : 'left';
+}
+
+function colorOf(value, fallback) {
+ return typeof value === 'string' && value !== '' ? value : fallback;
+}
+
+/** Scale is a multiplier: anything that is not a positive number means 1. */
+function positiveScale(value) {
+ return Number.isFinite(value) && value > 0 ? value : 1;
+}
+
+function toFinite(value, fallback) {
+ return Number.isFinite(value) ? value : fallback;
+}
+
+function toPositive(value, fallback) {
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
+}
+
+function clamp(value, low, high) {
+ if (high < low) return low;
+ return Math.min(Math.max(value, low), high);
+}
+
+/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */
+function formatNumber(value, places = 2) {
+ if (!Number.isFinite(value)) return '0';
+ const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2;
+ const factor = Math.pow(10, decimals);
+ const rounded = Math.round(value * factor) / factor;
+ return Object.is(rounded, -0) ? '0' : String(rounded);
+}
+
+/**
+ * Escape character data.
+ *
+ * Quotes are escaped too even though element content allows them: the caption
+ * is user text, and the extra entities cost nothing next to never emitting a
+ * document a stricter parser could object to.
+ */
+function escapeText(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&apos;');
+}
+
+/**
+ * Escape an attribute value.
+ *
+ * Only the double quote matters inside a double-quoted attribute, so single
+ * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN',
+ * sans-serif` stays readable in the file.
+ */
+function escapeAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+}
+
+function distance(a, b) {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+function lerp(a, b, t) {
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+}
diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js
new file mode 100644
index 0000000..d94e8e4
--- /dev/null
+++ b/public/bluebey-studio/src/clip.js
@@ -0,0 +1,174 @@
+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 }) {
+ const plane = new THREE.Plane();
+ const guide = makeGuide();
+ const occluder = makeWall();
+ scene.add(guide);
+ scene.add(occluder);
+ // 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;
+
+ /**
+ * @param {object} wall `state.render.wall`
+ *
+ * The guide is drawn only when `wall.guide` is on, which is also when the plane
+ * 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(wall) {
+ if (wall?.on !== true) {
+ occluder.visible = false;
+ guide.visible = false;
+ return;
+ }
+
+ const { normal, constant } = wallPlane(wall);
+ plane.set(new THREE.Vector3(normal[0], normal[1], normal[2]), constant);
+
+ const point = new THREE.Vector3(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0);
+ 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 [occluder, guide]) {
+ mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), plane.normal);
+ mesh.scale.setScalar(span);
+ }
+ occluder.position.copy(point);
+ occluder.visible = true;
+ guide.position.copy(point).addScaledVector(plane.normal, GUIDE_EPSILON);
+ guide.visible = wall.guide === true;
+ }
+
+ return {
+ plane,
+ guide,
+ occluder,
+ apply,
+ dispose() {
+ for (const mesh of [guide, 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..147af1c
--- /dev/null
+++ b/public/bluebey-studio/src/exporter.js
@@ -0,0 +1,323 @@
+import { traceAlphaContours, contoursToPathData } from './trace.js';
+import { roughenContours } from './handDrawn.js';
+import { captionToSvg } from './caption.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 caption 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,
+ captions = [],
+ captionFont = 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}"` : '';
+
+ // The caption is authored against the viewport size, so it scales with the
+ // requested SVG width. Its bubble and text are emitted as real vector shapes.
+ // Every bubble is emitted as its own real vector shapes, one after the other.
+ let captionSvg = '';
+ let captionUsedOutlines = captions.some(Boolean);
+ for (const caption of captions) {
+ if (!caption) continue;
+ const scale = width / Math.max(1, view.width || 1280);
+ const result = captionToSvg(caption, { width, height, scale, font: captionFont });
+ captionSvg += result.svg;
+ if (result.usedOutlines === false) captionUsedOutlines = false;
+ }
+
+ return {
+ contours: contours.length,
+ captionUsedOutlines,
+ 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}"/>
+${captionSvg}</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 `buildFaceMap` 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..18f1436
--- /dev/null
+++ b/public/bluebey-studio/src/face.js
@@ -0,0 +1,362 @@
+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, brow 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, originals }) {
+ this.eyeMesh = eyeMesh;
+ this.mouthMesh = mouthMesh;
+
+ // 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]]);
+ for (const mesh of [eyeMesh, mouthMesh]) {
+ 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);
+ 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,
+ brow: p.eyes.brow,
+ // ほっぺ and 頭の模様 are shared by the whole face, so they travel with
+ // the other shared eye fields (see `drawEyes` in src/faceArt.js).
+ cheeks: p.eyes.cheeks,
+ headMark: p.eyes.headMark,
+ // 眼鏡 / サングラス 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,
+ heartHideWhite: p.eyes.heartHideWhite === true,
+ 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 }) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ 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..65205fa
--- /dev/null
+++ b/public/bluebey-studio/src/faceArt.js
@@ -0,0 +1,1415 @@
+/**
+ * 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,
+ // Eyebrows are painted into the same texture as the eyes. The eye plane is a
+ // flat-ish patch on a round head, so only its lower part is really outside
+ // the head: a brow high on the forehead would be swallowed by it. The brow
+ // therefore sits just above the eyeball (row 156) and the eyeball is drawn
+ // over its lower edge afterwards.
+ // The brow sits just above the eyeball, and the eyeball is drawn over its
+ // lower edge. `lift` is that resting height, so `height: 0` is the brow's
+ // natural place and a POSITIVE height raises it (a negative one sinks it
+ // towards the eye, where the eyeball will cover it).
+ brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 },
+ // 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 manga blush) and 頭の模様 (a mark on the head), both drawn into the eye
+ * plate next to the eyes and brows.
+ *
+ * They are deliberately *generic* manga devices, not a copy of any one character:
+ * a pink patch crossed by short diagonal strokes is a stock convention, and the
+ * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot
+ * row) that starts off. Both stay editable through the panel. The cheek positions
+ * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in
+ * the plate's upper area, i.e. on the forehead above the brows.
+ */
+export const CHEEK_LAYOUT = {
+ drop: 64, // how far below the eye centre the patch sits (artwork px)
+ outward: 66, // how far outwards, away from the midline, it sits
+ radius: 50, // half-width of the patch
+ squash: 0.62, // half-height, as a fraction of the half-width
+ lines: 4, // hatch strokes across the patch
+ lineWidth: 5,
+ slant: 58, // hatching angle, degrees
+};
+
+export const HEAD_MARK_LAYOUT = {
+ cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline
+ cy: 92, // up on the forehead, above the brows
+ size: 46, // nominal radius of a shape
+ lineWidth: 9,
+};
+
+/**
+ * 眼鏡 / サングラス, 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 lens centre sits *below* the eye centre because the brow is drawn
+ * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to
+ * read as glasses would otherwise cut straight through it.
+ *
+ * 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,
+ frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke
+ highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width
+};
+
+/** 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,
+ // 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 },
+ // The tongue *lolling out* of the mouth (舌を出す). The same super-ellipse as
+ // the tongue above, but the crown hangs below its foot instead of rising over
+ // it, so the tip droops past the lip. `pos` is its own, because the in-mouth
+ // tongue sits off to one side and a lolling tongue reads better centred.
+ tongueOut: { pos: 0.5, width: 104, height: 118, crown: 2.2 },
+ 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.
+ */
+function clipEye(ctx, eye, open, lower) {
+ circlePath(ctx, eye.cx, eye.cy, eye.r);
+ ctx.clip();
+
+ 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;
+ const cy = lowest - rl;
+ ctx.beginPath();
+ 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 = clamp(lower, 0, 1);
+ if (bottom > 0.0005) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ const highest = eye.cy + eye.r - 2 * eye.r * bottom;
+ const cy = highest + rl;
+ ctx.beginPath();
+ 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();
+ }
+}
+
+function lidPath(ctx, eye, amount, lower) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ if (!lower) {
+ const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.beginPath();
+ ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true);
+ } else {
+ const highest = eye.cy + eye.r - 2 * eye.r * amount;
+ ctx.beginPath();
+ 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) {
+ const layout = EYE_LAYOUT.closedLine;
+ const { line, width } = style;
+
+ if (spec.closed === 'chevron') {
+ // The chevron points *at* the nose, matching the original artwork.
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 };
+ const armX = eye.cx - eye.towardNose * layout.armX;
+ const spread = layout.spread * (spec.spread ?? 1);
+ 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.
+ // `length` scales the whole shape.
+ const r = 34 * (spec.length ?? 1);
+ // 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 edges = { x: eye.r, y: EYE_LAYOUT.arch.edgesUp };
+ 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 },
+ { 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);
+ if (count >= 2) {
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 };
+ // 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;
+ const arms = count === 2
+ ? [{ x: outX, y: -layout.spread }, { x: outX, y: layout.spread }]
+ : [
+ { x: outX, y: -layout.spread },
+ { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread), y: 0 },
+ { x: outX, y: layout.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);
+ const midX = eye.cx - eye.towardNose * layout.offsetX;
+ const midY = eye.cy + layout.offsetY;
+ const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180;
+ const bow = (layout.bow ?? 0) * (spec.slant ?? 1);
+ 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 });
+}
+
+/**
+ * A stroke whose width runs from `w0` at the first point to `w1` at the last.
+ *
+ * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end
+ * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc.
+ */
+function taperedStroke(ctx, points, w0, w1, color) {
+ const count = points.length;
+ if (count < 2) return;
+ const side = [[], []];
+ for (let i = 0; i < count; i++) {
+ const t = i / (count - 1);
+ const w = (w0 + (w1 - w0) * t) / 2;
+ const before = points[Math.max(0, i - 1)];
+ const after = points[Math.min(count - 1, i + 1)];
+ const dx = after.x - before.x;
+ const dy = after.y - before.y;
+ const len = Math.hypot(dx, dy) || 1;
+ const nx = -dy / len;
+ const ny = dx / len;
+ side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w });
+ side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w });
+ }
+ ctx.save();
+ tracePolyline(ctx, [...side[0], ...side[1].reverse()], true);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.restore();
+}
+
+/** An eyebrow: a short arc above the eye, mirrored between the two eyes so a
+ * positive `angle` always means "inner end down" (an angry brow). */
+function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) {
+ const brow = style.brow;
+ if (!brow?.enabled) return;
+ const layout = EYE_LAYOUT.brow;
+ // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a
+ // length of 0 is asking for.
+ const length = Math.max(0, (brow.length ?? 1) * layout.length);
+ const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness);
+ const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0);
+ // `spacing` widens the gap between the two brows: a positive value moves each
+ // one away from the nose. `layout.offsetX` is the resting inset from the
+ // artwork (which the old code read off the wrong object, so it never applied).
+ const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0));
+ const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180;
+ const half = length / 2;
+ const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half };
+ const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half };
+ const bow = (brow.curve ?? layout.curve) * length;
+ const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16);
+ // A brow raised with the height slider used to run off the top of the canvas,
+ // where the clamped edge row was copied across the whole plate and the brow
+ // smeared upwards. Keep the whole stroke inside the artwork instead.
+ const top = limits.top + EDGE_MARGIN;
+ const highest = Math.min(...points.map((q) => q.y)) - thickness / 2;
+ if (highest < top) for (const q of points) q.y += top - highest;
+ // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and
+ // lower values turn the brow into a wedge that comes to a point at the nose.
+ const taper = clamp(brow.taper ?? 1, 0, 1);
+ const color = brow.color ?? style.line ?? '#55386e';
+ // A tapered wedge has no shape at zero length, so a dot always takes the plain
+ // round-capped stroke.
+ if (taper >= 0.999 || length < 2) {
+ strokePolyline(ctx, points, { color, width: thickness });
+ return;
+ }
+ const innerIsTo = eye.towardNose > 0;
+ taperedStroke(
+ ctx,
+ points,
+ thickness * (innerIsTo ? 1 : taper),
+ thickness * (innerIsTo ? taper : 1),
+ color,
+ );
+}
+
+/* ------------------------------------------------------- ほっぺ / 頭の模様 */
+
+/**
+ * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic
+ * manga blush. Drawn once per cheek; the caller draws these before the eyes.
+ */
+function drawCheek(ctx, cx, cy, spec) {
+ const size = clamp(spec.size ?? 1, 0.2, 3);
+ const r = CHEEK_LAYOUT.radius * size;
+ const ry = r * CHEEK_LAYOUT.squash;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(cx, cy, r, ry, 0, 0, TAU);
+ ctx.fillStyle = spec.color ?? '#f6a6b8';
+ ctx.fill();
+ // Clip the hatching to the patch, so the strokes keep a clean edge.
+ ctx.clip();
+
+ const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines));
+ if (lines > 0) {
+ const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180;
+ const dx = Math.cos(angle);
+ const dy = -Math.sin(angle);
+ const reach = r * 1.3;
+ ctx.strokeStyle = spec.hatchColor ?? '#e0708f';
+ ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size);
+ ctx.lineCap = 'round';
+ for (let i = 0; i < lines; i += 1) {
+ // Spread the strokes evenly across the patch.
+ const t = lines === 1 ? 0 : i / (lines - 1) - 0.5;
+ const px = cx + t * r * 1.2;
+ ctx.beginPath();
+ ctx.moveTo(px - dx * reach, cy - dy * reach);
+ ctx.lineTo(px + dx * reach, cy + dy * reach);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+}
+
+/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */
+function drawCheeks(ctx, spec) {
+ if (!spec?.enabled) return;
+ const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400);
+ const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400);
+ for (const layout of EYE_LAYOUT.eyes) {
+ // Outwards is away from the midline, i.e. the opposite of `towardNose`.
+ const cx = layout.cx - layout.towardNose * outward;
+ drawCheek(ctx, cx, layout.cy + drop, spec);
+ }
+}
+
+/**
+ * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of
+ * shapes on purpose - the point is a generic manga device, not a particular
+ * character's artwork - and it starts off.
+ */
+function drawHeadMark(ctx, spec) {
+ const shape = spec.shape ?? 'off';
+ if (shape === 'off' || shape === 'none') return;
+ const size = clamp(spec.size ?? 1, 0.2, 3);
+ const s = HEAD_MARK_LAYOUT.size * size;
+ const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400);
+ const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400);
+
+ ctx.save();
+ ctx.strokeStyle = spec.color ?? '#8a4fe0';
+ ctx.fillStyle = spec.color ?? '#8a4fe0';
+ ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ if (shape === 'hook') {
+ // A short curved hook, like a comma.
+ ctx.beginPath();
+ ctx.moveTo(cx - s * 1.05, cy + s * 0.45);
+ ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15);
+ ctx.stroke();
+ } else if (shape === 'spiral') {
+ const steps = 60;
+ ctx.beginPath();
+ for (let i = 0; i <= steps; i += 1) {
+ const t = i / steps;
+ const angle = t * TAU * 2.2;
+ const radius = s * (0.14 + 0.86 * t);
+ const x = cx + Math.cos(angle) * radius;
+ const y = cy + Math.sin(angle) * radius;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+ } else if (shape === 'strokes') {
+ // Three short strokes standing side by side.
+ for (let i = -1; i <= 1; i += 1) {
+ ctx.beginPath();
+ ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6);
+ ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6);
+ ctx.stroke();
+ }
+ } else if (shape === 'dots') {
+ const radius = Math.max(1.5, ctx.lineWidth * 0.8);
+ for (let i = -1; i <= 1; i += 1) {
+ ctx.beginPath();
+ ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+}
+
+/**
+ * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes,
+ * so the eyes, brows and glasses win any overlap and the cheeks are not doubled.
+ */
+function drawFaceExtras(ctx, style) {
+ drawCheeks(ctx, style.cheeks);
+ drawHeadMark(ctx, style.headMark);
+}
+
+/**
+ * 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;
+
+ // 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, 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,
+ );
+ // 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;
+ // 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();
+ // A light streak inside the glass, clipped to the lens so it can never spill
+ // over the frame. Shaded drawings only: 線画's lens is empty paper.
+ if (!line) {
+ ctx.save();
+ traceRoundedPolygon(ctx, lens, ry * L.corner);
+ ctx.clip();
+ strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], {
+ color: 'rgba(255, 255, 255, 0.5)',
+ width: Math.max(2, rx * L.highlightWidth),
+ });
+ ctx.restore();
+ }
+ } 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 sits low, so their stub starts down there instead of
+ // floating below the lens.
+ const outerX = shades
+ ? lensCx - eye.towardNose * rx * L.outerEndX
+ : lensCx - eye.towardNose * rx;
+ const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : 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
+ */
+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 lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke;
+ const irisScale = style.irisScale ?? 1;
+ const lookMax = style.lookMax ?? 1;
+ const highlightOn = style.highlight !== false;
+ const heartScale = style.heartScale ?? 0.85;
+ const heartColor = style.heartColor ?? '#e0344f';
+ const irisRadius = EYE_LAYOUT.irisRadius * irisScale;
+ const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius);
+ const heartTravel = EYE_LAYOUT.radius * 0.9;
+ const lowerLid = clamp(style.lowerLid ?? 0, 0, 1);
+
+ // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
+ // rather than inside the per-eye loop below.
+ drawFaceExtras(ctx, style);
+
+ for (const layout of EYE_LAYOUT.eyes) {
+ const spec = (p.eyes ?? {})[layout.key] ?? {};
+ const open = clamp(spec.open ?? 1, 0, 1);
+ const eye = {
+ // `eyeX` slides this eye - and its lid, brow 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,
+ };
+ const lineStyle = { line: lineColor, width: lidWidth };
+ const isHeart = (spec.irisShape ?? 'circle') === 'heart';
+
+ drawBrow(ctx, eye, spec, style, limits);
+
+ // --- 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 = () => {
+ const amount = clamp(spec.tear ?? 0, 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();
+ };
+
+ if (open <= 0.02) {
+ drawShutEye(ctx, eye, spec, lineStyle);
+ // The glasses sit over the shut-eye artwork too, but under the tear below.
+ drawGlasses(ctx, eye, line, style, limits);
+ drawTear();
+ continue;
+ }
+
+ // --- eyeball -------------------------------------------------------
+ ctx.save();
+ // A heart is not an eyeball, so once the eye is fully open it is not clipped
+ // to the eyeball circle - that is what lets a heart be much larger than the
+ // iris. While the lid is partly closed the clip is still needed.
+ if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open));
+
+ const eyeTravel = isHeart ? heartTravel * lookMax : travel * 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 eyeball's white is only painted in the shaded styles: in line art the
+ // paper behind shows through, which is what a line drawing wants. (The SVG
+ // export traces the alpha mask, so a white fill would close the eye up
+ // completely.)
+ if (!line && !(isHeart && style.heartHideWhite)) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
+ const irisX = eye.cx + dx;
+ const irisY = eye.cy + dy;
+ if (isHeart) {
+ // A heart is wider than a circle, so cap it at the plane rather than the
+ // eyeball; a red heart has no pupil highlight (it would sit on top of it).
+ // A heart is not an eyeball, so it may grow well past the eye: the plate
+ // keeps the whole canvas, and the eyes plate is drawn over the mouth, so a
+ // big heart has room. The cap only stops it from running off the canvas.
+ const heartRadius = Math.min(irisRadius * heartScale, EYE_LAYOUT.radius * 2.1);
+ fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor);
+ } else {
+ fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
+ }
+ if (highlightOn && !isHeart) {
+ const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * irisScale;
+ const hy = irisY + EYE_LAYOUT.highlightOffset.y * irisScale;
+ const hr = EYE_LAYOUT.highlightRadius * irisScale;
+ 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 - both of which read as
+ // the white glint of the original artwork.
+ 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);
+ if (lidFade > 0.01 || lowerLid > 0.01) {
+ ctx.save();
+ 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, open, false);
+ ctx.stroke();
+ }
+ if (lowerLid > 0.01) {
+ ctx.globalAlpha = line ? 1 : lowerLid;
+ lidPath(ctx, eye, lowerLid, true);
+ ctx.stroke();
+ }
+ 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);
+ // How far the tongue hangs *out* of the mouth (below the lip). Separate from
+ // `tongue`, which is the one that pokes up over the lip of a closed mouth.
+ const tongueOutAmount = clamp(p.tongueOut ?? 0, 0, 1);
+
+ // --- a round "O" mouth (surprised, singing) ---------------------------
+ const roundAmount = clamp(p.round ?? 0, 0, 1);
+ if (roundAmount > 0.004) {
+ let ry = L.sag * 0.42 * roundAmount * clamp(p.thickness ?? 1, 0.5, 2);
+ // The nose hides the middle of the upper face, so keep the whole oval below
+ // it and inside the plate. (`L.safeBottom` is only the round mouth's own size
+ // limit; it no longer has to stay clear of the canvas edge.)
+ ry = Math.min(ry, (L.safeBottom - thickness / 2 - L.noseClear) / 2.1);
+ const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 0.8);
+ const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - 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();
+ }
+ if (tongueOutAmount > 0.01) {
+ // A tongue hanging out below the round mouth. Its foot is a flat line
+ // inside the lower lip; the tip is clamped to the plate's lower edge.
+ const drop = Math.min(L.tongueOut.height * tongueOutAmount, Math.max(0, edgeBottom - (centreY + ry * 0.5)));
+ if (drop > 4) {
+ const half = (L.tongueOut.width * tongueOutAmount) / 2;
+ const footY = centreY + ry * 0.5;
+ ctx.beginPath();
+ ctx.moveTo(L.centreX - half, footY);
+ for (let i = 0; i <= 26; i++) {
+ const dx = 1 - 2 * (i / 26);
+ ctx.lineTo(L.centreX + dx * half, footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown)));
+ }
+ ctx.closePath();
+ 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();
+ }
+
+ /**
+ * The tongue lolling *out* of the mouth, hanging below `curve`.
+ *
+ * The mirror of `drawTongue`: the foot sits along the lip (or the lower jaw
+ * when the mouth is open) and the crown drops *below* it. The tip is clamped
+ * against the plate's real lower edge, so a tongue on a low mouth gets shorter
+ * instead of being smeared off the chin by the texture's clamp.
+ */
+ function drawTongueOut(pointAt, curve, height) {
+ const half = (L.tongueOut.width * tongueOutAmount) / 2;
+ const pos = clamp(p.tongueOutPos ?? L.tongueOut.pos, 0.05, 0.95);
+ const arcLength = Math.max(1, chordLength(curve));
+ const dt = clamp(half / arcLength, 0.01, 0.45);
+ const t0 = clamp(pos - dt, 0, 1);
+ const t1 = clamp(pos + dt, 0, 1);
+ const base = [];
+ for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16));
+ const mid = pointAt(clamp(pos, 0, 1));
+ const drop = Math.min(height, Math.max(0, edgeBottom - mid.y));
+ if (drop < 2) return;
+ 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 + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.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 (tongueOutAmount > 0.01 && tongueOpen > 0.01) {
+ // A tongue lolling *out* over the lower jaw is not clipped to the mouth:
+ // it hangs in front of the chin. Drawn before the outline, which then
+ // crosses its root.
+ drawTongueOut(jawAt, jaw, L.tongueOut.height * tongueOutAmount * tongueOpen);
+ } else 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 out. 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. `tongueOut`
+ // hangs the tongue the other way, *below* the lip line. The lip line goes on
+ // afterwards, so the tongue comes out from under it.
+ if (tongueOutAmount > 0.01) {
+ drawTongueOut(lipAt, arc, L.tongueOut.height * tongueOutAmount);
+ } else 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..2b3877e
--- /dev/null
+++ b/public/bluebey-studio/src/gacha.js
@@ -0,0 +1,466 @@
+/**
+ * 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.3, 0.5],
+ 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.15, 0.35],
+ },
+ {
+ 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.2, 0.45],
+ 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 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]);
+ if (side === 'left') {
+ leftOpen = 0;
+ leftLines = lines;
+ } else {
+ rightOpen = 0;
+ rightLines = lines;
+ }
+ } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) {
+ leftOpen = 0;
+ rightOpen = 0;
+ leftClosed = spec.shutBoth.closed;
+ rightClosed = spec.shutBoth.closed;
+ leftLines = spec.shutBoth.closedLines;
+ rightLines = spec.shutBoth.closedLines;
+ }
+
+ const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1]));
+ const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1]));
+
+ return {
+ left: { open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' },
+ right: { 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;
+ }
+
+ 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.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/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..f68b602
--- /dev/null
+++ b/public/bluebey-studio/src/look.js
@@ -0,0 +1,180 @@
+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.
+ contact.visible = wantsShadow;
+ 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..a9d27f8
--- /dev/null
+++ b/public/bluebey-studio/src/main.js
@@ -0,0 +1,2426 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.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, THEMES } 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 { createBackdrop, backdropStyle } from './background.js';
+import { drawCaption, layoutCaption } from './caption.js';
+import { loadFont, captionFontStack } from './textOutlines.js';
+import { buildProp, PROP_DEFAULTS, disposeProp } from './props.js';
+import { rollAll, decodeSeed } from './gacha.js';
+import { encodeState, decodeState } from './urlState.js';
+import { createMouthFlap, levelToMouth } from './mouthFlap.js';
+import { createZip } from './zip.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';
+
+/** The caption font is only fetched when a bubble is first used. */
+const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf';
+const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2';
+/** 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: 'ポーズ', caption: 'セリフ', 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`;
+ }
+ 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);
+
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(60, 60),
+ new THREE.ShadowMaterial({ opacity: 0.22, transparent: true }),
+ );
+ 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 captionCanvas = null;
+ /** Set while undo/redo replays a snapshot, so it is not recorded again. */
+ let suspendHistory = false;
+
+ // ------------------------------------------------------------------- 擬音
+ // `redrawCaption` 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, next to the
+ // caption overlay it is layered under.
+ 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();
+ redrawCaption();
+ 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);
+
+ 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);
+
+ // ------------------------------------------------------------- controllers
+ const styles = new Styles({ meshes: model.parts.body });
+ const face = new Face({
+ eyeMesh: model.parts.eyeMesh,
+ mouthMesh: model.parts.mouthMesh,
+ 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 });
+
+ // --------------------------------------------------------------- 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;
+
+ // ------------------------------------------------------- caption overlay
+ // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the
+ // very same routine the PNG export uses - so the preview cannot lie.
+ captionCanvas = document.createElement('canvas');
+ captionCanvas.id = 'caption-layer';
+ captionCanvas.style.cssText =
+ 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2';
+ stage.append(captionCanvas);
+
+ /** opentype font for outline export and text layout (lazily fetched). */
+ let captionFont = null;
+ let captionFontPromise = null;
+ /** Where each bubble was last drawn, in CSS pixels (in state order). */
+ const CAPTION_KEYS = ['caption', 'caption2'];
+ const captionBoxes = new Map();
+ const captionHandles = new Map();
+ /** Set while a bubble is being dragged. */
+ let captionDrag = null;
+
+ // The bubbles are draggable, but the caption canvas has to stay
+ // `pointer-events: none`: it covers the whole viewport, and the orbit controls
+ // and the bone picking live underneath it. So each bubble gets a separate
+ // transparent drag box, parked exactly over it by `positionCaptionHandles`.
+ for (const key of CAPTION_KEYS) {
+ const handle = document.createElement('div');
+ handle.id = `caption-handle-${key}`;
+ handle.style.cssText = 'position:absolute;display:none;cursor:move;'
+ + 'touch-action:none;pointer-events:auto;z-index:3';
+ handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key));
+ handle.addEventListener('pointermove', moveCaptionDrag);
+ handle.addEventListener('pointerup', endCaptionDrag);
+ handle.addEventListener('pointercancel', endCaptionDrag);
+ handle.dataset.captionHandle = '1';
+ stage.append(handle);
+ captionHandles.set(key, handle);
+ }
+
+ /** Park each drag target over its bubble (or hide it when it is not shown). */
+ function positionCaptionHandles() {
+ for (const key of CAPTION_KEYS) {
+ const handle = captionHandles.get(key);
+ const box = captionBoxes.get(key);
+ if (!handle) continue;
+ if (!box) {
+ handle.style.display = 'none';
+ continue;
+ }
+ // A few pixels of slack, so the rounded corners are still easy to grab.
+ handle.style.display = 'block';
+ handle.style.left = `${box.x - 4}px`;
+ handle.style.top = `${box.y - 4}px`;
+ handle.style.width = `${box.w + 8}px`;
+ handle.style.height = `${box.h + 8}px`;
+ }
+ }
+
+ function startCaptionDrag(event, key) {
+ const caption = state[key];
+ const box = captionBoxes.get(key);
+ if (caption?.enabled !== true || !box) return;
+ // Touching a bubble also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectCaption?.(key);
+ event.preventDefault();
+ event.stopPropagation();
+ // Not all pointers can be captured (and a synthetic one cannot), so a
+ // failure here must not stop the drag from starting.
+ try {
+ captionHandles.get(key)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ captionDrag = {
+ key,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ boxX: box.x,
+ boxY: box.y,
+ };
+ }
+
+ function moveCaptionDrag(event) {
+ if (!captionDrag || event.pointerId !== captionDrag.pointerId) return;
+ const caption = state[captionDrag.key];
+ if (!caption) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width;
+ caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height;
+ // Store back what the layout will actually use, so pushing past an edge does
+ // not pile up an out-of-range value that has to be undone before the bubble
+ // moves again. `refresh` redraws the bubble and records one undo step (the
+ // history coalesces a run of the same label).
+ const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont });
+ if (width > 0) caption.x = layout.box.x / width;
+ if (height > 0) caption.y = layout.box.y / height;
+ refresh('caption');
+ }
+
+ function endCaptionDrag(event) {
+ if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return;
+ captionDrag = null;
+ // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel.
+ app.actions.syncPanel?.();
+ }
+
+ async function ensureCaptionFont() {
+ if (captionFontPromise) return captionFontPromise;
+ captionFontPromise = (async () => {
+ try {
+ const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`);
+ document.fonts.add(await face.load());
+ } catch (error) {
+ console.warn('[bluebey] caption font face failed', error);
+ }
+ try {
+ captionFont = await loadFont(assetUrl('bluebey-caption.ttf'));
+ } catch (error) {
+ console.warn('[bluebey] caption outlines unavailable', error);
+ }
+ redrawCaption();
+ return captionFont;
+ })();
+ return captionFontPromise;
+ }
+
+ function redrawCaption() {
+ if (!captionCanvas) 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 (captionCanvas.width !== wantW || captionCanvas.height !== wantH) {
+ captionCanvas.width = wantW;
+ captionCanvas.height = wantH;
+ }
+ const ctx = captionCanvas.getContext('2d');
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ // 擬音はセリフより背面に置く: the bubbles stay readable on top of them.
+ const gionItems = state.gion?.items ?? [];
+ for (const item of gionItems) ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 });
+ for (const key of CAPTION_KEYS) {
+ const caption = state[key];
+ captionBoxes.delete(key);
+ if (!caption?.enabled) continue;
+ // The vendored font is only fetched once a bubble is actually used; when it
+ // arrives `ensureCaptionFont` redraws this canvas.
+ if (!captionFontPromise) void ensureCaptionFont();
+ captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont }));
+ }
+ positionCaptionHandles();
+ ensureGionHandles(gionItems);
+ positionGionHandles(gionItems);
+ }
+
+ // ----------------------------------------------------------------- 擬音
+ // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles.
+ // The canvas is `pointer-events: none`, so - exactly like a caption - 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);
+ redrawCaption();
+ }
+ 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]) {
+ propRoot.remove(child);
+ disposeProp(child);
+ }
+ for (const item of items) {
+ const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotY: 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.y = ((item.rotY ?? def.rotY ?? 0) * DEG);
+ propRoot.add(group);
+ }
+ 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, 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) {
+ if (state.render.wall?.on !== true || state.render.wall.guide !== true) return null;
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const hits = propRaycaster.intersectObject(clip.guide, false);
+ return hits.length ? hits[0].point.clone() : null;
+ }
+
+ function onPropPointerDown(event) {
+ if (event.button !== 0) return;
+ if (event.target?.dataset?.captionHandle) return; // a bubble drags itself
+ 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);
+ const wall = state.render.wall;
+ wallDrag.pointerId = event.pointerId;
+ wallDrag.offset.set(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0).sub(onWall);
+ 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 next = propPoint.add(wallDrag.offset);
+ const wall = state.render.wall;
+ // The same ranges the panel's sliders offer, so a drag and a number always
+ // describe the same place.
+ wall.x = round2(clampNumber(next.x, -4, 4));
+ wall.y = round2(clampNumber(next.y, -2, 4));
+ wall.z = round2(clampNumber(next.z, -4, 4));
+ clip.apply(wall);
+ 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 });
+ /** How far the mouth is open right now because of the 口パク animation. */
+ let flapMouth = 0;
+ const mouthFlap = createMouthFlap({
+ onLevel: (level) => {
+ flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1);
+ app.needsRender = true;
+ },
+ onEnd: () => { flapMouth = 0; app.needsRender = true; },
+ });
+
+ 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.guide,
+ rig.helper,
+ model.parts.eyeMesh,
+ model.parts.mouthMesh,
+ ...styles.outlineMeshes,
+ ];
+ }
+ 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.occluder]);
+
+ /**
+ * 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, mouthFlap, propRoot, mirrorGroup, container,
+ get captionFont() { return captionFont; },
+ get backdropImage() { return backdropImage; },
+ });
+
+ const recorder = createRecorder(canvas);
+
+ // -------------------------------------------------------------- the panel
+ const panel = buildPanel(app, document.getElementById('panel'));
+ app.panel = panel;
+
+ Object.assign(app.actions, {
+ capturePose,
+ applyState,
+ refresh,
+ 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'),
+ applyTheme: (id) => applyTheme(id),
+ rollGacha: (seed) => rollGacha(seed),
+ copyShareLink: () => copyShareLink(),
+ shareImage: (network) => shareImage(network),
+ toggleMouthFlap: () => toggleMouthFlap(),
+ 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;
+
+ 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);
+
+ 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];
+ return {
+ open: clamp01(own.open * 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,
+ // The tears are per eye, so one eye can cry on its own.
+ tear: own.tear ?? 0,
+ 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,
+ };
+ };
+
+ // Lip-sync and a look-at target both drive the mouth and the eyes without
+ // being part of the saved expression.
+ const mouth = { ...state.face.mouth };
+ if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth);
+ // A talking mouth has no tongue sticking out. This follows the whole flap
+ // session rather than the opening: the envelope dips through zero between
+ // syllables, and keying it off the opening made the tongue flicker back into
+ // view on every closed frame.
+ if (mouthFlap.running) mouth.tongue = 0;
+
+ return {
+ eyes: {
+ // Everything shared by both eyes (colours, iris scale, brows, 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.
+ clip.apply(state.render.wall);
+
+ const azimuth = state.render.lightAzimuth * DEG;
+ const elevation = state.render.lightElevation * DEG;
+ const distance = model.size.y * 3;
+ key.position.set(
+ Math.cos(elevation) * Math.sin(azimuth) * distance,
+ Math.sin(elevation) * distance + halfHeight,
+ Math.cos(elevation) * Math.cos(azimuth) * distance,
+ );
+ key.intensity = state.render.lightIntensity;
+ ambient.intensity = state.render.ambient;
+ 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 === 'caption' || scope === 'gion') redrawCaption();
+
+ // 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;
+ }
+
+ /**
+ * 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) {
+ const source = backdropSource();
+ if (!source) return false;
+ const style = backdropStyle(state.view);
+ if (!style.visible) return false;
+ const iw = source.naturalWidth || source.videoWidth || 0;
+ const ih = source.naturalHeight || source.videoHeight || 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.
+ 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.offset.x * width, height / 2 + 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);
+ 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 } = {}) {
+ 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, the 擬音 stamps and the bubbles live in DOM layers behind
+ // and above the WebGL canvas, so an export has to paint them in itself - in
+ // the same order as the screen: backdrop, then stamps, then bubbles.
+ const ctx = result.canvas.getContext('2d');
+ // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the
+ // bubbles 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)) 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;
+ }
+ const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled);
+ if (bubbles.length) {
+ await ensureCaptionFont();
+ for (const caption of bubbles) {
+ drawCaption(ctx, caption, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ font: captionFont,
+ });
+ }
+ 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);
+ 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 guideWasVisible = clip.guide.visible;
+ rig.helper.visible = false;
+ clip.guide.visible = false;
+ try {
+ return await fn();
+ } finally {
+ rig.setGizmoVisible(wasVisible);
+ clip.guide.visible = guideWasVisible;
+ 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つ戻しました' : 'やり直しました');
+ }
+
+ function applyTheme(id) {
+ const theme = THEMES.find((item) => item.id === id);
+ if (!theme) return;
+ state.render.theme = id;
+ state.render.colors = { ...theme.colors };
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ /** おまかせ: 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)'
+ : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)');
+ }
+
+ function toggleMouthFlap() {
+ if (mouthFlap.running) {
+ mouthFlap.stop();
+ toast('口パクを止めました');
+ return;
+ }
+ const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', {
+ rate: state.mouthFlap?.rate ?? 1,
+ });
+ toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`);
+ }
+
+ 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, rotY: 0, scale: 1 };
+ state.props.items = [...(state.props.items ?? []), {
+ kind,
+ x: def.x ?? 0,
+ y: def.y ?? 0,
+ z: def.z ?? 0,
+ rotY: def.rotY ?? 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)),
+ caption: JSON.parse(JSON.stringify(state.caption)),
+ caption2: JSON.parse(JSON.stringify(state.caption2)),
+ // 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: 'caption', 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. Drawn with the vendored caption font, so the sheet does not
+ * depend on whatever fonts the viewer happens to have.
+ */
+ 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 sheet of them
+ * plus a zip of the individual frames.
+ */
+ async function saveStory({ scale = 2, includeZip = true } = {}) {
+ 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 ?? {});
+ state.caption = { ...saved.caption, ...(panel.caption ?? {}) };
+ state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) };
+ // 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;
+ if (numbered) await ensureCaptionFont();
+
+ 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);
+ if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH);
+ });
+
+ const stamp = exporter.timestamp();
+ exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`);
+ if (includeZip) {
+ const files = await Promise.all(captures.map(async (capture, index) => ({
+ name: `panel-${String(index + 1).padStart(2, '0')}.png`,
+ data: new Uint8Array(await capture.blob.arrayBuffer()),
+ })));
+ exporter.downloadBlob(createZip(files), `bluebey-comic-${stamp}.zip`);
+ }
+ 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();
+
+ 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); });
+ }
+
+ function togglePanel() {
+ document.body.classList.toggle('panel-hidden');
+ 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..083c745
--- /dev/null
+++ b/public/bluebey-studio/src/model.js
@@ -0,0 +1,239 @@
+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
+ *
+ * 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 };
+
+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;
+
+ 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.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;
+ 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);
+
+ // 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],
+ eyeMesh,
+ mouthMesh: mouth,
+ 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();
+}
diff --git a/public/bluebey-studio/src/mouthFlap.js b/public/bluebey-studio/src/mouthFlap.js
new file mode 100644
index 0000000..1109398
--- /dev/null
+++ b/public/bluebey-studio/src/mouthFlap.js
@@ -0,0 +1,267 @@
+/**
+ * 口パク (mouth flap): open and close the mouth as if talking, with no sound.
+ *
+ * WHY no speech: the studio used to read the caption out with the Web Speech
+ * API, but that voice belongs to the device (it is the OS's own speech engine),
+ * so a recording of it is not something we are free to hand out. What a clip
+ * actually needs is the *mouth motion*, and that needs no voice at all:
+ * `createMouthEnvelope` builds a smooth, seeded signal in the 3-6
+ * syllable-per-second band, and `createMouthFlap` ticks it at about 30 Hz for
+ * as long as the line would take to say.
+ *
+ * Nothing here touches `speechSynthesis`, so there is no permission prompt, no
+ * device dependency, and a recording of the animation is the studio's own work.
+ */
+
+const TAU = Math.PI * 2;
+/** The mouth is sampled at ~30 Hz: enough for an animation, cheap to run. */
+const LEVEL_HZ = 30;
+/** How long the envelope takes to close after `stop()`, in seconds. */
+const CLOSE_SECONDS = 0.3;
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+const clamp01 = (value) => clamp(value, 0, 1);
+
+/**
+ * mulberry32, the same tiny generator `handDrawn.js` uses. The mouth must be
+ * reproducible for a seed, so `Math.random` is not an option.
+ *
+ * @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;
+ };
+}
+
+/**
+ * The mouth source: a deterministic, smooth mouth opening in 0..1.
+ *
+ * A syllable is one closed -> open -> closed cycle, so the signal is a cosine
+ * pulse whose frequency is slowly modulated between 3 and 6 Hz and shaped by a
+ * slower amplitude wobble. The syllable range and the modulation constants come
+ * from the seed, so two envelopes with the same seed are exactly identical, and
+ * the signal is continuous with a bounded slope: it never jumps, which is what
+ * keeps the mouth from flickering.
+ *
+ * @param {{ seed?: number }} [options]
+ * @returns {{ value: (t: number) => number, start: (at?: number) => void, stop: () => void }}
+ */
+export function createMouthEnvelope({ seed = 1 } = {}) {
+ const random = mulberry32(seed);
+ const phase0 = random() * TAU;
+ const phase1 = random() * TAU;
+ // 4.2..4.8 syllables/s on average, swung by up to 1.2 either way, then kept
+ // inside 3..6 so the read stays in the range of human speech.
+ const rateMean = 4.2 + random() * 0.6;
+ const rateSwing = Math.min(rateMean - 3, 6 - rateMean, 0.7 + random() * 0.8);
+ const rateMod = 0.21 + random() * 0.25;
+ const ampMod = 0.4 + random() * 0.3;
+
+ let startAt = 0;
+ let stopAt = Infinity;
+ let lastAt = 0;
+
+ /** The envelope at `u` seconds after the start, ignoring start and stop. */
+ function raw(u) {
+ // The integral of the modulated rate, so theta stays continuous (a
+ // modulated sine would kink whenever the rate changed).
+ const theta = TAU * (rateMean * u
+ + (rateSwing / (TAU * rateMod)) * (Math.cos(phase0) - Math.cos(TAU * rateMod * u + phase0)));
+ const pulse = 0.5 - 0.5 * Math.cos(theta);
+ const amp = 0.55 + 0.2 * Math.sin(TAU * ampMod * u + phase1);
+ return clamp01(amp * pulse);
+ }
+
+ return {
+ /**
+ * The mouth opening at `t`, in the caller's own time base. Before `start`
+ * it is 0, and after `stop()` it fades to 0 within `CLOSE_SECONDS`.
+ *
+ * @param {number} t
+ * @returns {number} 0..1
+ */
+ value(t) {
+ const time = Number.isFinite(t) ? t : 0;
+ if (time > lastAt) lastAt = time;
+ if (time < startAt) return 0;
+ const fading = time - stopAt;
+ if (fading > 0) {
+ const fade = 1 - fading / CLOSE_SECONDS;
+ if (fade <= 0) return 0;
+ return raw(time - startAt) * fade;
+ }
+ return raw(time - startAt);
+ },
+
+ /** Begin (or restart) the envelope at `at`, opening from closed. */
+ start(at = 0) {
+ startAt = Number.isFinite(at) ? at : 0;
+ stopAt = Infinity;
+ lastAt = startAt;
+ },
+
+ /** Stop: the envelope decays to 0 from wherever it is. */
+ stop() {
+ stopAt = lastAt;
+ },
+ };
+}
+
+/**
+ * Map a 0..1 level to the app's mouth-opening range.
+ *
+ * WHY not `level` straight through: a half-open mouth is the readable one, and
+ * a full gape looks like a shout. 0 stays 0 (a closed mouth must stay closed)
+ * and 1 lands at 0.55. `gain` lets a caller push the mouth wider for a loud
+ * passage, but the result is clamped to 0..0.9 so it is never a full gape.
+ *
+ * @param {number} level
+ * @param {number} [gain=1]
+ * @returns {number} 0..0.9
+ */
+export function levelToMouth(level, gain = 1) {
+ if (!Number.isFinite(level)) return 0;
+ const scale = Number.isFinite(gain) ? gain : 1;
+ return clamp(clamp01(level) * 0.55 * scale, 0, 0.9);
+}
+
+/**
+ * Roughly how long a line takes to say, in seconds **at rate 1**.
+ *
+ * A Japanese syllable is about 0.155 s, so the count of characters is a good
+ * enough clock - and it is the only clock available once there is no voice to
+ * listen to. An empty line still gets a few seconds, so the button never looks
+ * like it did nothing.
+ *
+ * @param {string} text
+ * @returns {number} seconds, 1.1..40 (or 3 for an empty line)
+ */
+export function speakingSeconds(text) {
+ const chars = [...String(text ?? '').trim()].length;
+ if (chars === 0) return 3;
+ return clamp(0.55 + chars * 0.155, 1.1, 40);
+}
+
+/**
+ * The mouth-flap animation.
+ *
+ * `start(text, { rate })` opens the mouth in the seeded syllable rhythm and
+ * stops itself once the line would have been said, then reports `onEnd`. A
+ * second `start` restarts it, and `stop()` fades the mouth shut early - which is
+ * what the panel's button does while it is running.
+ *
+ * @param {{
+ * onLevel?: (level: number) => void,
+ * onEnd?: () => void,
+ * }} [options]
+ */
+export function createMouthFlap({ onLevel, onEnd } = {}) {
+ const envelope = createMouthEnvelope();
+ let handlers = { onLevel, onEnd };
+ let timer = null;
+ let closing = false;
+ let running = false;
+ let startedAt = 0;
+ let lastEmit = 0;
+ let currentRate = 1;
+ /** The envelope's own time at which the line is over. */
+ let limitAt = Infinity;
+
+ const nowSeconds = () => (globalThis.performance?.now?.() ?? Date.now()) / 1000;
+ const rafSupported = typeof globalThis.requestAnimationFrame === 'function';
+ const schedule = (fn) => (rafSupported
+ ? globalThis.requestAnimationFrame(fn)
+ : globalThis.setTimeout(fn, 1000 / LEVEL_HZ));
+ const unschedule = (id) => {
+ if (id == null) return;
+ if (rafSupported) globalThis.cancelAnimationFrame(id);
+ else globalThis.clearTimeout(id);
+ };
+
+ /**
+ * One level sample, about 30 times a second. Once the line has run its
+ * length the loop stops itself, and while the moth is closing it keeps going
+ * until the mouth is shut - so no timer is ever left behind.
+ */
+ function tick() {
+ timer = null;
+ const stamp = nowSeconds();
+ const elapsed = (stamp - startedAt) * currentRate;
+ if (!closing && elapsed >= limitAt) {
+ closing = true;
+ envelope.stop();
+ }
+ const level = envelope.value(elapsed);
+ if (closing || stamp - lastEmit >= 1 / LEVEL_HZ - 0.001) {
+ lastEmit = stamp;
+ handlers.onLevel?.(level);
+ }
+ if (closing && level <= 0) {
+ running = false;
+ handlers.onEnd?.();
+ return;
+ }
+ timer = schedule(tick);
+ }
+
+ /**
+ * Start flapping. `seconds` overrides the length guessed from the text.
+ *
+ * @param {string} text
+ * @param {{ rate?: number, seconds?: number }} [options]
+ * @returns {number} how long the flap will run, in seconds
+ */
+ function start(text, { rate = 1, seconds = null } = {}) {
+ stopNow();
+ currentRate = clamp(Number.isFinite(rate) ? rate : 1, 0.2, 4);
+ limitAt = Number.isFinite(seconds) && seconds > 0 ? seconds : speakingSeconds(text);
+ startedAt = nowSeconds();
+ lastEmit = 0;
+ closing = false;
+ running = true;
+ envelope.start(0);
+ timer = schedule(tick);
+ return limitAt / currentRate;
+ }
+
+ /** Clear the timer immediately (no fade). */
+ function stopNow() {
+ unschedule(timer);
+ timer = null;
+ closing = false;
+ running = false;
+ envelope.stop();
+ }
+
+ /** Fade the mouth shut and let the loop clean itself up. */
+ function stop() {
+ if (timer == null) {
+ running = false;
+ envelope.stop();
+ return;
+ }
+ closing = true;
+ envelope.stop();
+ }
+
+ function dispose() {
+ stopNow();
+ handlers = { onLevel: null, onEnd: null };
+ }
+
+ return {
+ get running() {
+ return running;
+ },
+ start,
+ stop,
+ dispose,
+ };
+}
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..28ea8f9
--- /dev/null
+++ b/public/bluebey-studio/src/panel.js
@@ -0,0 +1,2508 @@
+import {
+ h, section, subhead, hint, slider, check, segmented, buttons,
+ colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast,
+} from './ui.js';
+import {
+ FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
+ defaultState, applyPatch,
+} from './presets.js';
+import { STYLE_DEFS } from './styles.js';
+import { EYE_VARIANTS } from './model.js';
+import { ENVIRONMENTS } from './look.js';
+import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
+import { PROP_LIBRARY, applyPropFaceScale } from './props.js';
+import {
+ GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
+} from './gion.js';
+
+/** Fallbacks for the fields an older settings file may predate. */
+const BODY_COLOR_FALLBACKS = {
+ body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0',
+ leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0',
+};
+const CAPTION_SLIDER_FALLBACKS = {
+ fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4,
+};
+const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' };
+const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 };
+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 had no multi-line field, and the two features that take free prose
+ * (the caption and the spoken text) want the same shape, so it is built once
+ * here rather than growing the widget kit for a single caller.
+ */
+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(); };
+
+ /** Patch one scope of the state without re-reading the whole UI. */
+ /** 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();
+
+ // Four 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 now carries a
+ // small stroke icon so the row can be scanned at a glance.
+ 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: '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 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('モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。スライダーは「休めの姿勢からの差」です。'));
+
+ // こまかい設定: 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 = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({
+ label, min: -2, max: 2, 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 }));
+
+ // --- 操作 -----------------------------------------------------------------
+ const poseActions = section(tabPose, '操作');
+ poseActions.add(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() },
+ { id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() },
+ { id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() },
+ ],
+ }));
+ poseActions.add(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;
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyPatch(state.pose, preset.pose ?? {});
+ if (!state.pose.root) state.pose.root = [0, 0, 0];
+ replace({}, 'pose');
+ }
+
+ 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, 'プリセット');
+
+ 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 = section(tabFace, '目');
+
+ const eyeWidgets = {};
+ // 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;
+ // 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();
+ },
+ });
+ for (const key of ['left', 'right']) {
+ const label = key === 'left' ? '左の目' : '右の目';
+ const open = slider({
+ label: `${label}の開き`,
+ min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`),
+ onInput: (value) => {
+ state.face.eyes[key].open = value;
+ if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].open = value;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ const closed = segmented({
+ label: `${label}の閉じ方`,
+ options: [
+ { value: 'line', label: '線' },
+ { value: 'three', label: '3の目' },
+ { value: 'arch', label: 'わらう' },
+ ],
+ value: 'line',
+ onChange: (value) => {
+ state.face.eyes[key].closed = value;
+ // 線 and わらう are the shut-eye artwork, so shut the eye for the user.
+ state.face.eyes[key].open = 0;
+ if (state.face.eyes.linked) {
+ const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
+ other.open = 0;
+ other.closed = value;
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ const closedLines = segmented({
+ label: `${label}の線の数`,
+ options: [
+ { value: '1', label: '1本' },
+ { value: '2', label: '2本' },
+ { value: '3', label: '3本' },
+ ],
+ value: '1',
+ hint: '2本以上は端点がつながった形になります',
+ onChange: (value) => {
+ state.face.eyes[key].closedLines = Number(value);
+ state.face.eyes[key].closed = 'line';
+ state.face.eyes[key].open = 0;
+ if (state.face.eyes.linked) {
+ const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
+ other.open = 0;
+ other.closed = 'line';
+ other.closedLines = Number(value);
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // The direction of a "3" eye. It used to be tied to the nose side, which made
+ // the pair a mirror image; now each eye says which way its 3 faces.
+ const threeFlip = segmented({
+ label: `${label}の3の向き`,
+ options: [
+ { value: 'normal', label: '3' },
+ { value: 'flip', label: '3(逆)' },
+ ],
+ value: 'normal',
+ hint: '「3の目」のときの向きです。左右で別々に選べます',
+ onChange: (value) => {
+ state.face.eyes[key].threeFlip = value === 'flip';
+ state.face.eyes[key].closed = 'three';
+ state.face.eyes[key].open = 0;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // Where this eye sits: per eye, so a lopsided face (one eye slid across) is
+ // possible.
+ 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'); },
+ });
+ // Tears are per eye, and so are where they hang and which way they lean.
+ // When the eyes are linked, a tear edit mirrors onto the other eye: the size
+ // and the height are copied, but the sideways position and the tilt flip
+ // sign. Two drops leaning the same way read as a tilted face, not as crying.
+ const otherKey = key === 'left' ? 'right' : 'left';
+ const setTear = (patch) => {
+ Object.assign(state.face.eyes[key], patch);
+ if (state.face.eyes.linked) {
+ const mirrored = { ...patch };
+ if (patch.tearX != null) mirrored.tearX = -patch.tearX;
+ if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt;
+ Object.assign(state.face.eyes[otherKey], mirrored);
+ }
+ app.actions.refresh('face');
+ sync();
+ };
+ const tear = slider({
+ label: `${label}の涙`, min: 0, max: 1.6, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (value) => setTear({ tear: value }),
+ });
+ const tearY = slider({
+ label: `${label}の涙の高さ`, min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => setTear({ tearY: value }),
+ });
+ const tearX = slider({
+ label: `${label}の涙のよこ位置`, min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => setTear({ tearX: value }),
+ });
+ const tearTilt = slider({
+ label: `${label}の涙の傾き`, min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (value) => setTear({ tearTilt: value }),
+ });
+ eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt };
+ if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
+ faceSection.add(open.el);
+ faceSection.add(closed.el);
+ faceSection.add(closedLines.el);
+ faceSection.add(threeFlip.el);
+ faceSection.add(eyeX.el);
+ faceSection.add(tear.el);
+ faceSection.add(tearY.el);
+ faceSection.add(tearX.el);
+ faceSection.add(tearTilt.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();
+ },
+ }],
+ }));
+
+ const irisShapeSegment = segmented({
+ label: '瞳の形',
+ options: [
+ { value: 'circle', label: '丸' },
+ { value: 'heart', label: 'ハート' },
+ ],
+ value: 'circle',
+ onChange: (value) => {
+ state.face.eyes.left.irisShape = value;
+ state.face.eyes.right.irisShape = value;
+ app.actions.refresh('face');
+ },
+ });
+ faceSection.add(irisShapeSegment.el);
+
+ const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
+ faceSection.add(linkedNote);
+
+ const lookPad = xyPad({
+ value: { x: 0, y: 0 },
+ center: 'eye',
+ onChange: ({ x, y }) => {
+ state.face.eyes.left.lookX = x;
+ state.face.eyes.left.lookY = y;
+ state.face.eyes.right.lookX = x;
+ state.face.eyes.right.lookY = y;
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' },
+ lookPad.el,
+ h('p', { class: 'hint', text: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' }))));
+
+ const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・ハート・涙・まぶた)');
+ const irisScaleSlider = slider({
+ label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.irisScale = value; app.actions.refresh('face'); },
+ });
+ const heartScaleSlider = slider({
+ label: 'ハートの大きさ', min: 0.4, max: 2.6, step: 0.01, value: 2, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.heartScale = value; app.actions.refresh('face'); },
+ });
+ const heartWhiteToggle = check({
+ label: 'ハートのとき白目を消す',
+ value: true,
+ onChange: (value) => { state.face.eyes.heartHideWhite = value; app.actions.refresh('face'); },
+ });
+ const heartColorField = colorField({
+ label: 'ハートの色', value: '#e0344f',
+ swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'],
+ onChange: (v) => { state.face.eyes.heartColor = v; app.actions.refresh('face'); },
+ });
+ const lookMaxSlider = slider({
+ label: '目線の振れ幅', min: 0, max: 1.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.lookMax = value; app.actions.refresh('face'); },
+ });
+ const lidWidthSlider = slider({
+ label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => { state.face.eyes.lidWidth = value; app.actions.refresh('face'); },
+ });
+ const lowerLidSlider = slider({
+ label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); },
+ });
+ eyeAdvanced.add(irisScaleSlider.el);
+ eyeAdvanced.add(heartScaleSlider.el);
+ eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true }));
+ eyeAdvanced.add(heartColorField.el);
+ eyeAdvanced.add(lookMaxSlider.el);
+ eyeAdvanced.add(lidWidthSlider.el);
+ eyeAdvanced.add(lowerLidSlider.el);
+
+ const highlightToggle = check({
+ label: '瞳のハイライト',
+ value: true,
+ onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); },
+ });
+ eyeAdvanced.add(controlRow(null, highlightToggle.el, { wide: true }));
+
+ 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);
+
+ const eyeSource = selectField({
+ label: '目のパーツ',
+ // 実際にモデルに入っている手描きパーツだけを一覧に出す(消したものが残ると混乱するため)
+ options: [
+ { value: 'parametric', label: 'パラメータで作る(おすすめ)' },
+ ...EYE_VARIANTS.filter((variant) => model.originals.eyes[variant.key])
+ .map((variant) => ({ value: variant.key, label: `手描き:${variant.label}` })),
+ ],
+ value: 'parametric',
+ onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(eyeSource.el);
+
+ // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます)
+ const rebuildEyeOptions = () => {
+ const select = eyeSource.el.querySelector('select');
+ if (!select) return;
+ const current = select.value;
+ select.replaceChildren();
+ select.append(h('option', { value: 'parametric', text: 'パラメータで作る(おすすめ)' }));
+ for (const variant of EYE_VARIANTS) {
+ if (model.originals.eyes[variant.key]) {
+ select.append(h('option', { value: variant.key, text: `手描き:${variant.label}` }));
+ }
+ }
+ for (const [key, name] of app.face.customNames) {
+ select.append(h('option', { value: key, text: `読み込み:${name}` }));
+ }
+ select.value = current;
+ };
+ rebuildEyeOptions();
+
+ const eyeImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ eyeImagePicker.addEventListener('change', async () => {
+ const file = eyeImagePicker.files?.[0];
+ eyeImagePicker.value = '';
+ if (!file) return;
+ try {
+ await app.face.loadImage('eyes', file);
+ state.face.eyes.source = app.face.eyeSource;
+ rebuildEyeOptions();
+ sync();
+ toast(`目の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ faceSection.add(eyeImagePicker);
+ faceSection.add(buttons({
+ items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }],
+ }));
+
+ // --- eyebrows (the original character has none; this is extra range)
+ faceSection = section(tabFace, '眉');
+ faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。'));
+
+ const browToggle = check({
+ label: '眉を表示',
+ value: false,
+ onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, browToggle.el, { wide: true }));
+
+ const browSliders = {
+ angle: slider({
+ label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); },
+ }),
+ height: slider({
+ label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); },
+ }),
+ length: slider({
+ label: '長さ', min: 0, max: 1.6, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '点' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); },
+ }),
+ thickness: slider({
+ label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); },
+ }),
+ curve: slider({
+ label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); },
+ }),
+ taper: slider({
+ label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))),
+ onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。'
+ + '高さは + で上がり、- にすると目にかくれて見えなくなります。'));
+ faceSection.add(browSliders.angle.el);
+ const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)');
+ browBox.add(browSliders.height.el);
+ browBox.add(browSliders.length.el);
+ browBox.add(browSliders.thickness.el);
+ browBox.add(browSliders.spacing.el);
+ browBox.add(browSliders.curve.el);
+ browBox.add(browSliders.taper.el);
+ const browColor = colorField({
+ label: '眉の色', value: '#55386e',
+ swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'],
+ onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); },
+ });
+ browBox.add(browColor.el);
+ browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、'
+ + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。'
+ + '「鼻側の細さ」を 0 にすると鼻側がとがります。'));
+ browBox.add(buttons({
+ items: [
+ {
+ id: 'golgo',
+ label: 'ゴル風',
+ onClick: () => {
+ // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer
+ // end and tapering to a point at the nose, angled steeply down towards
+ // it, with the two brows sitting close together.
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ angle: 36,
+ height: 6,
+ length: 1.1,
+ thickness: 3.5,
+ curve: 0,
+ taper: 0,
+ spacing: -10,
+ color: '#12101a',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'ryotsu',
+ label: '両風',
+ onClick: () => {
+ // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting
+ // low over the eyes. `spacing` is what closes the gap - at this length
+ // the two inner ends meet over the nose (see the panel's note).
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ angle: 10,
+ height: 4,
+ length: 0.6,
+ thickness: 3.5,
+ curve: 0.1,
+ taper: 1,
+ spacing: -205,
+ color: '#181220',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ // --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
+ faceSection = section(tabFace, '眼鏡・サングラス');
+ faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ + '線画のときはレンズを塗らず、フレームの線だけになります。'));
+
+ const glassesToggle = check({
+ label: '眼鏡をかける',
+ value: false,
+ onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
+
+ // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの
+ // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、
+ // 作りこんだフレームは種類を変えても失われない。
+ const GLASSES_KINDS = {
+ glasses: { lensColor: '#2b2433', lensOpacity: 0.22 },
+ sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 },
+ };
+ const setGlassesKind = (kind) => {
+ Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] });
+ };
+
+ const glassesKindSegment = segmented({
+ label: '種類',
+ options: [
+ { value: 'glasses', label: '眼鏡' },
+ { value: 'sunglasses', label: 'サングラス' },
+ ],
+ value: 'glasses',
+ onChange: (value) => {
+ setGlassesKind(value);
+ 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'); },
+ }),
+ 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);
+ 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();
+ },
+ },
+ ],
+ }));
+
+ // --- ほっぺ (a manga blush) and 頭の模様 (a mark on the head)
+ faceSection = section(tabFace, 'ほっぺ・頭の模様');
+ faceSection.add(hint('ほっぺ(マンガの赤らみ)と、頭の模様を、目や眉と同じ絵にかけます。'
+ + 'どちらも決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。'
+ + 'はじめは両方とも出ていません。'));
+
+ const cheeksToggle = check({
+ label: 'ほっぺを出す',
+ value: false,
+ onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, cheeksToggle.el, { wide: true }));
+
+ const cheekSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); },
+ }),
+ lines: slider({
+ label: '線の数', min: 0, max: 7, step: 1, value: 4,
+ format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`),
+ onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(cheekSliders.size.el);
+ const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)');
+ cheekBox.add(cheekSliders.offsetY.el);
+ cheekBox.add(cheekSliders.spacing.el);
+ cheekBox.add(cheekSliders.lines.el);
+ const cheekColor = colorField({
+ label: 'ほっぺの色', value: '#f6a6b8',
+ swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'],
+ onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); },
+ });
+ cheekBox.add(cheekColor.el);
+ cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。'
+ + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。'));
+
+ const headMarkShape = segmented({
+ label: '頭の模様',
+ options: [
+ { value: 'off', label: 'なし' },
+ { value: 'hook', label: 'かぎ' },
+ { value: 'spiral', label: 'うずまき' },
+ { value: 'strokes', label: '三本線' },
+ { value: 'dots', label: '点' },
+ ],
+ value: 'off',
+ onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(headMarkShape.el);
+
+ const headMarkSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); },
+ }),
+ offsetX: slider({
+ label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); },
+ }),
+ lineWidth: slider({
+ label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(headMarkSliders.size.el);
+ const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)');
+ headMarkBox.add(headMarkSliders.offsetX.el);
+ headMarkBox.add(headMarkSliders.offsetY.el);
+ headMarkBox.add(headMarkSliders.lineWidth.el);
+ const headMarkColor = colorField({
+ label: '模様の色', value: '#8a4fe0',
+ swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
+ onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
+ });
+ headMarkBox.add(headMarkColor.el);
+ headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、'
+ + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。'));
+
+ // --- mouth
+ faceSection = section(tabFace, '口');
+
+ 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'); } }),
+ tongueOut: slider({ label: '舌を外に出す', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOut = v; app.actions.refresh('face'); } }),
+ tongueOutPos: slider({ label: '外に出す舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.5, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOutPos = 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'); } }),
+ };
+ const mouthVisible = ['smile', 'round', 'width', 'thickness', 'tilt', 'offsetY'];
+ for (const key of mouthVisible) faceSection.add(mouthSliders[key].el);
+ const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)');
+ for (const key of ['open', 'tongue', 'tonguePos', 'tongueOut', 'tongueOutPos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el);
+ mouthBox.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);
+
+ const mouthSource = selectField({
+ label: '口のパーツ',
+ options: [
+ { value: 'parametric', label: 'パラメータで作る(おすすめ)' },
+ { value: 'original', label: '手描き(原本)' },
+ ],
+ value: 'parametric',
+ onChange: (value) => { state.face.mouth.source = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(mouthSource.el);
+
+ const mouthImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ mouthImagePicker.addEventListener('change', async () => {
+ const file = mouthImagePicker.files?.[0];
+ mouthImagePicker.value = '';
+ if (!file) return;
+ try {
+ await app.face.loadImage('mouth', file);
+ state.face.mouth.source = 'original';
+ sync();
+ toast(`口の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ faceSection.add(mouthImagePicker);
+ faceSection.add(buttons({
+ items: [{ id: 'mouthimg', label: '口の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) mouthImagePicker.click(); } }],
+ }));
+
+ /* ---------------------------------------------------------------- caption */
+
+ const captionSection = section(tabFace, 'セリフ(吹き出し)');
+
+ // Two bubbles share this one set of controls: the selector says which bubble
+ // they are editing, and every handler below reads the active one when it runs,
+ // so switching does not have to rewire anything.
+ let activeCaption = 'caption';
+ const cap = () => state[activeCaption];
+ const captionBubbleSegment = segmented({
+ label: 'どの吹き出し',
+ options: [
+ { value: 'caption', label: '①' },
+ { value: 'caption2', label: '②' },
+ ],
+ value: 'caption',
+ onChange: (value) => { activeCaption = value; sync(); },
+ });
+ captionSection.add(captionBubbleSegment.el);
+ captionSection.add(hint('吹き出しは 2 つまで出せます。①と②を切り替えて、それぞれ別の位置・形・色にできます。'));
+
+ const captionToggle = check({
+ label: 'この吹き出しを出す',
+ value: false,
+ onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); },
+ });
+ captionSection.add(controlRow(null, captionToggle.el, { wide: true }));
+
+ const captionText = textArea({
+ label: 'セリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { cap().text = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionText.el);
+
+ captionSection.add(buttons({
+ label: '例',
+ items: CAPTION_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); },
+ })),
+ }));
+
+ const bubbleSegment = segmented({
+ label: '形',
+ options: BUBBLE_STYLES,
+ value: 'round',
+ onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); },
+ });
+ const tailSegment = tailPad({
+ label: 'しっぽ',
+ value: 'left',
+ onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(bubbleSegment.el);
+ captionSection.add(tailSegment.el);
+
+ const captionSliders = {
+ fontSize: slider({
+ label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); },
+ }),
+ lineHeight: slider({
+ label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); },
+ }),
+ padding: slider({
+ label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); },
+ }),
+ radius: slider({
+ label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); },
+ }),
+ borderWidth: slider({
+ label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); },
+ }),
+ };
+ for (const widget of Object.values(captionSliders)) captionSection.add(widget.el);
+
+ const captionPosition = {
+ x: slider({
+ label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().x = v; app.actions.refresh('caption'); },
+ }),
+ y: slider({
+ label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().y = v; app.actions.refresh('caption'); },
+ }),
+ };
+ captionSection.add(captionPosition.x.el);
+ captionSection.add(captionPosition.y.el);
+
+ const captionMaxWidthSlider = slider({
+ label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionMaxWidthSlider.el);
+
+ const alignSegment = segmented({
+ label: 'そろえ',
+ options: [
+ { value: 'left', label: '左' },
+ { value: 'center', label: '中' },
+ { value: 'right', label: '右' },
+ ],
+ value: 'left',
+ onChange: (value) => { cap().align = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(alignSegment.el);
+
+ const boldToggle = check({
+ label: '太字', value: false,
+ onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); },
+ });
+ const systemFontToggle = check({
+ label: '標準フォントを使う', value: false,
+ onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); },
+ });
+ captionSection.add(controlRow(null, boldToggle.el, { wide: true }));
+ captionSection.add(controlRow(null, systemFontToggle.el, { wide: true }));
+
+ const captionColors = {
+ textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }),
+ bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }),
+ borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }),
+ };
+ for (const widget of Object.values(captionColors)) captionSection.add(widget.el);
+ captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。'
+ + 'ビューポートで吹き出しをドラッグしても動かせます。'));
+
+ /* -------------------------------------------------------------- 口パク */
+
+ // 口パク only: the mouth moves as if talking, and no sound is made at all.
+ // (The studio used to read the line with the device's own speech voices, but
+ // those belong to the device, so a recording of them is not ours to hand out.)
+ const flapSection = section(tabFace, '口パク(声は出ません)');
+ const flapText = textArea({
+ label: '口パクするセリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { state.mouthFlap.text = value; },
+ });
+ flapSection.add(flapText.el);
+ flapSection.add(buttons({
+ items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }],
+ }));
+
+ const flapSliders = {
+ rate: slider({
+ label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.mouthFlap.rate = v; },
+ }),
+ mouthGain: slider({
+ label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.mouthFlap.mouthGain = v; },
+ }),
+ };
+ for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
+ flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
+ + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+
+ /* ------------------------------------------------------ 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: 'off',
+ onChange: (value) => { state.anim.mode = value; 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 = section(tabFace, '見る先');
+ // `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 */
+
+ 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('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。'));
+
+ /* ------------------------------------------------------------- body colour */
+
+ const colourSection = section(tabView, '体の色');
+ const themeSegment = segmented({
+ label: 'テーマ',
+ options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })),
+ value: 'original',
+ onChange: (id) => { app.actions.applyTheme(id); sync(); },
+ });
+ colourSection.add(themeSegment.el);
+ colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。'));
+
+ /** Write one part colour, creating `colors` if an old settings file lacked it. */
+ const setBodyColor = (part, value) => {
+ state.render.colors = state.render.colors ?? {};
+ state.render.colors[part] = value;
+ app.actions.refresh('render');
+ };
+ const bodyColors = {
+ body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }),
+ accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }),
+ nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }),
+ leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }),
+ vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }),
+ feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }),
+ };
+ for (const widget of Object.values(bodyColors)) colourSection.add(widget.el);
+
+ 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'); },
+ });
+ colourSection.add(envIntensitySlider.el);
+ colourSection.add(buttons({
+ items: [{
+ id: 'resetColors',
+ label: 'プリセットの色に戻す',
+ onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); },
+ }],
+ }));
+
+ /* -------------------------------------------------------------------- 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 }));
+ lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。「接地影だけ」は足もとの丸い影だけにします。'));
+
+ 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(environmentSegment.el);
+
+ /* -------------------------------------------------------------- background */
+
+ const backdropSection = section(tabView, '背景');
+ // 素材 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('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。'));
+ backdropSection.add(buttons({
+ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }],
+ }));
+
+ /* ------------------------------------------------------------------- props */
+
+ // 舞台: the things you put the character *among* - the props and the wall - in
+ // one place, since they are set up together.
+ const stageSection = section(tabView, '舞台');
+ const propSection = details(stageSection.body, '小物');
+ 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') }),
+ rotY: slider({ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ 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 surface can be enlarged on its own, so the frame
+ // and posts 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;
+ if (item.kind === 'sign') {
+ 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 });
+ });
+ };
+ 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 = details(stageSection.body, '擬音(マンガのオノマトペ)');
+ 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 a clip plane and bury the character in it, so only the
+ // part in front of the wall still shows. It is the same cut the camera's own
+ // far plane makes, put where you want it (see src/clip.js).
+ const wallSection = details(stageSection.body, '見えない壁');
+ const wallConfig = () => (state.render.wall = state.render.wall ?? defaultState().render.wall);
+ wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。'));
+
+ const wallToggle = check({
+ label: '見えない壁で一部を隠す',
+ value: false,
+ onChange: (value) => { wallConfig().on = value; app.actions.refresh('render'); sync(); },
+ });
+ const wallSliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().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) => { wallConfig().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) => { wallConfig().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) => { wallConfig().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) => { wallConfig().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) => { wallConfig().size = v; app.actions.refresh('render'); sync(); } }),
+ };
+ wallSection.add(wallToggle.el);
+ for (const widget of Object.values(wallSliders)) wallSection.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 wallGuideToggle = check({
+ label: '板を表示(ビューポートでドラッグして動かせます)',
+ value: false,
+ onChange: (value) => { wallConfig().guide = value; app.actions.refresh('render'); sync(); },
+ });
+ wallSection.add(controlRow(null, wallGuideToggle.el, { wide: true }));
+ wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**'
+ + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。'
+ + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'));
+ wallSection.add(buttons({
+ items: [{
+ id: 'wallReset',
+ label: '見えない壁をリセット',
+ onClick: () => {
+ state.render.wall = defaultState().render.wall;
+ 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(tabView, '画面・光');
+ 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'); },
+ });
+ lightSection.add(lightIntensitySlider.el);
+ lightSection.add(ambientSlider.el);
+ lightSection.add(exposureSlider.el);
+
+ /* ----------------------------------------------------------------- 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, both bubbles 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 ?? {},
+ caption: panel.caption ?? {},
+ caption2: panel.caption2 ?? {},
+ // 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: 'caption', 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(tabView, '画面の情報');
+ const statsLine = h('p', { class: 'hint', text: '計測中…' });
+ infoSection.add(statsLine);
+ infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
+
+ 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(() => {
+ for (const key of ['left', 'right']) {
+ eyeWidgets[key].open.set(state.face.eyes[key].open);
+ eyeWidgets[key].closed.set(state.face.eyes[key].closed);
+ eyeWidgets[key].closedLines.set(String(state.face.eyes[key].closedLines ?? 1));
+ eyeWidgets[key].threeFlip.set(state.face.eyes[key].threeFlip ? 'flip' : 'normal');
+ // The direction only means anything for the "3" eye.
+ eyeWidgets[key].threeFlip.el.style.display = state.face.eyes[key].closed === 'three' ? '' : 'none';
+ eyeWidgets[key].tear.set(state.face.eyes[key].tear ?? 0);
+ eyeWidgets[key].tearY.set(state.face.eyes[key].tearY ?? 0);
+ eyeWidgets[key].tearX.set(state.face.eyes[key].tearX ?? 0);
+ eyeWidgets[key].tearTilt.set(state.face.eyes[key].tearTilt ?? 0);
+ eyeWidgets[key].eyeX.set(state.face.eyes[key].eyeX ?? 0);
+ }
+ irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle');
+
+ lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY });
+ highlightToggle.set(state.face.eyes.highlight);
+ irisScaleSlider.set(state.face.eyes.irisScale);
+ heartScaleSlider.set(state.face.eyes.heartScale ?? 0.85);
+ heartWhiteToggle.set(state.face.eyes.heartHideWhite === true);
+ heartColorField.set(state.face.eyes.heartColor ?? '#e0344f');
+ lookMaxSlider.set(state.face.eyes.lookMax ?? 1);
+ lidWidthSlider.set(state.face.eyes.lidWidth ?? 14);
+ lowerLidSlider.set(state.face.eyes.lowerLid ?? 0);
+ browToggle.set(state.face.eyes.brow.enabled);
+ browSliders.angle.set(state.face.eyes.brow.angle);
+ browSliders.height.set(state.face.eyes.brow.height);
+ browSliders.length.set(state.face.eyes.brow.length);
+ browSliders.thickness.set(state.face.eyes.brow.thickness);
+ browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0);
+ browSliders.curve.set(state.face.eyes.brow.curve);
+ browSliders.taper.set(state.face.eyes.brow.taper ?? 1);
+ browColor.set(state.face.eyes.brow.color);
+ 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);
+ eyeSource.set(state.face.eyes.source);
+ });
+
+ syncers.push(() => {
+ cheeksToggle.set(state.face.eyes.cheeks.enabled === true);
+ for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]);
+ cheekColor.set(state.face.eyes.cheeks.color);
+ headMarkShape.set(state.face.eyes.headMark.shape ?? 'off');
+ for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
+ headMarkColor.set(state.face.eyes.headMark.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]);
+ mouthSource.set(state.face.mouth.source);
+ });
+
+ 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);
+ 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(() => {
+ themeSegment.set(state.render?.theme ?? 'original');
+ for (const [key, widget] of Object.entries(bodyColors)) {
+ widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]);
+ }
+ 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);
+ 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.rotY.set(item.rotY ?? 0);
+ entry.sliders.scale.set(item.scale ?? 1);
+ entry.faceScale?.set(item.faceScale ?? 1);
+ applyPropFaceScale(groups[entry.index], item.faceScale);
+ }
+ });
+
+ 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(() => {
+ const wall = state.render?.wall ?? defaultState().render.wall;
+ wallToggle.set(wall.on === true);
+ for (const [key, widget] of Object.entries(wallSliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
+ wallGuideToggle.set(wall.guide === true);
+ });
+
+ syncers.push(() => {
+ captionBubbleSegment.set(activeCaption);
+ const bubble = cap() ?? {};
+ captionToggle.set(bubble.enabled === true);
+ captionText.set(bubble.text ?? '');
+ bubbleSegment.set(bubble.bubble ?? 'round');
+ tailSegment.set(bubble.tail ?? 'left');
+ for (const [key, widget] of Object.entries(captionSliders)) {
+ widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]);
+ }
+ captionPosition.x.set(bubble.x ?? 0.58);
+ captionPosition.y.set(bubble.y ?? 0.16);
+ captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36);
+ alignSegment.set(bubble.align ?? 'left');
+ boldToggle.set(bubble.bold === true);
+ systemFontToggle.set(bubble.font === 'system');
+ for (const [key, widget] of Object.entries(captionColors)) {
+ widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]);
+ }
+ });
+
+ syncers.push(() => {
+ flapText.set(state.mouthFlap?.text ?? '');
+ for (const [key, widget] of Object.entries(flapSliders)) {
+ widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]);
+ }
+ });
+
+ 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 bubble the caption controls are editing (a drag on it calls this). */
+ selectCaption(key) {
+ if (key !== 'caption' && key !== 'caption2') return;
+ activeCaption = key;
+ sync();
+ },
+ /** 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..3146e0b
--- /dev/null
+++ b/public/bluebey-studio/src/presets.js
@@ -0,0 +1,698 @@
+/**
+ * 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.
+ */
+
+/**
+ * One speech bubble's defaults.
+ *
+ * A factory rather than a constant, because there are two bubbles (see
+ * `caption2`) and they must not share one object - editing one would edit both.
+ */
+function captionDefaults() {
+ return {
+ enabled: false,
+ text: 'こんにちは、ぶるべーです。',
+ // Anchor of the bubble's box, as a fraction of the image (0..1).
+ x: 0.58,
+ y: 0.16,
+ maxWidth: 0.36, // fraction of the image width for the text column
+ bubble: 'round', // round | rect | shout | none
+ tail: 'left', // left | right | top | bottom | topLeft | topRight | bottomLeft | bottomRight | none
+ fontSize: 34, // pixels at 1x; scales with the export
+ lineHeight: 1.42,
+ padding: 18,
+ radius: 24,
+ textColor: '#3f2b52',
+ bubbleColor: '#ffffff',
+ borderColor: '#55386e',
+ borderWidth: 4,
+ bold: false,
+ align: 'left', // left | center | right
+ font: 'rounded', // rounded (vendored) | system
+ };
+}
+
+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,
+ pngScale: 2,
+ pngTransparent: false,
+ svgWidth: 2048,
+ lightAzimuth: -38,
+ lightElevation: 40,
+ lightIntensity: 2.1,
+ ambient: 0.9,
+ exposure: 1,
+ // Body colours. A theme fills these in; the colour pickers then adjust
+ // them one part at a time, so a theme is a starting point, not a mode.
+ theme: 'original',
+ 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). A placeable clipping plane that hides
+ // whatever is behind it, so the character can be buried in the wall and
+ // only the rest of the body shows. See src/clip.js.
+ wall: {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through (world units)
+ 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)
+ },
+ },
+ 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: {
+ eyes: {
+ source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
+ linked: true, // move both eyes together
+ left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 },
+ right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 },
+ irisScale: 1,
+ lookMax: 1,
+ highlight: true,
+ lidWidth: 14,
+ lowerLid: 0,
+ heartScale: 2,
+ heartColor: '#e0344f',
+ heartHideWhite: true,
+ // 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',
+ // ほっぺ (a manga blush) and 頭の模様 (a mark on the head) are drawn into
+ // the same plate as the eyes and brows. Both are generic manga devices,
+ // not a copy of any one character, and both start off. The mark's shape
+ // is one of `off`, `hook`, `spiral`, `strokes`, `dots`.
+ cheeks: {
+ enabled: false,
+ size: 1,
+ spacing: 0, // extra gap outwards from the eyes
+ offsetY: 0, // extra drop below the eyes
+ lines: 4, // hatch strokes; 0 leaves a plain patch
+ color: '#f6a6b8',
+ },
+ headMark: {
+ shape: 'off',
+ size: 1,
+ offsetX: 0,
+ offsetY: 0,
+ lineWidth: 1,
+ color: '#8a4fe0',
+ },
+ // Eyebrows are drawn into the eye texture. Off by default, because the
+ // original character has no eyebrows.
+ brow: {
+ enabled: false,
+ angle: 0,
+ height: 0,
+ length: 1,
+ thickness: 1,
+ curve: 0.14,
+ // How wide the nose-side end is: 1 = a plain stroke, 0 = a wedge that
+ // comes to a point (the ゴル風 look). `spacing` widens the gap between
+ // the two brows.
+ taper: 1,
+ spacing: 0,
+ color: '#55386e',
+ },
+ // 眼鏡 / サングラス are drawn into the same texture as the eyes and brows,
+ // 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
+ 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,
+ 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
+ tongueOut: 0, // 0..1; >0 hangs the tongue *out*, below the lip
+ tongueOutPos: 0.5, // 0..1 along the lip, for the tongue that hangs out
+ 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',
+ },
+ },
+ // ---------------------------------------------------------------- caption
+ // The speech bubbles drawn next to the character when exporting. They live in
+ // the state so they are saved, shared and undone like everything else. There
+ // are two of them, so a line and a reply (or a narrator and a speaker) fit.
+ caption: captionDefaults(),
+ // The second bubble starts on the other side, so the two do not overlap.
+ caption2: { ...captionDefaults(), text: '', x: 0.08, y: 0.44 },
+ // -------------------------------------------------------- 口パク (mouth flap)
+ // The mouth moves as if talking, with no sound at all: the device's own
+ // speech voices belong to the device, so a recording of them is not ours to
+ // hand out. Only the *length* of the text matters here.
+ mouthFlap: {
+ text: 'こんにちは。小平市のぶるべーです。',
+ rate: 1, // how fast the syllables come
+ mouthGain: 1, // how wide the mouth opens
+ },
+ // ----------------------------------------------------------------- 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, caption).
+ 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: { open: 0, lookX: 0, lookY: 0, closed: 'arch' },
+ right: { open: 0, 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: { open: 0, 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,
+ },
+ mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 },
+ },
+ },
+ {
+ id: 'love',
+ label: 'すき',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
+ right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
+ irisScale: 1,
+ // Size 2 with the eyeball's white hidden is the size that reads best:
+ // any white behind the heart pokes out around its lobes.
+ heartScale: 2,
+ heartColor: '#e0344f',
+ heartHideWhite: true,
+ 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.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearY: 26, tearTilt: -20 },
+ right: { open: 0.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, 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: 'bow', label: 'おじぎ', pose: { bones: { master: [22, 0, 0] } } },
+ { id: 'deep-bow', label: 'ふかくおじぎ', pose: { bones: { master: [42, 0, 0] } } },
+ { id: 'tilt', label: 'くびかしげ', pose: { bones: { master: [0, 0, 16] } } },
+ {
+ 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: '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));
+}
+
+/**
+ * Body colour themes (C-1). Each is a full set of part colours, so picking one
+ * is a single assignment; the individual pickers in the panel then let you drift
+ * away from it without losing the rest.
+ *
+ * ぶるべー is a leaf-sprout mascot, so the themes are named after plants and
+ * weather rather than after colours.
+ */
+export const THEMES = [
+ {
+ id: 'original',
+ label: 'オリジナル',
+ colors: { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0' },
+ },
+ {
+ id: 'sakura',
+ label: 'さくら',
+ colors: { body: '#f7c6d9', accent: '#e0709b', nose: '#c25b7e', leaf: '#b8e08a', vein: '#8dbb54', feet: '#c25b7e' },
+ },
+ {
+ id: 'matcha',
+ label: 'まっちゃ',
+ colors: { body: '#cfe3ac', accent: '#6f9c3a', nose: '#5c8030', leaf: '#9fd463', vein: '#79ab3c', feet: '#5c8030' },
+ },
+ {
+ id: 'sora',
+ label: 'そら',
+ colors: { body: '#bcd9f7', accent: '#4a7fd4', nose: '#3f6cae', leaf: '#a8e08a', vein: '#7cbb58', feet: '#3f6cae' },
+ },
+ {
+ id: 'lemon',
+ label: 'レモン',
+ colors: { body: '#f8e9a6', accent: '#d8a520', nose: '#b3871a', leaf: '#b9de74', vein: '#8fbb45', feet: '#b3871a' },
+ },
+ {
+ id: 'momo',
+ label: 'もも',
+ colors: { body: '#f9cbae', accent: '#e07a4a', nose: '#bd6238', leaf: '#aede7c', vein: '#84b84e', feet: '#bd6238' },
+ },
+ {
+ id: 'sumi',
+ label: 'すみ',
+ colors: { body: '#9aa2b8', accent: '#4b5468', nose: '#3c4356', leaf: '#8fbf7a', vein: '#6d9a58', feet: '#3c4356' },
+ },
+ {
+ id: 'yozora',
+ label: 'よぞら',
+ colors: { body: '#8f8fd0', accent: '#403f8f', nose: '#34336f', leaf: '#7fc7b0', vein: '#5aa08c', feet: '#34336f' },
+ },
+];
+
+/** Bubble shapes offered for captions. */
+export const BUBBLE_STYLES = [
+ { value: 'round', label: 'まる' },
+ { value: 'rect', label: 'しかく' },
+ { value: 'shout', label: 'さけぶ' },
+ { value: 'none', label: 'なし' },
+];
+
+/** A couple of ready-made captions, so the feature is discoverable. */
+export const CAPTION_PRESETS = [
+ { id: 'greeting', label: 'あいさつ', caption: { enabled: true, text: 'こんにちは、ぶるべーです。' } },
+ { id: 'explain', label: '説明', caption: { enabled: true, text: 'ここがポイントです。', bubble: 'rect', tail: 'left' } },
+ { id: 'surprise', label: 'びっくり', caption: { enabled: true, text: 'えっ!?', bubble: 'shout', tail: 'left', fontSize: 40, bold: true } },
+];
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
new file mode 100644
index 0000000..5ad958a
--- /dev/null
+++ b/public/bluebey-studio/src/props.js
@@ -0,0 +1,459 @@
+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;
+}
+
+/** 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 separate, named child
+ * tagged `userData.canvasTexture`, so a later feature can paint a CanvasTexture
+ * onto it without touching the frame - and `applyPropFaceScale` can grow just
+ * that surface, about its own centre, leaving the frame and posts alone. It is a
+ * thin box rather than a plane: 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]);
+ addBox(group, material('accent'), [1.42, 0.98, 0.12], [0, 2.21, 0]);
+
+ const face = addBox(group, material('paper'), [1.18, 0.76, 0.08], [0, 2.21, 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);
+ addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14);
+ addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14);
+
+ 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);
+ addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]);
+
+ const front = new THREE.Group();
+ front.position.set(0, 1.00, 0.62);
+ front.rotation.x = flapsDown ? 0 : 1.05;
+ group.add(front);
+ addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]);
+
+ 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);
+ addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16);
+ 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.
+ addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16);
+
+ // The one lid, peeled back on a hinge at the back of the mouth so its
+ // underside shows. It is twisted slightly sideways so it does not read as a
+ // knob sitting straight up on the can.
+ const hinge = new THREE.Group();
+ hinge.position.set(0, 0.42, -0.135);
+ hinge.rotation.x = -1.05;
+ hinge.rotation.z = 0.4;
+ group.add(hinge);
+ addCylinder(hinge, material('metal', 0.9), 0.132, 0.132, 0.018, [0, 0.12, 0], 18);
+
+ group.rotation.z = 0.05;
+});
+
+// ---------------------------------------------------------------------------
+// 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.
+ */
+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 },
+];
+
+/**
+ * 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 surface
+ // alone, so the frame and posts stay put. The panel owns that control.
+ sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1 },
+ 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 },
+};
+
+/** 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 surface about its own centre, so only the 看板's face grows
+ * and its frame and posts stay where they are. `addBox` puts the mesh's origin at
+ * the box 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) {
+ const face = propFace(group);
+ if (!face) return;
+ const value = Number(faceScale);
+ const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
+ face.scale.setScalar(scale);
+}
+
+/** 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..2922dfa
--- /dev/null
+++ b/public/bluebey-studio/src/rig.js
@@ -0,0 +1,196 @@
+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();
+
+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;
+
+ this.controls = new TransformControls(camera, domElement);
+ this.controls.setMode('rotate');
+ this.controls.setSpace('local');
+ this.controls.setSize(0.8);
+ 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);
+ });
+
+ this.raycaster = new THREE.Raycaster();
+ this.pointer = new THREE.Vector2();
+ this.pressAt = null;
+ this.onPointerDown = this.handlePointerDown.bind(this);
+ this.onPointerUp = this.handlePointerUp.bind(this);
+ domElement.addEventListener('pointerdown', this.onPointerDown);
+ domElement.addEventListener('pointerup', this.onPointerUp);
+ }
+
+ /* ------------------------------------------------------------ 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;
+ }
+ if (!silent) this.onChange?.(this.selected);
+ }
+
+ selectAt(event) {
+ const rect = this.domElement.getBoundingClientRect();
+ this.pointer.set(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
+ );
+ this.raycaster.setFromCamera(this.pointer, this.controls.camera);
+
+ const targets = this.pickTargets().filter((mesh) => mesh.visible);
+ const hits = this.raycaster.intersectObjects(targets, false);
+ if (!hits.length) return null;
+
+ const point = hits[0].point;
+ let best = null;
+ let bestDistance = Infinity;
+ const world = new THREE.Vector3();
+ for (const entry of this.bones) {
+ entry.bone.getWorldPosition(world);
+ const distance = world.distanceToSquared(point);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = entry.name;
+ }
+ }
+ if (best) this.select(best);
+ return best;
+ }
+
+ handlePointerDown(event) {
+ // Remember where the press started so a drag never changes the selection.
+ this.pressAt = { x: event.clientX, y: event.clientY, axis: this.controls.axis };
+ }
+
+ handlePointerUp(event) {
+ const press = this.pressAt;
+ this.pressAt = null;
+ if (!press || press.axis) return; // pressed on the gizmo, not the model
+ if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) return;
+ this.selectAt(event);
+ }
+
+ /* ----------------------------------------------------------------- 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 euler = new THREE.Euler().setFromQuaternion(delta, 'XYZ');
+ return { x: euler.x * DEG, y: euler.y * DEG, z: euler.z * DEG };
+ }
+
+ 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);
+ }
+
+ /** `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;
+ }
+
+ dispose() {
+ this.domElement.removeEventListener('pointerdown', this.onPointerDown);
+ this.domElement.removeEventListener('pointerup', this.onPointerUp);
+ 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..2ca7148
--- /dev/null
+++ b/public/bluebey-studio/src/style.css
@@ -0,0 +1,673 @@
+: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%;
+ 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); }
+
+#viewport-hint {
+ position: fixed;
+ left: 18px;
+ bottom: 14px;
+ z-index: 10;
+ margin: 0;
+ font-size: 11.5px;
+ color: #8a80a0;
+ pointer-events: 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; }
+
+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); }
+}
+
+/* ----------------------------------------------------------------- sections */
+
+.tabs {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ display: flex;
+ gap: 4px;
+ padding: 2px 0 10px;
+ margin: -2px 0 4px;
+ background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0));
+}
+
+.tab {
+ flex: 1;
+ font: inherit;
+ font-size: 12px;
+ padding: 8px 2px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: #fff;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.tab:hover { border-color: var(--accent-2); }
+.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
+.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-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; }
+.subhead {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: #8d80ab;
+ text-transform: uppercase;
+ margin-top: 3px;
+}
+.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;
+}
diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js
new file mode 100644
index 0000000..d7bd7b9
--- /dev/null
+++ b/public/bluebey-studio/src/styles.js
@@ -0,0 +1,350 @@
+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 });
+ 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;
+ }
+
+ 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;
+ }
+
+ /** 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`; 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.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);
+ }
+
+ 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();
+ this.invisibleMaterial.dispose();
+ this.gradientMap.dispose();
+ for (const material of this.toonMaterials.values()) material.dispose();
+ this.toonMaterials.clear();
+ }
+}
+
+/** 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..71f79b4
--- /dev/null
+++ b/public/bluebey-studio/src/ui.js
@@ -0,0 +1,326 @@
+/**
+ * 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 } = {}) {
+ const body = h('div', { class: 'sec-body' });
+ const head = h('button', { type: 'button', class: 'sec-head' },
+ 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.setAttribute('aria-hidden', 'true');
+ // Sit the icon on the label's text baseline; the span and the text node keep
+ // the two from touching.
+ 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;
+}
+
+/**
+ * Tabbed container: returns the per-tab panels to fill in. Each definition may
+ * carry an `icon` (an SVG path `d`); the label stays visible next to it.
+ */
+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' },
+ definition.icon ? tabIcon(definition.icon) : null,
+ definition.icon ? ' ' : null,
+ 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..a3a7e48
--- /dev/null
+++ b/public/bluebey-studio/src/urlState.js
@@ -0,0 +1,162 @@
+/**
+ * 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.
+ */
+
+/** 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;
+}
+
+/** 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(stripForUrl(state) ?? null);
+ 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: 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' });
+}