diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-09 09:47:24 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-09 09:47:24 +0900 |
| commit | d5b8d58a82a040a2facb5d226b64648aa66b2629 (patch) | |
| tree | b9c90118b304dc62aecd1241c5aa7f423cfece3f /public/bluebey-studio/src | |
| parent | 74326a4c4d433dbc113fa0748f14ad0337258776 (diff) | |
bluebey-studio: /bluebey-studio/ をテスト公開用にBasic認証つきで復帰
準拠版アプリを public/bluebey-studio/ に戻し、.htaccess でBasic認証(ユーザー bluebey)を設定。/bluebey-studio/ のみ保護し他は素通し。本公開は市の確認後に認証を外す想定。
Diffstat (limited to 'public/bluebey-studio/src')
27 files changed, 15188 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js new file mode 100644 index 0000000..e569d12 --- /dev/null +++ b/public/bluebey-studio/src/animation.js @@ -0,0 +1,204 @@ +/** + * The "living" layer: idle body motion, blinking and idle eye drift. + * + * These never touch the saved state. `pose()` returns the base pose with the + * idle offsets added, and `eyeOpen`/`look` are multipliers the caller merges + * into the face parameters, so turning the animation off restores exactly the + * pose and expression the user had before. + */ + +export class Animator { + constructor({ state }) { + this.state = state; + this.time = 0; + this.blinkPhase = -1; + this.blinkDuration = 0.17; + this.blinkTimer = 2 + Math.random() * 1.5; + this.eyeOpen = 1; + this.look = { x: 0, y: 0 }; + this.idleWeight = 0; + this.snotScale = 1; + } + + reset() { + this.eyeOpen = 1; + this.look = { x: 0, y: 0 }; + this.blinkPhase = -1; + this.blinkTimer = 2; + this.time = 0; + this.snotScale = 1; + } + + update(dt) { + const speed = clamp(this.state.anim.speed ?? 1, 0.1, 3); + const step = Math.min(dt, 0.1) * speed; + this.time += step; + + const moving = (this.state.anim.mode ?? 'off') !== 'off'; + this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4); + + // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks + // again. It is a real object (see src/main.js), so the motion is published here + // as a plain multiplier the render loop applies to the bubble's scale. + this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep' + ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3)) + : 1; + + this.updateBlink(step); + this.updateLook(step); + } + + updateBlink(dt) { + if ((this.state.anim.mode ?? 'off') === 'sleep') { + // Asleep: the eyes stay shut, whatever the blink setting says. + this.eyeOpen = 0; + this.blinkPhase = -1; + return; + } + if (!this.state.anim.blink) { + this.eyeOpen = 1; + this.blinkPhase = -1; + return; + } + if (this.blinkPhase >= 0) { + this.blinkPhase += dt / this.blinkDuration; + if (this.blinkPhase >= 1) { + this.blinkPhase = -1; + this.eyeOpen = 1; + const base = Math.max(0.5, this.state.anim.blinkInterval ?? 3.4); + // Blink again sooner sometimes, so it does not look metronomic. + this.blinkTimer = base * (0.55 + Math.random()); + if (Math.random() < 0.22) this.blinkTimer *= 0.35; // occasional double blink + } else { + this.eyeOpen = 1 - Math.pow(Math.sin(Math.PI * this.blinkPhase), 0.8); + } + return; + } + this.blinkTimer -= dt; + if (this.blinkTimer <= 0) this.blinkPhase = 0; + } + + updateLook(dt) { + if (!this.state.anim.lookAround) { + this.look.x = 0; + this.look.y = 0; + return; + } + const t = this.time; + this.look.x = Math.sin(t * 0.37) * 0.3 + Math.sin(t * 0.13 + 1.7) * 0.14; + this.look.y = Math.sin(t * 0.29 + 0.6) * 0.2; + } + + /** Base pose plus the current movement offsets; `pose` is `{ bones, root }`. */ + pose(basePose = {}) { + const bones = { ...(basePose.bones ?? {}) }; + const root = [...(basePose.root ?? [0, 0, 0])]; + const mode = this.state.anim.mode ?? 'off'; + if (mode === 'off' || this.idleWeight <= 0.001) return { bones, root }; + + const w = this.idleWeight; + const t = this.time; + const add = (name, dx, dy, dz) => { + const current = bones[name] ?? [0, 0, 0]; + bones[name] = [current[0] + dx * w, current[1] + dy * w, current[2] + dz * w]; + }; + + if (mode === 'walk') { + // A waddle for a character with no legs: a step bob, a side-to-side rock + // and alternating arms and feet. + // + // Z is the forward/back swing, and it is mirrored between the sides - so the + // SAME value on both arms swings them opposite ways, which is what a walk + // wants. X (which the first version used) is the *lift*, so opposite signs + // there just raised one flipper and dropped the other one. + // `legsupport` pivots at the middle of the body, so the legs take a much + // smaller angle than the arms. The legs swing on the OPPOSITE phase to the + // arms, so when a hand comes forward it is the opposite foot that steps out + // (the same value as the arms put them in step, i.e. a "same-side" waddle). + const phase = Math.sin(t * 2.4); + const bob = Math.abs(Math.sin(t * 2.4)); + add('master', -2, 0, phase * 4); + add('arm.l', 6, 0, phase * 26); + add('arm.r', 6, 0, phase * 26); + add('hand.l', 0, 0, phase * 10); + add('hand.r', 0, 0, phase * 10); + add('legsupport.l', 0, 0, -phase * 9); + add('legsupport.r', 0, 0, -phase * 9); + root[1] += bob * 0.05; + return { bones, root }; + } + + if (mode === 'wave') { + // One flipper is held up and sweeps back and forth, with the hand lagging a + // little behind it; the body and the other flipper rock along gently. The + // lift is kept modest: raising the arm much further folds the flipper over + // the top of the head, where its inner edge cuts into the face, so the X + // here (and its swing) stop well short of that. The Z swing is what reads + // as the wave, and it is biased slightly *back* for the same reason. + const wave = Math.sin(t * 3.2); + add('arm.l', 66 + wave * 8, 0, 6 + wave * 10); + add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 14); + add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4); + add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3); + return { bones, root }; + } + + if (mode === 'sleep') { + // Slow breathing: the body rises and settles, the flippers drift, and the + // bubble swells and shrinks (that swell is `snotScale`, read by the loop). + const breath = 0.5 - 0.5 * Math.cos(t * 1.3); + add('master', Math.sin(t * 0.65) * 1.1, 0, 0); + add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5); + add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5); + root[1] += breath * 0.03; + return { bones, root }; + } + + // Default: gentle breathing, as if standing there alive. + add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6); + add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5); + add('arm.r', 0, 0, -Math.sin(t * 1.28 + 0.5) * 4.5); + add('hand.l', 0, 0, Math.sin(t * 1.05 + 1) * 3); + add('hand.r', 0, 0, -Math.sin(t * 1.05 + 1) * 3); + root[1] += Math.sin(t * 1.5) * 0.022; + return { bones, root }; + } +} + +/** + * Records the viewport canvas to a WebM blob while the animation plays. + * Chrome and Edge support `MediaRecorder` on `canvas.captureStream()`. + */ +export function createRecorder(canvas) { + if (typeof MediaRecorder === 'undefined' || !canvas.captureStream) return null; + const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'] + .find((type) => MediaRecorder.isTypeSupported?.(type)); + return { canvas, mimeType: mimeType ?? '', recorder: null, chunks: [] }; +} + +export function startRecording(session) { + if (!session) return false; + const stream = session.canvas.captureStream(30); + const recorder = session.mimeType + ? new MediaRecorder(stream, { mimeType: session.mimeType, videoBitsPerSecond: 8000000 }) + : new MediaRecorder(stream); + session.chunks = []; + session.recorder = recorder; + recorder.ondataavailable = (event) => { if (event.data?.size) session.chunks.push(event.data); }; + recorder.start(100); + return true; +} + +export function stopRecording(session) { + return new Promise((resolve) => { + if (!session?.recorder || session.recorder.state === 'inactive') { + resolve(null); + return; + } + const { recorder, chunks } = session; + recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' })); + recorder.stop(); + }); +} + +const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js new file mode 100644 index 0000000..adc7f94 --- /dev/null +++ b/public/bluebey-studio/src/background.js @@ -0,0 +1,666 @@ +/** + * The studio backdrop, as a DOM layer that sits behind the WebGL canvas. + * + * The renderer is created with `alpha: true`, so whenever it is cleared to + * alpha 0 the page shows through. Painting the backdrop into a sibling layer + * placed *under* `#view` therefore gives the character a scene without touching + * the scene graph: the CC0 photo, the user's own picture and the phone's camera + * feed are all just CSS on one element, and no texture has to be uploaded per + * frame. + * + * `backdropStyle()` is deliberately pure. It is the only place that turns the + * `view.background*` slice into a CSS descriptor, so the behaviour `apply()` + * ships with is exactly the behaviour the unit tests cover. + */ + +/** The library already downloaded into `assets/backgrounds/` (see CREDITS.json). + * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes + * with a floor, a horizon and perspective, which is what gives a picture depth + * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the + * exceptions: original illustrations drawn for this studio by the author, not + * CC0 photos. */ +export const BACKGROUND_PRESETS = [ + { name: 'empty_warehouse_01', label: '倉庫' }, + { name: 'ballroom', label: '広間' }, + { name: 'kloppenheim_06_puresky', label: '空と雲' }, + { name: 'autumn_park', label: '秋の公園' }, + // The places a councillor explains things in, or stands in to make a point. + // Poly Haven has no true classroom, desert or 社長室, so these are the closest + // real places it does have. + { name: 'newman_cafeteria', label: '学校' }, + { name: 'wooden_lounge', label: '社長室(木の部屋)' }, + { name: 'minedump_flats', label: '砂漠' }, + { name: 'spiaggia_di_mondello', label: '海岸' }, + // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。 + { name: 'mutoujima', label: '無人島' }, + { name: 'dokan', label: '土管のある空き地' }, + { name: 'uchu', label: '宇宙船の中' }, + // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。 + { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' }, + { name: 'basement_boxing_ring', label: '地下室のリング' }, + { name: 'autoshop_01', label: '自動車工場' }, + { name: 'urban_alley_01', label: '路地' }, +]; + +const DEFAULT_PRESET = 'autumn_park'; + +/** + * Manga effect-line backdrops, drawn *procedurally* (no file, no network). + * + * They are the classic stress marks: a burst of lines from behind the character + * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and + * short lines ringing a wide ellipse so the empty middle reads as isolation + * (`ellipse`). + * Each is a canvas painted black on white; the same generator runs for the + * preview, the PNG and the standalone build. + */ +export const EFFECT_PRESETS = [ + { name: 'focus', label: '集中線' }, + { name: 'fall', label: '落ち込み線' }, + { name: 'speed', label: '疾走線' }, + { name: 'ellipse', label: '楕円集中線' }, +]; + +const DEFAULT_EFFECT = 'focus'; + +/** The canvas every effect is painted on, so preview and export agree. */ +export const EFFECT_SIZE = { width: 1200, height: 800 }; + +/** + * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks + * the same on every run, in every build, and in the tests. + */ +function makeRandom(seedText) { + let seed = 2166136261; + for (let i = 0; i < seedText.length; i++) { + seed ^= seedText.charCodeAt(i); + seed = Math.imul(seed, 16777619); + } + return () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the + * same name and size it always issues the same black-on-white strokes, so the + * unit tests can pin the look down and the cache below stays sound. + * + * @param {CanvasRenderingContext2D} ctx + * @param {'focus'|'fall'|'speed'|'ellipse'} name + * @param {{width:number,height:number}} [size] + */ +export function drawEffectLines(ctx, name, size = EFFECT_SIZE) { + const width = positiveOrOne(size?.width); + const height = positiveOrOne(size?.height); + const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; + const random = makeRandom(kind); + + ctx.save(); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = '#111111'; + ctx.lineCap = 'butt'; + + if (kind === 'focus') { + // A burst: lines radiate from a point behind the head, and stop short of it + // so the character's face is not crossed by ink. + const cx = width * 0.5; + const cy = height * 0.42; + const reach = Math.hypot(width, height) * 1.1; + const near = Math.min(width, height); + for (let i = 0; i < 150; i++) { + const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03; + const inner = near * (0.12 + random() * 0.5); + ctx.lineWidth = 1 + random() * 4; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner); + ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach); + ctx.stroke(); + } + } else if (kind === 'fall') { + // Lines raining down from the top edge, of uneven length. + for (let i = 0; i < 90; i++) { + const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8)); + const length = height * (0.25 + random() * 0.7); + ctx.lineWidth = 1 + random() * 3; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, length); + ctx.stroke(); + } + } else if (kind === 'speed') { + // Lines streaming in from one side or the other. + for (let i = 0; i < 70; i++) { + const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6)); + const length = width * (0.35 + random() * 0.65); + const x = random() < 0.5 ? 0 : width - length; + ctx.lineWidth = 1 + random() * 3.5; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + length, y); + ctx.stroke(); + } + } else if (kind === 'ellipse') { + // A dense ring of short lines *outside* a wide ellipse: the blank inside is + // the point (the 「ひとり」 panel), so nothing is drawn there. The start + // points are spaced by arc length, not by angle - an equal angular step + // bunches the lines at the two ends of a wide ellipse and leaves gaps along + // the flat top and bottom, which is what made the old ring look uneven. Each + // line then leaves along the ellipse's outward normal, so the inner edge + // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and + // every line is anchored to the ring, so none reads as a stray scratch. + const cx = width * 0.5; + const cy = height * 0.5; + const rx = Math.min(width * 0.42, height * 0.9); + const ry = rx * 0.55; + const lines = 200; + const reach = Math.min(width, height) * 0.24; + + // Cumulative arc length around the ring, so the lines can be spread evenly + // around it rather than evenly by angle. + const samples = 512; + const arc = [0]; + for (let s = 1; s <= samples; s++) { + const prev = ((s - 1) / samples) * Math.PI * 2; + const next = (s / samples) * Math.PI * 2; + const dx = (Math.cos(next) - Math.cos(prev)) * rx; + const dy = (Math.sin(next) - Math.sin(prev)) * ry; + arc.push(arc[s - 1] + Math.hypot(dx, dy)); + } + const total = arc[samples]; + + let s = 1; + for (let i = 0; i < lines; i++) { + const target = ((i + 0.5) / lines) * total; + while (s < samples && arc[s] < target) s++; + const span = arc[s] - arc[s - 1] || 1; + const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2; + const cos = Math.cos(t); + const sin = Math.sin(t); + // The outward normal of an ellipse points along (cos/rx, sin/ry), not along + // the radius from the centre, so the lines meet the ring at a right angle. + const nx = cos / rx; + const ny = sin / ry; + const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1); + ctx.lineWidth = 1 + random() * 3.5; + const startX = cx + cos * rx; + const startY = cy + sin * ry; + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.lineTo(startX + nx * length, startY + ny * length); + ctx.stroke(); + } + } + ctx.restore(); +} + +/** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */ +const FIT_STYLES = { + cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' }, + contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' }, + stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' }, + tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' }, +}; +const DEFAULT_FIT = 'cover'; + +/** A live frame is a replaced element, so the same fit maps to `object-fit`. */ +const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' }; + +const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']); + +/** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */ +const GYRO_INTERVAL_MS = 33; + +/** Coerce to a finite number, or `null` when the value is not one. */ +function finiteOrNull(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function normalizeMode(mode) { + return MODES.has(mode) ? mode : 'solid'; +} + +function clamp01(value) { + const n = finiteOrNull(value); + if (n == null) return 0; + return Math.min(1, Math.max(0, n)); +} + +function nonNegative(value) { + const n = finiteOrNull(value); + return n == null ? 0 : Math.max(0, n); +} + +function positiveOrOne(value) { + const n = finiteOrNull(value); + return n == null || n <= 0 ? 1 : n; +} + +/** The shortest signed distance between two angles, in degrees. */ +function wrapDeg(degrees) { + return ((degrees + 540) % 360) - 180; +} + +/** + * Turn the `view` slice into the plain descriptor `apply()` renders from. + * Every field is optional, so a half-written state still yields a usable look; + * an unknown `background` falls back to `solid` and an unknown `backgroundFit` + * to `cover`. + * + * `offset` is in fractions of the layer size, and `visible` is false only for + * `transparent` (where the page background is meant to show through). + */ +export function backdropStyle(viewState) { + const state = viewState && typeof viewState === 'object' ? viewState : {}; + const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT]; + const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object' + ? state.backgroundOffset + : {}; + + return { + backgroundSize: fit.backgroundSize, + backgroundRepeat: fit.backgroundRepeat, + mirror: state.cameraMirror === true, + darken: clamp01(state.backgroundDarken), + blur: nonNegative(state.backgroundBlur), + offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 }, + scale: positiveOrOne(state.backgroundScale), + visible: normalizeMode(state.background) !== 'transparent', + }; +} + +/** `assets/backgrounds/<name>.webp` - what the multi-file build serves. */ +function defaultResolveUrl(name) { + return `assets/backgrounds/${name}.webp`; +} + +/** + * Build the backdrop layer for a `#stage` element. + * + * `resolveUrl(name)` maps a preset name to an image URL; the single-file build + * passes one that reads an inlined map, while the default points at + * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so + * the host can repaint. + * + * The returned object is the only way in: the layer never reads or writes the + * studio state itself, `apply(viewState)` is the single entry point. + */ +export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { + if (!stage || typeof stage.prepend !== 'function') { + throw new Error('createBackdrop: ステージ要素(#stage)が必要です'); + } + + const doc = stage.ownerDocument ?? globalThis.document; + const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl; + const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {}; + + const el = doc.createElement('div'); + el.className = 'backdrop'; + el.setAttribute('aria-hidden', 'true'); + Object.assign(el.style, { + position: 'absolute', + inset: '0', + zIndex: '0', + overflow: 'hidden', + pointerEvents: 'none', + transformOrigin: 'center', + }); + + // The image surface is a plain div so blur/scale/tint stay pure CSS. + const media = doc.createElement('div'); + Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' }); + + // The camera feed is a muted, inline, autoplaying video for the AR mode. + const video = doc.createElement('video'); + video.muted = true; + video.playsInline = true; + video.autoplay = true; + video.setAttribute('muted', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('aria-hidden', 'true'); + Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' }); + + // The dark veil sits on top of whichever surface is showing, so the character + // keeps its contrast against a busy photo. + const veil = doc.createElement('div'); + Object.assign(veil.style, { position: 'absolute', inset: '0' }); + + el.append(media, video, veil); + // Prepended, i.e. before the canvas; the canvas is lifted back above it. + stage.prepend(el); + + const canvas = stage.querySelector('canvas'); + if (canvas) { + canvas.style.position = 'relative'; + canvas.style.zIndex = '1'; + } + + /** The most recent state slice, so the imperative helpers keep its styling. */ + let last = {}; + let stream = null; + + let gyroEnabled = false; + let gyroAttached = false; + let gyroBase = null; + let gyroCallback = null; + let lastGyroAt = 0; + + const effectUrls = new Map(); + + /** The data URL for an effect-line backdrop, generated once and cached. */ + function effectUrl(name) { + const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT; + if (effectUrls.has(key)) return effectUrls.get(key); + const surface = doc.createElement('canvas'); + surface.width = EFFECT_SIZE.width; + surface.height = EFFECT_SIZE.height; + const surfaceCtx = surface.getContext('2d'); + if (!surfaceCtx) return null; + drawEffectLines(surfaceCtx, key, EFFECT_SIZE); + const url = surface.toDataURL('image/png'); + effectUrls.set(key, url); + return url; + } + + function imageUrlOf(mode, viewState) { + if (mode === 'image') { + return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage + ? viewState.backgroundImage + : null; + } + if (mode === 'effect') { + const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect + ? viewState.backgroundEffect + : DEFAULT_EFFECT; + return effectUrl(name); + } + if (mode !== 'preset') return null; + + const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset + ? viewState.backgroundPreset + : DEFAULT_PRESET; + try { + const url = resolve(name); + if (typeof url === 'string' && url) return url; + } catch { + // Fall through to the file path: an unknown preset is not worth throwing. + } + return defaultResolveUrl(name); + } + + function colorOr(value) { + return typeof value === 'string' && value ? value : '#ffffff'; + } + + /** + * Fit, mirror, blur and the blur bleeding past the edges (the negative inset + * keeps `filter: blur()` from fading the border to transparent). + */ + function styleSurface(node, style) { + node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0'; + node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none'; + node.style.transform = style.mirror ? 'scaleX(-1)' : 'none'; + + if (node === media) { + node.style.backgroundSize = style.backgroundSize; + node.style.backgroundRepeat = style.backgroundRepeat; + } else { + node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover'; + } + } + + function apply(viewState) { + last = viewState && typeof viewState === 'object' ? viewState : {}; + const style = backdropStyle(last); + + const wanted = normalizeMode(last.background); + const url = imageUrlOf(wanted, last); + // A preset that cannot be resolved is shown as a plain colour, never blank. + const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect'; + const mode = drawable && !url ? 'solid' : wanted; + + el.style.display = style.visible ? 'block' : 'none'; + el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent'; + el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0 + ? 'none' + : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`; + + veil.style.background = `rgba(0, 0, 0, ${style.darken})`; + veil.style.display = style.darken > 0 ? 'block' : 'none'; + + const showCamera = style.visible && mode === 'camera'; + const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect'); + // Leaving the AR mode must release the camera, not just hide the video. + if (!showCamera && stream) stopCamera(); + + styleSurface(media, style); + styleSurface(video, style); + media.style.display = showImage ? 'block' : 'none'; + video.style.display = showCamera ? 'block' : 'none'; + media.style.backgroundImage = showImage ? `url("${url}")` : 'none'; + + needsRender(); + } + + /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */ + function loadPreset(name) { + const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET; + apply({ ...last, background: 'preset', backgroundPreset: preset }); + } + + /** Apply a stored data URL (as kept in `view.backgroundImage`). */ + function applyImage(dataUrl) { + if (typeof dataUrl !== 'string' || !dataUrl) { + apply({ ...last, background: 'solid', backgroundImage: null }); + return; + } + apply({ ...last, background: 'image', backgroundImage: dataUrl }); + } + + /** Read a user File into a data URL, show it, and hand the URL back to save. */ + async function loadImageFile(file) { + const dataUrl = await new Promise((resolveUrl, reject) => { + const reader = new FileReader(); + reader.onload = () => resolveUrl(String(reader.result)); + reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした')); + reader.readAsDataURL(file); + }); + applyImage(dataUrl); + return dataUrl; + } + + /* Shown whenever the page is not a secure context. This is a fact about the + * web platform, not about any one browser, so the wording must not point a + * finger at the browser the user happens to be holding. */ + const INSECURE_CAMERA_REASON = + 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。'; + + /** + * Japanese, actionable text for the ways `getUserMedia` usually fails. On a + * phone this toast is all the user has to go on, so each case names the exact + * thing to tap instead of just stating that something went wrong. + */ + function cameraFailureReason(error) { + switch (error?.name) { + case 'NotAllowedError': + case 'PermissionDeniedError': + return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。'; + case 'NotFoundError': + case 'DevicesNotFoundError': + return '使えるカメラが見つかりませんでした'; + case 'NotReadableError': + case 'TrackStartError': + return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)'; + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + return '指定したカメラを使用できません'; + case 'SecurityError': + // No `mediaDevices` at all is the usual symptom, but this covers the + // same cause when a sandboxed frame raises the error instead. + return INSECURE_CAMERA_REASON; + default: + return 'カメラを起動できませんでした'; + } + } + + async function startCamera(facing = 'environment') { + const mediaDevices = globalThis.navigator?.mediaDevices; + if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { + // An insecure page has no `mediaDevices` at all. Say so plainly rather + // than leaving the user to think their camera or browser is broken. + return { + ok: false, + reason: globalThis.isSecureContext === false + ? INSECURE_CAMERA_REASON + : 'カメラを使うには https か localhost が必要です', + }; + } + + stopCamera(); + try { + const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } }); + stream = next; + video.srcObject = next; + try { + await video.play(); + } catch { + // A blocked autoplay still resolves to a painting stream in practice. + } + apply({ ...last, background: 'camera', cameraFacing: facing }); + return { ok: true }; + } catch (error) { + stopCamera(); + return { ok: false, reason: cameraFailureReason(error) }; + } + } + + function stopCamera() { + const active = stream; + stream = null; + if (active) { + try { + for (const track of active.getTracks()) track.stop(); + } catch { + // A track that cannot be stopped is already gone as far as we care. + } + } + if (video) { + try { + video.pause(); + } catch { + // Pausing a video without a source is expected to throw. + } + video.srcObject = null; + video.style.display = 'none'; + } + } + + function emitGyro(yaw, pitch, roll) { + gyroCallback?.({ yaw, pitch, roll }); + } + + /** + * Attach the orientation listener and take the current pose as zero, so the + * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`. + */ + function enableGyro() { + gyroEnabled = true; + gyroBase = null; + if (!gyroAttached && typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('deviceorientation', handleOrientation); + gyroAttached = true; + } + emitGyro(0, 0, 0); + } + + /** + * Degrees of turn from the pose gyro started at. `yaw` grows clockwise + * (turning the device to the right); `pitch` and `roll` follow the raw + * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz. + */ + function handleOrientation(event) { + if (!gyroEnabled) return; + + const alpha = typeof event?.alpha === 'number' ? event.alpha : null; + const beta = typeof event?.beta === 'number' ? event.beta : null; + const gamma = typeof event?.gamma === 'number' ? event.gamma : null; + if (alpha == null && beta == null && gamma == null) return; + + if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 }; + + const now = Date.now(); + if (now - lastGyroAt < GYRO_INTERVAL_MS) return; + lastGyroAt = now; + + emitGyro( + alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha), + beta == null ? 0 : wrapDeg(beta - gyroBase.beta), + gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma), + ); + } + + /** Ask for orientation permission (iOS) and start listening. */ + async function requestGyro() { + const OrientationEvent = globalThis.DeviceOrientationEvent; + if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false; + + if (typeof OrientationEvent.requestPermission === 'function') { + let granted = false; + try { + granted = (await OrientationEvent.requestPermission()) === 'granted'; + } catch { + granted = false; + } + if (!granted) return false; + } + + enableGyro(); + return true; + } + + /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */ + function onGyro(callback) { + gyroCallback = typeof callback === 'function' ? callback : null; + if (!gyroEnabled) enableGyro(); + return () => { + if (gyroCallback === callback) gyroCallback = null; + }; + } + + function dispose() { + stopCamera(); + if (gyroAttached) { + globalThis.removeEventListener?.('deviceorientation', handleOrientation); + gyroAttached = false; + } + gyroEnabled = false; + gyroCallback = null; + gyroBase = null; + el.remove(); + } + + return { + el, + apply, + loadPreset, + loadImageFile, + applyImage, + startCamera, + stopCamera, + /** The data URL of a drawn effect line, so exports can composite the same + * bitmap the preview shows. */ + effectUrl, + get cameraActive() { + return stream !== null; + }, + requestGyro, + onGyro, + snapshot() {}, + dispose, + }; +} diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js new file mode 100644 index 0000000..4fdcfd9 --- /dev/null +++ b/public/bluebey-studio/src/clip.js @@ -0,0 +1,202 @@ +import * as THREE from 'three'; + +/** + * 見えない壁 (the invisible wall): a clipping plane that hides whatever falls + * behind it, so the character can be buried in the wall and only the rest of the + * body shows. + * + * The wall is a *finite* rectangle: an invisible quad that writes depth and no + * colour, drawn before everything else in the opaque pass, so the character + * behind it is culled by the depth test. That is what a real wall does, and it + * is the only way to bound the effect - a `renderer.clippingPlanes` entry is an + * infinite half-space, so it can never be limited to a rectangle. + * + * Nothing needs to agree per-material: the depth buffer does the work, so the + * body, the outline hulls, the face plates, the ink pass and the offscreen + * renders an export uses are all hidden by the same wall. + * + * `wallPlane` is pure (no three.js maths), so the geometry can be unit-tested + * without a renderer. + */ + +const DEG = Math.PI / 180; + +/** + * A hair toward the kept side. The guide sits exactly on the cut, and a plane + * on its own boundary is half inside the discarded half - nudging it along the + * normal keeps all of it on the visible side. + */ +const GUIDE_EPSILON = 0.002; + +/** + * The plane a wall setting lies in. + * + * The wall is a *finite* quad, so this no longer decides which half of space is + * hidden - the quad's own depth does that. What `apply` needs from here is the + * normal, which is the direction the quad faces (and so the direction the wall + * lies along), plus the constant of the plane through the point it sits on. + * + * By default the wall lies in the XY plane through `(x, y, z)`; `yaw` turns it + * about Y and `tilt` leans it about X afterwards. + * + * @param {{x?:number,y?:number,z?:number,yaw?:number,tilt?:number}} [wall] + * @returns {{normal:[number,number,number], constant:number}} + */ +export function wallPlane(wall = {}) { + const yaw = (wall.yaw ?? 0) * DEG; + const tilt = (wall.tilt ?? 0) * DEG; + const cosYaw = Math.cos(yaw); + const sinYaw = Math.sin(yaw); + const cosTilt = Math.cos(tilt); + const sinTilt = Math.sin(tilt); + + // Start from +Z, turn about Y, then lean about the (already turned) X axis. + let nx = sinYaw; + let ny = -cosYaw * sinTilt; + let nz = cosYaw * cosTilt; + + const length = Math.hypot(nx, ny, nz) || 1; + nx /= length; + ny /= length; + nz /= length; + + // Constant so the plane passes through (x, y, z): dot(n, p) + c = 0. + const constant = -(nx * (wall.x ?? 0) + ny * (wall.y ?? 0) + nz * (wall.z ?? 0)); + return { normal: [nx, ny, nz], constant }; +} + +/** The faint plane shown while placing the wall. Hidden from every export. */ +/** + * The wall itself: an invisible, *finite* quad that writes depth but no colour. + * + * It is drawn before everything else in the opaque pass (`renderOrder`), so the + * character behind it is culled by the depth test. That is what a real wall does, + * and - unlike a clip plane, which is an infinite half-space - it only hides what + * the rectangle actually covers. So the size sliders are the wall's real size. + */ +function makeWall() { + const material = new THREE.MeshBasicMaterial({ + colorWrite: false, + side: THREE.DoubleSide, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + mesh.name = 'wall'; + // Before everything, including the silhouette-only hulls (renderOrder -1), so + // its depth hides a nose buried in the wall (see styles.js hullMaterialFor). + mesh.renderOrder = -2; + mesh.visible = false; + mesh.castShadow = false; + mesh.receiveShadow = false; + return mesh; +} + +function makeGuide() { + const geometry = new THREE.PlaneGeometry(1, 1); + const material = new THREE.MeshBasicMaterial({ + color: 0x8a4fe0, + transparent: true, + opacity: 0.16, + side: THREE.DoubleSide, + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'wall-guide'; + mesh.userData.isHelper = true; + mesh.visible = false; + + // An edge, so the plane's extent is legible even where the fill is faint. + const edge = new THREE.LineSegments( + new THREE.EdgesGeometry(geometry), + new THREE.LineBasicMaterial({ color: 0x8a4fe0, transparent: true, opacity: 0.55, toneMapped: false }), + ); + edge.userData.isHelper = true; + mesh.add(edge); + return mesh; +} + +export function createClipper({ scene, renderer, model }) { + // A little larger than the character: big enough to read as a wall, small + // enough not to cover the whole viewport. + const guideSpan = Math.max(model.size.x, model.size.y, model.size.z) * 1.5; + const FROM = new THREE.Vector3(0, 0, 1); // the plane the quad starts in + + function makeSlot() { + const occluder = makeWall(); + const guide = makeGuide(); + scene.add(guide); + scene.add(occluder); + return { occluder, guide, normal: new THREE.Vector3(0, 0, 1) }; + } + // Two walls. Each slot keeps its own rectangle, so the two are independent. + const slots = [makeSlot(), makeSlot()]; + + /** + * @param {Array<object>} walls up to two `state.render.wall` settings, in slot + * order. + * @param {{x?:number,y?:number,z?:number}} [offset] the character's own + * translation. A wall's position is stored *relative to the character*, so + * adding this makes the walls travel with the body when it is moved. + * @param {number} [yaw] the character's own turn about Y, in radians. The stored + * position is rotated by it (and added to the wall's own `yaw`), so a wall + * stays glued to the body when the character is turned. + * + * The guide is drawn only when `wall.guide` is on, which is also when the wall + * can be grabbed in the viewport. Off, the wall is invisible: the cut still + * applies, so you can see the character half-hidden with nothing in the way. + */ + function apply(walls, offset, yaw = 0) { + const list = Array.isArray(walls) ? walls : [walls]; + const ox = offset?.x ?? 0; + const oy = offset?.y ?? 0; + const oz = offset?.z ?? 0; + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const yawDeg = (yaw * 180) / Math.PI; + slots.forEach((slot, index) => { + const wall = list[index]; + if (!wall || wall.on !== true) { + slot.occluder.visible = false; + slot.guide.visible = false; + return; + } + // The stored position is relative to the character, so it turns with the + // body: rotate (x, z) about Y by the character's yaw, then add the offset. + const lx = wall.x ?? 0; + const lz = wall.z ?? 0; + const x = cos * lx + sin * lz + ox; + const y = (wall.y ?? 0) + oy; + const z = -sin * lx + cos * lz + oz; + const { normal } = wallPlane({ yaw: (wall.yaw ?? 0) + yawDeg, tilt: wall.tilt, x, y, z }); + slot.normal.set(normal[0], normal[1], normal[2]); + const span = guideSpan * Math.max(0.05, wall.size ?? 1); + // The wall and its guide are the same rectangle, turned to face along the + // plane's normal: the wall is the occluder, the guide is the tinted copy + // that is shown only while placing. + for (const mesh of [slot.occluder, slot.guide]) { + mesh.quaternion.setFromUnitVectors(FROM, slot.normal); + mesh.scale.setScalar(span); + } + slot.occluder.position.set(x, y, z); + slot.occluder.visible = true; + slot.guide.position.set(x, y, z).addScaledVector(slot.normal, GUIDE_EPSILON); + slot.guide.visible = wall.guide === true; + }); + } + + return { + guides: slots.map((slot) => slot.guide), + occluders: slots.map((slot) => slot.occluder), + apply, + dispose() { + for (const slot of slots) { + for (const mesh of [slot.guide, slot.occluder]) { + mesh.geometry.dispose(); + mesh.material.dispose(); + mesh.removeFromParent(); + } + } + }, + }; +} diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js new file mode 100644 index 0000000..ea9356e --- /dev/null +++ b/public/bluebey-studio/src/exporter.js @@ -0,0 +1,306 @@ +import { traceAlphaContours, contoursToPathData } from './trace.js'; +import { roughenContours } from './handDrawn.js'; +import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js'; + +/** + * Export helpers. + * + * Everything renders through the live renderer at a temporary resolution, so an + * export always matches what is on screen (same camera, same framing) and the + * only difference is the pixel size. + */ + +/** Render the current view into an offscreen 2D canvas at an arbitrary size. */ +export function renderStill(view, { width, height, background }) { + const { renderer, scene, camera } = view; + const previousAspect = camera.aspect; + + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + + applyBackground(renderer, background); + // The screen-space outline renders the scene itself, so it replaces the plain + // render rather than following it. (buildSVG passes outlineOptions of its own: + // it wants the ink and nothing else, because the ink is what it traces.) + if (view.outline && view.outlineOptions?.enabled) { + view.outline.setSize(width, height); + view.outline.render(() => renderer.render(scene, camera), { camera, ...view.outlineOptions }); + } else { + renderer.render(scene, camera); + } + + // Copy before restoring: resizing the renderer throws the frame away. + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(renderer.domElement, 0, 0); + + camera.aspect = previousAspect; + camera.updateProjectionMatrix(); + view.restore?.(); + + return canvas; +} + +/** `background` is `{ mode: 'transparent' }` or `{ mode: 'solid', color }`. */ +export function applyBackground(renderer, background) { + if (!background || background.mode === 'transparent') { + renderer.setClearColor(0x000000, 0); + } else { + renderer.setClearColor(background.color ?? '#ffffff', 1); + } +} + +/** A PNG data URL of the current view, scaled up by `scale`. */ +export async function capturePNG(view, { scale = 2, background, width, height }) { + const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280; + const baseHeight = view.height || view.renderer.domElement.clientHeight || 800; + const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale))); + const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale))); + const canvas = renderStill(view, { width: outWidth, height: outHeight, background }); + const blob = await canvasToBlob(canvas); + return { blob, canvas, width: outWidth, height: outHeight }; +} + +/** + * Encode a canvas as a PNG blob. Exported because the caller composites the + * backdrop and the 擬音 stamps onto the rendered canvas afterwards, and then has + * to re-encode it. + */ +export function canvasToBlob(canvas) { + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); +} + +export async function copyCanvasToClipboard(canvas) { + if (!navigator.clipboard || typeof ClipboardItem === 'undefined') { + throw new Error('この環境ではクリップボードにコピーできません'); + } + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); +} + +/** + * Vector line art: render the outline-only style, trace the coverage mask of + * the result and emit one even-odd path. Rendering at a high resolution keeps + * the traced curves smooth. + */ +export function buildSVG(view, { + styles, + face, + faceParams, + width = 2048, + threshold = 0.4, + lineColor = '#111111', + lineWidth = null, + background = null, + handDrawn = null, + outlinePixels = 2, +} = {}) { + const previousStyle = styles.style; + const previousMode = face.styleMode; + + // The `outline` style keeps every body mesh invisible (colorWrite off) and lets + // the outline passes draw the lines, exactly as on screen: the hulls ink the + // body, the screen-space pass inks the leaves. What reaches the trace is + // therefore the ink alone - and a traced stroke comes out as its own outline, + // i.e. two nested contours that `fill-rule="evenodd"` fills as a line of the + // same width. That is what finally gives the leaves, which no inverted hull can + // outline, clean even lines in the vector file too. + // + // `withOutlineFor` in main.js sets that split up for this call, and guards it so + // a renderer without the outline pass still falls back to a hull-only trace. + const screenSpace = Boolean(view.outline); + + styles.setStyle('outline'); + face.setParams(faceParams, 'line'); + face.flush(performance.now(), 0); + + const aspect = (view.height || 800) / (view.width || 1280); + const height = Math.max(64, Math.round(width * aspect)); + // `radius` is in screen pixels, so it has to grow with the render: the SVG is + // drawn far larger than the viewport, and a 2px line would become a hairline. + const scale = width / Math.max(1, view.width || 1280); + const canvas = renderStill( + { + ...view, + outlineOptions: { + enabled: screenSpace, + color: lineColor, + radius: outlinePixels * scale, + }, + }, + { width, height, background: { mode: 'transparent' } }, + ); + + styles.setStyle(previousStyle); + face.setParams(faceParams, previousMode); + face.flush(performance.now(), 0); + + const pixels = canvas.getContext('2d').getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = pixels[i * 4 + 3]; + + let contours = traceAlphaContours(alpha, width, height, { + threshold, + simplifyTolerance: 0.6, + minArea: 5, + }); + // 手描き風: nudge the traced outlines so they read as pen strokes instead of + // the mathematically smooth curves a mask trace produces. + if (handDrawn && handDrawn.amount > 0) { + contours = roughenContours(contours, { + amount: handDrawn.amount, + seed: handDrawn.seed ?? 1, + scale: handDrawn.scale ?? 40, + passes: handDrawn.passes ?? 1, + }); + } + const d = contoursToPathData(contours, (x, y) => [x, y], 2); + + const rect = background + ? `<rect width="${width}" height="${height}" fill="${background}"/>\n` + : ''; + const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : ''; + + return { + contours: contours.length, + svg: `<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> +<title>ぶるべー 線画</title> +${rect}<path fill="${lineColor}"${widthAttr} fill-rule="evenodd" d="${d}"/> +</svg> +`, + }; +} + +/** Alignment guides: blue on the white paper, light enough to paint over. */ +const FACE_MAP_GUIDE = 'rgba(90, 140, 220, 0.55)'; +/** Half-length of the centre ticks, in artwork-window pixels. */ +const FACE_MAP_TICK = 16; + +/** + * Build the「下地」image an author paints a custom face texture on. + * + * The studio loads a hand-drawn image by drawing it at the artwork window's own + * offset and size (see `Face.loadImage` / `placeInWindow`), so an image the size + * of the *plate canvas*, with its artwork aligned to `offsetX/offsetY`, imports + * 1:1. That is exactly what this hands out: the live drawing as a reference, an + * opaque white window to paint on, and faint guides for the eye centres, the lid + * line or the mouth chord. + * + * The white and the guides go on a **copy**: the plate canvas *is* the live + * texture, and painting it would show up in the view. + * + * @param {import('./face.js').Face} face + * @param {'eyes'|'mouth'} kind + * @returns {HTMLCanvasElement} + */ +export function buildFaceMap(face, kind) { + const eyes = kind === 'eyes'; + const layout = eyes ? face.eyeLayout : face.mouthLayout; + const plate = eyes ? face.eyeCanvas : face.mouthCanvas; + + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(plate, 0, 0); + + const { offsetX, offsetY, window: win } = layout; + // `destination-over` so the white paper lands *under* the copy: filling it + // normally would erase the very drawing the author lines the new art up against. + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(offsetX, offsetY, win.width, win.height); + ctx.globalCompositeOperation = 'source-over'; + + // The plate canvas keeps the artwork at its original pixel size (only the canvas + // grows around it), so a fixed-width stroke reads the same on both parts. + ctx.strokeStyle = FACE_MAP_GUIDE; + ctx.fillStyle = FACE_MAP_GUIDE; + ctx.lineWidth = 2; + + // Where the drawing has to fit, and where its middle is. The cross uses short + // ticks, not full-width lines, so it cannot be mistaken for artwork. + ctx.strokeRect(offsetX, offsetY, win.width, win.height); + const cx = offsetX + win.width / 2; + const cy = offsetY + win.height / 2; + guideLine(ctx, cx - FACE_MAP_TICK, cy, cx + FACE_MAP_TICK, cy); + guideLine(ctx, cx, cy - FACE_MAP_TICK, cx, cy + FACE_MAP_TICK); + + if (eyes) { + // Both eyeballs (`radius`) and the lid line through their centres, so a + // hand-drawn brow or eye can be placed against the parametric ones. + const [a, b] = EYE_LAYOUT.eyes; + const y = offsetY + a.cy; + guideLine(ctx, offsetX + a.cx, y, offsetX + b.cx, y); + for (const eye of EYE_LAYOUT.eyes) { + const ex = offsetX + eye.cx; + const ey = offsetY + eye.cy; + ctx.beginPath(); + ctx.arc(ex, ey, EYE_LAYOUT.radius, 0, Math.PI * 2); + ctx.stroke(); + // A small dot marks the exact centre, where the eyeball pivots. + ctx.beginPath(); + ctx.arc(ex, ey, ctx.lineWidth * 1.5, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // The lip line the mouth is drawn around: end to end, plus a tick at the centre. + const y = offsetY + MOUTH_LAYOUT.chordY; + const { centreX, halfChord } = MOUTH_LAYOUT; + guideLine(ctx, offsetX + centreX - halfChord, y, offsetX + centreX + halfChord, y); + guideLine(ctx, offsetX + centreX, y - FACE_MAP_TICK, offsetX + centreX, y + FACE_MAP_TICK); + } + + return canvas; +} + +/** One guide segment, kept out of the map builders so the placements stay readable. */ +function guideLine(ctx, x0, y0, x1, y1) { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export function downloadText(text, filename, type = 'application/json') { + downloadBlob(new Blob([text], { type }), filename); +} + +export function readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +export function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} + +export function timestamp() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js new file mode 100644 index 0000000..f42b823 --- /dev/null +++ b/public/bluebey-studio/src/face.js @@ -0,0 +1,375 @@ +import * as THREE from 'three'; +import { drawEyes, drawMouth, canvasLayout } from './faceArt.js'; + +/** + * Owns the two procedural textures (eyes and mouth) and keeps them in sync with + * the face parameters. + * + * The artwork is carried by the model's face plates, but those plates are shells + * of the *whole* front half of the body, so their UVs cover far more than the + * 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto + * 0..1 and the canvas is enlarged to match, which is what removes the + * clamp-to-edge smear at the border and what gives a moved tear or mouth room to + * move (see `canvasLayout` in src/faceArt.js). + * + * Both parts can independently fall back to the original hand-drawn texture + * that ships inside the GLB. + */ +export class Face { + constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) { + this.eyeMesh = eyeMesh; + this.mouthMesh = mouthMesh; + // The `hair-plate`, if the model has one, is never drawn: ぶるべー has no + // hair. Hiding the mesh outright (rather than showing an empty texture) keeps + // it from tinting the head on platforms where transparent textures do. + this.hairMesh = hairMesh; + if (hairMesh) hairMesh.visible = false; + + // Rewrite each plate's UVs so its own range fills 0..1, and work out the + // canvas that keeps the artwork exactly where the 2022 textures put it. + this.eyeLayout = remapPlate(eyeMesh); + this.mouthLayout = remapPlate(mouthMesh); + + // The hand-drawn eye textures bake the face colour into their background, + // which is invisible in the lit "real" style but shows up as a flat purple + // patch in the flat and line-art styles. Key it out once, here. Both kinds + // of original are window-sized artwork, so they belong *in* the window, not + // stretched across the enlarged canvas. + this.originals = { + eyes: Object.fromEntries( + Object.entries(originals.eyes).map(([key, texture]) => [key, keyOutBackground(texture, this.eyeLayout)]), + ), + mouth: placeInWindowTexture(originals.mouth, this.mouthLayout), + }; + + this.customNames = new Map(); + this.eyeCanvas = makeCanvas(this.eyeLayout); + this.mouthCanvas = makeCanvas(this.mouthLayout); + this.eyeCtx = this.eyeCanvas.getContext('2d'); + this.mouthCtx = this.mouthCanvas.getContext('2d'); + + this.eyeTexture = makeTexture(this.eyeCanvas); + this.mouthTexture = makeTexture(this.mouthCanvas); + + this.params = null; + this.styleMode = 'paint'; + this.eyeSource = 'parametric'; + this.mouthSource = 'parametric'; + this.dirty = true; + this.lastDraw = -Infinity; + + // The face plates are shells of the body surface, pushed a hair outwards by + // the loader, so they win the depth test against the body by themselves. + // Nudging them with a polygon offset as well keeps the two from z-fighting + // along grazing angles. Depth *testing* stays on: that is what lets the nose + // (which pokes further out) and an arm waved in front of the face hide the + // artwork the way they should - it only ever hid the old planes because + // those did not reach far enough down the head. + const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]); + if (hairMesh) orders.set(hairMesh, 3); + for (const mesh of [eyeMesh, mouthMesh, ...(hairMesh ? [hairMesh] : [])]) { + const material = mesh.material; + material.polygonOffset = true; + material.polygonOffsetFactor = -1; + material.polygonOffsetUnits = -2; + material.depthTest = true; + material.depthWrite = false; + // The GLB may describe the plate material as opaque; the artwork is a + // texture with alpha, so blend instead of replacing what is behind it. + material.transparent = true; + material.side = THREE.DoubleSide; + material.needsUpdate = true; + // Tears are drawn on the eye plate and the mouth on the other one, and + // both sit on the same shell: draw the mouth first so a teardrop can fall + // across it instead of being painted over. + mesh.renderOrder = orders.get(mesh) ?? 0; + } + + this.applyTextures(); + } + + /** Show or hide the mouth without touching the artwork. */ + setMouthVisible(visible) { + if (this.mouthMesh.visible !== visible) this.mouthMesh.visible = visible; + } + + applyTextures() { + const eyeMap = this.eyeSource === 'parametric' + ? this.eyeTexture + : (this.originals.eyes[this.eyeSource] ?? this.eyeTexture); + const mouthMap = this.mouthSource === 'parametric' + ? this.mouthTexture + : (this.originals.mouth ?? this.mouthTexture); + + if (this.eyeMesh.material.map !== eyeMap) { + this.eyeMesh.material.map = eyeMap; + this.eyeMesh.material.needsUpdate = true; + } + if (this.mouthMesh.material.map !== mouthMap) { + this.mouthMesh.material.map = mouthMap; + this.mouthMesh.material.needsUpdate = true; + } + } + + /** + * Load a hand-drawn image and use it for the eyes or the mouth. Any size is + * accepted; it is scaled to the layout the model expects (1024 x 380). + * Returns the key the new artwork is registered under. + */ + async loadImage(kind, file) { + const layout = kind === 'eyes' ? this.eyeLayout : this.mouthLayout; + const bitmap = await createImageBitmap(file); + // Any size is accepted; it is scaled into the artwork window, which is where + // the plate's rewritten UVs expect it. + const canvas = placeInWindow(bitmap, layout); + if (typeof bitmap.close === 'function') bitmap.close(); + const texture = makeTexture(canvas); + + if (kind === 'eyes') { + const key = `custom-${this.customNames.size + 1}`; + this.customNames.set(key, file.name); + this.originals.eyes[key] = texture; + this.setSource('eyes', key); + return key; + } + this.originals.mouth = texture; + this.setSource('mouth', 'original'); + return 'original'; + } + + /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */ + setSource(kind, source) { + if (kind === 'eyes') { + if (this.eyeSource === source) return; + this.eyeSource = source; + } else { + if (this.mouthSource === source) return; + this.mouthSource = source; + } + this.applyTextures(); + this.dirty = true; + } + + setParams(params, styleMode = this.styleMode) { + this.params = params; + this.styleMode = styleMode; + this.dirty = true; + } + + /** + * Redraw the procedural textures when they are stale. `minInterval` throttles + * redraws while animating; pass 0 before an export so nothing is left pending. + */ + flush(now = performance.now(), minInterval = 0) { + if (!this.dirty) return false; + if (now - this.lastDraw < minInterval) return false; + this.redraw(); + this.lastDraw = now; + return true; + } + + redraw() { + const p = this.params; + if (!p) { + this.dirty = false; + return; + } + // Hiding the mouth has to hide the plane itself, not just stop drawing on + // it - otherwise the last drawing stays on screen. + this.setMouthVisible(p.mouth.visible !== false); + // A tongue poking over a *closed* lip can reach up behind the 3D nose, which + // would hide it. In that one case let the mouth artwork win the depth test, + // so the tongue reads; otherwise the nose (and an arm) still hide the mouth + // the way they should. The lip line and the rest of the mouth sit clear of + // the nose, so only the tongue can overlap it. + const mouth = p.mouth ?? {}; + const tongueOverLip = (mouth.round ?? 0) <= 0.004 + && (mouth.open ?? 0) <= 0.004 + && (mouth.tongue ?? 1) > 0.01; + this.mouthMesh.material.depthTest = !tongueOverLip; + const mode = this.styleMode === 'line' ? 'line' : 'paint'; + + if (this.eyeSource === 'parametric') { + const layout = this.eyeLayout; + const ctx = this.eyeCtx; + ctx.clearRect(0, 0, layout.width, layout.height); + ctx.save(); + // The artwork window sits at an offset inside the bigger canvas. + ctx.translate(layout.offsetX, layout.offsetY); + drawEyes(ctx, { + mode, + eyes: { left: p.eyes.left, right: p.eyes.right }, + style: { + white: p.eyes.white, + iris: p.eyes.iris, + line: p.eyes.line, + irisScale: p.eyes.irisScale, + lookMax: p.eyes.lookMax, + highlight: p.eyes.highlight, + lidWidth: p.eyes.lidWidth, + lowerLid: p.eyes.lowerLid, + lidShape: p.eyes.lidShape, + lidTilt: p.eyes.lidTilt, + lashes: p.eyes.lashes, + // 眼鏡 / サングラス are shared by both eyes and drawn into this same + // texture, so they travel with the other shared eye fields. + glasses: p.eyes.glasses, + heartScale: p.eyes.heartScale, + heartColor: p.eyes.heartColor, + tearColor: p.eyes.tearColor, + // Where the artwork's real edges are, so a brow lifted too far or a + // tear dropped too low can stop inside the artwork (see `canvasLayout`). + limits: layout.limits, + }, + }); + ctx.restore(); + this.eyeTexture.needsUpdate = true; + } + + if (this.mouthSource === 'parametric' && p.mouth.visible) { + const layout = this.mouthLayout; + const ctx = this.mouthCtx; + ctx.clearRect(0, 0, layout.width, layout.height); + ctx.save(); + ctx.translate(layout.offsetX, layout.offsetY); + drawMouth(ctx, { mode, ...p.mouth, limits: layout.limits }); + ctx.restore(); + this.mouthTexture.needsUpdate = true; + } + + this.dirty = false; + } +} + +function makeCanvas({ width, height }, scale = 1) { + const canvas = document.createElement('canvas'); + canvas.width = Math.round(width * scale); + canvas.height = Math.round(height * scale); + return canvas; +} + +/** + * Rewrite a plate's UVs so its own range maps onto 0..1, and return the canvas + * that keeps the artwork at its original pixel size (`canvasLayout`). + * + * The plate covers the whole front half of the body, so its UVs used to run well + * outside the artwork (v -1.02..1.93 on the eye plate). Everything past the edge + * clamped to the canvas border, which is what stretched a tear or a brow that + * reached it. After this the whole plate samples real canvas, so nothing clamps. + */ +function remapPlate(mesh) { + const uv = mesh?.geometry?.attributes?.uv; + if (!uv) return canvasLayout(null); + let u0 = Infinity; + let u1 = -Infinity; + let v0 = Infinity; + let v1 = -Infinity; + for (let i = 0; i < uv.count; i += 1) { + const u = uv.getX(i); + const v = uv.getY(i); + if (u < u0) u0 = u; + if (u > u1) u1 = u; + if (v < v0) v0 = v; + if (v > v1) v1 = v; + } + const du = Math.max(1e-6, u1 - u0); + const dv = Math.max(1e-6, v1 - v0); + for (let i = 0; i < uv.count; i += 1) { + uv.setXY(i, (uv.getX(i) - u0) / du, (uv.getY(i) - v0) / dv); + } + uv.needsUpdate = true; + return canvasLayout({ u0, u1, v0, v1 }); +} + +/** + * Draw a hand-drawn 1024 x 380 image where the artwork window now sits, on a + * canvas as big as the plate's own UV range. The plates cover far more than the + * window (see `canvasLayout`) and the UVs were rewritten to match, so the window + * is exactly where that image belongs. + */ +function placeInWindow(source, layout) { + const canvas = document.createElement('canvas'); + canvas.width = layout.width; + canvas.height = layout.height; + if (source) { + canvas.getContext('2d').drawImage( + source, layout.offsetX, layout.offsetY, layout.window.width, layout.window.height, + ); + } + return canvas; +} + +/** `placeInWindow` for an existing texture (keeps `null` as `null`). */ +function placeInWindowTexture(texture, layout) { + const image = texture?.image; + if (!image || !image.width || !image.height) return texture ?? null; + return makeTexture(placeInWindow(image, layout)); +} + +function makeTexture(canvas) { + const texture = new THREE.CanvasTexture(canvas); + // glTF puts v = 0 at the top of the image and the loader uploads the model's + // own textures with flipY = false; matching that keeps the artwork aligned + // with the mesh UVs. + texture.flipY = false; + texture.colorSpace = THREE.SRGBColorSpace; + texture.premultiplyAlpha = false; + texture.needsUpdate = true; + return clampToEdge(texture); +} + +/** + * The face plates are shells of the whole body, so their UVs run well past the + * 0..1 of the artwork (a plate corner can sit at v = -1.26). glTF's default + * sampler repeats, which would stamp the drawing back onto the model several + * times - a frown flicked up onto the forehead, a smile's end onto its side. + * Clamping sends everything outside the artwork to the blank edge of the canvas + * instead, where nothing is drawn. + */ +function clampToEdge(texture) { + if (!texture) return texture; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.needsUpdate = true; + return texture; +} + +/** + * Make the flat colour that fills the background of a hand-drawn texture + * transparent. The edge is feathered rather than hard, so the anti-aliased + * pixels along the artwork do not leave a pale fringe. + */ +function keyOutBackground(texture, layout) { + const image = texture?.image; + if (!image || !image.width || !image.height) return texture; + + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + // The background is uniform, so the top-left pixel is a reliable sample. + const baseR = pixels[0]; + const baseG = pixels[1]; + const baseB = pixels[2]; + const fullyClear = 30; // at or below this distance: transparent + const fullySolid = 96; // at or above this distance: untouched + + for (let i = 0; i < pixels.length; i += 4) { + const distance = Math.abs(pixels[i] - baseR) + + Math.abs(pixels[i + 1] - baseG) + + Math.abs(pixels[i + 2] - baseB); + if (distance <= fullyClear) { + pixels[i + 3] = 0; + } else if (distance < fullySolid) { + const ratio = (distance - fullyClear) / (fullySolid - fullyClear); + pixels[i + 3] = Math.round(pixels[i + 3] * ratio); + } + } + ctx.putImageData(imageData, 0, 0); + + return makeTexture(placeInWindow(canvas, layout)); +} diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js new file mode 100644 index 0000000..4aaa2d6 --- /dev/null +++ b/public/bluebey-studio/src/faceArt.js @@ -0,0 +1,1283 @@ +/** + * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now + * drawn from parameters onto a canvas, which is then used as the texture of the + * two overlay planes that already exist on the model. + * + * The plates have a linear UV mapping fitted to the original artwork, so drawing + * in "texture pixels" lands exactly where the original artwork did. Every constant + * below was measured from the original textures (see README for the numbers), so + * the defaults reproduce the 2022 artwork while every part of it stays editable. + * + * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows + * downwards. `canvasLayout` then says where that window sits on the bigger canvas + * a face plate actually needs (see below), and `Face` hands the bounds back as + * `limits`, so the drawing can clamp against the real edge instead of assuming + * the window is all there is. + * + * glTF stores v downwards too, and the textures are uploaded with flipY = false, + * so no flipping is needed anywhere. + */ + +const TAU = Math.PI * 2; +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +const HUGE = 4000; + +/** + * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every + * constant below is written in this space. + */ +export const ART_WINDOW = { width: 1024, height: 380 }; + +/** Keep this much clear at the canvas border, so filtering has room to breathe. */ +const EDGE_MARGIN = 8; + +/** What a drawing falls back to when no plate bounds are supplied. */ +const WINDOW_LIMITS = { + top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width, +}; + +/** + * The canvas a face plate's drawing needs, and where the artwork window sits on it. + * + * WHY: the plates are the *whole* front half of the body, so their UVs run far + * outside the 0..1 the artwork was drawn in (measured on the shipped model: the + * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a + * twentieth of the border). Everything outside 0..1 used to clamp to the canvas + * edge row, so the moment a drawing reached the border - a tear dropped low, a + * brow lifted, a thick mouth pushed down - that edge row was copied across the + * rest of the plate and the mark stretched into a long smear. + * + * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which + * removes the clamp altogether. The map is affine, so the artwork keeps its exact + * pixel size and position and merely shifts by `offset`; the canvas grows by the + * same factor, and those extra rows are the room a moved tear, brow or mouth + * needs (`limits` says where the real edge now is, in window coordinates). + * + * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range + * @param {{width:number,height:number}} [window] the artwork window + */ +export function canvasLayout(range, window = ART_WINDOW) { + const u0 = Number.isFinite(range?.u0) ? range.u0 : 0; + const u1 = Number.isFinite(range?.u1) ? range.u1 : 1; + const v0 = Number.isFinite(range?.v0) ? range.v0 : 0; + const v1 = Number.isFinite(range?.v1) ? range.v1 : 1; + const du = Math.max(1e-6, u1 - u0); + const dv = Math.max(1e-6, v1 - v0); + const width = Math.max(1, Math.round(window.width * du)); + const height = Math.max(1, Math.round(window.height * dv)); + // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly. + const offsetX = Math.round(-u0 * window.width) + 0; + const offsetY = Math.round(-v0 * window.height) + 0; + return { + width, + height, + offsetX, + offsetY, + window, + // The canvas edges in the artwork's own coordinates. `top` is negative when + // the plate reaches above the window: that is the extra room. + limits: { + top: -offsetY + 0, + bottom: height - offsetY, + left: -offsetX + 0, + right: width - offsetX, + }, + }; +} + +/** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */ +export const EYE_LAYOUT = { + radius: 84, + irisRadius: 62.5, + highlightRadius: 28, + highlightOffset: { x: 29.5, y: -18 }, + // The model's left eye (its own left, +x) sits in the u > 0.5 half. + eyes: [ + { key: 'right', cx: 267.5, cy: 240, towardNose: 1 }, + { key: 'left', cx: 755.5, cy: 240, towardNose: -1 }, + ], + lidRadiusFactor: 1.35, + lidStroke: 14, + // A teardrop hangs well below the eye. The plate keeps the whole canvas, so + // the drop can sit low without being clipped (measured with a ruler grid). + tear: { size: 34, offsetX: 66, offsetY: 88 }, + closedLine: { + // The single shut line is the *long* form of the eyelid, the same reach as + // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not + // change how wide the eye reads. + length: 205, + offsetX: 6, + offsetY: -10, + slantDeg: 3.9, + bow: 3, + // 2- and 3-line shut eyes, copied from the original `eyes-close-tight` + // artwork: three strokes sharing one vertex that points at the nose, opening + // to a wide bird's foot / arrow shape. `spread` is half the height the arms + // open to at the far end. + armSpan: 205, + spread: 62, + vertex: 87.5, + armX: 104, + }, + arch: { edgesUp: 8, apexUp: 44 }, + // まつげ: a few strokes flicking out from the upper-outer lid (see drawEyes). + // `raise` lifts them a touch above the lid edge (2% of the lid slider). + lash: { length: 30, width: 8, angles: [46, 66, 86], raise: 0.04 }, + // "ふつう"の目の瞳を、両目とも少し鼻側に寄せてかわいく見せる(px)。 + irisInward: 7, +}; + + +/** + * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows. + * + * Not measured off any artwork - the character has no glasses - but the numbers + * are tied to the eyeball so a lens is always a little wider than the eye it + * covers. + * + * The two kinds get separate shapes on purpose. They used to share one ellipse + * and differ only in how dark the lens was filled, which made them hard to tell + * apart; the round 眼鏡 below and the wide, angular サングラス in + * `SUNGLASSES_LAYOUT` now read as different objects at a glance. + */ +const GLASSES_LAYOUT = { + widthFactor: 1.08, // lens half-width, as a multiple of the eyeball radius + heightFactor: 1.0, // a circle, so the round 眼鏡 stays round (a flatter + // ellipse used to drift towards the shades below) + drop: 20, // the lens centre hangs this far below the eye centre (artwork px) + bridgeRise: 8, // how much the bridge arcs up over the nose + bridgeWidth: 0.75, // bridge thickness, as a fraction of the frame stroke + templeLength: 1.5, // temple stub length, as a multiple of the eyeball radius + templeRise: 30, // how far the temple climbs towards the side of the head +}; + +/** + * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a + * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner* + * set further out (`tipX`), and whose top edge then sweeps back down towards the + * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is + * short, so the lens tapers inwards. The eye is allowed to poke out above and + * below it, so the lens no longer has to cover the whole eyeball. A level bar + * joins the two inner ends, so the pair still reads as one dark visor. The ratios + * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the + * same thing for both kinds. + */ +const SUNGLASSES_LAYOUT = { + widthFactor: 1.6, // a long lens: the outer end reaches well past the eyeball + heightFactor: 0.58, // slim, so the eye is free to show above and below it + drop: 8, // sits a little lower than the round pair + corner: 0.05, // corner rounding, as a fraction of the half-height; kept + // tiny so the outer corner stays pointed + topSkew: 1.0, // the pointed corner rises this far above the lens top (x half-height) + innerTop: 0.42, // the inner top is low, which is what makes the top edge climb + innerBottom: 0.34, // the inner bottom is pinched up towards the nose (x half-height) + outerEndX: 0.75, // the low outer end sits this far out (x half-width) + outerBottom: 0.82, // ...and this deep (x half-height) + tipX: 1.22, // the pointed corner juts this far out (x half-width) + bridgeWidth: 1.15, // a short, thick bar - thicker than the frame stroke + bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height) + templeLength: 1.15, // a short stub, like the round pair's but a little shorter + templeRise: 24, + // A heavy rim. It used to be slimmer than the round pair's stroke, which read + // as a thin pair of shades; the reference look is a chunky frame. + frameFactor: 1.6, +}; + +/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole + * 1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer + * has to be squeezed into a band. The only limit left is the canvas itself. */ +export const MOUTH_LAYOUT = { + centreX: 511.5, + chordY: 88, + halfChord: 420.5, + sag: 177, + // With the ends and the depth both fixed - which is what the slider promises - + // the only thing left to choose about the curve is *where* it bends. 1/3 is a + // quadratic Bézier (a parabola): it is flattest at the apex and falls away + // fastest near the ends. Pulling the cubic's control points in towards the ends + // makes the middle of the smile straighter still and lets the fall happen near + // the corners, which is what reads as a *gentler* curve at the same depth. + sagBend: 0.2, + thickness: 21, + // How big the round "O" oval gets at `round: 1`, as a multiple of the smile's + // own sag. The oval's size comes from this and `round` alone: `thickness` is + // only the stroke weight, so 口の太さ and 丸く開く no longer move together. + roundScale: 1.3, + // Kept for reference: the 2022 plane only showed these rows. + bandTop: 46, + bandBottom: 332, + // Only used to cap the *size* of the round "O" mouth, so the surprise face + // stays a mouth and not a hole. The mouth's travel no longer stops here: the + // drawing is given the plate's real edges through `limits`. + safeBottom: 372, + // The nose is a separate mesh that pokes out in front of the plate. The plate + // is depth tested, so the nose hides whatever is drawn behind it - but a frown + // arcs *up* into that hiding place, so slide the mouth down until the middle + // of the arc clears the nose. Only the middle is checked: a smile curves away + // from the nose, and checking its ends instead is what used to pin the whole + // mouth in place and make the height slider do nothing. + noseClear: 132, + // The original hand-drawn mouth is a *shallow* arc with tall corner strokes + // flicking up at the ends (measured from the artwork: the arc's own sag is + // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that + // split is what makes `smile: 1` read as the original; making the arc itself + // deep instead looked too steep. + // + // The tongue rises from the lip line to a rounded top just above the corner + // strokes. Measured off the original: its crown is a *super-ellipse* - + // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth, + // almost flat top. It is convex upwards, but it is **not** a point. + tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 }, + corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 }, + openRise: 64, + // Was 0.25: opening the mouth used to flatten the smile to stay inside the + // texture band. There is no band to stay inside now. + openFlatten: 0, +}; + +/* ------------------------------------------------------------------ helpers */ + +function circlePath(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + ctx.closePath(); +} + +function fillCircle(ctx, cx, cy, r, color) { + circlePath(ctx, cx, cy, r); + ctx.fillStyle = color; + ctx.fill(); +} + +/** + * A heart, used for the "love" eyes. The path is wider than it is tall, which + * is what makes it read as a heart rather than a blob at small sizes. + */ +function heartPath(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy + r * 0.80); + ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40); + ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80); + ctx.closePath(); +} + +function fillHeart(ctx, cx, cy, r, color) { + heartPath(ctx, cx, cy, r); + ctx.fillStyle = color; + ctx.fill(); +} + +/** A teardrop, used by the crying expression. */ +function dropPath(ctx, cx, cy, size) { + ctx.beginPath(); + ctx.moveTo(cx, cy - size * 1.32); + ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78); + ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32); + ctx.closePath(); +} + +function strokeCircle(ctx, cx, cy, r, color, width) { + circlePath(ctx, cx, cy, r); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.stroke(); +} + +function tracePolyline(ctx, points, closed) { + if (!points.length) return; + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y); + if (closed) ctx.closePath(); +} + +function strokePolyline(ctx, points, { color, width, closed = false }) { + if (points.length < 2) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + tracePolyline(ctx, points, closed); + ctx.stroke(); + ctx.restore(); +} + +function fillPolygon(ctx, points, color) { + if (points.length < 3) return; + ctx.save(); + ctx.fillStyle = color; + tracePolyline(ctx, points, true); + ctx.fill(); + ctx.restore(); +} + +/** + * Trace a closed polygon with rounded corners. Used for the squarish + * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps. + * Each corner is cut back by `radius` (clamped so short edges cannot overlap) + * and joined with a quadratic through the original vertex. + */ +function traceRoundedPolygon(ctx, points, radius) { + const n = points.length; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const prev = points[(i + n - 1) % n]; + const cur = points[i]; + const next = points[(i + 1) % n]; + const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1; + const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1; + const cut = Math.min(radius, inLen / 2, outLen / 2); + const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut }; + const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut }; + if (i === 0) ctx.moveTo(from.x, from.y); + else ctx.lineTo(from.x, from.y); + ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y); + } + ctx.closePath(); +} + +/** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */ +function quadraticPoints(p0, p1, p2, steps) { + const out = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const u = 1 - t; + out.push({ + x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x, + y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y, + }); + } + return out; +} + +/** A point on a cubic Bézier. */ +function cubicPoint(p0, p1, p2, p3, t) { + const u = 1 - t; + const a = u * u * u; + const b = 3 * u * u * t; + const c = 3 * u * t * t; + const d = t * t * t; + return { + x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, + y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, + }; +} + +/** Points along a cubic Bézier, `steps` segments (steps + 1 points). */ +function cubicPoints(p0, p1, p2, p3, steps) { + const out = []; + for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps)); + return out; +} + +/** + * The four control points of the mouth's centreline: the same two ends, the same + * depth, but `bend` decides where the curve actually bends (see `sagBend`). + */ +function mouthControls(left, right, sag, bend) { + const width = right.x - left.x; + const y = left.y + (4 / 3) * sag; + return { + p0: left, + p1: { x: left.x + width * bend, y }, + p2: { x: right.x - width * bend, y }, + p3: right, + }; +} + +function rotate(points, pivot, degrees) { + if (!degrees) return points; + const a = (degrees * Math.PI) / 180; + const cos = Math.cos(a); + const sin = Math.sin(a); + return points.map((p) => { + const dx = p.x - pivot.x; + const dy = p.y - pivot.y; + return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos }; + }); +} + +/* --------------------------------------------------------------------- eyes */ + +/** + * Intersect the canvas clip with the eyeball disc and the (possibly closed) + * lids. Because the overlay plane is transparent and sits in front of the face, + * clipping is all that is needed: whatever is clipped away shows the real + * shaded face behind it. That is what makes the eyelid blend perfectly without + * baking a face-coloured background into the texture. + */ +/** Clip to the upper and lower lids only, without the eyeball circle. */ +function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { + const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180; + const rotated = Math.abs(tilt) > 1e-4; + const spin = () => { + ctx.translate(eye.cx, eye.cy); + ctx.rotate(tilt); + ctx.translate(-eye.cx, -eye.cy); + }; + if (rotated) spin(); + + const lower = clamp(lowerLid, 0, 1); + // The upper and lower lids are independent: 上まぶたの高さ sets where the upper + // lid sits on its own, so raising 下まぶたの高さ does not drag the upper lid + // down with it. A blink still shuts the eye because `open` reaching 0 (or the + // upper lid dropping to the lower one) hands over to the shut-eye drawing + // before this clip is used. + const upper = 1 - clamp(open, 0, 1); + if (upper > 0.0005) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + const lowest = eye.cy - eye.r + 2 * eye.r * upper; + ctx.beginPath(); + if (flat) { + // A flat lid: a straight edge straight across the eye. + ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE); + } else { + const cy = lowest - rl; + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy + HUGE); + ctx.lineTo(eye.cx - HUGE, cy + HUGE); + } + ctx.closePath(); + ctx.clip(); + } + + const bottom = lower; + if (bottom > 0.0005) { + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + const highest = eye.cy + eye.r - 2 * eye.r * bottom; + ctx.beginPath(); + if (flat) { + ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE); + } else { + const cy = highest + rl; + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy - HUGE); + ctx.lineTo(eye.cx - HUGE, cy - HUGE); + } + ctx.closePath(); + ctx.clip(); + } + + if (rotated) { + // Undo the rotation for the caller, but keep the clip we just set. + ctx.translate(eye.cx, eye.cy); + ctx.rotate(-tilt); + ctx.translate(-eye.cx, -eye.cy); + } +} + +/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */ +function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + clipLids(ctx, eye, open, lowerLid, flat, tiltDeg); +} + +function lidPath(ctx, eye, amount, lower, flat = false) { + ctx.beginPath(); + if (flat) { + // A straight lid edge; the caller clips it to the eyeball. + const y = lower + ? eye.cy + eye.r - 2 * eye.r * amount + : eye.cy - eye.r + 2 * eye.r * (1 - amount); + ctx.moveTo(eye.cx - eye.r * 2, y); + ctx.lineTo(eye.cx + eye.r * 2, y); + return; + } + const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; + if (!lower) { + const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount); + ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true); + } else { + const highest = eye.cy + eye.r - 2 * eye.r * amount; + ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false); + } +} + +/** The "eyes shut" artwork: a line, a chevron or a happy arch. */ +function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity) { + const layout = EYE_LAYOUT.closedLine; + const { line, width } = style; + // 形の大きさ is a multiplier on the shape's *fitting* size. With the white shown + // that fitting size is what just fits inside the eyeball, so the default (1) + // sits in the white; raising it may push the shape out past the white, which is + // allowed. `maxHalf` is Infinity when the white is hidden, so nothing is scaled + // back there. + const k = (unitHalf) => shapeScale * (maxHalf === Infinity ? 1 : Math.min(1, maxHalf / unitHalf)); + + if (spec.closed === 'chevron') { + // The chevron points *at* the nose, matching the original artwork. + const scale = k(layout.armX); + const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale }; + const armX = eye.cx - eye.towardNose * layout.armX * scale; + const spread = layout.spread * (spec.spread ?? 1) * scale; + strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width }); + strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width }); + return; + } + + if (spec.closed === 'three') { + // A real "3": an upper bowl and a lower bowl that meet at a single pinch on + // the nose side. Stacking two C's instead made the two lobes sit on top of + // each other; joining them at a point is what makes it read as the digit. + const r = 34 * k(1.7 * 34); + // Which way the digit faces. It used to follow the nose side, which made the + // pair a mirror image; each eye can now be set either way, because a pair of + // mirrored 3s does not always read the way you want. + const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose; + const sx = eye.cx; + const cy = eye.cy + layout.offsetY; + const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y }); + const pinch = at(0.72, 0); + const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26); + const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26); + strokePolyline(ctx, upper, { color: line, width }); + strokePolyline(ctx, lower, { color: line, width }); + return; + } + + if (spec.closed === 'arch') { + const scale = k(eye.r); + const edges = { x: eye.r * scale, y: EYE_LAYOUT.arch.edgesUp * scale }; + const points = quadraticPoints( + { x: eye.cx - edges.x, y: eye.cy + edges.y }, + { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 * scale }, + { x: eye.cx + edges.x, y: eye.cy + edges.y }, + 24, + ); + strokePolyline(ctx, points, { color: line, width }); + return; + } + + // Default: the gently slanted line of the original artwork, mirrored so it + // always slopes down towards the nose. With `closedLines` set to 2 or 3 the + // strokes share one endpoint instead - a ">" or bird's-foot shape, which is + // how manga draws a happily squeezed-shut eye. + const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3); + const scale = k(layout.length / 2); + if (count >= 2) { + const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale }; + // Reach as far as the single line does, not just a short stub: the arms are + // the *long* form of the same eyelid the one-line version covers. + const outX = -eye.towardNose * layout.armSpan * scale; + const spread = layout.spread * scale; + const arms = count === 2 + ? [{ x: outX, y: -spread }, { x: outX, y: spread }] + : [ + { x: outX, y: -spread }, + { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread) * scale, y: 0 }, + { x: outX, y: spread }, + ]; + for (const arm of arms) { + strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width }); + } + return; + } + + const half = (layout.length / 2) * (spec.length ?? 1) * scale; + const midX = eye.cx - eye.towardNose * layout.offsetX * scale; + const midY = eye.cy + layout.offsetY * scale; + const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180; + const bow = (layout.bow ?? 0) * (spec.slant ?? 1) * scale; + const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half }; + const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half }; + const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20); + strokePolyline(ctx, points, { color: line, width }); +} + + + + + + +/** + * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its + * temple. + * + * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork) + * so the lens sits in front of the eye, and before that eye's tear so a teardrop + * falls past the lens. Nothing about a pair of glasses is per-eye, so the two + * halves are mirrored from `eye.towardNose` and meet at the face's midline. The + * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one. + * + * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so + * the paper behind shows through exactly as the eyeball's white does (see the note + * in `drawEyes`); the frames are then stroked in the line colour. + * + * @param {CanvasRenderingContext2D} ctx + * @param {{cx:number,cy:number,r:number,towardNose:number}} eye + * @param {boolean} line true in 線画 mode + * @param {object} style the bag `drawEyes` received + * @param {{top:number,bottom:number,left:number,right:number}} limits + */ +function drawGlasses(ctx, eye, line, style, limits) { + const spec = style.glasses; + if (!spec?.enabled) return; + + const r = eye.r; + const scale = clamp(spec.scale ?? 1, 0.2, 3); + // The two kinds share everything about *where* they sit but not their shape: + // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above). + const shades = spec.kind === 'sunglasses'; + const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT; + const rx = r * L.widthFactor * scale; + const ry = rx * L.heightFactor; + const frame = Math.max( + 1, + (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1), + ); + const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33'); + // The outward direction (towards the temple) for this eye. The angular lens is + // built in a local frame whose u runs outwards, so the two sides mirror exactly. + const out = -eye.towardNose; + // The pointed outer corner makes the shades taller on that side; clamp against + // the full height so even that point cannot reach the canvas border. + const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry; + + // The face's midline: the two eyes are symmetric about it, so it is where the + // two halves of the bridge meet and what `tilt` turns the pair about. + const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2; + // `lensGap` slides the two lenses apart (positive) or together (negative) along + // the face, symmetrically about the midline. It is clamped so the inner edges may + // meet at the midline but the two lenses can never cross each other. + const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx); + const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE); + // Keep the lens (and so everything that hangs off it) inside the artwork. The + // rest of the file clamps against `limits` for the same reason: a mark that + // reaches the canvas border gets the edge row copied across the whole plate. + const lensCx = clamp( + eye.cx + out * gap, + limits.left + EDGE_MARGIN + rx, + limits.right - EDGE_MARGIN - rx, + ); + const lensCy = clamp( + eye.cy + L.drop + (spec.offsetY ?? 0), + limits.top + EDGE_MARGIN + halfV, + limits.bottom - EDGE_MARGIN - halfV, + ); + // Local (u, v) -> canvas, with u outwards and v downwards. + const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v }); + + ctx.save(); + // `tilt` leans the whole pair at once. Rotating about the midline keeps the + // bridge centred between the lenses instead of swinging it off to one side. + if (spec.tilt) { + ctx.translate(midX, lensCy); + ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180); + ctx.translate(-midX, -lensCy); + } + + // --- lens ------------------------------------------------------------ + // The one path is filled and then stroked, so the fill and the frame can never + // drift apart. + if (shades) { + // A long cat-eye, not an ellipse: the outer end starts low, rises to a + // pointed, lifted outer corner set further out, and the top edge sweeps back + // towards the nose. The inner end is short, so the lens tapers inwards. + const lens = [ + at(-rx, -ry * L.innerTop), // inner top (near the nose) + at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)), // pointed, lifted outer corner + at(rx * L.outerEndX, ry * L.outerBottom), // low outer end + at(-rx, ry * L.innerBottom), // inner bottom (pinched towards the nose) + ]; + traceRoundedPolygon(ctx, lens, ry * L.corner); + if (!line) { + ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); + ctx.fillStyle = spec.lensColor ?? '#2b2433'; + ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.strokeStyle = colour; + ctx.lineWidth = frame; + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU); + ctx.closePath(); + if (!line) { + ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1); + ctx.fillStyle = spec.lensColor ?? '#2b2433'; + ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.strokeStyle = colour; + ctx.lineWidth = frame; + ctx.stroke(); + } + + // --- bridge ---------------------------------------------------------- + // Each eye draws the half of the bridge from its own lens to the midline. + const innerX = eye.towardNose > 0 + ? Math.min(lensCx + rx, midX) + : Math.max(lensCx - rx, midX); + if (shades) { + // A short, thick, level bar high on the lenses: with the two lenses it reads + // as one continuous visor across the eyes. + const barY = lensCy - ry * L.bridgeLift; + strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], { + color: colour, + width: frame * L.bridgeWidth, + }); + } else { + // The two halves meet at the midline with a horizontal tangent, so the join is smooth. + const noseX = lensCy - L.bridgeRise; + strokePolyline(ctx, quadraticPoints( + { x: innerX, y: lensCy }, + { x: (innerX + midX) / 2, y: noseX }, + { x: midX, y: noseX }, + 12, + ), { color: colour, width: frame * L.bridgeWidth }); + } + + // --- temple ---------------------------------------------------------- + // A stub outwards from the outer edge of the lens. It stays short on purpose: + // the plate only reaches so far, and a temple that ran off it would smear. The + // round pair's outer edge is a vertical line at the lens centre height. The + // shades' outer end drops low, so their arm leaves from the *top* edge instead + // - from the pointed outer corner - which is where a cat-eye's arm attaches. + const outerX = shades + ? lensCx - eye.towardNose * rx * L.tipX + : lensCx - eye.towardNose * rx; + const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy; + const reach = clamp( + outerX - eye.towardNose * r * L.templeLength, + limits.left + EDGE_MARGIN, + limits.right - EDGE_MARGIN, + ); + strokePolyline(ctx, [ + { x: outerX, y: outerY }, + { + x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX), + y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN), + }, + ], { color: colour, width: frame }); + + ctx.restore(); +} + +/** + * Draw the pair of eyes into a 1024 x 380 canvas. + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} p + * @param {'paint'|'line'} [p.mode] paint = filled cartoon eyes, line = outline only + * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }` + * @param {object} [p.style] colours and shared shaping parameters + */ +/** + * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or + * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ + * rolls only set `open`/`closed`/`irisShape`, so this falls back to those. + */ +function eyeShapeKey(spec) { + const shape = spec.shape; + const explicitShut = shape === 'three' || shape === 'arch' + || (typeof shape === 'string' && shape.startsWith('line')); + // An explicit shut artwork is used as-is; the lid height only trims it. + if (explicitShut) return shape; + // A lowered lid on an open or heart eye reads as a shut line - a blink. It must + // NOT borrow a 3 or an arch from a stale `closed`, which used to flash on screen + // for a frame when blinking after picking 3/わらう and then switching to heart. + if ((spec.open ?? 1) <= 0.02) { + if (!shape) { + // Older saved states have no `shape`; derive the shut artwork from `closed`. + if (spec.closed === 'three') return 'three'; + if (spec.closed === 'arch') return 'arch'; + return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`; + } + return 'line1'; + } + if (shape) return shape; + return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; +} + +export function drawEyes(ctx, p) { + const mode = p.mode ?? 'paint'; + const line = mode === 'line'; + const style = p.style ?? {}; + const limits = style.limits ?? WINDOW_LIMITS; + const white = style.white ?? '#ffffff'; + const irisColor = style.iris ?? '#150e1b'; + const lineColor = style.line ?? '#55386e'; + const lookMax = style.lookMax ?? 1; + const heartScale = 2; + const heartColor = style.heartColor ?? '#e0344f'; + // A lens that is effectively opaque hides the eye completely, so a blink would + // show only as a flicker of the shut artwork through the lens. While such a + // lens is on, the eyes are drawn open and no blink is visible at all. This is a + // rendering decision, not an animation one - the blink still runs underneath. + const lensOpaque = style.glasses?.enabled === true + && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99; + + for (const layout of EYE_LAYOUT.eyes) { + const spec = (p.eyes ?? {})[layout.key] ?? {}; + const eye = { + // `eyeX` slides this eye - and its lid and tear - sideways on its own. + cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), + cy: layout.cy, + r: EYE_LAYOUT.radius, + towardNose: layout.towardNose, + }; + // Each lid can be set per eye, falling back to the shared value. + const lidWidth = spec.lidWidth ?? style.lidWidth ?? EYE_LAYOUT.lidStroke; + const lidFlat = (spec.lidShape ?? style.lidShape ?? 'curve') === 'flat'; + // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). + const lidTilt = clamp(spec.lidTilt ?? style.lidTilt ?? 0, -45, 45); + const lineStyle = { line: lineColor, width: lidWidth }; + // Which artwork this eye shows. `shape` is the explicit choice; older saved + // states and the おまかせ rolls only set `open`/`closed`, so fall back. + const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec); + const explicitShut = typeof spec.shape === 'string' + && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line')); + const isHeart = shapeKey === 'heart'; + const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line'); + // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a + // line, the 3 and the arch share one path with the older fields. + const shapeSpec = isShutShape + ? { + ...spec, + closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'), + closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1), + } + : spec; + // The upper lid (`open`) and the lower lid are independent, and both shape + // every eye now: the shut artwork is drawn as the eye's content and is + // trimmed by them. A shut eye saved the old way stored open = 0 meaning + // "shut"; lift it or the artwork would be clipped away. An explicit shut + // `shape` keeps its own lid height, so the lid can be lowered onto it. + const rawOpen = spec.open ?? 1; + const open = lensOpaque + ? 1 + : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1); + const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1); + // 大きさ: one knob for the iris, the heart and the shut artwork. + const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2); + const irisRadius = EYE_LAYOUT.irisRadius * shapeScale; + // `white` is tri-state: only the plain open eye shows it by default. + const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open'; + // While the white is shown, a shape that would poke out of the eyeball is + // scaled back inside it; `shapeHalfLimit` is the half-size it may reach. + const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity; + + // --- tear ---------------------------------------------------------- + // A teardrop hangs below the eye, so it is drawn on top of everything else + // (the opaque eyeball would otherwise cover it) and for a shut eye too. The + // amount, the height and the tilt are all *per eye*, because one eye crying + // while the other does not is a real expression. + const drawTear = () => { + if (spec.tearOn !== true) return; + const amount = clamp(spec.tear ?? 0.3, 0, 1.6); + if (amount <= 0.01) return; + const size = EYE_LAYOUT.tear.size * amount; + const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX; + let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0); + // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip + // inside the artwork: a drop that reached the canvas border used to be + // smeared down the chin by the clamp. + const tearBottom = limits.bottom - EDGE_MARGIN; + if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78; + ctx.save(); + if (spec.tearTilt) { + ctx.translate(tearX, tearY); + ctx.rotate((spec.tearTilt * Math.PI) / 180); + ctx.translate(-tearX, -tearY); + } + dropPath(ctx, tearX, tearY, size); + if (!line) { + ctx.fillStyle = style.tearColor ?? '#8fd8ff'; + ctx.fill(); + } + ctx.strokeStyle = lineColor; + ctx.lineWidth = lidWidth * 0.55; + ctx.stroke(); + ctx.restore(); + }; + + // How far the gaze can push the content. A heart (or a shut line shown on the + // white) is much bigger than the iris, so it gets a smaller travel - and with + // the white shown the content is also clipped to the eyeball - so it never + // pokes outside the white. + const heartFit = isHeart && showWhite + ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale)) + : 1; + const heartRadius = Math.min( + EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit, + EYE_LAYOUT.radius * 2.1, + ); + let travelBase = irisRadius; + if (isHeart) travelBase = heartRadius; + const eyeTravel = Math.max(0, EYE_LAYOUT.radius - travelBase) * lookMax; + let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; + let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel; + const dist = Math.hypot(dx, dy); + if (dist > eyeTravel && dist > 0) { + dx = (dx / dist) * eyeTravel; + dy = (dy / dist) * eyeTravel; + } + + // --- the eye's content, clipped by the lids ------------------------- + // Every shape is drawn the same way now: a white backing, then the artwork + // (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids. + // The shut artwork and a heart both reach past the eyeball, so they are only + // lid-clipped - but once the white is shown they are clipped to the eyeball + // too, so they stay inside it. Clipping the heart by its *lids* rather than by + // the eyeball is also what lets a raised lower lid cover it from below: the + // heart replaces the eyeball, but it must still sit behind both lids. + ctx.save(); + if ((isShutShape || isHeart) && !showWhite) { + clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); + } else if (!isHeart || open < 0.999 || showWhite) { + clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); + } + if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); + if (isShutShape) { + ctx.translate(dx, dy); + drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit); + } else if (isHeart) { + fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor); + } else { + const irisX = eye.cx + dx + eye.towardNose * EYE_LAYOUT.irisInward; + const irisY = eye.cy + dy; + fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor); + // 光彩 (the white glint) is per eye, falling back to the shared switch. + const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false; + if (highlightOn) { + const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale; + const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale; + const hr = EYE_LAYOUT.highlightRadius * shapeScale; + if (line) { + // Punch the sparkle out of the iris rather than painting it white. On + // screen the paper behind shows through, and in the SVG export (which + // only sees alpha) it stays a hole in the pupil. + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + fillCircle(ctx, hx, hy, hr, '#000000'); + ctx.restore(); + } else { + fillCircle(ctx, hx, hy, hr, white); + } + } + } + ctx.restore(); + + // Between the eyeball and the tear: the lens covers the eye, and a tear still + // falls in front of it. + drawGlasses(ctx, eye, line, style, limits); + + drawTear(); + + // --- lid strokes --------------------------------------------------- + // Faded in as the lid starts to cover the eye, so opening the eye all the + // way leaves the clean original artwork with no extra line. + const lidFade = clamp((0.95 - open) / 0.1, 0, 1); + // The tilt is mirrored between the eyes, so a positive value reads the same + // way on both (an inward, angry-looking squint). + const eyeTilt = lidTilt * eye.towardNose; + // The upper lid line has to sit where the clip put it - and that is set by + // `open` alone, so it stays put when the lower lid moves. + const upperAmt = clamp(open, 0, 1); + // A heart is a replacement for the eyeball, not an eye behind a lid: it keeps + // no lower-lid line, which used to cut across the heart. + const showLowerLid = lowerLid > 0.01; + if (lidFade > 0.01 || showLowerLid) { + ctx.save(); + if (eyeTilt) { + ctx.translate(eye.cx, eye.cy); + ctx.rotate((eyeTilt * Math.PI) / 180); + ctx.translate(-eye.cx, -eye.cy); + } + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + ctx.globalAlpha = line ? 1 : lidFade; + ctx.strokeStyle = lineColor; + ctx.lineWidth = lidWidth; + if (lidFade > 0.01) { + lidPath(ctx, eye, upperAmt, false, lidFlat); + ctx.stroke(); + } + if (showLowerLid) { + ctx.globalAlpha = line ? 1 : lowerLid; + lidPath(ctx, eye, lowerLid, true, lidFlat); + ctx.stroke(); + } + ctx.restore(); + } + + // --- まつげ -------------------------------------------------------- + // Short strokes flicking outwards from the upper-outer lid. They sit outside + // the eyeball, so they are drawn after the lid block and are not clipped. + if (spec.lashes ?? style.lashes) { + // The lashes ride the upper lid: they drop as the lid lowers (2r per unit of + // open) and tilt with まぶたの傾き, so they stay on the lid edge. まつげの位置 + // slides them along the lid, まつげの角度 tilts the strokes themselves. + const outDir = -eye.towardNose; + const lidDrop = 2 * eye.r * (1 - open); + const lashPos = clamp(spec.lashPos ?? style.lashPos ?? 0, -80, 80); + const lashAngle = clamp(spec.lashAngle ?? style.lashAngle ?? 0, -80, 80) * outDir; + const a = (lashAngle * Math.PI) / 180; + const ca = Math.cos(a); + const sa = Math.sin(a); + ctx.save(); + if (eyeTilt) { + ctx.translate(eye.cx, eye.cy); + ctx.rotate((eyeTilt * Math.PI) / 180); + ctx.translate(-eye.cx, -eye.cy); + } + for (const deg of EYE_LAYOUT.lash.angles) { + const t = ((deg + lashPos) * Math.PI) / 180; + const ux = outDir * Math.cos(t); + const uy = -Math.sin(t); + // The stroke points along the radial direction, tilted by まつげの角度. + const dx = ux * ca - uy * sa; + const dy = ux * sa + uy * ca; + const r0 = eye.r * 0.96; + const bx = eye.cx + ux * r0; + const by = eye.cy + uy * r0 + lidDrop - eye.r * EYE_LAYOUT.lash.raise; + strokePolyline(ctx, [ + { x: bx, y: by }, + { x: bx + dx * EYE_LAYOUT.lash.length, y: by + dy * EYE_LAYOUT.lash.length }, + ], { color: lineColor, width: EYE_LAYOUT.lash.width }); + } + ctx.restore(); + } + + // --- outline (line-art mode only) --------------------------------- + if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75); + } +} + +/* -------------------------------------------------------------------- mouth */ + +/** + * Draw the mouth into a 1024 x 380 canvas. + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} p + * @param {'paint'|'line'} [p.mode] + */ +export function drawMouth(ctx, p) { + const mode = p.mode ?? 'paint'; + const line = mode === 'line'; + const L = MOUTH_LAYOUT; + const limits = p.limits ?? WINDOW_LIMITS; + // The artwork's real edges, in the artwork's own coordinates, with a margin + // kept clear. `top` is negative when the plate reaches above the window, which + // is the room a moved mouth has to work with. + const edgeLeft = limits.left + EDGE_MARGIN; + const edgeRight = limits.right - EDGE_MARGIN; + const edgeTop = limits.top + EDGE_MARGIN; + const edgeBottom = limits.bottom - EDGE_MARGIN; + + const openAmount = clamp(p.open ?? 0, 0, 1); + const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3); + const cornerAmount = clamp(p.corners ?? 1, 0, 1.6); + // The corner strokes flick out past the ends of the chord, so the ends cannot + // use the whole canvas: a very wide mouth used to smear sideways off the plate. + const cornerReach = L.corner.toX * cornerAmount; + const halfChord = Math.min( + L.halfChord * clamp(p.width ?? 1, 0.2, 1.6), + Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX), + ); + + // A negative smile bulges the line upwards, which reads as a frown. + const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4); + // A frown's middle is the highest point of the mouth, and that is exactly + // where the nose is in the way, so slide the whole mouth down until the curve + // is in the open. The push is worked out from the *base* position and the + // height slider is added afterwards: adding it first made the push cancel the + // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a + // frowning mouth like むっと. + const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw)); + let y0 = base + (p.offsetY ?? 0); + // The smile flattens slightly as the mouth opens, so the whole mouth keeps + // fitting inside the band the mouth plane actually shows. + let sag = sagRaw * (1 - L.openFlatten * openAmount); + // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of + // the chord, so the allowance is not symmetric - padding the bottom by the + // corner's depth used to cost most of the height slider's travel. + const cornerRise = L.corner.toY * cornerAmount + thickness; + const bottomEdge = y0 + Math.max(0, sag) + thickness; + if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom; + const topEdge = y0 + Math.min(0, sag) - cornerRise; + if (topEdge < edgeTop) y0 += edgeTop - topEdge; + const pivot = { x: L.centreX, y: y0 + sag * 0.5 }; + const tilt = p.tilt ?? 0; + + const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44'); + const innerColor = p.innerColor ?? '#4a0f1e'; + const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d'); + + const left = { x: L.centreX - halfChord, y: y0 }; + const right = { x: L.centreX + halfChord, y: y0 }; + const lip = mouthControls(left, right, sag, L.sagBend); + const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96); + + const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6); + + // --- a round "O" mouth (surprised, singing) --------------------------- + const roundAmount = clamp(p.round ?? 0, 0, 1); + if (roundAmount > 0.004) { + let ry = L.sag * 1.05 * L.roundScale * roundAmount; + // Keep the oval below the nose and inside the plate, but allow it to grow to + // roughly half the head. The stroke's share is a fixed margin, not the live + // `thickness`: otherwise 口の太さ would quietly resize the oval too. + ry = Math.min(ry, (edgeBottom - L.thickness / 2 - L.noseClear) / 2.1); + const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 1.4); + const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - L.thickness / 2 - ry * 1.02); + if (ry > 5) { + ctx.save(); + ctx.translate(pivot.x, pivot.y); + ctx.rotate((tilt * Math.PI) / 180); + ctx.translate(-pivot.x, -pivot.y); + ctx.beginPath(); + ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU); + if (!line) { + ctx.fillStyle = innerColor; + ctx.fill(); + if (tongueAmount > 0.01) { + ctx.beginPath(); + ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + } + ctx.strokeStyle = strokeColor; + ctx.lineWidth = line ? thickness * 0.8 : thickness; + ctx.stroke(); + ctx.restore(); + } + return; + } + + // The smile line is the upper lip. Opening the mouth drops the lower jaw + // below it, and the room for that is limited by the texture band the mouth + // plane shows - otherwise the jaw is silently clipped away. + // Everything has to fit in the part of the plane that faces the camera. + let rise = L.openRise * openAmount; + const budget = edgeBottom - thickness / 2 - y0; + if (sag + rise > budget) { + rise = Math.max(0, budget - Math.min(sag, budget)); + if (sag + rise > budget) sag = Math.max(-80, budget - rise); + } + const jawCubic = mouthControls(left, right, sag + rise, L.sagBend); + const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96); + const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t); + const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t); + const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt); + + const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95); + const tongueWidth = L.tongue.width * tongueAmount; + const tongueHeight = L.tongue.height * tongueAmount; + + /** + * The tongue, rising from `curve` to `height` above its middle. + * + * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can + * sit along it without this needing to know what kind of curve it is. + */ + function drawTongue(pointAt, curve, height) { + const arcLength = Math.max(1, chordLength(curve)); + const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45); + const t0 = clamp(tonguePos - dt, 0, 1); + const t1 = clamp(tonguePos + dt, 0, 1); + const base = []; + for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16)); + const mid = pointAt(clamp(tonguePos, 0, 1)); + const half = tongueWidth / 2; + // The foot runs left to right; the crown comes back right to left, so the two + // together are already a closed ring. + const footRight = base[base.length - 1]; + const footLeft = base[0]; + const crown = []; + const steps = 26; + for (let i = 0; i <= steps; i++) { + const f = i / steps; + const dx = 1 - 2 * f; + const footY = footRight.y + (footLeft.y - footRight.y) * f; + crown.push({ + x: mid.x + dx * half, + y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)), + }); + } + const shaped = rotate([...base, ...crown], pivot, tilt); + if (line) { + strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true }); + return; + } + tracePolyline(ctx, shaped, true); + ctx.fillStyle = tongueColor; + ctx.fill(); + } + + if (openAmount > 0.004) { + if (!line) fillPolygon(ctx, openShape, innerColor); + // The tongue rises from the lower lip *into* the mouth, so it has to arrive + // with the opening: at a hair's width the jaw curve is a sliver, and the + // tongue used to burst straight out of it and sit on the chin. + const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1); + if (tongueAmount > 0.01 && tongueOpen > 0.01) { + ctx.save(); + tracePolyline(ctx, openShape, true); + ctx.clip(); + // Inside an open mouth the tongue sits on the lower jaw. + drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3)); + ctx.restore(); + } + // One outline around the whole mouth reads as lips; the smile stroke alone + // would leave the lower edge as a bare colour change. + strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true }); + } else { + // Closed lips - but the tongue still pokes over the lip. That is what the + // original artwork does (the mouth reads as a smile with the tongue showing, + // like ペコちゃん), and it is why a plain line looked wrong there. The lip + // line goes on afterwards, so the tongue comes out from under it. + if (tongueAmount > 0.01) { + drawTongue(lipAt, arc, tongueHeight); + } + strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness }); + } + + // --- corner marks --------------------------------------------------- + if (cornerAmount > 0.01) { + const C = L.corner; + const cornerAngle = p.cornerAngle ?? 0; + for (const [end, side] of [[left, -1], [right, 1]]) { + const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount }; + const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount }; + // A smooth curve, not a three-point kink. `mid` is the control point, so it + // is pulled twice as far as the bow should reach; both corners bow the same + // way (downwards on screen), which is what the original artwork does. + const control = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount, + }; + const curve = quadraticPoints(from, control, to, 16); + // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so + // both sides move together. + const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve; + strokePolyline(ctx, rotate(shaped, pivot, tilt), { + color: line ? strokeColor : (p.cornerColor ?? '#725497'), + // The *length* of the corner mark follows `corners`, but its thickness + // does not: `corners: 0.71` is the length that matches the original, and + // the original's stroke is the full thickness. + width: C.width, + }); + } + } +} + +/** Approximate length of a polyline. */ +function chordLength(points) { + let total = 0; + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + } + return total; +} diff --git a/public/bluebey-studio/src/gacha.js b/public/bluebey-studio/src/gacha.js new file mode 100644 index 0000000..c44f80f --- /dev/null +++ b/public/bluebey-studio/src/gacha.js @@ -0,0 +1,481 @@ +/** + * Seeded "おまかせ" rolls for the face and the pose. + * + * The studio is a hundred sliders, and that is exactly the problem: when you sit + * down to make an expression you reach for the same three settings every time. + * This module is the antidote - one press and a stranger picks the numbers for + * you, from ranges a person would actually dial in, so the result is plausible + * rather than a mash of the full slider span. + * + * Two ideas hold it together: + * + * - A roll picks an *emotion* first (ふつう, うれしい, びっくり, ...), then samples + * every part inside that emotion's window. Correlated parts read as one face; + * an independent draw per slider reads as noise. + * + * - The randomness is seeded, and the seed is a short base36 string that fits in + * a URL. `rollAll(seed)` is a pure function of that string, so a look the user + * liked can be reproduced and shared (`?seed=...`) instead of lost. A "seed" + * is either a number or text; text that is already a base36 number is read as + * one, so `decodeSeed(seed)` is always accepted back by `makeRandom`. + * + * The face and pose vocabulary, and every bound quoted below, follows + * `src/presets.js` and the slider ranges in `src/panel.js`. Nothing here touches + * the DOM or the app state: the functions return patches for `applyPatch`. + */ + +/** + * FNV-1a over UTF-16 code units, for text that is not already a seed. + */ +function fnv1a(text) { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * Normalise any seed to a 32-bit integer. Text that is already a base36 number + * is read as one - that is what `decodeSeed` emits, so it has to come back in + * unchanged - and anything else is hashed. `hashSeed`, `makeRandom` and + * `decodeSeed` all go through this, so the four entry points never disagree. + */ +function toSeedInt(seed) { + if (typeof seed === 'number' && Number.isFinite(seed)) return Math.trunc(seed) >>> 0; + const text = String(seed ?? '').trim(); + if (/^[0-9a-z]+$/.test(text)) { + const parsed = parseInt(text, 36); + if (Number.isFinite(parsed)) return parsed >>> 0; + } + return fnv1a(text); +} + +/** + * Turn text into a stable 32-bit seed (for `?seed=` in a URL). + * + * A word becomes an FNV-1a hash; text that already looks like a base36 seed is + * passed through, so `decodeSeed`'s output round-trips. + */ +export function hashSeed(text) { + return toSeedInt(text); +} + +/** + * A seeded PRNG (mulberry32). Returns `() => number` in `[0, 1)`. + * Accepts a number or a string; strings go through the same rule as `decodeSeed`. + */ +export function makeRandom(seed) { + let state = toSeedInt(seed); + return function random() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * The short, readable form of a seed (base36), for the "現在のシード" field. + * Idempotent: `decodeSeed(decodeSeed(x)) === decodeSeed(x)`, and the result is + * accepted straight back by `makeRandom`. + */ +export function decodeSeed(seed) { + return (toSeedInt(seed) >>> 0).toString(36); +} + +function rand(random, min, max) { + return min + random() * (max - min); +} + +/** An angle in whole degrees, so the pose matches the step-1 sliders. */ +function deg(random, min, max) { + return Math.round(rand(random, min, max)); +} + +function round2(value) { + return Math.round(value * 100) / 100; +} + +function pick(random, list) { + return list[Math.floor(random() * list.length)]; +} + +function chance(random, probability) { + return random() < probability; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +/** + * The mood table. Each entry samples only inside a range that reads as that + * emotion, which is what keeps the parts agreeing with each other. + * + * Fields: `eyes` (open/irisScale/lookMax/lookX/lookY, all in slider units), + * `mouth` (smile/open/round/corners/cornerAngle/tilt/offsetY/tongue/width/ + * thickness), plus optional `tear`, `lowerLid`, `heart`, `wink` and `shutBoth`. + */ +const MOODS = [ + { + id: 'normal', + eyes: { + open: [0.92, 1], irisScale: [0.95, 1.06], lookMax: [0.55, 1], + lookX: [-0.28, 0.28], lookY: [-0.16, 0.16], + }, + mouth: { + smile: [0.62, 0.95], open: [0, 0.05], width: [0.9, 1.1], thickness: [0.9, 1.15], + corners: [0.8, 1.2], cornerAngle: [-6, 6], tilt: [-4, 4], offsetY: [-5, 6], + tongue: [0, 1.1], + }, + }, + { + id: 'happy', + eyes: { + open: [0.88, 1], irisScale: [0.96, 1.08], lookMax: [0.5, 0.95], + lookX: [-0.3, 0.3], lookY: [-0.2, 0.05], + }, + mouth: { + smile: [1.05, 1.3], open: [0.02, 0.22], width: [0.95, 1.2], thickness: [0.9, 1.15], + corners: [1.05, 1.4], cornerAngle: [0, 12], tilt: [-4, 6], offsetY: [-8, 2], + tongue: [0.9, 1.35], + }, + }, + { + id: 'surprised', + eyes: { + open: [0.95, 1], irisScale: [0.8, 0.9], lookMax: [0.3, 0.5], + lookX: [-0.15, 0.15], lookY: [-0.1, 0.1], + }, + mouth: { + smile: [0, 0.2], open: [0.1, 0.32], round: [0.55, 0.8], width: [0.82, 0.98], + thickness: [1, 1.2], corners: [0, 0.15], cornerAngle: [-3, 3], tilt: [-3, 3], + offsetY: [-6, 6], tongue: [0, 0.4], + }, + }, + { + id: 'sad', + eyes: { + open: [0.55, 0.75], irisScale: [0.95, 1.05], lookMax: [0.45, 0.85], + lookX: [-0.2, 0.2], lookY: [-0.35, -0.12], + }, + mouth: { + smile: [-0.7, -0.4], open: [0, 0.05], width: [0.68, 0.88], thickness: [1.05, 1.3], + corners: [0, 0.1], cornerAngle: [-4, 4], tilt: [-5, 5], offsetY: [6, 16], + tongue: [0, 0.1], + }, + lowerLid: [0.16, 0.28], + tear: [0.6, 1.2], + tearChance: 1, + }, + { + id: 'angry', + eyes: { + open: [0.55, 0.7], irisScale: [0.82, 0.92], lookMax: [0.5, 0.9], + lookX: [-0.15, 0.15], lookY: [-0.16, 0.02], + }, + mouth: { + smile: [-0.85, -0.5], open: [0, 0.04], width: [0.78, 0.94], thickness: [1.05, 1.3], + corners: [0, 0.05], cornerAngle: [-6, 6], tilt: [-6, 6], offsetY: [8, 18], + tongue: [0, 0.05], + }, + lowerLid: [0.08, 0.2], + }, + { + id: 'love', + eyes: { + open: [1, 1], irisScale: [0.9, 1.02], lookMax: [0.4, 0.8], + lookX: [-0.16, 0.16], lookY: [-0.36, -0.2], + }, + mouth: { + smile: [1.15, 1.3], open: [0.08, 0.3], width: [0.98, 1.18], thickness: [0.98, 1.2], + corners: [1.2, 1.4], cornerAngle: [4, 14], tilt: [2, 8], offsetY: [-2, 6], + tongue: [1.1, 1.4], + }, + heart: true, + }, + { + id: 'sleepy', + eyes: { + open: [0.32, 0.5], irisScale: [0.95, 1.05], lookMax: [0.4, 0.7], + lookX: [-0.18, 0.18], lookY: [-0.26, -0.08], + }, + mouth: { + smile: [0.5, 0.82], open: [0, 0.08], width: [0.82, 1], thickness: [0.92, 1.1], + corners: [0.5, 0.9], cornerAngle: [-4, 4], tilt: [-7, -1], offsetY: [4, 13], + tongue: [0.3, 0.8], + }, + lowerLid: [0.1, 0.24], + tear: [0.2, 0.5], + tearChance: 0.5, + shutBoth: { chance: 0.4, closed: 'line', closedLines: 1 }, + }, + { + id: 'wink', + eyes: { + open: [0.95, 1], irisScale: [0.95, 1.05], lookMax: [0.5, 0.9], + lookX: [-0.2, 0.2], lookY: [-0.15, 0.1], + }, + mouth: { + smile: [1, 1.3], open: [0.02, 0.18], width: [0.95, 1.15], thickness: [0.92, 1.12], + corners: [0.9, 1.2], cornerAngle: [4, 12], tilt: [-5, 5], offsetY: [-6, 4], + tongue: [0.8, 1.25], + }, + wink: true, + }, +]; + +/** + * Sample the eyes. Both eyes normally agree (a tiny jitter keeps the face from + * looking printed); a wink shuts exactly one, and ねむい sometimes shuts both. + * A shut-eye style below 1 line is only ever picked when that eye is closed. + */ +function rollEyes(random, spec) { + const [openMin, openMax] = spec.open; + let leftOpen = round2(rand(random, openMin, openMax)); + let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1)); + let leftShape = 'open'; + let rightShape = 'open'; + let leftClosed = 'line'; + let rightClosed = 'line'; + let leftLines = 1; + let rightLines = 1; + + if (spec.wink) { + const side = chance(random, 0.5) ? 'left' : 'right'; + const lines = pick(random, [2, 3]); + // A shut eye is the artwork with the lids open now; the lid height is its own + // slider, so `open` is 1 and the shape says which line it is. + if (side === 'left') { + leftOpen = 1; + leftLines = lines; + leftShape = `line${lines}`; + } else { + rightOpen = 1; + rightLines = lines; + rightShape = `line${lines}`; + } + } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) { + leftOpen = 1; + rightOpen = 1; + leftClosed = spec.shutBoth.closed; + rightClosed = spec.shutBoth.closed; + leftLines = spec.shutBoth.closedLines; + rightLines = spec.shutBoth.closedLines; + const shape = spec.shutBoth.closed === 'line' + ? `line${spec.shutBoth.closedLines}` + : spec.shutBoth.closed; + leftShape = shape; + rightShape = shape; + } + + const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1])); + const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1])); + + return { + left: { shape: leftShape, open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, + right: { shape: rightShape, open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' }, + irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])), + lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])), + highlight: true, + }; +} + +/** Sample the mouth. A round "O" and a smile are mutually exclusive by design. */ +function rollMouth(random, spec) { + return { + visible: true, + smile: round2(rand(random, spec.smile[0], spec.smile[1])), + open: round2(rand(random, spec.open[0], spec.open[1])), + round: spec.round ? round2(rand(random, spec.round[0], spec.round[1])) : 0, + width: round2(rand(random, spec.width[0], spec.width[1])), + thickness: round2(rand(random, spec.thickness[0], spec.thickness[1])), + tilt: Math.round(rand(random, spec.tilt[0], spec.tilt[1])), + offsetY: Math.round(rand(random, spec.offsetY[0], spec.offsetY[1])), + corners: round2(rand(random, spec.corners[0], spec.corners[1])), + cornerAngle: Math.round(rand(random, spec.cornerAngle[0], spec.cornerAngle[1])), + tongue: round2(rand(random, spec.tongue[0], spec.tongue[1])), + }; +} + +/** + * A patch for the `face` section: `{ eyes, mouth }`, ready for + * `applyPatch(state.face, patch)`. + * + * Bounds, in case you are reading this from the tests: + * eyes.open 0..1, irisScale 0.8..1.1, lookMax 0.3..1, + * lookX -0.35..0.35, lookY -0.45..0.2, + * mouth.smile -1..1.4 (negative = frown, only when the eyes agree), + * mouth.open 0..0.35, mouth.round 0 or 0.4..0.8, + * corners 0..1.4, cornerAngle -30..30, tilt -8..8, offsetY -20..30, + * width 0.6..1.3, thickness 0.7..1.4, tongue 0..1.6, + * eyes.left/right.tear 0..1.6. + */ +export function randomFace(random) { + const mood = pick(random, MOODS); + const eyes = rollEyes(random, mood.eyes); + const mouth = rollMouth(random, mood.mouth); + + // Tears only read on a sad or sleepy face: a frown, or eyes that are half shut. + // A fully shut eye never cries. + const halfShut = eyes.left.open > 0 && eyes.left.open < 0.78; + if (mood.tear && eyes.left.open > 0 && (mouth.smile < 0 || halfShut) + && chance(random, mood.tearChance ?? 1)) { + // The tears are per eye, so both have to be set. (A crying face wants both; + // the shape still allows one eye to cry on its own.) + const amount = round2(rand(random, mood.tear[0], mood.tear[1])); + eyes.left.tear = amount; + eyes.right.tear = amount; + eyes.left.tearOn = true; + eyes.right.tearOn = true; + } + + if (mood.lowerLid) { + eyes.lowerLid = round2(rand(random, mood.lowerLid[0], mood.lowerLid[1])); + } + + // A heart pupil needs both eyes open, or it reads as a broken iris. + if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) { + eyes.left.irisShape = 'heart'; + eyes.right.irisShape = 'heart'; + eyes.left.shape = 'heart'; + eyes.right.shape = 'heart'; + eyes.heartScale = round2(rand(random, 0.9, 1.15)); + eyes.heartColor = '#e0344f'; + eyes.highlight = false; + } + + return { eyes, mouth }; +} + +/** + * The pose moves. Each composes one to three bones (never the whole rig) inside + * safe limits: arms up to ~74°, master lean up to 24°, root offsets small. + * + * Bounds: master [-20..24, -28..28, -14..14], armsupport [-18..76, -12..12, + * -24..24], arm [-14..20, -8..8, -12..12], legsupport [-8..24, -8..8, -12..12], + * root x/z -0.15..0.15 and y 0..0.45. + */ +const POSE_MOVES = [ + // A lean or a nod - one bone, the safest thing in the table. + (random) => ({ + bones: { master: [deg(random, 6, 24), deg(random, -10, 10), deg(random, -6, 6)] }, + }), + // A curious tilt to one side. + (random) => { + const sign = chance(random, 0.5) ? 1 : -1; + return { bones: { master: [deg(random, -6, 8), 0, sign * deg(random, 8, 14)] } }; + }, + // A wave from one arm. + (random) => { + const side = pick(random, ['l', 'r']); + return { + bones: { + [`armsupport.${side}`]: [deg(random, 42, 74), deg(random, -8, 8), deg(random, -14, 14)], + [`arm.${side}`]: [deg(random, 6, 18), 0, 0], + }, + }; + }, + // A two-armed cheer, with a small hop. + (random) => ({ + bones: { + master: [deg(random, -14, -2), 0, 0], + 'armsupport.l': [deg(random, 52, 74), 0, deg(random, 4, 18)], + 'armsupport.r': [deg(random, 52, 74), 0, -deg(random, 4, 18)], + }, + root: [0, round2(rand(random, 0.05, 0.3)), 0], + }), + // Introducing something off to one side. + (random) => { + const side = pick(random, ['l', 'r']); + const sign = side === 'l' ? 1 : -1; + const other = side === 'l' ? 'r' : 'l'; + return { + bones: { + master: [deg(random, 2, 8), sign * deg(random, 16, 26), 0], + [`armsupport.${side}`]: [deg(random, 24, 40), 0, sign * deg(random, 10, 20)], + [`armsupport.${other}`]: [deg(random, 2, 14), 0, 0], + }, + }; + }, + // A little hop on the spot. + (random) => ({ + bones: { + master: [deg(random, -12, -2), 0, 0], + 'legsupport.l': [deg(random, 8, 22), 0, deg(random, -8, 8)], + 'legsupport.r': [deg(random, 8, 22), 0, deg(random, -8, 8)], + }, + root: [0, round2(rand(random, 0.18, 0.42)), 0], + }), + // Both arms up in a simple stretch. + (random) => ({ + bones: { + 'armsupport.l': [deg(random, 46, 74), 0, deg(random, 4, 16)], + 'armsupport.r': [deg(random, 46, 74), 0, -deg(random, 4, 16)], + }, + }), + // A dance sway: one arm leads, the other follows. + (random) => { + const sign = chance(random, 0.5) ? 1 : -1; + return { + bones: { + master: [deg(random, -4, 4), 0, sign * deg(random, 4, 12)], + 'armsupport.l': [deg(random, 30, 46), 0, sign * deg(random, 8, 18)], + 'armsupport.r': [deg(random, 12, 26), 0, -sign * deg(random, 8, 18)], + }, + root: [round2(rand(random, -0.08, 0.08)), 0, 0], + }; + }, +]; + +/** A patch for the `pose` section: `{ bones, root }`. */ +export function randomPose(random) { + const move = pick(random, POSE_MOVES)(random); + return { bones: move.bones, root: move.root ?? [0, 0, 0] }; +} + +/** + * A patch for the `lookAt` section. About half the time it enables a target off + * to one side, so the character glances away instead of staring at the camera. + * Bounds: |x| 1.2..3.2 when enabled, y 1.6..3.4, z 2..4.2, amount 0.5..1. + */ +export function randomLook(random) { + if (chance(random, 0.45)) { + const sign = chance(random, 0.5) ? 1 : -1; + return { + lookAt: { + enabled: true, + x: round2(sign * rand(random, 1.2, 3.2)), + y: round2(rand(random, 1.6, 3.4)), + z: round2(rand(random, 2, 4.2)), + turnBody: chance(random, 0.35), + amount: round2(rand(random, 0.5, 1)), + }, + }; + } + return { + lookAt: { enabled: false, x: 0, y: 2.6, z: 3, turnBody: false, amount: round2(rand(random, 0.6, 1)) }, + }; +} + +/** + * One press of おまかせ: face, pose and look, all from the same seed. + * + * @param {number | string} seed a number, or text that `decodeSeed` also accepts + * @returns {{ seed: string, patch: { face: object, pose: object, lookAt: object } }} + * `seed` is the readable base36 form, for the URL and the seed field. + */ +export function rollAll(seed) { + const random = makeRandom(seed); + return { + seed: decodeSeed(seed), + patch: { + face: randomFace(random), + pose: randomPose(random), + lookAt: randomLook(random).lookAt, + }, + }; +} diff --git a/public/bluebey-studio/src/gion.js b/public/bluebey-studio/src/gion.js new file mode 100644 index 0000000..832313b --- /dev/null +++ b/public/bluebey-studio/src/gion.js @@ -0,0 +1,226 @@ +/** + * 擬音 (マンガのオノマトペ) のスタンプ. + * + * The source images are dense sheets: several sounds, each in a white-outline and + * a solid version, packed so tightly that an automatic slice (outline tracing / + * connected components) tears a single character into fragments. So nothing here + * guesses at a sheet's layout. Instead the user draws a rectangle over the sheet + * in the picker and the app crops exactly that rectangle, which means the feature + * works for any sheet that arrives later. + * + * That is why the crops are stored as *source pixels* (`sx, sy, sw, sh`) taken + * from the bitmap's own `naturalWidth` / `naturalHeight`, never as fractions of + * some assumed grid: the sheet's pixel size is read at runtime. + * + * The module is pure and DOM-free in the same sense as `trace.js` and + * `caption.js`: no globals are touched except a read of the injected + * `__BLUEBEY_GIONS__` lookup, and `drawGion` takes the loaded bitmaps as an + * argument, so the caller owns loading and the tests can drive the geometry in + * Node with a stub context. + */ + +/** + * The sheets the studio offers. It ships with none: the original otarunet sheet + * may not be redistributed with the app, and the images the author draws later are + * added here. So this list starts empty and the 擬音 section stays out of the panel + * until an entry appears. + * + * To add one: put the image in `assets/manga-gion/`, add `{ name, label }` here + * (`name` is the file name *with* its extension, e.g. `dokaan.png`) and add the + * same `name` to `GION_NAMES` in `tools/build-standalone.mjs` for the one-file + * build. Use an image with a transparent background - the crop is drawn as-is, so + * a white background would sit on the picture as a white rectangle. + */ +export const GION_SHEETS = []; + +/** + * The default display width of a stamp, in pixels at 1x. A crop that is tall and + * thin will be narrower than this after the aspect is applied; the user resizes + * it from the panel either way. + */ +export const DEFAULT_STAMP_WIDTH = 240; + +/** + * Where a sheet's image lives. The single-file build inlines every sheet as a + * data URL in `window.__BLUEBEY_GIONS__`; the normal build reads the file. + * + * @param {string} name the sheet file name, with its extension, e.g. `dokaan.png` + * @returns {string} + */ +export function gionSheetUrl(name) { + const inlined = globalThis.__BLUEBEY_GIONS__ ?? {}; + return inlined[name] ?? `assets/manga-gion/${name}`; +} + +/** Clamp `value` into `lo..hi`; a non-finite value becomes `lo`. */ +export function clamp(value, lo, hi) { + if (!Number.isFinite(value)) return lo; + return Math.min(hi, Math.max(lo, value)); +} + +/** True when `image` has pixels to draw (a not-yet-loaded Image has none). */ +export function imageReady(image) { + if (!image) return false; + const width = Number(image.naturalWidth ?? image.width ?? 0); + const height = Number(image.naturalHeight ?? image.height ?? 0); + // `complete` is the browser's own answer; a stub in a test omits it, so only an + // explicit `false` blocks the draw. + return width > 0 && height > 0 && image.complete !== false; +} + +/** The bitmap's width / height. 1 when it is not loaded yet. */ +export function imageAspect(image) { + const width = toPositive(image?.naturalWidth ?? image?.width, 1); + const height = toPositive(image?.naturalHeight ?? image?.height, 1); + return width / height; +} + +/** + * The rectangle a stamp occupies on screen, in pixels, with rotation left out + * (the caller rotates about the returned centre). + * + * `item.x` / `item.y` are the centre as a fraction of the viewport, `item.w` is + * the width in pixels at 1x, and the height follows the bitmap's aspect ratio, + * so a wide crop and a tall crop are both placed by their width alone. + * + * @param {object} item a stamp: `{ x, y, w, ... }` + * @param {object} [viewport] `{ width, height, scale }` of the target surface + * @param {number} [aspect] bitmap width / height + * @returns {{x: number, y: number, w: number, h: number, cx: number, cy: number}} + */ +export function stampRect(item, { width = 0, height = 0, scale = 1 } = {}, aspect = 1) { + const w = toPositive(item?.w, DEFAULT_STAMP_WIDTH) * positiveScale(scale); + const h = w / toPositive(aspect, 1); + const cx = toFinite(item?.x, 0.5) * toNonNegative(width, 0); + const cy = toFinite(item?.y, 0.5) * toNonNegative(height, 0); + return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy }; +} + +/** Turn two corner points into a rectangle with a positive width and height. */ +export function normalizeRect(a, b) { + const ax = toFinite(a?.x, 0); + const ay = toFinite(a?.y, 0); + const bx = toFinite(b?.x, 0); + const by = toFinite(b?.y, 0); + return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) }; +} + +/** + * Map a marquee (drawn in screen pixels over the fitted sheet) to a pixel crop of + * the source bitmap. + * + * `sheetRect` is where the sheet is currently displayed, so the marquee becomes a + * fraction of the sheet and then a fraction of the bitmap - the sheet's own pixel + * size comes from `natural`, never from a hard-coded number. The crop is clamped + * to the bitmap and is at least 1x1, so it can always be drawn. + * + * @param {{x: number, y: number, w: number, h: number}} marquee screen pixels + * @param {{x: number, y: number, w: number, h: number}} sheetRect screen pixels + * @param {object} natural the sheet image (or any `{ naturalWidth, naturalHeight }`) + * @returns {{sx: number, sy: number, sw: number, sh: number}} + */ +export function cropFromMarquee(marquee, sheetRect, natural) { + const nw = Math.max(1, Math.round(toPositive(natural?.naturalWidth, 1))); + const nh = Math.max(1, Math.round(toPositive(natural?.naturalHeight, 1))); + const rect = { + x: toFinite(marquee?.x, 0), + y: toFinite(marquee?.y, 0), + w: toNonNegative(marquee?.w, 0), + h: toNonNegative(marquee?.h, 0), + }; + const displayW = toNonNegative(sheetRect?.w, 0); + const displayH = toNonNegative(sheetRect?.h, 0); + + const left = displayW > 0 ? clamp((rect.x - sheetRect.x) / displayW, 0, 1) : 0; + const right = displayW > 0 ? clamp((rect.x + rect.w - sheetRect.x) / displayW, 0, 1) : 0; + const top = displayH > 0 ? clamp((rect.y - sheetRect.y) / displayH, 0, 1) : 0; + const bottom = displayH > 0 ? clamp((rect.y + rect.h - sheetRect.y) / displayH, 0, 1) : 0; + + const sx = clamp(Math.round(left * nw), 0, nw - 1); + const sy = clamp(Math.round(top * nh), 0, nh - 1); + return { + sx, + sy, + sw: clamp(Math.round((right - left) * nw), 1, nw - sx), + sh: clamp(Math.round((bottom - top) * nh), 1, nh - sy), + }; +} + +/** + * Fit a bitmap inside a box, keeping its aspect ratio and centring it. The picker + * uses this to show a whole sheet - however large - at a size the user can drag + * over. + * + * @param {object} natural the sheet image + * @param {{width: number, height: number}} box the available area, in pixels + * @param {object} [options] + * @param {number} [options.padding=0] a margin to keep inside the box + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function fitSheet(natural, box, { padding = 0 } = {}) { + const nw = toPositive(natural?.naturalWidth ?? natural?.width, 1); + const nh = toPositive(natural?.naturalHeight ?? natural?.height, 1); + const pad = toNonNegative(padding, 0); + const availW = Math.max(0, toNonNegative(box?.width, 0) - pad * 2); + const availH = Math.max(0, toNonNegative(box?.height, 0) - pad * 2); + const scale = Math.min(availW / nw, availH / nh); + const w = nw * scale; + const h = nh * scale; + return { x: pad + (availW - w) / 2, y: pad + (availH - h) / 2, w, h }; +} + +/** + * Paint every stamp onto a 2D context. + * + * The same function runs over the live viewport overlay (scale 1) and into the + * exported PNG (scale = output pixels / CSS pixels), which is what keeps the + * preview and the file in step. A stamp whose sheet has not loaded yet is simply + * skipped, so the rest of the picture is never held up by one image. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Array<object>} items the stamps + * @param {Map<string, CanvasImageSource>} images loaded sheets, by name + * @param {object} [options] + * @param {number} [options.width=0] + * @param {number} [options.height=0] + * @param {number} [options.scale=1] + */ +export function drawGion(ctx, items, images, { width = 0, height = 0, scale = 1 } = {}) { + if (!ctx || !Array.isArray(items)) return; + const viewport = { width, height, scale: positiveScale(scale) }; + for (const item of items) { + const image = images?.get?.(item?.sheet); + if (!imageReady(image)) continue; + const rect = stampRect(item, viewport, imageAspect(image)); + if (!(rect.w > 0) || !(rect.h > 0)) continue; + const rot = toFinite(item?.rot, 0); + ctx.save(); + ctx.translate(rect.cx, rect.cy); + if (rot !== 0) ctx.rotate((rot * Math.PI) / 180); + // The flip is applied before the draw so the same source rect feeds both. + if (item?.flip) ctx.scale(-1, 1); + ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, -rect.w / 2, -rect.h / 2, rect.w, rect.h); + ctx.restore(); + } +} + +/* ----------------------------------------------------------------- numbers */ + +function toFinite(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function toPositive(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function toNonNegative(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function positiveScale(value) { + return toPositive(value, 1); +} diff --git a/public/bluebey-studio/src/glbExport.js b/public/bluebey-studio/src/glbExport.js new file mode 100644 index 0000000..6e3e997 --- /dev/null +++ b/public/bluebey-studio/src/glbExport.js @@ -0,0 +1,148 @@ +import * as THREE from 'three'; +import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; + +/** + * Hand the posed character back as a 3D file. + * + * WHY: a PNG or a WebM only travels as pixels. Being able to take the pose, the + * expression and the props into Blender (or embed them elsewhere as a model) + * turns the studio into a front end for the character instead of a picture + * maker, and it costs nothing: what is on screen already *is* a glTF scene, so + * the only job here is to hand the live objects to three's exporter with the + * right options and a Blob around the result. + * + * The caller passes exactly what it wants in the file - the character root and + * any props - so no lights, helpers or ground ever enter it. The inverted-hull + * outlines are the one wrinkle: they are visible (they are part of the look) + * but they are duplicates of the body geometry, so exporting them would double + * the file and leave a black shell around the character. `needsTemporaryHide` + * decides what to hide for the duration, and `exportGLB` restores it after. + */ + +const GLTF_BINARY_TYPE = 'model/gltf-binary'; +const GLTF_JSON_TYPE = 'model/gltf+json'; +/** The largest texture a GPU can be assumed to handle everywhere. */ +const MAX_TEXTURE_SIZE = 4096; + +/** + * True for objects the exporter must skip: the inverted-hull outlines (named + * `...:outline`), anything the app tagged as a helper or as excluded from the + * export, and anything already hidden. The name check is the important one: an + * outline is visible on screen, so `onlyVisible` alone would not drop it. + * + * @param {THREE.Object3D} object + * @returns {boolean} + */ +export function needsTemporaryHide(object) { + if (!object) return false; + if (typeof object.name === 'string' && object.name.endsWith(':outline')) return true; + if (object.userData?.isHelper === true) return true; + if (object.userData?.excludeFromExport === true) return true; + return object.visible === false; +} + +/** Triangles in a geometry, ignoring draw ranges (the export does too). */ +function triangleCount(geometry) { + if (!geometry) return 0; + const index = geometry.index; + const count = index ? index.count : geometry.attributes?.position?.count ?? 0; + return Math.floor(count / 3); +} + +/** + * Count what `exportGLB` will write, for the summary the app shows first. + * + * Walks the same pruned tree the exporter sees, so hiding an outline or a + * helper is reflected in the numbers. A `SkinnedMesh` is also a `Mesh`, so it + * counts in both `meshes` and `skinnedMeshes`; a material shared by several + * meshes counts once, and every texture a counted material refers to counts + * once. + * + * @param {THREE.Object3D[]} objects + * @returns {{ meshes: number, skinnedMeshes: number, materials: number, textures: number, triangles: number }} + */ +export function describeScene(objects) { + const materials = new Set(); + const textures = new Set(); + let meshes = 0; + let skinnedMeshes = 0; + let triangles = 0; + + const visit = (object) => { + if (!object || needsTemporaryHide(object)) return; + if (object.isMesh) { + meshes += 1; + if (object.isSkinnedMesh) skinnedMeshes += 1; + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material) continue; + materials.add(material); + for (const value of Object.values(material)) { + if (value && value.isTexture) textures.add(value); + } + } + triangles += triangleCount(object.geometry); + } + for (const child of object.children ?? []) visit(child); + }; + + for (const root of Array.isArray(objects) ? objects : []) visit(root); + return { meshes, skinnedMeshes, materials: materials.size, textures: textures.size, triangles }; +} + +/** + * Hide every object the file must not contain, remembering what to restore. + * Stops at the first excluded ancestor: the exporter skips a hidden subtree, so + * there is no need to walk inside one. + */ +function hideExcluded(object, hidden) { + if (needsTemporaryHide(object)) { + hidden.push({ object, visible: object.visible }); + object.visible = false; + return; + } + for (const child of object.children ?? []) hideExcluded(child, hidden); +} + +/** + * Export `objects` as a GLB (or a `.gltf` JSON) Blob. + * + * @param {THREE.Object3D[]} objects the character root and any props + * @param {{ binary?: boolean, name?: string }} [options] + * @returns {Promise<Blob>} `model/gltf-binary`, or `model/gltf+json` when not binary + */ +export async function exportGLB(objects, { binary = true, name = 'bluebey' } = {}) { + const roots = (Array.isArray(objects) ? objects : []).filter(Boolean); + if (roots.length === 0) { + throw new Error('exportGLB: objects is empty, there is nothing to export'); + } + if (describeScene(roots).meshes === 0) { + throw new Error('exportGLB: objects contain no visible meshes to export'); + } + + const hidden = []; + try { + for (const root of roots) hideExcluded(root, hidden); + + // The exporter names a bare array of objects "AuxScene". Wrapping them in a + // named Scene keeps the caller's objects where they are (we push straight + // into `children`, exactly as the exporter does) and gives the file a real + // scene name. + const scene = new THREE.Scene(); + scene.name = name; + for (const root of roots) scene.children.push(root); + + const exporter = new GLTFExporter(); + const result = await exporter.parseAsync(scene, { + binary, + onlyVisible: true, + truncateDrawRange: false, + maxTextureSize: MAX_TEXTURE_SIZE, + }); + + if (binary) return new Blob([result], { type: GLTF_BINARY_TYPE }); + return new Blob([JSON.stringify(result)], { type: GLTF_JSON_TYPE }); + } finally { + for (const { object, visible } of hidden) object.visible = visible; + } +} diff --git a/public/bluebey-studio/src/handDrawn.js b/public/bluebey-studio/src/handDrawn.js new file mode 100644 index 0000000..e7fbce5 --- /dev/null +++ b/public/bluebey-studio/src/handDrawn.js @@ -0,0 +1,391 @@ +import { contoursToPathData } from './trace.js'; + +/** + * Hand-drawn distortion for traced contours. + * + * WHY: `trace.js` recovers the silhouette of the mascot from a rendered alpha + * mask. The result is geometrically faithful but *mechanically* smooth: it reads + * as the outline of a printed sticker, not as a pen stroke. This module nudges + * the traced points along a smooth, seeded wobble so the very same silhouette + * looks inked by hand, without changing its point count or where it sits. + * + * The displacement is value noise interpolated along the contour, so neighbouring + * points move by nearly the same amount and the outline stays a wobbly *line* + * rather than pixel jitter. Everything is seeded (never `Math.random`), so a + * build is reproducible, and the module is pure: it never touches the DOM and + * its only import is the path-data helper in `trace.js`, which keeps the output + * format identical to the un-roughened export. + */ + +/** Lattice cells in the non-wrapping noise table (the pattern repeats after this). */ +const NOISE_PERIOD = 4096; + +const EPSILON = 1e-9; + +/** + * mulberry32: a tiny, fast 32-bit generator. Good enough for visual noise and, + * crucially, fully reproducible; the seed is the only source of variation. + * + * @param {number} seed + * @returns {() => number} values in [0, 1) + */ +function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Hermite ramp: 0 at t=0, 1 at t=1, flat at both ends (C1 continuity). */ +function smoothstep(t) { + return t * t * (3 - 2 * t); +} + +/** + * A circular table of random values in -1..1. `count` is the number of cells in + * one lap; sampling wraps at `count`, which is what lets a closed stroke's + * wobble meet itself exactly at the seam. + * + * @param {number} seed + * @param {number} count + * @returns {Float64Array} + */ +function buildNoiseTable(seed, count) { + const rng = mulberry32(seed); + const table = new Float64Array(count); + for (let i = 0; i < count; i++) table[i] = rng() * 2 - 1; + return table; +} + +/** + * Smoothly interpolate the table at `t`, wrapping around its ends. `smoothstep` + * makes the value continuous and its slope continuous at every cell boundary, so + * the wobble has no visible kinks. + */ +function sampleTable(table, t) { + const len = table.length; + const base = Math.floor(t); + const f = t - base; + const i0 = ((base % len) + len) % len; + const i1 = (i0 + 1) % len; + const a = table[i0]; + const b = table[i1]; + return a + (b - a) * smoothstep(f); +} + +/** + * One-dimensional value noise, exposed mainly so tests can pin its behaviour. + * The returned function is continuous, roughly in -1..1, deterministic for a + * given seed, and returns 0 for non-finite input. + * + * @param {number} [seed=1] + * @returns {(t: number) => number} + */ +export function makeNoise(seed = 1) { + const table = buildNoiseTable(seed, NOISE_PERIOD); + return function noise(t) { + if (!Number.isFinite(t)) return 0; + return sampleTable(table, t); + }; +} + +/** Euclidean distance between two points. */ +function distance(a, b) { + return Math.hypot(b.x - a.x, b.y - a.y); +} + +/** Cumulative arc length of every point, measured from the first. */ +function arcPositions(points) { + const pos = new Float64Array(points.length); + for (let i = 1; i < points.length; i++) { + pos[i] = pos[i - 1] + distance(points[i - 1], points[i]); + } + return pos; +} + +/** True when a closed contour repeats its first point at the end. */ +function hasClosingDuplicate(points) { + const first = points[0]; + const last = points[points.length - 1]; + return Math.abs(first.x - last.x) <= EPSILON && Math.abs(first.y - last.y) <= EPSILON; +} + +/** + * Neighbour index for each point, honouring the wrap of a closed contour. Open + * ends get -1, which makes the tangent fall back to a one-sided difference. + */ +function neighborIndices(count, closed, duplicate) { + const prev = new Int32Array(count); + const next = new Int32Array(count); + if (!closed) { + for (let i = 0; i < count; i++) { + prev[i] = i > 0 ? i - 1 : -1; + next[i] = i < count - 1 ? i + 1 : -1; + } + return { prev, next }; + } + // A repeated closing point is a copy of point 0, so the ring has one fewer + // distinct vertex and the last index borrows point 0's two neighbours, which + // is what makes its wobble identical to the first point's. + const ring = duplicate ? count - 1 : count; + const firstPrev = (ring - 1) % ring; + const firstNext = ring > 1 ? 1 : 0; + for (let i = 0; i < count; i++) { + if (duplicate && i === count - 1) { + prev[i] = firstPrev; + next[i] = firstNext; + } else { + prev[i] = (i - 1 + ring) % ring; + next[i] = (i + 1) % ring; + } + } + return { prev, next }; +} + +/** + * Unit tangent at `i`, measured from the point before to the point after so the + * wobble follows the stroke instead of the sampling. Returns null for a + * degenerate point, where there is no direction to displace along. + */ +function tangentAt(points, i, prev, next) { + const before = prev[i] >= 0 ? points[prev[i]] : points[i]; + const after = next[i] >= 0 ? points[next[i]] : points[i]; + const dx = after.x - before.x; + const dy = after.y - before.y; + const len = Math.hypot(dx, dy); + if (!(len > EPSILON)) return null; + return { x: dx / len, y: dy / len }; +} + +/** + * Fade the wobble to zero at both ends of an open stroke, over `ramp` units. + * Without this the ends would fly off the traced geometry; a smooth ramp keeps + * the stroke anchored while still looking freehand. + */ +function edgeWindow(s, length, ramp) { + if (!(ramp > 0)) return 1; + const head = Math.min(1, s / ramp); + const tail = Math.min(1, (length - s) / ramp); + return smoothstep(head) * smoothstep(tail); +} + +/** Derive a distinct, deterministic seed for each extra pass. */ +function mixSeed(seed, pass) { + return (seed + pass * 0x9e3779b1) >>> 0; +} + +/** Apply one wobble pass. `roughenPolyline` owns the input copy and the passes. */ +function roughenOnce(points, { amount, seed, closed, scale }) { + const count = points.length; + const copy = () => points.map((p) => ({ x: p.x, y: p.y })); + if (count < 2 || !(amount > 0) || !(scale > 0)) return copy(); + + const duplicate = closed ? hasClosingDuplicate(points) : false; + const pos = arcPositions(points); + const length = pos[count - 1]; + let perimeter = length; + if (closed) perimeter += distance(points[count - 1], points[0]); + + const { prev, next } = neighborIndices(count, closed, duplicate); + + // A closed stroke samples a circular table with a whole number of cells per + // lap, so the last point lands on the first cell and the seam closes. An open + // stroke samples plain (non-wrapping) noise and fades it out near the ends. + let table; + let cells = 0; + if (closed) { + if (!(perimeter > 0)) return copy(); + cells = Math.max(2, Math.round(perimeter / scale)); + table = buildNoiseTable(seed, cells); + } else { + table = buildNoiseTable(seed, NOISE_PERIOD); + } + const ramp = Math.min(scale, length * 0.25); + + const out = new Array(count); + for (let i = 0; i < count; i++) { + const tangent = tangentAt(points, i, prev, next); + const p = points[i]; + if (!tangent) { + out[i] = { x: p.x, y: p.y }; + continue; + } + const t = closed ? (pos[i] / perimeter) * cells : pos[i] / scale; + let weight = sampleTable(table, t); + if (!closed) weight *= edgeWindow(pos[i], length, ramp); + const shift = amount * weight; + if (shift === 0) { + // Keep the original exactly (also avoids turning -0 into 0). + out[i] = { x: p.x, y: p.y }; + continue; + } + // Displace perpendicular to the tangent, i.e. along the local pen normal. + out[i] = { x: p.x - tangent.y * shift, y: p.y + tangent.x * shift }; + } + if (closed && duplicate) { + // Belt and braces: pin the explicit seam shut after any rounding. + out[count - 1] = { x: out[0].x, y: out[0].y }; + } + return out; +} + +/** + * Displace every point of a polyline perpendicular to its local direction by + * smooth noise. The input is never mutated and the point count never changes. + * + * Open polylines keep their first and last point exactly; closed ones wrap, so + * the wobble is continuous across the seam. `amount` is the peak displacement in + * the same units as the points, and `scale` is how much arc length one wobble + * spans (larger = lazier, longer wobble). With `passes > 1` the displacement is + * re-noised a few times at a share of `amount`, so the peak stays within + * `amount` however many passes are used. + * + * @param {Array<{x: number, y: number}>} points + * @param {object} [options] + * @param {number} [options.amount=2] peak displacement, in point units + * @param {number} [options.seed=1] deterministic seed + * @param {boolean} [options.closed=false] treat the polyline as a ring + * @param {number} [options.scale=40] arc length covered by one wobble + * @param {number} [options.passes=1] number of noise layers + * @returns {Array<{x: number, y: number}>} a new array of new points + */ +export function roughenPolyline(points, options = {}) { + const list = Array.isArray(points) ? points : []; + const amount = options.amount ?? 2; + const seed = options.seed ?? 1; + const closed = options.closed ?? false; + const scale = options.scale ?? 40; + const passes = Math.max(1, Math.floor(options.passes ?? 1)); + + let current = list.map((p) => ({ x: p.x, y: p.y })); + if (current.length < 2 || amount === 0) return current; + for (let pass = 0; pass < passes; pass++) { + current = roughenOnce(current, { + amount: amount / passes, + seed: mixSeed(seed, pass), + closed, + scale, + }); + } + return current; +} + +/** + * Roughen a list of contours, with the closed/open choice per contour. Following + * `trace.js`'s convention, a contour is assumed to be a closed ring unless the + * caller says otherwise: pass `options.closed` as a boolean for all of them or + * as an array of flags indexed like `contours`. + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {object} [options] see `roughenPolyline`, plus `closed` as an array + * @returns {Array<Array<{x: number, y: number}>>} + */ +export function roughenContours(contours, options = {}) { + const list = Array.isArray(contours) ? contours : []; + const closedOption = options.closed; + const out = []; + for (let i = 0; i < list.length; i++) { + const closed = Array.isArray(closedOption) ? closedOption[i] ?? true : closedOption ?? true; + out.push(roughenPolyline(list[i], { ...options, closed })); + } + return out; +} + +/** + * Roughen a list of contours and return their SVG `d` attribute, using exactly + * the format of `contoursToPathData`: one `M … L … Z` subpath per contour, + * coordinates rounded to `options.round` decimal places (default 2, the same + * meaning as that function's `decimals` argument). + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {object} [options] roughening options, plus `round` and `mapPoint` + * @param {number} [options.round=2] decimal places in the output + * @param {(x: number, y: number) => [number, number]} [options.mapPoint] + * @returns {string} + */ +export function handDrawnPathData(contours, options = {}) { + const roughened = roughenContours(contours, options); + const mapPoint = options.mapPoint ?? ((x, y) => [x, y]); + return contoursToPathData(roughened, mapPoint, options.round ?? 2); +} + +/** + * Offset a stroke to both sides by a width that breathes slightly along its + * length, giving the `[left, right]` polylines a pen stroke can be filled + * between. Both sides keep the point count and order of `points`. + * + * The width only varies (it never reaches zero), so the two sides stay well + * defined; `variation` is the fraction of `width` the wobble may add or remove. + * + * @param {Array<{x: number, y: number}>} points + * @param {object} [options] + * @param {number} [options.width=3] full stroke width + * @param {number} [options.seed=1] + * @param {boolean} [options.closed=false] + * @param {number} [options.variation=0.35] relative width wobble + * @param {number} [options.scale=40] arc length covered by one width wobble + * @returns {[Array<{x: number, y: number}>, Array<{x: number, y: number}>]} + */ +export function taperStroke(points, options = {}) { + const list = Array.isArray(points) ? points : []; + const width = options.width ?? 3; + const seed = options.seed ?? 1; + const closed = options.closed ?? false; + const variation = options.variation ?? 0.35; + const scale = options.scale ?? 40; + + const count = list.length; + const left = new Array(count); + const right = new Array(count); + if (count === 0) return [left, right]; + if (count === 1) { + left[0] = { x: list[0].x, y: list[0].y }; + right[0] = { x: list[0].x, y: list[0].y }; + return [left, right]; + } + + const duplicate = closed ? hasClosingDuplicate(list) : false; + const pos = arcPositions(list); + const length = pos[count - 1]; + let perimeter = length; + if (closed) perimeter += distance(list[count - 1], list[0]); + + const { prev, next } = neighborIndices(count, closed, duplicate); + + let table; + let cells = 0; + if (closed && perimeter > 0 && scale > 0) { + cells = Math.max(2, Math.round(perimeter / scale)); + table = buildNoiseTable(seed, cells); + } else { + table = buildNoiseTable(seed, NOISE_PERIOD); + } + const span = scale > 0 ? scale : 1; + const halfWidth = Math.abs(width) / 2; + + for (let i = 0; i < count; i++) { + const tangent = tangentAt(list, i, prev, next); + const p = list[i]; + if (!tangent) { + left[i] = { x: p.x, y: p.y }; + right[i] = { x: p.x, y: p.y }; + continue; + } + const t = closed && cells > 0 ? (pos[i] / perimeter) * cells : pos[i] / span; + // Clamped well above zero so both sides keep a usable offset even when the + // caller asks for a large `variation`. + const factor = Math.max(0.05, 1 + variation * sampleTable(table, t)); + const w = halfWidth * factor; + left[i] = { x: p.x - tangent.y * w, y: p.y + tangent.x * w }; + right[i] = { x: p.x + tangent.y * w, y: p.y - tangent.x * w }; + } + if (closed && duplicate) { + left[count - 1] = { x: left[0].x, y: left[0].y }; + right[count - 1] = { x: right[0].x, y: right[0].y }; + } + return [left, right]; +} diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js new file mode 100644 index 0000000..7a54c46 --- /dev/null +++ b/public/bluebey-studio/src/hats.js @@ -0,0 +1,135 @@ +import * as THREE from 'three'; + +/** + * Hats for ぶるべー. Each hat is a small group of primitives whose base sits at + * y = 0, so the app can drop it straight onto the top of the head (see + * `hatMount` in src/main.js). The character's front is +z, so a brim or a badge + * faces +z. + * + * These are deliberately not textured: like the props, they are built from a few + * rounded primitives so they take a clean outline and read in every render style. + */ + +const material = (color, opts = {}) => new THREE.MeshStandardMaterial({ + color, + roughness: 0.72, + metalness: 0, + ...opts, +}); + +function add(parent, geometry, mat, x = 0, y = 0, z = 0) { + const mesh = new THREE.Mesh(geometry, mat); + mesh.position.set(x, y, z); + mesh.castShadow = true; + mesh.receiveShadow = false; + parent.add(mesh); + return mesh; +} + +const cylinder = (rt, rb, h, seg = 24) => new THREE.CylinderGeometry(rt, rb, h, seg); +const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI) => ( + new THREE.SphereGeometry(r, w, h, phiStart, phiLength, thetaStart, thetaLength) +); +const box = (w, h, d) => new THREE.BoxGeometry(w, h, d); + +/** + * The 線画 fill a part takes, as how far it steps from the paper towards the ink + * (see `addMesh` in src/styles.js). Every hat part already gets the light default + * tone, but two parts of the *same* hat then come out the same shade - so a part + * that sits flush on another (a band on a crown) loses its seam. Tagging it with + * a darker tone puts that seam back, without touching リアル / フラット, where the + * part's own colour already draws it. + */ +function tone(mesh, strength) { + mesh.userData.tone = strength; + return mesh; +} + +/** シルクハット: a tall black crown, a narrow band and a flat brim. */ +function buildSilk() { + const group = new THREE.Group(); + const black = material('#1b1722'); + const band = material('#3a2f4a'); + add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0); + add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0); + tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34); + return group; +} + +/** キャップ: a baseball cap - a low dome, a rim and a bill at the front. */ +function buildCap() { + const group = new THREE.Group(); + const blue = material('#3f6fd0'); + const dark = material('#2f54a4'); + // The crown: a hemisphere whose flat face sits on y = 0. + const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0); + crown.scale.set(1, 0.92, 1); + tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30); + // The bill, tipped down a little at the front. + const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30); + bill.scale.set(1, 1, 1.25); + bill.rotation.x = -0.12; + tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30); + return group; +} + +/** コック帽: a tall pleated toque - a cylindrical band under a big puffy crown. */ +function buildChef() { + const group = new THREE.Group(); + const white = material('#f7f6f2'); + const shade = material('#e6e3da'); + // The band: a tall cylinder that sits on the head. + add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0); + tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26); + // The crown: a big squashed sphere ringed with lobes, so the top reads as the + // classic pleated toque rather than a plain dome. + const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0); + puff.scale.set(1, 0.82, 1); + for (let i = 0; i < 8; i += 1) { + const a = (i / 8) * Math.PI * 2; + const lobe = add(group, sphere(0.5, 14, 12), white, Math.cos(a) * 0.92, 1.52, Math.sin(a) * 0.92); + lobe.scale.set(1, 0.9, 1); + } + const top = add(group, sphere(0.62, 16, 12), white, 0, 1.92, 0); + top.scale.set(1, 0.78, 1); + return group; +} + +/** 消防士の帽子: a red helmet with a brim, a top ridge and a small front badge. */ +function buildFire() { + const group = new THREE.Group(); + const red = material('#c5342f'); + const dark = material('#8f241f'); + const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 }); + const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0); + dome.scale.set(1, 0.95, 1); + const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28); + brim.scale.set(1, 1, 1.1); + // The ridge along the top, front to back. + const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28); + crest.rotation.x = -0.05; + tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22); + return group; +} + +const BUILDERS = { + silk: buildSilk, + cap: buildCap, + chef: buildChef, + fire: buildFire, +}; + +/** The hats offered in the panel: `none` clears it. */ +export const HAT_LIBRARY = [ + { id: 'none', label: 'なし' }, + { id: 'silk', label: 'シルクハット' }, + { id: 'cap', label: 'キャップ' }, + { id: 'chef', label: 'コック帽' }, + { id: 'fire', label: '消防士' }, +]; + +/** Builds the named hat, or `null` for `none` / an unknown name. */ +export function buildHat(id) { + const builder = BUILDERS[id]; + return builder ? builder() : null; +} diff --git a/public/bluebey-studio/src/history.js b/public/bluebey-studio/src/history.js new file mode 100644 index 0000000..d988b50 --- /dev/null +++ b/public/bluebey-studio/src/history.js @@ -0,0 +1,105 @@ +/** + * Undo / redo for the whole studio state. + * + * The studio is a big pile of sliders, and before this every experiment was + * one-way: nudging the wrong slider meant dialling the old value back by hand. + * A history of whole-state snapshots is the simplest thing that can possibly + * work here, because the state is already the single source of truth and every + * control funnels through `applyState`. + * + * Two details matter for it to feel right rather than merely correct: + * + * - Dragging a slider fires an event per pixel, which would bury the history in + * hundreds of near-identical entries. Entries therefore carry a label, and a + * new entry with the *same* label within `coalesceMs` REPLACES the previous + * one instead of stacking on top of it. So a whole drag becomes one step. + * + * - `view.backgroundImage` can be a multi-megabyte data URL, and `state` also + * holds the caption text. The snapshots copy objects by hand rather than via + * `JSON.parse(JSON.stringify(...))`, because assigning a string in JavaScript + * shares it instead of duplicating it - so a hundred snapshots of a heavy + * state stay cheap. + */ + +const SHALLOW_TYPES = new Set(['string', 'number', 'boolean', 'undefined']); + +/** Deep copy that shares string data (and handles the odd null/array). */ +function copy(value) { + if (value === null || SHALLOW_TYPES.has(typeof value)) return value; + if (Array.isArray(value)) return value.map(copy); + if (typeof value === 'object') { + const out = {}; + for (const [key, inner] of Object.entries(value)) out[key] = copy(inner); + return out; + } + return value; // functions, symbols: not part of the saved state +} + +export class History { + constructor({ limit = 120, coalesceMs = 700, onChange = null } = {}) { + this.limit = Math.max(2, limit); + this.coalesceMs = coalesceMs; + this.onChange = onChange; + /** @type {{ state: object, label: string, at: number }[]} */ + this.entries = []; + this.index = -1; + } + + /** Forget everything and start from `state` (call after load/reset). */ + reset(state, label = 'start') { + this.entries = [{ state: copy(state), label, at: Date.now() }]; + this.index = 0; + this.onChange?.(this); + } + + get canUndo() { return this.index > 0; } + get canRedo() { return this.index >= 0 && this.index < this.entries.length - 1; } + + /** Label of the step undo would jump to, for the button tooltip. */ + get undoLabel() { return this.canUndo ? this.entries[this.index].label : null; } + get redoLabel() { return this.canRedo ? this.entries[this.index + 1].label : null; } + + /** + * Record the state *after* a change. Repeating the same label in quick + * succession (a slider drag) keeps a single entry that follows the value. + */ + push(state, label = '変更') { + const now = Date.now(); + const top = this.entries[this.index]; + const sameDrag = top + && top.label === label + && now - top.at <= this.coalesceMs + && this.index === this.entries.length - 1; + + if (sameDrag) { + this.entries[this.index] = { state: copy(state), label, at: now }; + } else { + this.entries.length = this.index + 1; + this.entries.push({ state: copy(state), label, at: now }); + if (this.entries.length > this.limit) this.entries.shift(); + this.index = this.entries.length - 1; + } + this.onChange?.(this); + return this; + } + + /** The previous snapshot, or `null` when there is nothing to go back to. */ + undo() { + if (!this.canUndo) return null; + this.index -= 1; + this.onChange?.(this); + return copy(this.entries[this.index].state); + } + + redo() { + if (!this.canRedo) return null; + this.index += 1; + this.onChange?.(this); + return copy(this.entries[this.index].state); + } + + /** A plain description of where we are, for tests and debug output. */ + describe() { + return this.entries.map((entry, i) => `${i === this.index ? '*' : ' '}${entry.label}`).join(' | '); + } +} diff --git a/public/bluebey-studio/src/look.js b/public/bluebey-studio/src/look.js new file mode 100644 index 0000000..d755c3b --- /dev/null +++ b/public/bluebey-studio/src/look.js @@ -0,0 +1,184 @@ +import * as THREE from 'three'; +import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; + +/** + * The "look" of the scene, as opposed to the character's shape: body colours, + * mirroring, shadows and the lighting environment. + * + * These all live here rather than in `main.js` because they are a *policy* about + * how the model should be presented, and they need to be re-applied as a group + * whenever any of them changes. `main.js` only has to call `apply()`. + * + * Two things are worth knowing: + * + * - Colours are written onto the model's ORIGINAL materials. The flat/toon + * style caches its own materials derived from them, so `styles.refreshColors()` + * has to be called afterwards or the toon shading keeps the old colour. + * + * - Shadow softness is `LightShadow.radius` with a PCF soft shadow map, plus a + * hand-drawn contact blob for the "ground shadow only" mode. (VSM was tried + * for its wider blur and cut the shadow off in a straight line where it met + * the feet - see the note in `apply`.) + */ + +export const ENVIRONMENTS = [ + { value: 'gradient', label: 'スタジオ(明るい)' }, + { value: 'room', label: '室内(自然な反射)' }, + { value: 'none', label: 'なし(のっぺり)' }, +]; + +/** Material name -> which entry of `render.colors` it takes. */ +const PART_COLORS = { + blb: 'body', + Hand: 'accent', + Foots: 'feet', + Nose: 'nose', + Leaf: 'leaf', + Vein: 'vein', +}; + +const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0)); + +/** + * A soft blob that sits under the feet: the shadow a toy would cast on a table. + * It is a plain plane with a radial gradient, so it is cheap and it works in the + * line-art styles too, where the real shadow map is switched off. + */ +function makeContactShadow(size) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62); + gradient.addColorStop(0, 'rgba(0,0,0,0.55)'); + gradient.addColorStop(0.55, 'rgba(0,0,0,0.28)'); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 128, 128); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + mesh.rotation.x = -Math.PI / 2; + mesh.renderOrder = -0.5; + mesh.userData.isHelper = true; + mesh.name = 'contact-shadow'; + mesh.scale.setScalar(Math.max(1, size.x) * 1.15); + return mesh; +} + +export function createLook({ renderer, scene, styles, model, ground, key, character, gradientEnvironment }) { + const pmrem = new THREE.PMREMGenerator(renderer); + const roomEnvironment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture; + + const contact = makeContactShadow(model.size); + scene.add(contact); + + const environmentFor = (name) => { + if (name === 'room') return roomEnvironment; + if (name === 'none') return null; + return gradientEnvironment ?? null; + }; + + function applyColors(colors) { + if (!colors) return; + let touched = false; + for (const mesh of model.parts.body) { + const material = styles.originals.get(mesh) ?? mesh.material; + const part = PART_COLORS[material?.name]; + if (!part || !colors[part]) continue; + if (!material.color) material.color = new THREE.Color(); + material.color.set(colors[part]); + touched = true; + } + // The toon materials are cached copies, so they need the same edit. + if (touched) styles.refreshColors?.(); + return touched; + } + + return { + contact, + + /** + * @param {object} render `state.render` + * @param {{ line?: boolean }} [context] `line` is true in the line-art + * styles, where shadows and reflections are deliberately switched off. + */ + apply(render, { line = false } = {}) { + if (!render) return; + applyColors(render.colors); + + // Mirroring by negative scale is safe: the renderer flips the winding for + // a negative determinant, and the normals go through the inverse-transpose. + character.scale.x = render.mirror ? -1 : 1; + + const wantsShadow = render.shadow !== false && !line; + const blobOnly = render.contactShadow === true; + ground.visible = wantsShadow && !blobOnly; + ground.material.opacity = clamp01(render.shadowOpacity ?? 0.22); + key.castShadow = wantsShadow && !blobOnly; + key.shadow.needsUpdate = true; + + // The blob is drawn in BOTH modes. On its own it *is* the shadow (the + // "ground shadow only" setting); with the shadow map on it fills the gap + // where the body hides its own shadow right at the feet, which otherwise + // reads as "the shadow is cut off". It is the only thing keeping the + // character looking like it is standing on the floor rather than above it. + // The blob sits at the feet. It used to be drawn whenever the shadow was on, + // which left a faint ring behind at the origin once the character was moved; + // it is now opt-in (`render.contactBlob`, default off). It is always shown in + // the 「接地影だけ」 mode, where it is the only shadow there is. + contact.visible = wantsShadow && (blobOnly || render.contactBlob === true); + contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6)); + const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6)); + contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25)); + contact.position.y = 0.012; + + const softness = Number(render.shadowSoftness) || 1.6; + // PCF, always. three 0.186 removed PCFSoftShadowMap (asking for it warns and + // falls back to this anyway), and VSM needs a depth-variance bias that cut + // the shadow away in a straight line right at the feet. `radius` is what + // softens PCF. + if (renderer.shadowMap.type !== THREE.PCFShadowMap) { + renderer.shadowMap.type = THREE.PCFShadowMap; + // Every shadow-receiving material has to be recompiled for the switch. + ground.material.needsUpdate = true; + for (const mesh of model.parts.body) { + const material = mesh.material; + if (Array.isArray(material)) material.forEach((m) => { m.needsUpdate = true; }); + else if (material) material.needsUpdate = true; + } + } + // PCF's radius is in *shadow map texels*, and the map covers about 15 world + // units, so a radius of 1-2 is invisible. This scales it into something the + // eye can see without the sampling turning into noise. + if ('radius' in key.shadow) key.shadow.radius = Math.max(0.5, softness * 5); + + const environment = line ? null : environmentFor(render.environment); + if (scene.environment !== environment) scene.environment = environment; + if ('environmentIntensity' in scene) { + scene.environmentIntensity = Number(render.envIntensity ?? 1); + } + }, + + /** The colours a theme would set, as a plain `{ part: '#rrggbb' }`. */ + palette(theme) { + return { ...(theme?.colors ?? {}) }; + }, + + dispose() { + contact.geometry.dispose(); + contact.material.map?.dispose(); + contact.material.dispose(); + contact.removeFromParent(); + roomEnvironment.dispose(); + pmrem.dispose(); + }, + }; +} diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js new file mode 100644 index 0000000..f861ade --- /dev/null +++ b/public/bluebey-studio/src/main.js @@ -0,0 +1,2798 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { loadModel } from './model.js'; +import { Face } from './face.js'; +import { Styles, isLineStyle } from './styles.js'; +import { Rig } from './rig.js'; +import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; +import { defaultState, applyPatch } from './presets.js'; +import { buildPanel } from './panel.js'; +import * as exporter from './exporter.js'; +import { toast } from './ui.js'; +import { History } from './history.js'; +import { createLook } from './look.js'; +import { createClipper } from './clip.js'; +import { buildHat } from './hats.js'; +import { createBackdrop, backdropStyle } from './background.js'; +import { captionFontStack } from './textOutlines.js'; +import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js'; +import { rollAll, decodeSeed } from './gacha.js'; +import { encodeState, decodeState } from './urlState.js'; +import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; +import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; + +const DEG = Math.PI / 180; +/** The other direction: what three's Spherical reports is radians. */ +const RAD_TO_DEG = 180 / Math.PI; +const MODEL_URL = 'assets/bluebey.glb'; + +/** How far the eyes can swing for a look-at target, in radians. */ +const MAX_LOOK_YAW = 0.5; +const MAX_LOOK_PITCH = 0.34; + +/** Undo steps are labelled by the part of the state that changed. */ +const SCOPE_LABELS = { + all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', gion: '擬音', +}; + +/** + * The single-file build inlines the fonts and the backdrop library, so the same + * code reads a data URL there and a file URL in the normal build. + */ +function assetUrl(file, kind) { + const inlined = globalThis.__BLUEBEY_FONTS__ ?? {}; + if (kind === 'background') { + const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {}; + return backdrops[file] ?? `assets/backgrounds/${file}.webp`; + } + if (kind === 'beard') { + const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; + return beards[file] ?? `assets/beards/${file}`; + } + return inlined[file] ?? `assets/fonts/${file}`; +} + +/** + * Where the model comes from. The normal build fetches `assets/bluebey.glb`; + * the single-file build (tools/build-standalone.mjs) inlines the same bytes as + * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened + * straight from disk without any web server. + */ +function resolveModelSource() { + const injected = globalThis.__BLUEBEY_MODEL__; + if (!injected) return MODEL_URL; + if (typeof injected !== 'string') return injected; + try { + const binary = atob(injected); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } catch (error) { + console.error('inline model could not be decoded, falling back to the file', error); + return MODEL_URL; + } +} + +const state = defaultState(); + +const app = { + state, + model: null, + container: null, + ready: false, + needsRender: true, + /** The 擬音 stamp the panel is editing, so the viewport can outline it. */ + gionSelected: null, + /** Set by init(), used by the panel. */ + actions: {}, +}; + +/* ------------------------------------------------------------------ startup */ + +// The view-rig classes are declared below this point; deferring by a microtask +// guarantees the whole module has finished evaluating before anything is built. +queueMicrotask(() => { + init().catch((error) => { + console.error(error); + showLoadError(error); + }); +}); + +async function init() { + const canvas = document.getElementById('view'); + // #stage owns the layout; the canvas only fills it (see style.css). + const stage = document.getElementById('stage') ?? canvas; + + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + + const scene = new THREE.Scene(); + const environment = makeEnvironment(renderer); + + const key = new THREE.DirectionalLight(0xffffff, 2.1); + key.castShadow = true; + key.shadow.mapSize.set(1536, 1536); + key.shadow.bias = -0.0008; + key.shadow.normalBias = 0.02; + const fill = new THREE.DirectionalLight(0xffffff, 0.5); + const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9); + scene.add(key, key.target, fill, ambient); + + // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from + // ever hiding the character, so moving ぶるべー below it no longer makes the body + // (or its textures) vanish - the shadow still falls on it, but it never occludes. + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(200, 200), + new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + // The camera lives in two places: `state.view` (what the panel, the saved file + // and a shared link use) and the orbit controls (what the mouse moves). Capture + // the controls' own position back into the state on every change, so the two + // never disagree - otherwise any refresh of the view yanked the camera back to + // wherever the panel had last put it. + const view = new ViewRig(canvas, () => { + view.captureInto(state.view); + app.needsRender = true; + }); + + /** + * The screen-space outline (see src/outline.js). The normal pass must not see + * the ground, the contact shadow or the gizmo, so they are excluded once those + * objects exist (see below). + */ + const outline = createScreenOutline({ + renderer, + scene, + camera: view.camera, + width: canvas.clientWidth || 1280, + height: canvas.clientHeight || 800, + }); + outline.exclude([ground]); + + /** + * The drawing buffer must follow the canvas' CSS size. Without this the + * browser stretches the default 300x150 buffer across the window and the + * whole picture looks coarse (and every exported trace is built from those + * few pixels). + * + * But it must not be *unlimited* either: on a large or high-density display + * the buffer can reach many millions of pixels, and with antialiasing, a + * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds + * per frame - which looks exactly like "nothing is showing". So the pixel + * count is capped and the ratio is lowered automatically on slow hardware. + */ + const MAX_DRAWING_PIXELS = 2_600_000; + const QUALITY_FLOOR = 0.5; + let quality = Math.min(window.devicePixelRatio || 1, 2); + let autoReductions = 0; + + function fitQuality(width, height, wanted) { + let ratio = wanted; + while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) { + ratio = Math.round((ratio - 0.1) * 100) / 100; + } + return Math.max(QUALITY_FLOOR, ratio); + } + + let lastResize = 0; + let resizeBurst = 0; + + /** Declared early: the viewport can resize before the model has loaded. */ + let overlayCanvas = null; + /** Set while undo/redo replays a snapshot, so it is not recorded again. */ + let suspendHistory = false; + + // ------------------------------------------------------------------- 擬音 + // `redrawOverlay` runs from the very first `resizeViewport`, so the sheet cache + // and the drag handles have to exist before it does (a `const` cannot be read + // before it is evaluated). The drawing itself lives further down. + const gionImages = new Map(); // sheet name -> loaded Image + const gionLoading = new Map(); // sheet name -> in-flight Promise + const gionHandles = new Map(); // stamp id -> transparent drag div + let gionDrag = null; + /** Hands out stamp ids; a counter so removing one never reuses a live id. */ + let gionSeq = 0; + + function resizeViewport() { + const width = stage.clientWidth || window.innerWidth; + const height = stage.clientHeight || window.innerHeight; + + // Safety net: if something ever feeds the viewport size back into itself, + // stop flipping the drawing buffer on and off and say so. + const now = performance.now(); + if (now - lastResize < 60) { + resizeBurst += 1; + if (resizeBurst > 30 && resizeBurst % 30 === 0) { + console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst); + } + if (resizeBurst > 30) return; + } else { + resizeBurst = 0; + } + lastResize = now; + + const wanted = Math.min(window.devicePixelRatio || 1, 2); + quality = fitQuality(width, height, wanted); + renderer.setPixelRatio(quality); + renderer.setSize(width, height, false); + outline.setSize(width * quality, height * quality); + view.resize(); + redrawOverlay(); + app.needsRender = true; + } + resizeViewport(); + new ResizeObserver(() => resizeViewport()).observe(stage); + + // Losing the WebGL context leaves a permanently blank canvas, so say so + // instead of leaving the user staring at nothing. + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n' + + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。'); + }); + canvas.addEventListener('webglcontextrestored', () => { + app.needsRender = true; + }); + + // -------------------------------------------------------------- the model + const model = await loadModel(resolveModelSource(), { + onProgress: (ratio) => setLoadingProgress(ratio), + }); + app.model = model; + + // The character hangs off a mirror group of its own, so "左右反転" flips the + // model without touching the pose offset (`container.position`) or the props. + const mirrorGroup = new THREE.Group(); + mirrorGroup.add(model.root); + const container = new THREE.Group(); + container.add(mirrorGroup); + scene.add(container); + app.container = container; + + // 小物 live outside the mirror group: they are scenery, not part of the body. + const propRoot = new THREE.Group(); + scene.add(propRoot); + + // --- 鼻ちょうちん: a modelled bubble hung under the nose -------------------- + // The nose is a *skinned* part of the body, so a child of the nose mesh would + // sit at the origin instead of on the face. The whole body (nose included) + // rides the `master` bone, so the bubble is parented there, placed at the nose's + // rest position in that bone's local frame, and read from the face state each + // frame in the render loop. + function applySnotBubble(group, snot) { + if (!group) return; + group.visible = snot?.enabled === true; + if (!group.visible) return; + // The offsets are applied in the *bone's* frame, along the world axes measured + // once at rest, so the bubble travels with the body. Recomputing them against + // the live world matrix made it snap back whenever the body had moved. + const k = 0.01; // artwork px -> world units, enough to nudge the bubble + const { localBase, axes } = group.userData; + group.position.copy(localBase) + .addScaledVector(axes.x, (snot.offsetX ?? 0) * k) + .addScaledVector(axes.y, (snot.offsetY ?? 0) * k) + .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k); + group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1; + group.scale.setScalar(group.userData.baseScale); + group.userData.material.color.set(snot.color ?? '#dfe8ff'); + } + + const snotBubble = (() => { + const nose = model.parts.noseMesh; + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!nose || !master || !nose.isSkinnedMesh) return null; + model.root.updateMatrixWorld(true); + // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the + // back of the model, in fact) - sample the skinned positions instead, which is + // where the nose actually ends up. + const attr = nose.geometry.attributes.position; + const step = Math.max(1, Math.floor(attr.count / 400)); + const point = new THREE.Vector3(); + const centre = new THREE.Vector3(); + let samples = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + centre.add(point); + samples += 1; + } + if (!samples) return null; + centre.multiplyScalar(1 / samples); + nose.localToWorld(centre); + let radius = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + nose.localToWorld(point); + radius = Math.max(radius, point.distanceTo(centre)); + } + if (!Number.isFinite(radius) || radius <= 0) radius = 0.25; + const material = new THREE.MeshStandardMaterial({ + color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92, + }); + const group = new THREE.Group(); + group.name = 'snot-bubble'; + const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material); + group.add(bubble); + const bone = master.bone; + const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)); + const localBase = bone.worldToLocal(baseWorld.clone()); + // World-axis directions expressed in the bone's frame, captured at rest: the + // sliders keep meaning "right / up / towards the viewer" while the bubble rides + // with the body. + const boneQuat = new THREE.Quaternion(); + bone.getWorldQuaternion(boneQuat); + const invQuat = boneQuat.clone().invert(); + const axes = { + x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat), + y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat), + z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat), + }; + group.position.copy(localBase); + group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 }; + group.visible = false; + bone.add(group); + return group; + })(); + const snotMeshes = snotBubble ? [...snotBubble.children] : []; + applySnotBubble(snotBubble, state.face.eyes.snot); + + // The hat rides the `master` bone like the snot bubble does: it is parented + // there, placed at the top of the head in that bone's local frame, and turned + // so it is world-aligned at rest (the bone's own rest rotation cancelled once). + // The character's front is +z, so the hats are modelled facing +z. + const hatMount = (() => { + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!master) return null; + model.root.updateMatrixWorld(true); + const bone = master.bone; + const group = new THREE.Group(); + group.name = 'hat'; + group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0))); + group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert()); + bone.add(group); + return group; + })(); + + let hatKindCurrent = null; + let hatObject = null; + /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */ + let customHatObject = null; + const hatMeshes = []; + /** Last transform applied, so the render loop only wakes when it changes. */ + const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null }; + /** Set once the outline pass exists, so a hat change can refresh the exclude set. */ + let refreshOutlineExclusion = null; + /** Set once the style system exists, so a hat can register for styles + outline. */ + let hatStyleRegister = null; + let hatStyleUnregister = null; + + /** + * Swap the mounted hat for `built` (or clear it), keeping the style system and + * the outline exclusion in step. + * + * The old meshes are collected BEFORE they are unregistered: `removeMesh` + * deletes the outline hull (a child of the hat group) while we traverse, which + * used to shorten `children` mid-loop and call `.traverse` on `undefined`. + */ + function mountHat(built) { + if (hatObject) { + const old = []; + hatObject.traverse((o) => { if (o.isMesh) old.push(o); }); + hatMount.remove(hatObject); + for (const mesh of old) hatStyleUnregister?.(mesh); + // The custom model is kept so it can be re-chosen; a built hat is dropped. + if (hatObject !== customHatObject) { + for (const mesh of old) { + mesh.geometry?.dispose(); + if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose()); + else mesh.material?.dispose(); + } + } + hatObject = null; + } + hatMeshes.length = 0; + if (built) { + hatMount.add(built); + hatObject = built; + const meshes = []; + built.traverse((o) => { if (o.isMesh) meshes.push(o); }); + for (const mesh of meshes) { + hatMeshes.push(mesh); + hatStyleRegister?.(mesh); + } + } + refreshOutlineExclusion?.(); + } + + function applyHat(kind) { + if (!hatMount) return; + const custom = state.face.hat?.custom === true && customHatObject; + const key = custom ? 'custom' : kind; + if (key !== hatKindCurrent) { + hatKindCurrent = key; + mountHat(custom ? customHatObject : buildHat(kind)); + } + + // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the + // character's front and +x its right: a plain translation and rotation read + // the way the sliders say. + const hat = state.face.hat ?? {}; + const x = hat.x ?? 0; + const z = hat.z ?? 0; + const height = hat.height ?? 0; + const tiltX = hat.tiltX ?? 0; + const tiltZ = hat.tiltZ ?? 0; + if (hatObject) { + hatObject.position.set(x, height, z); + hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180); + } + if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) { + hatXform.x = x; + hatXform.z = z; + hatXform.height = height; + hatXform.tiltX = tiltX; + hatXform.tiltZ = tiltZ; + app.needsRender = true; + } + } + /** + * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0 + * (the mount sits on the head), and scaled to about the head's width, then + * mounted like any built hat. + */ + async function loadHatModel(file) { + if (!hatMount) return; + const buffer = await exporter.readFileAsArrayBuffer(file); + const loader = new GLTFLoader(); + const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject)); + const root = gltf.scene; + const box = new THREE.Box3().setFromObject(root); + const size = box.getSize(new THREE.Vector3()); + const target = Math.max(0.2, model.size.x * 0.95); + const scale = target / Math.max(size.x, size.z, 0.001); + root.scale.setScalar(scale); + box.setFromObject(root); + const center = box.getCenter(new THREE.Vector3()); + root.position.x -= center.x; + root.position.z -= center.z; + root.position.y -= box.min.y; + root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } }); + customHatObject = root; + state.face.hat.custom = true; + state.face.hat.kind = 'none'; + hatKindCurrent = null; + refresh('all'); + app.panel?.sync(); + } + + applyHat(state.face.hat?.kind ?? 'none'); + + const halfHeight = model.size.y * 0.5; + view.frame(model.size, halfHeight); + + // shadow camera big enough for the whole character + const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6; + const shadowCamera = key.shadow.camera; + shadowCamera.left = -radius; + shadowCamera.right = radius; + shadowCamera.top = radius; + shadowCamera.bottom = -radius; + shadowCamera.near = 0.5; + shadowCamera.far = radius * 9; + shadowCamera.updateProjectionMatrix(); + key.target.position.set(0, halfHeight, 0); + + // The light's offset from the character, so the key light (and therefore its + // shadow camera) can follow the character around: moving far from the origin + // used to leave the shadow behind and cut it off at the shadow camera's edge. + const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3); + function updateKeyLight() { + key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z); + key.position.copy(key.target.position).add(keyOffset); + } + + // ------------------------------------------------------------- controllers + const styles = new Styles({ meshes: model.parts.body }); + // Hats are built at runtime, so they are not in the GLB's mesh list. Register + // them with the style system (and the current hat) so they follow flat/line art + // and get an outline hull like every other part. + hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true }); + hatStyleUnregister = (mesh) => styles.removeMesh(mesh); + for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true }); + const face = new Face({ + eyeMesh: model.parts.eyeMesh, + mouthMesh: model.parts.mouthMesh, + hairMesh: model.parts.hairMesh, + originals: model.originals, + }); + const animator = new Animator({ state }); + + // ------------------------------------------------------------------- look + const look = createLook({ + renderer, + scene, + styles, + model, + ground, + key, + character: mirrorGroup, + gradientEnvironment: environment, + }); + + // --------------------------------------------------------------- 見えない壁 + // A clip plane that hides part of the character, so it can look half buried + // in a wall (see src/clip.js). Applied in `refresh`, with the render settings. + const clip = createClipper({ scene, renderer, model }); + // A wall's position is stored relative to the character, so every time the + // walls are applied they are shifted by the body's own offset - that is what + // makes them travel with ぶるべー when it is moved. + const characterOffset = () => { + const root = state.pose?.root; + return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 }; + }; + const applyWalls = () => clip.apply( + [state.render.wall, state.render.wall2], + characterOffset(), + wallYaw(), + ); + // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any + // body yaw the 見る先 mode added. The walls take this too, so turning the + // character with Shift+drag no longer slides the hidden region around. + const wallYaw = () => { + const masterY = state.pose?.bones?.master?.[1] ?? 0; + return (masterY + (container.rotation.y * 180) / Math.PI) * DEG; + }; + + // --------------------------------------------------------------- backdrop + const backdrop = createBackdrop({ + stage, + resolveUrl: (name) => assetUrl(name, 'background'), + onNeedsRender: () => { app.needsRender = true; }, + }); + /** The bitmap the exports paint behind the model (an Image or the camera video). */ + let backdropImage = null; + + /** + * Story-panel previews (keyed by panel id) and the counter that hands out + * those ids. + * + * WHY the previews live outside the state: a thumbnail is an image, and the + * state is exactly what a shared link carries - a data URL per panel would + * push the link past what a URL can hold. They are keyed by id and rebuilt as + * panels are added, so a restored link simply shows panels without previews. + * (Declared up here because `buildPanel` runs - and syncs - before the panel + * functions below are reached.) + */ + const storyThumbs = new Map(); + let storySeq = 0; + + // ------------------------------------------------------- 擬音 overlay + // The 擬音 stamps are drawn into their own 2D canvas laid over the WebGL one. + overlayCanvas = document.createElement('canvas'); + overlayCanvas.id = 'overlay-layer'; + overlayCanvas.style.cssText = + 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'; + stage.append(overlayCanvas); + + function redrawOverlay() { + if (!overlayCanvas) return; + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + const ratio = Math.min(2, window.devicePixelRatio || 1); + const wantW = Math.round(width * ratio); + const wantH = Math.round(height * ratio); + if (overlayCanvas.width !== wantW || overlayCanvas.height !== wantH) { + overlayCanvas.width = wantW; + overlayCanvas.height = wantH; + } + const ctx = overlayCanvas.getContext('2d'); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + const gionItems = state.gion?.items ?? []; + for (const item of gionItems) ensureGionSheet(item.sheet); + drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 }); + ensureGionHandles(gionItems); + positionGionHandles(gionItems); + } + + // ----------------------------------------------------------------- 擬音 + // A stamp is a crop of a sheet, drawn on the overlay canvas. + // The canvas is `pointer-events: none`, so each stamp gets its own transparent + // drag box, parked over it by `positionGionHandles`. + + /** Load a sheet once, on first use, and redraw when it arrives. */ + function ensureGionSheet(name) { + if (gionImages.has(name)) return Promise.resolve(gionImages.get(name)); + if (gionLoading.has(name)) return gionLoading.get(name); + const image = new Image(); + const promise = new Promise((resolve) => { + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + }).then((loaded) => { + gionLoading.delete(name); + if (loaded) { + gionImages.set(name, loaded); + redrawOverlay(); + } + return loaded; + }); + gionLoading.set(name, promise); + image.src = gionSheetUrl(name); + return promise; + } + + /** Create one drag target per stamp, and drop the ones whose stamp is gone. */ + function ensureGionHandles(items) { + const live = new Set(); + for (const item of items) { + live.add(item.id); + if (gionHandles.has(item.id)) continue; + const handle = document.createElement('div'); + handle.className = 'gion-handle'; + handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id)); + handle.addEventListener('pointermove', moveGionDrag); + handle.addEventListener('pointerup', endGionDrag); + handle.addEventListener('pointercancel', endGionDrag); + handle.dataset.gionHandle = '1'; + stage.append(handle); + gionHandles.set(item.id, handle); + } + for (const [id, handle] of gionHandles) { + if (live.has(id)) continue; + handle.remove(); + gionHandles.delete(id); + } + } + + /** Park each drag target over its stamp (or hide it while the sheet loads). */ + function positionGionHandles(items) { + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + for (const item of items) { + const handle = gionHandles.get(item.id); + if (!handle) continue; + const image = gionImages.get(item.sheet); + if (!imageReady(image)) { + handle.style.display = 'none'; + continue; + } + const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image)); + handle.style.display = 'block'; + handle.style.left = `${rect.x}px`; + handle.style.top = `${rect.y}px`; + handle.style.width = `${rect.w}px`; + handle.style.height = `${rect.h}px`; + // Match the drawn stamp, which is rotated about its centre. + handle.style.transformOrigin = 'center'; + handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none'; + handle.classList.toggle('selected', item.id === app.gionSelected); + } + } + + function startGionDrag(event, id) { + const item = (state.gion?.items ?? []).find((entry) => entry.id === id); + if (!item) return; + // Touching a stamp also selects it in the panel, so the sliders edit the one + // you just grabbed. + app.panel?.selectGion?.(id); + event.preventDefault(); + event.stopPropagation(); + try { + gionHandles.get(id)?.setPointerCapture(event.pointerId); + } catch { + /* capture is a nicety, not a requirement */ + } + gionDrag = { + id, + pointerId: event.pointerId, + fromX: event.clientX, + fromY: event.clientY, + x: item.x ?? 0.5, + y: item.y ?? 0.5, + }; + } + + function moveGionDrag(event) { + if (!gionDrag || event.pointerId !== gionDrag.pointerId) return; + const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id); + if (!item) return; + event.preventDefault(); + const width = stage.clientWidth || 1280; + const height = stage.clientHeight || 800; + item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width); + item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height); + refresh('gion'); + } + + function endGionDrag(event) { + if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return; + gionDrag = null; + app.actions.syncPanel?.(); + } + + /** Add a stamp from a picker crop, centred, and hand it to the panel. */ + function addGionStamp({ sheet, sx, sy, sw, sh }) { + gionSeq += 1; + const item = { + id: `g${gionSeq}`, + sheet, + sx, sy, sw, sh, + x: 0.5, + y: 0.5, + w: DEFAULT_STAMP_WIDTH, + rot: 0, + flip: false, + }; + state.gion = state.gion ?? { items: [] }; + state.gion.items = [...(state.gion.items ?? []), item]; + app.gionSelected = item.id; + void ensureGionSheet(sheet); + applyState({}, { scope: 'gion', sync: true }); + toast('擬音を追加しました'); + } + + // ------------------------------------------------------------------ props + let propSignature = ''; + + function applyProps() { + const items = state.props?.items ?? []; + const signature = JSON.stringify([items, state.render.colors]); + if (signature === propSignature) return; + propSignature = signature; + for (const child of [...propRoot.children]) { + // Hand every mesh back to the style system before the prop is disposed. + // `removeMesh` restores each mesh's own material (a prop under 線画 is + // wearing the shared paper material, which must not be disposed). Collect + // the meshes first: `removeMesh` deletes the outline hull, a child of the + // prop group. + const meshes = []; + child.traverse((object) => { + if (styles.originals.has(object)) meshes.push(object); + }); + for (const mesh of meshes) styles.removeMesh(mesh); + propRoot.remove(child); + disposeProp(child); + } + for (const item of items) { + const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; + let group; + try { + group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 }); + } catch (error) { + console.warn('[bluebey] unknown prop', item.kind, error); + continue; + } + group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0); + group.rotation.set( + (item.rotX ?? def.rotX ?? 0) * DEG, + (item.rotY ?? def.rotY ?? 0) * DEG, + (item.rotZ ?? def.rotZ ?? 0) * DEG, + ); + propRoot.add(group); + // Register with the style system, so フラット and 線画 reach the props too + // and each part gets an outline hull like the body and the hats. `lineOnly` + // keeps that outline out of リアル/フラット, where the props read by shading. + group.traverse((object) => { + if (object.isMesh) styles.addMesh(object, { lineOnly: true }); + }); + // 看板 only: paint the saved text onto the freshly built face (no-op for + // every other prop, which has no writing surface). + applyPropText(group, item.text); + } + refreshOutlineExclusion?.(); + app.needsRender = true; + } + + // ------------------------------------------------------------ prop dragging + // A prop can be dragged with the mouse. The pointer picks one, then moves it in + // the plane that faces the camera, so it follows the cursor at whatever depth + // it already sits at. (The ground plane is the other obvious choice, but the + // camera sits almost level with the floor, so the floor is nearly edge-on and + // a prop dragged across it would fly off.) + // + // The listener is on `stage` in the CAPTURE phase: that runs before the canvas + // and `view`'s container, so stopping the event there keeps the orbit controls + // and the bone picking out of a prop drag. + const propRaycaster = new THREE.Raycaster(); + const propPointer = new THREE.Vector2(); + const propPlane = new THREE.Plane(); + const propPoint = new THREE.Vector3(); + const propNormal = new THREE.Vector3(); + let propDrag = null; + // The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's + // offset from the wall's anchor so grabbing a corner does not make it jump. + const wallDrag = { pointerId: null, key: 'wall', offset: new THREE.Vector3() }; + + /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */ + function pointerNdc(event) { + const rect = canvas.getBoundingClientRect(); + return propPointer.set( + ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, + -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, + ); + } + + /** The prop under the pointer as `{ index, group, point }`, or null. */ + function propAt(event) { + if (!propRoot.children.length) return null; + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + const hits = propRaycaster.intersectObjects(propRoot.children, true); + if (!hits.length) return null; + // The meshes hang off the group `propRoot` holds, so walk up to that group. + let node = hits[0].object; + while (node.parent && node.parent !== propRoot) node = node.parent; + const items = state.props?.items ?? []; + const index = propRoot.children.indexOf(node); + if (index < 0 || index >= items.length) return null; + return { index, group: node, point: hits[0].point.clone() }; + } + + /** + * The point on the wall's guide under the pointer, or null. + * + * Only while the wall is on AND its guide is switched on: the guide is both + * what there is to grab and the switch that says "I am placing the wall now", + * so with it off a huge invisible plane cannot swallow every orbit drag. + */ + function wallAt(event) { + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + let best = null; + for (let index = 0; index < clip.guides.length; index += 1) { + const key = index === 0 ? 'wall' : 'wall2'; + const wall = state.render[key]; + if (wall?.on !== true || wall?.guide !== true) continue; + const hits = propRaycaster.intersectObject(clip.guides[index], false); + if (!hits.length) continue; + if (!best || hits[0].distance < best.distance) { + best = { index, key, point: hits[0].point.clone() }; + } + } + return best; + } + + function onPropPointerDown(event) { + if (event.button !== 0) return; + if (rig?.controls?.dragging) return; // the rotation gizmo is in charge + const hit = propAt(event); + if (hit) { + // Claim the pointer, so nothing underneath sees it. + event.stopPropagation(); + event.preventDefault(); + view.camera.getWorldDirection(propNormal); + propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point); + propDrag = { + pointerId: event.pointerId, + index: hit.index, + group: hit.group, + // Grabbing a corner rather than the middle must not make it jump. + offset: hit.group.position.clone().sub(hit.point), + }; + stage.style.cursor = 'grabbing'; + app.needsRender = true; + return; + } + + const onWall = wallAt(event); + if (!onWall) { + // Nothing to grab. Remember the press, and settle on pointer-up whether it + // was a tap (set the 見る先 target) or the start of an orbit. + if (state.lookAt?.enabled === true && !rig?.controls?.dragging) { + lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + } + return; + } + // A prop wins if it is under the pointer; otherwise the wall takes it. + event.stopPropagation(); + event.preventDefault(); + view.camera.getWorldDirection(propNormal); + propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point); + const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]); + const offset = characterOffset(); + const yaw = wallYaw(); + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const px = onWall.point.x - offset.x; + const pz = onWall.point.z - offset.z; + wallDrag.pointerId = event.pointerId; + wallDrag.key = onWall.key; + // Keep the grab point's offset *in the wall's own (character-relative) frame*, + // so grabbing a corner does not make the wall jump. + wallDrag.offset.set( + (wall.x ?? 0) - (cos * px - sin * pz), + (wall.y ?? 0) - (onWall.point.y - offset.y), + (wall.z ?? 0) - (sin * px + cos * pz), + ); + stage.style.cursor = 'grabbing'; + app.needsRender = true; + } + + function onPropPointerMove(event) { + if (lookTap && event.pointerId === lookTap.pointerId + && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) { + lookTap = null; // that was an orbit drag, not a tap + } + if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) { + event.stopPropagation(); + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; + const offset = characterOffset(); + const yaw = wallYaw(); + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const px = propPoint.x - offset.x; + const pz = propPoint.z - offset.z; + const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]); + // Back into the wall's character-relative frame, then the same ranges the + // panel's sliders offer, so a drag and a number always describe the same + // place. + wall.x = round2(clampNumber(cos * px - sin * pz + wallDrag.offset.x, -4, 4)); + wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4)); + wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4)); + applyWalls(); + app.needsRender = true; + return; + } + if (!propDrag || event.pointerId !== propDrag.pointerId) return; + event.stopPropagation(); + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; + const next = propPoint.add(propDrag.offset); + const item = state.props?.items?.[propDrag.index]; + if (!item) return; + // Clamp to the ranges the panel's sliders offer, so a drag can never put a + // prop somewhere the numbers cannot describe (and never through the floor). + item.x = round2(clampNumber(next.x, -4, 4)); + item.y = round2(clampNumber(next.y, 0, 2)); + item.z = round2(clampNumber(next.z, -4, 4)); + propDrag.group.position.set(item.x, item.y, item.z); + // Keep `applyProps`' signature in step with what is on screen, so a refresh + // during the drag does not tear the group down and rebuild it from the state + // (which would re-create every geometry on every pointer move). + propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]); + app.needsRender = true; + } + + function onPropPointerUp(event) { + if (lookTap && (!event || event.pointerId === lookTap.pointerId)) { + lookTap = null; + if (event) applyLookTap(event); + return; + } + if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) { + wallDrag.pointerId = null; + stage.style.cursor = ''; + history.push(state, '見えない壁'); + app.panel?.sync(); + return; + } + if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return; + propDrag = null; + stage.style.cursor = ''; + // One undo step for the whole drag, and the panel catches up with the value. + history.push(state, '小物'); + app.panel?.sync(); + } + + // A tap (a press that does not turn into a drag) in the viewport sets the + // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves, + // so an orbit drag never moves the target by accident. + let lookTap = null; + const LOOK_TAP_SLOP = 5; + + stage.addEventListener('pointerdown', onPropPointerDown, { capture: true }); + window.addEventListener('pointermove', onPropPointerMove); + window.addEventListener('pointerup', onPropPointerUp); + window.addEventListener('pointercancel', onPropPointerUp); + + // ---------------------------------------------------------------- look-at + const lookScratch = new THREE.Vector3(); + const headScratch = new THREE.Vector3(); + const lookPlane = new THREE.Plane(); + const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const lookPlaneNormal = new THREE.Vector3(); + const lookPivot = new THREE.Vector3(); + const lookHit = new THREE.Vector3(); + const lookFloor = new THREE.Vector3(); + /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */ + let aimOffset = { x: 0, y: 0 }; + + /** + * The world point under a viewport tap, for the 見る先 target. + * + * The tap is projected onto the first thing it can sensibly mean: a vertical + * plane through the character that faces the camera (so a tap on the body or + * the backdrop keeps the character's own depth), or the floor, for a tap that + * lands on the ground in front. The nearer of the two wins. + */ + function lookAtPointFrom(event) { + propRaycaster.setFromCamera(pointerNdc(event), view.camera); + const ray = propRaycaster.ray; + const headY = container.position.y + model.size.y * 0.72; + lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z); + if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1); + lookPlane.setFromNormalAndCoplanarPoint( + lookPlaneNormal.normalize(), + lookPivot.set(container.position.x, headY, container.position.z), + ); + const onPlane = ray.intersectPlane(lookPlane, lookHit); + const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null; + if (!onPlane) return onFloor; + if (!onFloor) return onPlane; + return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor; + } + + /** A tap in the viewport: aim the eyes at whatever was under the pointer. */ + function applyLookTap(event) { + if (state.lookAt?.enabled !== true) return; + const point = lookAtPointFrom(event); + if (!point) return; + const cfg = state.lookAt; + // The same ranges the panel's sliders offer, so a tap and a number describe + // the same place. + cfg.x = round2(clampNumber(point.x, -6, 6)); + cfg.y = round2(clampNumber(point.y, 0, 5)); + cfg.z = round2(clampNumber(point.z, -6, 6)); + history.push(state, '見る先'); + refresh('lookAt'); + app.panel?.sync(); + } + + function applyLookAt() { + const cfg = state.lookAt; + if (!cfg?.enabled) { + // Switching the mode off leaves the character where it was: the body turn + // and the gaze are remembered, not re-derived, so nothing snaps back. + container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG; + const frozen = cfg?.freeze; + return frozen + ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) } + : { x: 0, y: 0 }; + } + + container.updateMatrixWorld(true); + lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0); + mirrorGroup.worldToLocal(lookScratch); + headScratch.set(0, model.size.y * 0.72, 0); + lookScratch.sub(headScratch); + + const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6; + const yaw = Math.atan2(lookScratch.x, lookScratch.z); + const pitch = Math.atan2(lookScratch.y, flat); + const amount = clamp(cfg.amount ?? 1, 0, 1); + const aim = { + x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1), + y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1), + }; + + // Turning the whole body is what a person does to look behind themselves; a + // half-strength turn keeps the feet planted while the body leans round. The + // turn is *stored*, so switching 体も向ける off stops updating it and the body + // stays where it is instead of snapping back to the front. + if (cfg.turnBody) { + cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG); + } + container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG; + + // Keep the gaze too, for the moment the mode is switched off. + cfg.freeze = aim; + return aim; + } + + // ---------------------------------------------------- history, gacha + const history = new History({ limit: 120 }); + + let rig = null; + // The gizmo always belongs to whichever camera is currently active. + view.onCameraChange = (camera, controls) => { + if (!rig) return; + rig.controls.camera = camera; + rig.orbit = controls; + }; + + rig = new Rig({ + bones: model.bones, + scene, + camera: view.camera, + domElement: canvas, + orbit: view.controls, + pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body], + onChange: () => { + capturePose(); + app.panel?.onRigChanged?.(); + app.needsRender = true; + }, + }); + + // The ground, the contact shadow and the gizmo are scenery, not character, and + // the two face plates are the *front half* of the body: their open edge would be + // picked up as a silhouette and draw a line across the face, while contributing + // nothing, since they sit exactly on the body. + // + // The outline hulls must stay out too, and that one is easy to miss: they are + // extra copies of every mesh sitting in the scene graph, and this pass swaps the + // material of every visible mesh. A hull is an expanded *back-face* shell, so + // handing it a plain front-side label material puts an expanded copy of the + // whole character in front of itself - it then paints its own label over + // everything, and every leaf edge against the body is inked as if the body were + // empty paper. That is exactly the faint dotted line along the leaves' bases. + function outlineExclusion() { + return [ + ground, + look.contact, + ...clip.guides, + rig.helper, + model.parts.eyeMesh, + model.parts.mouthMesh, + ...(model.parts.hairMesh ? [model.parts.hairMesh] : []), + ...snotMeshes, + ...hatMeshes, + ...styles.outlineMeshes, + ]; + } + outline.exclude(outlineExclusion()); + refreshOutlineExclusion = () => outline.exclude(outlineExclusion()); + // The wall is *not* excluded: it is handed to the pass as an occluder, so the + // labels behind it are culled as well. Excluded, the leaves and the nose + // buried in the wall kept their outlines, because the labels are read before + // its depth is applied. + outline.occlude(clip.occluders); + + /** + * The parts the screen-space pass draws, with the label each one writes. + * + * The leaves are the hard case: a hull cannot outline a shell that thin. They + * are drawn with the LEAF label, which means the pass inks them where they meet + * the paper and where they fold against each other, but *not* where they run + * into the body - a line there reads as the leaf sinking into the body, and the + * original artwork has none (the leaf simply passes behind the body). + * + * The nose joins them in the line-art styles, with the NOSE label, so its ring + * against the body *is* drawn: it is the only thing that shows the nose in a + * line drawing. In the shaded styles it is left out entirely, and its hull then + * keeps only the part that pokes out of the silhouette - which is what the + * original artwork does (the nose reads by its own colour there). + * + * @param {string} style + * @returns {Array<[import('three').Object3D, number]>} + */ + function screenParts(style) { + const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]); + if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]); + return parts; + } + + /** + * The label every part writes (see src/outline.js). + * + * The leaves and the nose are the outlined ones. The body's label is the one + * real choice here, and it is exposed as `render.leafBodyLine`: + * + * - `BEHIND` (the default) makes the body count as paper, so each leaf is + * outlined where it emerges from the body. In a line drawing the two are + * paper on paper, so without this the leaves and the body merge into one + * white shape with no way to tell them apart - which is the greater evil. + * - `SOLID` makes the body block that line, so a leaf simply passes behind it. + * Cleaner where it works, but the leaves lose their outline there. + * + * Everything else - the feet, the hands, the props - is *merely behind*, so it + * stays BEHIND either way: it still hides what is behind it, but a leaf lying + * across it keeps its outline. + */ + function outlineLabels(parts) { + const labels = new Map(parts); + const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL; + for (const mesh of model.parts.body ?? []) { + if (labels.has(mesh)) continue; + labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL); + } + return [...labels]; + } + + /** Point both outline systems at the right parts for `method`. */ + function applyOutlineMethod(style, method = state.render.outlineMethod) { + const hybrid = method === 'screen'; + const parts = hybrid ? screenParts(style) : []; + styles.setHullHidden(parts.map(([mesh]) => mesh)); + outline.only(parts.length ? outlineLabels(parts) : null); + } + + /** + * Run `fn` with the outline pass set up for another style, then put the + * on-screen configuration back. Both the hull/screen split and which parts the + * screen pass may see are shared by the live view and every offscreen render, + * so a pass that draws another style has to say so - the SVG and the "lines + * only" PNG are ink through and through, so they always want the line-art + * arrangement (a hull cannot outline the leaves at all). + */ + async function withOutlineFor({ style, method = 'screen' }, fn) { + applyOutlineMethod(style, method); + try { + return await fn(); + } finally { + applyOutlineMethod(state.render.style); + } + } + + Object.assign(app, { + renderer, scene, camera: view.camera, view, styles, face, rig, animator, + key, ambient, ground, environment, halfHeight, outline, clip, + look, backdrop, history, propRoot, mirrorGroup, container, + get backdropImage() { return backdropImage; }, + }); + + const recorder = createRecorder(canvas); + + // -------------------------------------------------------------- the panel + const panel = buildPanel(app, document.getElementById('panel')); + app.panel = panel; + + // On a phone the panel is a bottom sheet. Its grab handle drags the sheet's top + // edge up and down, so the menu can be pulled out of the way of the character. + const panelEl = document.getElementById('panel'); + const panelGrip = document.createElement('div'); + panelGrip.className = 'panel-grip'; + panelGrip.setAttribute('aria-hidden', 'true'); + panelEl.prepend(panelGrip); + installSheetDrag(panelEl, panelGrip); + + /** Drag on the phone sheet's handle; a no-op anywhere else. */ + function installSheetDrag(sheet, grip) { + const phone = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)'); + let drag = null; + const minHeight = () => Math.round(window.innerHeight * 0.16); + const maxHeight = () => Math.round(window.innerHeight * 0.94); + + grip.addEventListener('pointerdown', (event) => { + if (!phone.matches) return; + const rect = sheet.getBoundingClientRect(); + // Start from the height the sheet has now, so the first move does not jump. + drag = { id: event.pointerId, fromY: event.clientY, startH: rect.height }; + sheet.style.maxHeight = 'none'; + sheet.style.height = `${rect.height}px`; + try { grip.setPointerCapture(event.pointerId); } catch { /* capture is optional */ } + event.preventDefault(); + }); + grip.addEventListener('pointermove', (event) => { + if (!drag || event.pointerId !== drag.id) return; + // Dragging up grows the sheet (its top edge climbs); dragging down shrinks it. + const next = drag.startH + (drag.fromY - event.clientY); + sheet.style.height = `${Math.min(maxHeight(), Math.max(minHeight(), next))}px`; + event.preventDefault(); + }); + const end = (event) => { + if (!drag || (event && event.pointerId !== drag.id)) return; + drag = null; + }; + grip.addEventListener('pointerup', end); + grip.addEventListener('pointercancel', end); + + // A window grown to the desktop layout keeps the side column: drop the height + // the drag had fixed, so the panel goes back to filling the screen. + const clearOnDesktop = () => { + if (phone.matches) return; + sheet.style.height = ''; + sheet.style.maxHeight = ''; + }; + window.addEventListener('resize', clearOnDesktop); + } + + Object.assign(app.actions, { + capturePose, + applyState, + refresh, + loadHatModel, + patch: (scope, value) => applyState({ [scope]: value }, { scope }), + syncPanel: () => app.panel?.sync(), + imageCanvas: (options) => captureImage(options), + savePNG: (options = {}) => savePNG(options), + copyPNG: () => copyPNG(), + faceMapCanvas: (kind) => faceMapCanvas(kind), + saveFaceMap: (kind) => saveFaceMap(kind), + addGionStamp: (item) => addGionStamp(item), + saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`), + loadSettings: (text) => { + const data = JSON.parse(text); + applyState(data, { full: true, sync: true }); + toast('設定を読み込みました'); + }, + toggleRecording: () => toggleRecording(recorder), + resetPose: () => { + rig.reset(); + state.pose = { bones: {}, root: [0, 0, 0] }; + applyState({}, { scope: 'pose', sync: true }); + toast('ポーズをリセットしました'); + }, + setCameraPreset: (id) => setCameraPreset(id), + undo: () => restoreFromHistory('undo'), + redo: () => restoreFromHistory('redo'), + rollGacha: (seed) => rollGacha(seed), + copyShareLink: () => copyShareLink(), + shareImage: (network) => shareImage(network), + storyThumbs: () => storyThumbs, + toggleCamera: (on) => toggleCamera(on), + addProp: (kind) => addProp(kind), + addStoryPanel: () => addStoryPanel(), + saveStory: (options) => saveStory(options), + loadFromHash: () => loadFromHash(), + toggleAnimation: () => { + state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off'; + app.animator.reset(); + panel.sync(); + app.needsRender = true; + }, + }); + + installKeys(); + installTopbar(); + window.addEventListener('focus', () => { app.needsRender = true; }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) app.needsRender = true; + }); + + { + const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); + console.log( + '[bluebey] ready' + + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}` + + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}` + + ` quality=${quality}` + + ` dpr=${window.devicePixelRatio}` + + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}` + + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`, + ); + } + window.addEventListener('resize', () => resizeViewport()); + + applyState({}, {}); + await loadFromHash(); + history.reset(state); + rig.select('master', { silent: true }); + panel.sync(); + + let last = performance.now(); + let faceSignature = ''; + let slowFrames = 0; + let framesSinceFps = 0; + let fpsAt = performance.now(); + const warmupUntil = performance.now() + 2500; + + let snotSignature = null; + const loop = (now) => { + const rawDelta = now - last; + const dt = Math.min(0.05, rawDelta / 1000); + last = now; + + animator.update(dt); + + const posed = animator.pose(state.pose); + if (!rig.controls.dragging) rig.applyPose(posed); + container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0); + updateKeyLight(); + // The contact blob rides under the character, so it never lingers at the origin. + look.contact.position.x = container.position.x; + look.contact.position.z = container.position.z; + + applyHat(state.face.hat?.kind ?? 'none'); + + if (snotBubble) { + const snot = state.face.eyes.snot; + const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`; + if (sig !== snotSignature) { + snotSignature = sig; + applySnotBubble(snotBubble, snot); + app.needsRender = true; + } + // The sleeping breath swells the bubble; `snotScale` is 1 otherwise. + if (snotBubble.visible) { + const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1); + if (snotBubble.scale.x !== scale) { + snotBubble.scale.setScalar(scale); + app.needsRender = true; + } + } + } + + const params = buildFaceParams(); + const signature = JSON.stringify(params); + if (signature !== faceSignature) { + faceSignature = signature; + face.setParams(params, faceMode()); + app.needsRender = true; + } + if (face.flush(now, 26)) app.needsRender = true; + + view.controls.update(dt); + + const animating = (state.anim.mode ?? 'off') !== 'off' + || state.anim.lookAround + || animator.blinkPhase >= 0 + || recorder?.recorder?.state === 'recording'; + // Always paint for the first couple of seconds, so a missed "needs render" + // can never leave an empty window staring back at the user. + if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) { + app.needsRender = false; + + // The screen-space outline needs one extra pass *before* the scene, so it + // wraps the normal render instead of following it. `outline.render` paints + // the scene itself (ink and all), so there must be no second render after + // it - that would clear the canvas and wipe the ink straight back off. + const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false; + outline.render(() => renderer.render(scene, view.camera), { + enabled: wantsScreen, + camera: view.camera, + color: state.render.outlineColor, + // The label buffer is `quality`x the CSS size, so the radius has to carry + // that factor to keep the leaf/nose line a constant width in CSS pixels - + // otherwise it halves on a HiDPI display and jumps when the app lowers + // the quality on slow frames. + radius: outlineRadiusFor(view, state, quality), + }); + view.controlsChanged = false; + framesSinceFps += 1; + + // Hardware that cannot keep up gets a smaller drawing buffer instead of a + // slideshow (a very large buffer can look like "nothing ever appears"). + if (rawDelta > 90) slowFrames += 1; + else slowFrames = 0; + if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) { + autoReductions += 1; + quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100); + renderer.setPixelRatio(quality); + slowFrames = 0; + console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality); + } + } + + if (now - fpsAt > 1000) { + const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt)); + framesSinceFps = 0; + fpsAt = now; + const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); + panel.setStats?.( + `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps` + + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''), + ); + } + + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + + hideLoading(); + app.ready = true; + window.__bluebeyReady = true; + window.__bluebey = { + app, + state, + set: (patch) => applyState(patch), + face: (patch) => applyState({ face: patch }), + pose: (patch) => applyState({ pose: patch }), + view: (patch) => applyState({ view: patch }), + render: (patch) => applyState({ render: patch }), + selectBone: (name) => rig.select(name), + preset: (id) => app.actions.applyFacePreset?.(id), + posePreset: (id) => app.actions.applyPosePreset?.(id), + png: (options) => captureImage(options), + }; + + /* ------------------------------------------------------------- internals */ + + /** The camera the outline pass should use (kept in step with the view rig). */ + function outlineCamera() { + return view.camera; + } + + /** The gizmo/frame loop re-reads the bones, so store them back into state. */ + function capturePose() { + const pose = rig.getPose(); + state.pose.bones = pose.bones; + } + + function buildFaceParams() { + const eyes = state.face.eyes; + const openScale = animator.eyeOpen; + const drift = animator.look; + + const eyeFor = (key) => { + const own = eyes[key]; + // A shut artwork (1〜3線 / 3の目 / わらう) does not blink: its lid stays where + // the user put it, so the blink animation must not scale its `open`. + const shutArt = own.shape === 'three' || own.shape === 'arch' + || (typeof own.shape === 'string' && own.shape.startsWith('line')); + return { + // Which artwork this eye shows (ふつう / 1〜3線 / 3の目 / わらう / ハート). + // The renderer chooses the shut artwork from this, so it must travel. + shape: own.shape, + open: clamp01(own.open * (shutArt ? 1 : openScale)), + lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1), + lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1), + closed: own.closed, + closedLines: own.closedLines, + irisShape: own.irisShape, + threeFlip: own.threeFlip === true, + // 白目 / 光彩 are per eye and tri-state: `null` means "the usual" (open + // eyes show their white; the shared 光彩 switch decides the glint). + white: own.white ?? null, + highlight: own.highlight ?? null, + // 形の大きさ scales the drawn eye shape (the shut lines, the 3, the arch) + // and the heart. + shapeScale: own.shapeScale ?? 1, + // The tears are per eye, and only drawn when their own switch is on. + tearOn: own.tearOn === true, + tear: own.tear ?? 0.3, + tearY: own.tearY ?? 0, + // Where this eye sits, and where its teardrop hangs, can be nudged one + // side at a time (an asymmetric face). + eyeX: own.eyeX ?? 0, + tearX: own.tearX ?? 0, + tearTilt: own.tearTilt ?? 0, + // The lower lid can differ per eye (falling back to the shared value). + lowerLid: own.lowerLid ?? eyes.lowerLid ?? 0, + // The rest of the lids are per eye too now (null = use the shared value). + lidShape: own.lidShape ?? null, + lidWidth: own.lidWidth ?? null, + lidTilt: own.lidTilt ?? null, + lashes: own.lashes ?? null, + lashAngle: own.lashAngle ?? null, + lashPos: own.lashPos ?? null, + }; + }; + + // A look-at target drives the eyes without being part of the saved + // expression; the mouth is used as-is. + const mouth = { ...state.face.mouth }; + + return { + eyes: { + // Everything shared by both eyes (colours, iris scale, glasses, ...) has + // to travel with the per-eye values, because the face renderer + // reads them from here. Spreading `...eyes` is also what makes the face + // redraw when one of them (e.g. `glasses`) changes: the loop compares the + // JSON of this object against the last one it drew. + ...eyes, + left: eyeFor('left'), + right: eyeFor('right'), + }, + mouth, + }; + } + + function faceMode() { + return isLineStyle(state.render.style) ? 'line' : 'paint'; + } + + function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) { + if (full) { + const fresh = defaultState(); + applyPatch(fresh, patch); + for (const key of Object.keys(fresh)) state[key] = fresh[key]; + } else { + applyPatch(state, patch); + } + refresh(scope); + app.needsRender = true; + if (sync && !silent) app.panel?.sync(); + } + + /** Re-apply the parts of the state named by `scope` ('all' by default). */ + function refresh(scope = 'all') { + app.needsRender = true; + // Where the eyes aim depends on where the character is, so it is recomputed + // whenever anything that moves could change. + if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') { + aimOffset = applyLookAt(); + } + if (scope === 'all' || scope === 'render') { + styles.setStyle(state.render.style); + styles.setPaper(state.render.paper); + styles.setOutlineWidth(state.render.outlineWidth); + styles.setOutlineColor(state.render.outlineColor); + styles.setOutlineEnabled(state.render.outline); + // Which parts each outline method draws, and which hulls stand down for it + // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`. + applyOutlineMethod(state.render.style); + + const line = isLineStyle(state.render.style); + // Shadows, reflections, body colours and mirroring are the look module's + // job, so that they are applied together and stay consistent. + look.apply(state.render, { line }); + applyProps(); + // 見えない壁: the one setting that changes what is drawn rather than how. + applyWalls(); + + const azimuth = state.render.lightAzimuth * DEG; + const elevation = state.render.lightElevation * DEG; + const distance = model.size.y * 3; + keyOffset.set( + Math.cos(elevation) * Math.sin(azimuth) * distance, + Math.sin(elevation) * distance, + Math.cos(elevation) * Math.cos(azimuth) * distance, + ); + updateKeyLight(); + key.intensity = state.render.lightIntensity; + ambient.intensity = state.render.ambient; + key.color.set(state.render.lightColor ?? '#ffffff'); + ambient.color.set(state.render.ambientColor ?? '#ffffff'); + renderer.toneMappingExposure = state.render.exposure; + applyBackground(); + renderer.shadowMap.needsUpdate = true; + } + + if (scope === 'all' || scope === 'view') { + applyBackdrop(); + // The renderer's *clear* depends on the background mode as well: a preset + // photo, a loaded picture and the camera all show through a transparent + // clear (see `backgroundOf`). Without this, switching the background left + // the canvas clearing to the old opaque colour and hid the layer that had + // just been set up behind it - which is why the background only appeared + // after some other change (adding a prop) re-ran this. + applyBackground(); + } + if (scope === 'all' || scope === 'view' || scope === 'gion') redrawOverlay(); + + // A style change flips the face between paint and ink (`faceMode`), so the + // 'render' scope has to rebuild the face too - otherwise the old drawing + // stays on the plates until something else (a blink) happens to dirty them. + if (scope === 'all' || scope === 'face' || scope === 'render') { + face.setSource('eyes', state.face.eyes.source); + face.setSource('mouth', state.face.mouth.source); + face.setParams(buildFaceParams(), faceMode()); + } + + if (scope === 'all' || scope === 'view') { + view.setProjection(state.view.projection); + view.apply(state.view, model.size); + view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed); + } + + if (scope === 'all' || scope === 'pose') { + rig.applyPose(state.pose); + const root = state.pose.root ?? [0, 0, 0]; + container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0); + } + + // Every user-driven change funnels through here, so this is the one place + // that has to remember a step for undo. Undo itself sets `suspendHistory`. + if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope); + } + + /** Paint the backdrop bitmap behind the model, for an export. */ + function backdropSource() { + if (state.view.background === 'camera') { + const video = backdrop.el?.querySelector?.('video'); + return video && video.readyState >= 2 ? video : null; + } + return backdropImage; + } + + /** + * Freeze the current camera frame into a canvas. + * + * The AR preview is a live video, so by the time the PNG finishes encoding the + * feed has moved on and the saved picture no longer matches what was on screen + * when the shutter was pressed. Snapping the frame first keeps the two in step. + */ + function snapshotBackdrop() { + if (state.view.background !== 'camera') return undefined; + const video = backdrop.el?.querySelector?.('video'); + if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return undefined; + const frame = document.createElement('canvas'); + frame.width = video.videoWidth; + frame.height = video.videoHeight; + try { + frame.getContext('2d').drawImage(video, 0, 0); + } catch { + return undefined; + } + return frame; + } + + /** + * Paint the backdrop *behind* the rendered model. + * + * WHY the scratch canvas and `destination-over`: `ctx` already holds the + * character on a transparent field (see `exporter.renderStill`), so drawing the + * photo straight onto it with the default `source-over` painted OVER the + * character and the export came out as a bare photograph. Building the scene in + * a scratch canvas first also lets the dark veil sit on top of the photo, which + * it could not do once the photo had been slipped underneath. + */ + function drawBackdropInto(ctx, width, height, scale, sourceOverride) { + const source = sourceOverride ?? backdropSource(); + if (!source) return false; + const style = backdropStyle(state.view); + if (!style.visible) return false; + const iw = source.naturalWidth || source.videoWidth || source.width || 0; + const ih = source.naturalHeight || source.videoHeight || source.height || 0; + if (!iw || !ih) return false; + + const back = document.createElement('canvas'); + back.width = width; + back.height = height; + const paint = back.getContext('2d'); + + // The same CSS the live layer uses: blur (bleeding past the edges), the fit, + // the mirror and the scale/offset transform. The offset is in the element's + // own (pre-scale) space, so it is multiplied by `scale` here, exactly as + // `transform: scale(s) translate(...)` does in CSS. + const bleed = style.blur > 0 ? style.blur * 2 : 0; + const w = width + bleed * 2; + const h = height + bleed * 2; + paint.save(); + if (style.blur > 0) paint.filter = `blur(${style.blur}px)`; + paint.translate( + width / 2 + style.scale * style.offset.x * width, + height / 2 + style.scale * style.offset.y * height, + ); + paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale); + paint.translate(-width / 2, -height / 2); + + if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') { + const fit = style.backgroundSize === 'cover' + ? Math.max(w / iw, h / ih) + : Math.min(w / iw, h / ih); + const dw = iw * fit; + const dh = ih * fit; + paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh); + } else if (style.backgroundSize === 'auto') { + // CSS repeats the picture at its own size and anchors the grid on the box + // centre (`background-position: center`). A canvas pattern instead repeats + // at the image's *intrinsic* size, which is `scale`x too small for a + // canvas that is `scale`x the CSS box - so the export tiled `scale`x more + // densely than the preview. Paint the tiles by hand at `scale`, on a grid + // centred on the box centre; the CTM above already carries the offset / + // backgroundScale / mirror, so the tiles must not re-apply them. + const tw = iw * scale; + const th = ih * scale; + // Cover only the user-space area the visible canvas needs (widened by the + // blur bleed), mapped back through the CTM. The CTM holds no rotation, so + // its `a`/`d` scale factors are enough to invert it for the bounds. + const matrix = paint.getTransform(); + const [left, right] = [ + (-bleed - matrix.e) / matrix.a, + (width + bleed - matrix.e) / matrix.a, + ].sort((a, b) => a - b); + const [top, bottom] = [ + (-bleed - matrix.f) / matrix.d, + (height + bleed - matrix.f) / matrix.d, + ].sort((a, b) => a - b); + const cx = width / 2; + const cy = height / 2; + const firstCol = Math.floor((left - cx) / tw); + const lastCol = Math.ceil((right - cx) / tw); + const firstRow = Math.floor((top - cy) / th); + const lastRow = Math.ceil((bottom - cy) / th); + for (let row = firstRow; row <= lastRow; row++) { + for (let col = firstCol; col <= lastCol; col++) { + paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th); + } + } + } else { + paint.drawImage(source, -bleed, -bleed, w, h); + } + paint.restore(); + + if (style.darken > 0) { + paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`; + paint.fillRect(0, 0, width, height); + } + + ctx.save(); + ctx.globalCompositeOperation = 'destination-over'; + ctx.drawImage(back, 0, 0); + ctx.restore(); + return true; + } + + /** Load (or clear) the backdrop bitmap that the exports composite. */ + function applyBackdrop() { + const viewState = state.view; + backdrop.apply(viewState); + // The AR shutter rides the viewport, not the panel, so it shows/hides here. + if (app.arShutter) app.arShutter.hidden = viewState.background !== 'camera'; + // The touch hint would sit under the shutter, so it steps aside in AR mode. + const touchHint = document.getElementById('viewport-hint-touch'); + if (touchHint) touchHint.style.visibility = viewState.background === 'camera' ? 'hidden' : ''; + const url = viewState.background === 'preset' + ? assetUrl(viewState.backgroundPreset, 'background') + : viewState.background === 'image' + ? viewState.backgroundImage + : viewState.background === 'effect' + ? backdrop.effectUrl(viewState.backgroundEffect) + : null; + if (!url) { + if (viewState.background !== 'camera') backdropImage = null; + return; + } + if (backdropImage?.dataset?.url === url) return; + const image = new Image(); + image.dataset.url = url; + image.onload = () => { backdropImage = image; app.needsRender = true; }; + image.onerror = () => { backdropImage = null; }; + image.src = url; + } + + function applyBackground() { + exporter.applyBackground(renderer, backgroundOf(state.view)); + } + + async function captureImage({ scale = 2, transparent = null, lines = null } = {}) { + // Freeze the live camera frame up front, so the saved picture holds the frame + // that was on screen when the button was pressed (see `snapshotBackdrop`). + const frozenBackdrop = snapshotBackdrop(); + return withHiddenHelpers(async () => { + // The face texture is redrawn on a throttle (every 26 ms) while the render + // loop runs, so a capture taken straight after a change could still hold the + // *previous* expression. That is what made a comic panel occasionally show + // its neighbour's face: force the pending redraw through first. + face.flush(performance.now(), 0); + const wantsTransparent = transparent === true + || (transparent == null && state.view.background === 'transparent'); + // A transparent export of a line drawing drops the paper as well. The whole + // point of transparent line art is to lay the lines over another picture, + // and a white silhouette would just hide it - so this renders the `outline` + // style, which draws the ink and leaves the body out. A shaded style keeps + // its body, because there it is an ordinary cut-out of the character. + const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style)); + const drawn = wantsLines ? 'outline' : state.render.style; + const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view); + + const onScreen = styles.style; + if (drawn !== onScreen) styles.setStyle(drawn); + let result; + try { + result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({ + renderer, + scene, + camera: view.camera, + outline, + outlineOptions: { + enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false, + color: state.render.outlineColor, + // The export's label buffer is `scale`x the one on screen (renderStill + // sets it to the output size), so its radius grows with it to keep the + // line's weight relative to the model the same as in the preview. + radius: outlineRadiusFor(view, state, scale), + }, + width: canvas.clientWidth || window.innerWidth, + height: canvas.clientHeight || window.innerHeight, + restore: () => { resizeViewport(); applyStateBackgroundAgain(); }, + }, { scale, background })); + } finally { + if (drawn !== onScreen) styles.setStyle(onScreen); + } + + // The backdrop and the 擬音 stamps live in DOM layers behind the WebGL + // canvas, so an export has to paint them in itself - in the same order as + // the screen: backdrop, then stamps. + const ctx = result.canvas.getContext('2d'); + // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps are + // drawn at below. The backdrop's tiled fit needs it too, so its tiles come + // out the size the preview's CSS paints them. + const base = canvas.clientWidth || 1280; + const outputScale = result.width / Math.max(1, base); + let changed = false; + if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true; + const gionItems = state.gion?.items ?? []; + if (gionItems.length) { + for (const item of gionItems) await ensureGionSheet(item.sheet); + drawGion(ctx, gionItems, gionImages, { + width: result.width, + height: result.height, + scale: outputScale, + }); + changed = true; + } + if (changed) result.blob = await exporter.canvasToBlob(result.canvas); + return result; + }); + } + + function applyStateBackgroundAgain() { + exporter.applyBackground(renderer, backgroundOf(state.view)); + } + + async function savePNG({ + scale = state.render.pngScale ?? 2, + transparent = state.render.pngTransparent ? true : undefined, + } = {}) { + const result = await captureImage({ scale, transparent }); + exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`); + toast(`PNGを書き出しました(${result.width}×${result.height})`); + } + + /** + * The canvas the 下地 export paints on, *without* downloading it. Kept separate + * from `saveFaceMap` so the in-page round-trip check can look at exactly the + * pixels the button writes. + */ + function faceMapCanvas(kind) { + // A redraw is throttled while the render loop runs (see the loop's + // `face.flush(now, 26)`), so force any pending one through first or the map + // would show the expression before last. + face.flush(performance.now(), 0); + return exporter.buildFaceMap(face, kind); + } + + async function saveFaceMap(kind) { + const canvas = faceMapCanvas(kind); + if (!canvas) { + toast('この下地は書き出せませんでした'); + return; + } + const blob = await exporter.canvasToBlob(canvas); + const label = kind === 'eyes' ? '目' : '口'; + exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); + toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); + } + + /** Hide the gizmo and helpers so they never end up in an export. */ + async function withHiddenHelpers(fn) { + const wasVisible = rig.helper.visible; + const guidesWereVisible = clip.guides.map((guide) => guide.visible); + rig.helper.visible = false; + clip.guides.forEach((guide) => { guide.visible = false; }); + try { + return await fn(); + } finally { + rig.setGizmoVisible(wasVisible); + clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; }); + app.needsRender = true; + } + } + + async function copyPNG() { + const result = await captureImage({ scale: 2 }); + await exporter.copyCanvasToClipboard(result.canvas); + toast('画像をクリップボードにコピーしました'); + } + + /** + * Swap in another GLB. + * + * Kept as the implementation, but no longer wired to anything: the panel's + * 「GLBを差し替える」 button and the window-wide drop handler were removed on + * 2026-09-27. Putting the feature back is a button plus an action that calls + * this (the model returns on reload, so nothing is saved). + */ + async function replaceModel(file) { + if (!file) return; + try { + const buffer = await exporter.readFileAsArrayBuffer(file); + const next = await loadModel(buffer); + styles.dispose(); + rig.dispose(); + container.remove(model.root); + scene.remove(container); + container.add(next.root); + scene.add(container); + app.model = next; + toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`); + app.needsRender = true; + } catch (error) { + console.error(error); + toast('このファイルは読み込めませんでした'); + } + } + + let recording = false; + async function toggleRecording(session) { + if (!session) { + toast('この環境では録画できません'); + return; + } + if (!recording) { + if (!state.anim.idle) { + state.anim.idle = true; + panel.sync(); + } + recording = startRecording(session); + toast('録画を開始しました(もう一度押すと停止)'); + } else { + const blob = await stopRecording(session); + recording = false; + if (blob) { + exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`); + toast('録画を保存しました(WebM)'); + } + } + app.panel?.setRecording(recording); + } + + function setCameraPreset(id) { + const presets = { + front: { azimuth: 0, polar: 82 }, + threeQuarter: { azimuth: 34, polar: 78 }, + side: { azimuth: 90, polar: 84 }, + back: { azimuth: 180, polar: 82 }, + top: { azimuth: 24, polar: 26 }, + }; + applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true }); + } + + function restoreFromHistory(kind) { + const snapshot = kind === 'undo' ? history.undo() : history.redo(); + if (!snapshot) { + toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません'); + return; + } + suspendHistory = true; + try { + applyState(snapshot, { full: true, sync: true }); + } finally { + suspendHistory = false; + } + toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました'); + } + + /** おまかせ: a seeded random expression, so it can be reproduced and shared. */ + function rollGacha(seedText) { + const seed = seedText || String(Math.floor(Math.random() * 1e9)); + const roll = rollAll(seed); + suspendHistory = true; + try { + applyState(roll.patch, { scope: 'all' }); + } finally { + suspendHistory = false; + } + history.push(state, 'おまかせ'); + app.panel?.sync(); + const shown = decodeSeed(seed) ?? String(seed); + toast(`おまかせ表情(シード ${shown})`); + return shown; + } + + async function copyShareLink() { + try { + const encoded = await encodeState(state); + const link = `${location.origin}${location.pathname}#s=${encoded}`; + await navigator.clipboard.writeText(link); + toast(`この見た目のリンクをコピーしました(${link.length}文字)`); + } catch (error) { + console.error(error); + toast('リンクを作れませんでした'); + } + } + + /** The tag every shared picture carries. */ + const SHARE_TAG = '#ぶるべースタジオ'; + + /** The studio's own address - the landing page itself, not a state-reproducing + * `#s=` link. Shared alongside the tag so a viewer can find the studio. */ + function studioUrl() { + return `${location.origin}${location.pathname}`; + } + + /** + * Share the current look as a *picture*, not as a link. + * + * Neither X nor Facebook's web dialog can attach an image, so the picture goes + * through the OS share sheet (`navigator.share` with files) wherever the browser + * has one - a single tap on a phone, and it can go to any app. Where there is no + * share sheet (most desktops), the PNG is put on the clipboard and the network's + * compose window is opened with the tag already typed, so the picture only has to + * be pasted. The tag *and* the studio link always travel together: the user asks + * for both to be kept when the picture is shared. + */ + async function shareImage(network) { + let shot; + try { + shot = await captureImage({ scale: 1.5, transparent: false }); + } catch (error) { + console.error(error); + toast('画像を作れませんでした'); + return; + } + const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' }); + + if (!network && navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() }); + return; + } catch (error) { + if (error?.name === 'AbortError') return; + console.error(error); + } + } + + // No share sheet (or a named network): put the picture on the clipboard and + // open that network's compose window with the tag and the studio link filled in. + let copied = false; + try { + await exporter.copyCanvasToClipboard(shot.canvas); + copied = true; + } catch (error) { + console.error(error); + } + const studio = studioUrl(); + const target = network === 'facebook' + ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}` + : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`; + window.open(target, '_blank'); + toast(copied + ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)' + : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)'); + } + + async function toggleCamera(on) { + if (!on) { + backdrop.stopCamera(); + applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true }); + return; + } + const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment'); + if (!result?.ok) { + toast(result?.reason ?? 'カメラを使えません'); + return; + } + applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true }); + toast('カメラの映像を背景にしました(そのまま写真に撮れます)'); + } + + function addProp(kind) { + const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; + state.props.items = [...(state.props.items ?? []), { + kind, + x: def.x ?? 0, + y: def.y ?? 0, + z: def.z ?? 0, + rotX: def.rotX ?? 0, + rotY: def.rotY ?? 0, + rotZ: def.rotZ ?? 0, + scale: def.scale ?? 1, + }]; + applyState({}, { scope: 'render', sync: true }); + } + + async function addStoryPanel() { + const index = (state.story.panels?.length ?? 0) + 1; + // A counter, not the array length: removing a panel and then adding another + // used to hand out an id that was already taken, which confused the list. + storySeq += 1; + const id = `p${storySeq}`; + state.story.panels = [...(state.story.panels ?? []), { + id, + label: `コマ${index}`, + pose: JSON.parse(JSON.stringify(state.pose)), + face: JSON.parse(JSON.stringify(state.face)), + // The 擬音 stamps as well, so a panel comes back with everything it showed. + gion: JSON.parse(JSON.stringify(state.gion)), + // The camera as well as the pose: recalling a panel should put you back + // where you were looking when you recorded it. + view: JSON.parse(JSON.stringify(state.view)), + }]; + applyState({}, { scope: 'gion', sync: true }); + + // Then photograph what was just recorded, so the list shows the shot rather + // than only its name. + try { + const shot = await captureImage({ scale: 0.35, transparent: false }); + storyThumbs.set(id, { + version: (storyThumbs.get(id)?.version ?? 0) + 1, + url: thumbDataUrl(shot.canvas), + }); + toast(`コマ${index}を追加しました`); + } catch (error) { + console.warn('[bluebey] story thumbnail failed', error); + toast(`コマ${index}を追加しました(プレビューは作れませんでした)`); + } + app.panel?.sync(); + } + + /** Shrink a capture down to a thumbnail data URL for the panel list. */ + function thumbDataUrl(source, width = 168) { + const scale = width / Math.max(1, source.width); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(source.width * scale)); + canvas.height = Math.max(1, Math.round(source.height * scale)); + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(source, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/png'); + } + + /** + * A number in the corner of a comic panel, for sheets whose reading order is + * not obvious. + */ + function drawPanelNumber(ctx, number, x, y, cellW, cellH) { + const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075)); + const margin = radius * 0.7; + const cx = x + margin + radius; + const cy = y + margin + radius; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; + ctx.fill(); + ctx.lineWidth = Math.max(2, radius * 0.14); + ctx.strokeStyle = '#3f2b52'; + ctx.stroke(); + ctx.fillStyle = '#3f2b52'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`; + ctx.fillText(String(number), cx, cy + radius * 0.04); + ctx.restore(); + } + + /** + * まんが: replay the panels, capture each one, and hand back a framed sheet. + */ + async function saveStory({ scale = 2 } = {}) { + const panels = state.story?.panels ?? []; + if (!panels.length) { + toast('コマがありません。「今の状態をコマに追加」で作ってください'); + return; + } + const saved = JSON.parse(JSON.stringify(state)); + const captures = []; + suspendHistory = true; + try { + for (const panel of panels) { + const pose = panel.pose ?? {}; + state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] }; + state.face = JSON.parse(JSON.stringify(saved.face)); + applyPatch(state.face, panel.face ?? {}); + // The camera the panel was framed with, so a comic keeps its angles. + if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view)); + refresh('all'); + // Two frames: one to apply the pose, one to draw it. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + captures.push(await captureImage({ scale, transparent: false })); + } + } finally { + suspendHistory = false; + applyState(saved, { full: true, sync: true }); + } + + const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2)); + const gap = state.story?.gap ?? 12; + const padding = state.story?.padding ?? 20; + const rows = Math.ceil(captures.length / columns); + const cellW = Math.max(...captures.map((item) => item.width)); + const cellH = Math.max(...captures.map((item) => item.height)); + + // Once the sheet is more than one column wide, the reading order stops being + // obvious from the layout alone, so the panels are numbered. A single column + // (or two panels) reads top to bottom without help. + const numbered = columns >= 2 && captures.length >= 3; + + // A manga page reads by its panel frames. They are drawn inside each cell so + // the artwork keeps its full size, and are thick enough to survive the 1× + // export. + const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012)); + + const sheet = document.createElement('canvas'); + sheet.width = padding * 2 + cellW * columns + gap * (columns - 1); + sheet.height = padding * 2 + cellH * rows + gap * (rows - 1); + const ctx = sheet.getContext('2d'); + ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff'; + ctx.fillRect(0, 0, sheet.width, sheet.height); + captures.forEach((capture, index) => { + const x = padding + (index % columns) * (cellW + gap); + const y = padding + Math.floor(index / columns) * (cellH + gap); + ctx.drawImage(capture.canvas, x, y, cellW, cellH); + ctx.lineWidth = frame; + ctx.strokeStyle = '#2a1e33'; + ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame); + if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH); + }); + + const stamp = exporter.timestamp(); + exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`); + toast(`まんがを書き出しました(${captures.length}コマ)`); + } + + /** A shared link restores the whole look; otherwise this is the start point. */ + async function loadFromHash() { + // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。 + // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。 + const query = new URLSearchParams(location.search).get('s'); + const hash = location.hash.replace(/^#/, ''); + const encoded = query ?? new URLSearchParams(hash).get('s'); + if (!encoded) return false; + const decoded = await decodeState(encoded); + if (!decoded?.state) return false; + suspendHistory = true; + try { + applyState(decoded.state, { full: true, sync: true }); + } finally { + suspendHistory = false; + } + return true; + } + + function installTopbar() { + // The panel itself is visible from the start, but each of its sections starts + // collapsed (see `section()` in src/ui.js): the panel reads as a short list of + // headings, and you open the one you want instead of scrolling past twelve. + resizeViewport(); + + // AR (カメラ=AR) get a camera-app style shutter over the viewport, so a photo + // is one tap away. It is a DOM layer over the canvas, so it never lands in the + // export; its visibility follows the background mode (see `applyBackdrop`). + const shutter = document.createElement('button'); + shutter.type = 'button'; + shutter.id = 'ar-shutter'; + shutter.className = 'ar-shutter'; + shutter.title = '写真を撮る'; + shutter.setAttribute('aria-label', '写真を撮る'); + shutter.hidden = true; + shutter.addEventListener('click', () => { savePNG(); }); + stage.append(shutter); + app.arShutter = shutter; + + document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG()); + document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG()); + document.getElementById('btn-help')?.addEventListener('click', toggleHelp); + document.getElementById('btn-panel')?.addEventListener('click', togglePanel); + document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); }); + const help = document.getElementById('help'); + help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); }); + + // --- the mouse on the character ---------------------------------------- + // Dragging the model slides it across the view; holding Shift and dragging + // turns it around. Bone posing lives in the panel, so the mouse never grabs a + // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over + // the view, so a tap on the view first just dismisses the sheet. + const dragRay = new THREE.Raycaster(); + const dragNdc = new THREE.Vector2(); + const dragHitAt = (event) => { + const rect = canvas.getBoundingClientRect(); + dragNdc.set( + ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, + -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, + ); + dragRay.setFromCamera(dragNdc, view.camera); + const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body] + .filter((mesh) => mesh?.visible); + return dragRay.intersectObjects(targets, false)[0] ?? null; + }; + // World units per screen pixel, so the model keeps up with the cursor. + const dragScale = () => { + const camera = view.camera; + const height = Math.max(1, canvas.clientHeight); + if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height; + const distance = camera.position.distanceTo(view.controls.target); + return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height; + }; + let modelDrag = null; + + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || event.target !== canvas) return; + if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) { + togglePanel(); + return; + } + if (!dragHitAt(event)) return; + const root = state.pose.root ?? [0, 0, 0]; + const master = state.pose.bones?.master ?? [0, 0, 0]; + modelDrag = { + pointerId: event.pointerId, + spin: event.shiftKey, + x: event.clientX, + y: event.clientY, + root: [...root], + masterY: master[1] ?? 0, + }; + view.controls.enabled = false; + stage.style.cursor = 'grabbing'; + try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ } + }); + + canvas.addEventListener('pointermove', (event) => { + if (!modelDrag || event.pointerId !== modelDrag.pointerId) return; + const dx = event.clientX - modelDrag.x; + const dy = event.clientY - modelDrag.y; + if (modelDrag.spin) { + const bones = { ...(state.pose.bones ?? {}) }; + const master = bones.master ?? [0, 0, 0]; + bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]]; + state.pose.bones = bones; + } else { + const k = dragScale(); + state.pose.root = [ + round2(modelDrag.root[0] + dx * k), + round2(modelDrag.root[1] - dy * k), + modelDrag.root[2], + ]; + } + app.panel?.onRigChanged?.(); + app.needsRender = true; + }); + + const endModelDrag = (event) => { + if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return; + modelDrag = null; + view.controls.enabled = true; + stage.style.cursor = ''; + if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } + capturePose(); + app.panel?.onRigChanged?.(); + app.needsRender = true; + }; + canvas.addEventListener('pointerup', endModelDrag); + canvas.addEventListener('pointercancel', endModelDrag); + } + + function togglePanel() { + document.body.classList.toggle('panel-hidden'); + resizeViewport(); + } + + // The panel is a side column on a wide screen and a bottom sheet on a small + // one, where it starts closed so the 3D view fills the screen (see the + // small-screen rules in src/style.css). When the window is resized across that + // breakpoint, match the layout the other side expects instead of leaving the + // panel stuck in the previous state. + const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)'); + narrowScreenLayout.addEventListener('change', (event) => { + document.body.classList.toggle('panel-hidden', event.matches); + resizeViewport(); + }); + + function installKeys() { + window.addEventListener('keydown', (event) => { + // Undo/redo are the only shortcuts that need a modifier, so they are dealt + // with before the "no modifiers" rule below. + if ((event.ctrlKey || event.metaKey) && !event.altKey) { + const combo = event.key.toLowerCase(); + if (combo === 'z' || combo === 'y') { + event.preventDefault(); + restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo'); + return; + } + } + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return; + switch (event.key) { + case 's': case 'S': savePNG(); break; + case 'c': case 'C': copyPNG(); break; + case 'r': case 'R': app.actions.resetPose(); break; + case 'g': case 'G': { + const visible = !rig.helper.visible; + rig.setGizmoVisible(visible); + toast(visible ? 'ギズモを表示' : 'ギズモを隠しました'); + app.needsRender = true; + break; + } + case ' ': event.preventDefault(); app.actions.toggleAnimation(); break; + case '?': toggleHelp(); break; + case 'Tab': event.preventDefault(); togglePanel(); break; + case '1': setCameraPreset('front'); break; + case '2': setCameraPreset('threeQuarter'); break; + case '3': setCameraPreset('side'); break; + case '4': setCameraPreset('back'); break; + case '5': setCameraPreset('top'); break; + default: break; + } + }); + } + +} + +function toggleHelp(force) { + const help = document.getElementById('help'); + if (help) help.hidden = force === undefined ? !help.hidden : !force; +} + +/** A persistent message for problems that need the user to do something. */ +function showNotice(text) { + let node = document.getElementById('notice'); + if (!node) { + node = document.createElement('div'); + node.id = 'notice'; + document.body.append(node); + } + node.textContent = text; + node.hidden = false; +} + +/* ------------------------------------------------------------------- camera */ + +/** + * Orbiting camera with both a perspective and an orthographic projection. + * Only one of them is enabled at a time; switching copies the framing across so + * the model does not jump. + */ +class ViewRig { + constructor(domElement, onChange) { + this.domElement = domElement; + this.onChange = onChange; + this.projection = 'persp'; + this.controlsChanged = false; + + this.cameras = { + // A tight near/far keeps depth precision high, which matters because the + // outline hull is only a few hundredths of a unit away from the surface. + // The far plane has to sit well past the furthest the camera may go, or + // zooming all the way out slices the character on it - which is exactly + // what the old `far = 120` did, because the zoom limit was also 120. + persp: new THREE.PerspectiveCamera(30, 1, 1, 2000), + ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000), + }; + this.controlsMap = {}; + for (const [key, camera] of Object.entries(this.cameras)) { + const controls = new OrbitControls(camera, domElement); + controls.enableDamping = false; + controls.enablePan = true; + controls.minDistance = 1; + controls.maxDistance = 400; + controls.addEventListener('change', () => { + this.controlsChanged = true; + this.onChange?.(); + }); + this.controlsMap[key] = controls; + } + this.controlsMap.ortho.enabled = false; + this.camera = this.cameras.persp; + this.controls = this.controlsMap.persp; + this.target = new THREE.Vector3(0, 1, 0); + this.autoDistance = 12; + // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes + // the camera's actual height back into it - so the framing `frame()` chose + // needs its own field to measure the zoom against (see `zoomFactor`). + this.orthoHeight = 6; + this.autoOrthoHeight = 6; + this.resize(); + } + + /** Fit the model into view. */ + frame(size, targetY) { + this.target.set(0, targetY, 0); + for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target); + const fov = this.cameras.persp.fov * Math.PI / 180; + const heightDistance = size.y / (2 * Math.tan(fov / 2)); + const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect)); + this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45; + this.orthoHeight = size.y * 1.5; + this.autoOrthoHeight = this.orthoHeight; + for (const camera of Object.values(this.cameras)) { + camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance); + } + this.resize(); + } + + get aspect() { + const width = this.domElement.clientWidth || 1; + const height = this.domElement.clientHeight || 1; + return width / height; + } + + resize() { + const width = this.domElement.clientWidth || window.innerWidth; + const height = this.domElement.clientHeight || window.innerHeight; + const aspect = width / height; + this.cameras.persp.aspect = aspect; + this.cameras.persp.updateProjectionMatrix(); + this.setOrthoHeight(this.orthoHeight); + } + + setOrthoHeight(height) { + const ortho = this.cameras.ortho; + const halfHeight = height / 2; + const halfWidth = halfHeight * this.aspect; + ortho.left = -halfWidth; + ortho.right = halfWidth; + ortho.top = halfHeight; + ortho.bottom = -halfHeight; + ortho.zoom = 1; + ortho.updateProjectionMatrix(); + } + + /** + * How large the character looks now, relative to the auto-fit framing. + * + * 1 means "the default framing", which is where the panel's 大きさ sliders sit + * and so where the screen-space outline's pixel width is written against. The + * two projections scale differently - a perspective camera shows 1/distance as + * much per world unit, an orthographic one 1/height, and OrbitControls zooms + * the latter with `camera.zoom` rather than by moving it - so each is + * normalised by its own auto-fit value. + */ + zoomFactor() { + if (this.projection === 'ortho') { + const ortho = this.cameras.ortho; + const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001); + return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1; + } + const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target); + return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1; + } + + setProjection(kind) { + if (kind === this.projection) return; + const previous = this.camera; + const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp; + next.position.copy(previous.position); + next.quaternion.copy(previous.quaternion); + this.controlsMap[this.projection].enabled = false; + this.projection = kind; + this.camera = next; + this.controls = this.controlsMap[kind]; + this.controls.enabled = true; + this.controls.target.copy(this.target); + this.resize(); + this.onCameraChange?.(next, this.controls); + this.controlsChanged = true; + this.onChange?.(); + } + + /** Apply the serialisable view state (azimuth/polar/zoom). */ + apply(viewState, size) { + const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance; + const targetY = viewState.targetY || this.target.y; + this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0); + const spherical = new THREE.Spherical( + distance, + clamp(viewState.polar, 1, 179) * Math.PI / 180, + viewState.azimuth * Math.PI / 180, + ); + const offset = new THREE.Vector3().setFromSpherical(spherical); + for (const camera of Object.values(this.cameras)) { + camera.position.copy(this.target).add(offset); + camera.lookAt(this.target); + } + if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight); + else if (size) this.setOrthoHeight(this.orthoHeight); + for (const controls of Object.values(this.controlsMap)) { + controls.target.copy(this.target); + controls.update(); + } + this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom; + this.cameras.persp.updateProjectionMatrix(); + this.cameras.ortho.updateProjectionMatrix(); + } + + /** + * Write where the camera actually is back into the serialisable view state. + * + * WHY: the mouse orbit and pan move the `controls`, not the state. Without this + * the state kept the last camera the *panel* set, so any refresh of the view + * snapped the camera back to it - which is what made picking a background jump + * the camera. Keeping the two in step also means a shared link carries the + * camera you framed. + */ + captureInto(viewState) { + if (!viewState || typeof viewState !== 'object') return; + const target = this.controls?.target ?? this.target; + const spherical = new THREE.Spherical().setFromVector3( + new THREE.Vector3().subVectors(this.camera.position, target), + ); + viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100; + viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100; + viewState.distance = Math.round(spherical.radius * 1000) / 1000; + viewState.targetX = Math.round(target.x * 1000) / 1000; + viewState.targetY = Math.round(target.y * 1000) / 1000; + viewState.targetZ = Math.round(target.z * 1000) / 1000; + if (this.projection === 'ortho') { + const ortho = this.cameras.ortho; + viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000; + } + } + + setAutoRotate(enabled, speed) { + for (const controls of Object.values(this.controlsMap)) { + controls.autoRotate = enabled; + controls.autoRotateSpeed = speed; + } + } +} + +/** + * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`). + * + * A clamp keeps a very deep zoom from turning the line into either a smear or + * nothing at all, which is what the linear factor would do at the extremes of + * the 大きさ slider. + */ +const OUTLINE_ZOOM_MIN = 0.25; +const OUTLINE_ZOOM_MAX = 3; + +/** + * The screen-space outline's radius, in pixels of the label buffer. + * + * The body's lines are inverted hulls, i.e. an offset in *world* units, so they + * thicken as the character grows on screen and thin as it shrinks. This pass + * works in pixels instead, so left alone its lines - the waist leaves and the + * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy + * beside the body the moment you pull back. Scaling the radius by the rig's + * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying + * the width at the auto-fit framing (so the slider keeps its meaning). + * + * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the + * preview's buffer is `quality`x the CSS size (and `quality` itself follows the + * display density and the automatic reductions), and an export renders the label + * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width, + * so `outlinePixels` reads as CSS pixels whichever buffer it lands in. + */ +function outlineRadiusFor(view, state, extra = 1) { + const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX); + return (state.render.outlinePixels ?? 2) * zoom * extra; +} + +/* ----------------------------------------------------------------- scenery */ + +function makeEnvironment(renderer) { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, 0, 32); + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(0.5, '#ece7fa'); + gradient.addColorStop(1, '#b9aade'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 32); + const texture = new THREE.CanvasTexture(canvas); + texture.mapping = THREE.EquirectangularReflectionMapping; + texture.colorSpace = THREE.SRGBColorSpace; + const generator = new THREE.PMREMGenerator(renderer); + const environment = generator.fromEquirectangular(texture).texture; + texture.dispose(); + generator.dispose(); + return environment; +} + +/** Round to 2 decimals: the precision the panel's prop sliders show. */ +const round2 = (value) => Math.round(value * 100) / 100; + +const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); + +export function backgroundOf(viewState) { + if (viewState.background === 'transparent') return { mode: 'transparent' }; + // A preset, a loaded photo, a drawn effect line or the phone's camera all + // live in a DOM layer behind the WebGL canvas, so the renderer is left clear + // and the exports paint the bitmap into the picture themselves (see + // drawBackdropInto in main.js). + if (viewState.background === 'preset' || viewState.background === 'image' + || viewState.background === 'effect' || viewState.background === 'camera') { + return { mode: 'transparent' }; + } + return { mode: 'solid', color: viewState.backgroundColor }; +} + +/* ------------------------------------------------------------------- errors */ + +function setLoadingProgress(ratio) { + const node = document.getElementById('loading-text'); + if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`; +} + +function hideLoading() { + const node = document.getElementById('loading'); + if (node) node.classList.add('done'); +} + +function showLoadError(error) { + const text = document.getElementById('loading-text'); + const detail = document.getElementById('loading-error'); + if (text) text.textContent = '読み込みに失敗しました。'; + if (detail) { + detail.textContent = `${error?.message ?? error}\n\n` + + 'このページはローカルサーバー経由で開く必要があります。\n' + + 'bluebey-studio フォルダで python serve.py を実行し、\n' + + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。'; + } +} + +const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); +const clamp01 = (value) => clamp(value, 0, 1); diff --git a/public/bluebey-studio/src/model.js b/public/bluebey-studio/src/model.js new file mode 100644 index 0000000..3925e85 --- /dev/null +++ b/public/bluebey-studio/src/model.js @@ -0,0 +1,283 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +/** + * Loads bluebey.glb and works out everything the studio needs to know about it: + * the rig, which meshes are the face overlays, and the original hand-drawn + * textures that ship inside the file. + * + * The GLB layout (materials carry the reliable names; meshes are named after + * Blender objects): + * body parts Body, Foots, Leaf, Vein, LeftHand, RightHand, Nose + * overlays eyes-* (eight hand-drawn variants, one texture each) and Mouth + * face plates eyes-plate / mouth-plate, the big shells the studio draws on + * hair plate hair-plate, a shell that wraps right round the head (optional) + * + * The two face plates are what the artwork is painted onto. They are the front + * half of the body, so wherever the drawing lands there is a surface to carry + * it - the old hand-drawn planes only covered a patch of the head, which clipped + * the bottom of a deep smile and most of a teardrop. When a model has no plates + * (the 2022 file), the studio falls back to drawing on the hand-drawn planes. + */ + +/** Hand-drawn texture variants inside the GLB, keyed by their material suffix. */ +export const EYE_VARIANTS = [ + { key: 'opened', label: '開いた目' }, + { key: 'closed', label: '閉じた目' }, + { key: 'close-tight', label: 'ぎゅっと閉じ' }, + { key: 'left-wink', label: '左ウインク' }, + { key: 'look-up', label: '上を見る' }, + { key: 'look-down', label: '下を見る' }, + { key: 'look-left', label: '左を見る' }, + { key: 'look-right', label: '右を見る' }, +]; + +/** + * Friendly names for the eleven bones of the rig. Keys are lower-cased with + * dots removed, because three's GLTFLoader sanitises node names (`armsupport.l` + * arrives as `armsupportl`). + */ +const BONE_LABELS = { + master: '全身', + armsupportl: '左うでの付け根', + arml: '左うで', + handl: '左手先', + armr: '右うで', + handr: '右手先', + legsupportl: '左あしの付け根', + footl: '左足', + toel: '左つまさき', + legsupportr: '右あしの付け根', + footr: '右足', + toer: '右つまさき', +}; + +const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); + +/** + * The overlay planes hug the head, so nudge them outwards before anything else + * happens. They are pushed along their own normals (not away from the head + * centre): the mouth in particular needs to clear the head's lower surface and + * the ring of leaves around the base, otherwise a low or frowning mouth gets + * swallowed by them. + */ +const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004, hair: 0.008 }; + +export async function loadModel(source, { onProgress } = {}) { + const loader = new GLTFLoader(); + let gltf; + if (typeof source === 'string') { + gltf = await loader.loadAsync(source, (event) => { + if (onProgress && event.lengthComputable) onProgress(event.loaded / event.total); + }); + } else { + // A File/ArrayBuffer, e.g. from drag and drop or the file picker. + gltf = await loader.parseAsync(source, ''); + } + + const root = gltf.scene; + + // The file carries its own camera node; we drive our own. + const cameras = []; + root.traverse((object) => { if (object.isCamera) cameras.push(object); }); + for (const camera of cameras) camera.removeFromParent(); + + const eyes = new Map(); + const body = []; + /** The original material name of every body mesh (`blb`, `leaf`, `vein`, ...). */ + const kinds = new Map(); + /** The plane holding the hand-drawn mouth texture (a texture source only). */ + let mouthOriginal = null; + /** The big shells the artwork is drawn onto, if the file has them. */ + let eyePlate = null; + let mouthPlate = null; + /** The shell that wraps round the head, so hair shows from behind (optional). */ + let hairPlate = null; + + root.traverse((object) => { + if (!object.isMesh) return; + // Skinned bounds stop matching the pose, so culling would pop meshes away. + object.frustumCulled = false; + const material = Array.isArray(object.material) ? object.material[0] : object.material; + const materialName = (material?.name ?? '').toLowerCase(); + if (materialName === 'eyes-plate') { + eyePlate = object; + return; + } + if (materialName === 'mouth-plate') { + mouthPlate = object; + return; + } + if (materialName === 'hair-plate') { + hairPlate = object; + return; + } + if (materialName.startsWith('eyes-')) { + eyes.set(materialName.slice('eyes-'.length), object); + return; + } + if (materialName === 'mouth') { + mouthOriginal = object; + return; + } + body.push(object); + kinds.set(object, materialName); + }); + + // The waist leaves - the `Leaf` blade and the `Vein` rim that shares its mesh. + // These are the thin, overlapping shells the inverted-hull outline cannot draw + // (see MODEL-GUIDE.md §5), so the screen-space pass takes them over on its own. + const leaves = body.filter((mesh) => kinds.get(mesh) === 'leaf' || kinds.get(mesh) === 'vein'); + + // The drawing surface: the big plate when present, otherwise the 2022 plane. + const eyeMesh = eyePlate ?? eyes.get('opened') ?? eyes.values().next().value; + const mouth = mouthPlate ?? mouthOriginal; + // The wrapping hair shell, if the model carries one (`hair-plate`). + const hairMesh = hairPlate; + // Its cylindrical UV has a seam at the back of the head. Triangles that cross + // it get one UV near 0 and the next near 1, so the whole texture is stretched + // across them - a visible streak down the back. Close the seam per triangle. + if (hairMesh) healCylinderSeam(hairMesh); + if (!eyeMesh) throw new Error('eye mesh not found in the GLB'); + if (!mouth) throw new Error('mouth mesh not found in the GLB'); + + // The nose ball. It is a bump sitting on the body rather than a part with a + // silhouette of its own, so the outline pass treats it per style - see + // `outlineExclusion` in main.js. + const noseMesh = body.find((mesh) => (mesh.material?.name ?? '').toLowerCase() === 'nose') ?? null; + + // Only the drawing surface stays visible; every other overlay is a texture + // source, so hide it. (`mouthOriginal` may be the drawing surface itself.) + for (const mesh of eyes.values()) mesh.visible = mesh === eyeMesh; + if (mouthOriginal && mouthOriginal !== mouth) mouthOriginal.visible = false; + if (mouthPlate && eyePlate) mouthPlate.visible = true; + + /** @type {{ eyes: Record<string, THREE.Texture>, mouth: THREE.Texture|null }} */ + const originals = { eyes: {}, mouth: mouthOriginal?.material?.map ?? mouth.material.map ?? null }; + for (const [key, mesh] of eyes) { + if (mesh.material.map) originals.eyes[key] = mesh.material.map; + } + + // Push the overlay planes a hair off the body surface, so the two do not + // z-fight. The plates are shells of the body, so the offset is tiny; a large + // one is what made the mouth look like it floated in profile. + const bodyMesh = body.find((mesh) => mesh.geometry?.attributes?.position?.count > 2000) ?? body[0]; + const headCentre = new THREE.Box3() + .setFromBufferAttribute(bodyMesh.geometry.attributes.position) + .getCenter(new THREE.Vector3()); + inflateOverlay(eyeMesh, headCentre, OVERLAY_INFLATE.eyes); + inflateOverlay(mouth, headCentre, OVERLAY_INFLATE.mouth); + if (hairMesh) inflateOverlay(hairMesh, headCentre, OVERLAY_INFLATE.hair); + + // Recentre: put the character on the origin with its feet on the ground so the + // camera maths stays trivial. + const box = new THREE.Box3().setFromObject(root); + const centre = box.getCenter(new THREE.Vector3()); + root.position.x -= centre.x; + root.position.z -= centre.z; + root.position.y -= box.min.y; + root.updateMatrixWorld(true); + + const bounds = new THREE.Box3().setFromObject(root); + const size = bounds.getSize(new THREE.Vector3()); + const middle = bounds.getCenter(new THREE.Vector3()); + + const skinned = []; + root.traverse((object) => { if (object.isSkinnedMesh && object.skeleton) skinned.push(object); }); + const skeleton = skinned[0]?.skeleton ?? null; + + const bones = []; + if (skeleton) { + for (const bone of skeleton.bones) { + bones.push({ + name: bone.name, + label: BONE_LABELS[normalizeBoneName(bone.name)] ?? bone.name, + bone, + rest: bone.quaternion.clone(), + }); + } + } + + return { + gltf, + root, + size, + middle, + bounds, + skeleton, + bones, + parts: { + body, + kinds, + leaves, + overlays: [eyeMesh, mouth, ...(hairMesh ? [hairMesh] : [])], + eyeMesh, + mouthMesh: mouth, + hairMesh, + noseMesh, + hasFacePlates: Boolean(eyePlate && mouthPlate), + }, + originals, + }; +} + +function inflateOverlay(mesh, centre, distance) { + const geometry = mesh.geometry; + const position = geometry?.attributes?.position; + if (!position) return; + const normal = geometry.attributes.normal; + const radial = new THREE.Vector3(); + const offset = new THREE.Vector3(); + const vertexNormal = new THREE.Vector3(); + for (let i = 0; i < position.count; i++) { + radial.fromBufferAttribute(position, i).sub(centre); + if (radial.lengthSq() < 1e-12) continue; + offset.copy(radial).normalize(); + if (normal) { + // The planes are double sided, so the stored normal may point either way. + vertexNormal.fromBufferAttribute(normal, i); + if (vertexNormal.lengthSq() > 1e-12) { + if (vertexNormal.dot(offset) < 0) vertexNormal.negate(); + offset.copy(vertexNormal.normalize()); + } + } + position.setXYZ( + i, + position.getX(i) + offset.x * distance, + position.getY(i) + offset.y * distance, + position.getZ(i) + offset.z * distance, + ); + } + position.needsUpdate = true; + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); +} + +/** + * Make every triangle of a cylindrical UV island sit on one side of the seam. + * + * A cylinder unwrap cuts the surface open along one line (here the back of the + * head). A triangle that spans the cut has its corners at u ~ 1 and u ~ 0, so + * interpolating them drags the whole texture across the triangle - the streak + * seen down the back. Wrapping the low corners up by one turn keeps each + * triangle local; the texture's two edges are identical (the covering is drawn + * right across), so the join is invisible. + */ +function healCylinderSeam(mesh) { + const source = mesh?.geometry; + if (!source?.attributes?.uv) return; + // Give every triangle its own corners first. The seam's low and high sides + // share vertices, so editing a shared one would drag its neighbours too; a + // non-indexed copy isolates each triangle and the fix cannot leak. + const geometry = source.index ? source.toNonIndexed() : source; + const uv = geometry.attributes.uv; + for (let i = 0; i < uv.count; i += 3) { + const top = Math.max(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2)); + if (top - Math.min(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2)) <= 0.5) continue; + for (let k = 0; k < 3; k += 1) { + if (uv.getX(i + k) < top - 0.5) uv.setX(i + k, uv.getX(i + k) + 1); + } + } + uv.needsUpdate = true; + mesh.geometry = geometry; +} diff --git a/public/bluebey-studio/src/outline.js b/public/bluebey-studio/src/outline.js new file mode 100644 index 0000000..127d576 --- /dev/null +++ b/public/bluebey-studio/src/outline.js @@ -0,0 +1,368 @@ +import * as THREE from 'three'; + +/** + * A screen-space outline: the label version of "line art". + * + * The studio's other outline is an *inverted hull* - a copy of a mesh, expanded + * along its normals, drawn back-faces-only. That is cheap and its line is + * computed from the geometry, so it comes out smooth, but it cannot outline a + * thin closed solid: a leaf blade is 0.026 units thick and the hull expands by + * 0.022 in every direction, so the expanded front and back cross inside the leaf + * and the line breaks up. No amount of mesh fixing removes that; it is the + * technique. + * + * So the thin overlapping parts - the waist leaves, and the nose in the + * line-art styles - are handed to this pass instead, and everything else keeps + * its hull (see MODEL-GUIDE.md §5). This module renders the scene once into a + * buffer holding a *label* per pixel, then draws a full-screen pass that inks + * pixels where the labels disagree. + * + * ## Why labels, not coverage + * + * The parts want different lines: + * + * - a LEAF that runs into the body must NOT get a line along the intersection. + * Such a line reads as the leaf sinking into the body, and the original + * artwork does not draw one either - the leaf simply passes behind the body. + * - the NOSE is the opposite. It is a bump sitting on the body, so the ring + * where it meets the body *is* its outline. In a line drawing there is no + * colour to read the nose by, so that ring is the only thing that shows it. + * + * A single "coverage" cannot express that difference, so each part writes a + * label: + * + * 0 (paper) nothing is there + * BEHIND something that is merely behind: it still writes depth, so it + * hides what is behind *it*, but a leaf in front of it is still + * outlined - a foot behind the skirt does not swallow the leaf's + * edge. This is the default for meshes nobody claimed. + * SOLID the body: the leaves *emerge* from it, so a leaf must not be + * outlined where it meets it (that reads as the leaf sinking into + * the body, and the original artwork draws no line there either). + * LEAF a leaf + * NOSE the nose + * + * The label is read with `NearestFilter`, because it is an identity, not a + * colour: filtering it would blend two labels into a third value that means + * neither. + * + * ## What this pass deliberately does not do + * + * It does not look for folds, and so it needs no normals at all - only the + * labels. Surface shape was tried (a second buffer of filtered normals, with the + * fold test gated to the leaves) and taken out again: it did bring back the + * lines where one leaf lies over the next, but it cost a whole extra scene pass + * and it read as a grainy speckle across the skirt, because a leaf is thin + * enough (0.026) that its own rim is a 145-degree crease and the mesh arrives + * with those edges split into separate vertices. The recipe is written up in + * MODEL-GUIDE.md §5-2c if it is ever wanted back - for instance to make raised + * leaf veins show. + * + * Callers must keep the outline hulls out of this pass: `exclude()` takes them + * (see `outlineExclusion` in main.js). A hull is an expanded copy of a mesh, and + * this pass swaps a front-side material onto everything it sees, so a hull would + * paint an enlarged copy of the character's own label over all of it. + * + * A mesh that should *hide* parts of the character without being outlined itself + * (the invisible wall) is handed to `occlude()` instead: it is drawn for its + * depth alone, with a paper label, so the leaves and the nose buried behind it + * get no label - and therefore no line. + */ + +// Taps around the pixel, averaged into a coverage. 16 gives a smooth ramp. +const TAPS = 16; + +/** Written into the alpha channel; see the note above. */ +export const BEHIND_LABEL = 0.1; +export const SOLID_LABEL = 0.4; +export const LEAF_LABEL = 0.7; +export const NOSE_LABEL = 1; + +const VERTEX = /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + // The quad is already in clip space, so no camera maths is involved. + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +const FRAGMENT = /* glsl */` + uniform sampler2D uLabels; + uniform vec2 uTexel; + uniform vec3 uColor; + uniform float uRadius; + varying vec2 vUv; + + const float TAU = 6.28318530718; + + // The labels, read back out of the alpha channel (see the top of this file). + // They are exact values, not a gradient, so the tests are simple comparisons. + // + // "paper" means "does not block a line": the untouched background (0) and the + // parts that are merely behind (0.1). Only the body (0.4) blocks one. + float paper(float a) { return 1.0 - step(0.2, a); } + float drawn(float a) { return step(0.6, a); } + float nose(float a) { return step(0.85, a); } + + void main() { + vec4 here = texture2D(uLabels, vUv); + float ink = 0.0; + + for (int i = 0; i < ${TAPS}; i++) { + float angle = (float(i) / float(${TAPS})) * TAU; + vec2 offset = vec2(cos(angle), sin(angle)) * uTexel * uRadius; + vec4 there = texture2D(uLabels, vUv + offset); + + float edge = 0.0; + // 1. a drawn part against the paper. A leaf against the *body* fires + // nothing here, which is the point: the leaf simply passes behind it. + edge = max(edge, max(drawn(here.a) * paper(there.a), + drawn(there.a) * paper(here.a))); + // 2. the nose against anything that is not the nose, so its whole ring - + // including the part against the body - is inked. + edge = max(edge, abs(nose(here.a) - nose(there.a))); + + ink += edge; + } + + // Averaging the taps turns the flag into a coverage, and that is what takes + // the steps out of the line: a pixel half over an edge gets half the ink, so + // the line gets soft edges instead of landing on the pixel grid. It also + // reads lighter than a hard band of the same width, which is what makes it + // sit next to the hull's line without looking heavier. + ink /= float(${TAPS}); + // The ramp starts at about three taps out of sixteen rather than at one, so an + // isolated tap is not enough to ink a pixel; a real edge has half the taps + // crossing it, so asking for a few costs nothing there. + float line = smoothstep(0.19, 0.45, ink); + if (line < 0.02) discard; + gl_FragColor = vec4(uColor, line); + } +`; + +export function createScreenOutline({ renderer, scene, camera, width = 1280, height = 800 }) { + const target = new THREE.WebGLRenderTarget(width, height, { + // The label is an identity, not a shade: filtering would blend two labels into + // a third value that means neither of them. The softness of the line comes + // from spreading the taps in the shader, not from blurring this buffer. + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + depthBuffer: true, + // The alpha channel carries the label, so it must not be filled in. + format: THREE.RGBAFormat, + }); + target.texture.generateMipmaps = false; + + /** + * The label each part writes into the alpha channel. + * + * `transparent: true` keeps three.js from defining `OPAQUE`, which would force + * the alpha to 1, and `blending: NoBlending` makes the fragment *replace* the + * pixel instead of blending into it, so the labels stay exact. `depthWrite` + * stays on, which is what hides the parts that are behind something else. + */ + const labelMaterials = new Map(); + function labelMaterialFor(label) { + let material = labelMaterials.get(label); + if (material) return material; + material = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: label, + blending: THREE.NoBlending, + depthWrite: true, + }); + material.customProgramCacheKey = () => `bluebey-outline-label-${label}`; + labelMaterials.set(label, material); + return material; + } + + const uniforms = { + uLabels: { value: target.texture }, + uTexel: { value: new THREE.Vector2(1 / width, 1 / height) }, + uColor: { value: new THREE.Color('#2a1e33') }, + uRadius: { value: 1.4 }, + }; + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthTest: false, + depthWrite: false, + toneMapped: false, + }); + + // A single quad in clip space, with the camera taken out of the equation. + const quadScene = new THREE.Scene(); + const quadCamera = new THREE.Camera(); + quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)); + + /** Objects that must not appear in the outline (ground, shadows, gizmos). */ + const hidden = []; + /** Meshes whose material (and render order) is borrowed for the label pass. */ + const swapped = []; + const exclusion = new Set(); + + /** + * Meshes that only *occlude* in the label pass (the 見えない壁). + * + * They are drawn with a paper label and both sides, so their depth hides + * whatever is behind them: a leaf or the nose buried in the wall then gets no + * label at all, and so no line. Their own silhouette inks nothing either, + * because paper against paper is not an edge - which is what keeps the wall + * itself invisible instead of drawing a rectangle. + */ + const occluders = new Set(); + let occluderMaterial = null; + + function occluderMaterialFor() { + if (!occluderMaterial) { + occluderMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0, // paper: it never reads as a drawn part + blending: THREE.NoBlending, + depthWrite: true, + side: THREE.DoubleSide, // the wall can be seen from either side + }); + } + return occluderMaterial; + } + + /** Objects to draw in the label pass for their depth alone (see `occluders`). */ + function occlude(objects) { + occluders.clear(); + for (const object of objects) if (object) occluders.add(object); + } + + /** + * When set, these meshes write the given label and everything else writes + * BEHIND. Without it the whole scene shares one label. + * + * @type {Map<THREE.Object3D, number>|null} + */ + let focus = null; + + function setSize(nextWidth, nextHeight) { + const w = Math.max(1, Math.round(nextWidth)); + const h = Math.max(1, Math.round(nextHeight)); + if (target.width === w && target.height === h) return; + target.setSize(w, h); + uniforms.uTexel.value.set(1 / w, 1 / h); + } + + /** Layers/objects to leave out of the label pass (ground, shadow, gizmo, hulls). */ + function exclude(objects) { + exclusion.clear(); + for (const object of objects) if (object) exclusion.add(object); + } + + /** + * Hand the pass a split: these meshes get this label, and everything else is + * drawn as BEHIND - present, so it still hides what is behind it, but not + * blocking a leaf's outline. Callers should therefore name every part that a + * leaf must not be outlined against (the body) as well as the outlined ones + * (the leaves, the nose). + * + * `null` puts the whole scene back on one label. + * + * @param {Array<[THREE.Object3D, number]>|null} parts + */ + function only(parts) { + focus = parts ? new Map(parts) : null; + } + + /** + * Draw one frame. `baseRender` renders the scene the normal way; it is called + * between the label pass and the ink so the ink lands on top of it. + */ + function render(baseRender, options = {}) { + const enabled = options.enabled !== false; + if (!enabled) { + baseRender(); + return; + } + + uniforms.uColor.value.set(options.color ?? '#2a1e33'); + uniforms.uRadius.value = Math.max(0.6, options.radius ?? 1.4); + + // --- 1. the labels pass -------------------------------------------- + const previousOverride = scene.overrideMaterial; + const previousClear = renderer.getClearColor(new THREE.Color()); + const previousAlpha = renderer.getClearAlpha(); + hidden.length = 0; + swapped.length = 0; + const hide = (object) => { + if (object.visible) { + hidden.push(object); + object.visible = false; + } + }; + for (const object of exclusion) hide(object); + + if (focus) { + // A per-mesh material, so the meshes outside `focus` can occlude without + // contributing a line. (`overrideMaterial` would put one label on all of + // them, and a leaf ending on a foot would then be read the same way as a + // leaf ending on the body.) + // + // The occluders are also pushed to the front of the draw order. Every body + // mesh sits at the same origin, so three.js would otherwise fall back to + // insertion order and could draw a far-side leaf *before* the body that + // hides it - and writing a label cannot erase what is already in the + // buffer, only stop it being drawn. Drawn first, the depth test does it. + scene.traverse((object) => { + if (!object.isMesh || !object.visible) return; + swapped.push([object, object.material, object.renderOrder]); + if (occluders.has(object)) { + // Depth only, drawn first: the wall hides the labels behind it. + object.material = occluderMaterialFor(); + object.renderOrder = -1; + return; + } + const label = focus.get(object) ?? BEHIND_LABEL; + object.material = labelMaterialFor(label); + if (label < 0.6) object.renderOrder = -1; + }); + scene.overrideMaterial = null; + } else { + // No split given: nothing is outlined, so no label may read as "drawn". + scene.overrideMaterial = labelMaterialFor(SOLID_LABEL); + } + + renderer.setRenderTarget(target); + renderer.setClearColor(0x000000, 0); + renderer.clear(true, true, false); + renderer.render(scene, options.camera ?? camera); + + renderer.setRenderTarget(null); + scene.overrideMaterial = previousOverride; + for (const object of hidden) object.visible = true; + for (const [object, swappedMaterial, renderOrder] of swapped) { + object.material = swappedMaterial; + object.renderOrder = renderOrder; + } + renderer.setClearColor(previousClear, previousAlpha); + + // --- 2. the scene itself ------------------------------------------- + baseRender(); + + // --- 3. the ink, straight over the top ----------------------------- + renderer.autoClear = false; + renderer.render(quadScene, quadCamera); + renderer.autoClear = true; + } + + function dispose() { + target.dispose(); + for (const material of labelMaterials.values()) material.dispose(); + labelMaterials.clear(); + material.dispose(); + occluderMaterial?.dispose(); + for (const child of quadScene.children) child.geometry.dispose(); + } + + return { render, setSize, exclude, occlude, only, uniforms, target, material, dispose }; +} diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js new file mode 100644 index 0000000..a6e971d --- /dev/null +++ b/public/bluebey-studio/src/panel.js @@ -0,0 +1,2506 @@ +import { + h, section as uiSection, subhead, hint, slider, check, segmented, buttons, + colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, +} from './ui.js'; +import { + FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch, +} from './presets.js'; +import { STYLE_DEFS } from './styles.js'; +import { ENVIRONMENTS } from './look.js'; +import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js'; +import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js'; +import { HAT_LIBRARY } from './hats.js'; +import { + GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH, +} from './gion.js'; + +/** + * A small icon for each section heading, so the list a tab shows can be scanned + * at a glance. Keyed by the section title; a title with no icon just shows text. + */ +const SECTION_ICONS = { + 'ポーズ例': 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2', + '全身とボーンのスライダー': 'M4 7h16M4 12h16M4 17h16M8 7h.01M15 12h.01M11 17h.01', + '操作': 'M7 4l10 6-4.3 1.3L10.8 16 7 4Z', + 'うごき': 'M8 5l10 7-10 7V5Z', + 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', + '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z', + '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3', + '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4', + '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0', + '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z', + 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z', + '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01', + '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', + '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5', + '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8', + '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01', + '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01', + 'まんが': 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', + '共有': 'M8.2 10.6a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 6.4a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 20.8a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM10 10.4l5-2.6M10 13.6l5 2.6', + 'テクスチャの下地を書き出す': 'M4 4h16v16H4zM4 9.3h16M4 14.7h16M9.3 4v16M14.7 4v16', + '設定': 'M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8ZM12 2.6v2.4M12 19v2.4M2.6 12H5M19 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7', +}; + +/** `section`, with the heading icon looked up from its title. */ +const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options }); + +/** Fallbacks for the fields an older settings file may predate. */ +const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 }; + +/** + * The licence reminder the three image-import buttons show before they open the + * file picker. Kept in one place so the wording can change without hunting for + * each button. + */ +const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。'; + +/** True when the user confirmed they have the right to use the image. */ +function confirmImageLicense() { + return window.confirm(IMAGE_LICENSE_NOTICE); +} + +/** + * The panel's multi-line field. The 看板 prop's lettering is the only caller, but + * the shape (a control row wrapping a textarea) is worth keeping in one place. + */ +function textArea({ label, value = '', rows = 3, onChange }) { + const input = h('textarea', { + rows, + style: { + width: '100%', + boxSizing: 'border-box', + resize: 'vertical', + font: 'inherit', + fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace', + fontSize: '11.5px', + lineHeight: '1.5', + padding: '6px 8px', + border: '1px solid #e4dff0', + borderRadius: '8px', + background: '#fff', + color: '#2b2433', + }, + }); + input.value = value ?? ''; + input.addEventListener('input', () => onChange?.(input.value)); + return { + el: controlRow(label, input, { wide: true }), + // Assigning the same text again would jump the caret to the end, so only + // write when the value actually differs. + set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; }, + get: () => input.value, + }; +} + +/** + * The placed prop groups, in state order. + * + * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in + * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's + * per-item `faceScale` has to be re-applied to the freshly built mesh by the one + * place that knows that value - this panel. There is no direct scene reference + * here, so this walks up from the model's container and collects the props: they + * are the only objects tagged `userData.propId`, and the app adds them in order. + */ +function placedPropGroups(app) { + const scene = app.container?.parent; + if (!scene) return []; + const groups = []; + scene.traverse((object) => { + if (object.userData?.propId) groups.push(object); + }); + return groups; +} + +/** + * Builds the whole control panel and wires it to the app state. + * + * Widgets never call the heavy "apply everything" path while they are being + * dragged; they patch one scope and ask the app to refresh just that part. + */ +export function buildPanel(app, root) { + const state = app.state; + const model = app.model; + const rig = app.rig; + + const syncers = []; + const boneSyncers = []; + const sync = () => { for (const fn of syncers) fn(); }; + const syncBones = () => { for (const fn of boneSyncers) fn(); }; + + /** Replace the state and re-read every widget (used by presets and loads). */ + const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); + + root.replaceChildren(); + + // --- randomising ---------------------------------------------------------- + // Shared by 「顔をランダムに」 (face tab) and 「すべてをランダムに」 (the top bar). + const rnd = (min, max) => min + (max - min) * Math.random(); + const pick = (list) => list[Math.floor(Math.random() * list.length)]; + const randomEye = () => ({ + open: +rnd(0.4, 1).toFixed(2), + lookX: +rnd(-0.35, 0.35).toFixed(2), + lookY: +rnd(-0.25, 0.25).toFixed(2), + eyeX: Math.round(rnd(-16, 16)), + }); + const randomGlasses = () => { + const kind = pick(['glasses', 'sunglasses']); + const dark = kind === 'sunglasses'; + return { + enabled: true, + kind, + frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']), + lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']), + lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2), + lensGap: Math.round(rnd(-24, 56)), + frameWidth: +rnd(0.6, 1.8).toFixed(2), + scale: +rnd(0.9, 1.15).toFixed(2), + offsetY: Math.round(rnd(-18, 18)), + tilt: Math.round(rnd(-12, 12)), + }; + }; + const randomFacePatch = () => { + return { + eyes: { + left: randomEye(), + right: randomEye(), + // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 + glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(), + }, + // The mouth takes the height and the tilt too, so the face really changes. + mouth: { + smile: +rnd(-1, 1.2).toFixed(2), + // ときどき丸く開く口(O)にする。 + round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, + open: +rnd(0, 0.5).toFixed(2), + width: +rnd(0.7, 1.3).toFixed(2), + thickness: +rnd(0.7, 1.6).toFixed(2), + tilt: Math.round(rnd(-12, 12)), + offsetY: Math.round(rnd(-16, 16)), + }, + }; + }; + const randomFace = () => replace({ face: randomFacePatch() }, 'face'); + const randomAll = () => { + // 後ろを向く(180°)や大きく傾くポーズは避ける。見える顔が変わる範囲だけ。 + const poses = POSE_PRESETS.filter((preset) => { + const m = preset.pose?.bones?.master ?? [0, 0, 0]; + return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40; + }); + const pose = pick(poses.length ? poses : POSE_PRESETS); + app.actions.applyPosePreset?.(pose.id); + // The viewer style (実写/フラット/線画), the body colours, and 左右反転 are + // deliberately left alone. + replace({ face: randomFacePatch() }, 'face'); + sync(); + }; + + // A small action bar above the tabs: undo/redo apply to every tab, so they must + // not be buried inside one of them. 「すべてをランダムに」 sits here too. + const topBar = h('div', { class: 'panel-topbar' }); + const iconButton = (label, d, onClick) => { + const button = h('button', { type: 'button', class: 'icon-btn', title: label, 'aria-label': label }); + button.append(icon(d)); + button.addEventListener('click', onClick); + return button; + }; + topBar.append( + iconButton('元に戻す', 'M4.8 11.5h9.7a5.2 5.2 0 0 1 0 10.4h-3.6M4.8 11.5 9.6 6.7M4.8 11.5 9.6 16.3', () => app.actions.undo()), + iconButton('やり直す', 'M19.2 11.5h-9.7a5.2 5.2 0 0 0 0 10.4h3.6M19.2 11.5 14.4 6.7M19.2 11.5 14.4 16.3', () => app.actions.redo()), + iconButton('すべてをランダムに', 'M7.2 9.4a2.6 2.6 0 1 1 3.9 2.3c-.9.5-1.3 1.2-1.3 2.1v.4M9.2 17.4h.01M16.8 6.8v6.1M16.8 15.6h.01', () => randomAll()), + ); + root.append(topBar); + + // Tabs instead of one long scroll: the panel used to be a wall of controls, + // and everything is easier to find this way. Each tab is a small stroke icon + // (its name is the tooltip and the accessible label) sitting on the top edge + // of the content, so the row reads as tabs rather than buttons. + const tabBar = tabs(root, [ + { + id: 'pose', + label: 'ポーズ', + // A standing figure: head, body, arms and legs. + icon: 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2', + }, + { + id: 'face', + label: '顔', + // A smiley face: an outline, two eyes and a smile. + icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', + }, + { + id: 'view', + label: '見た目', + // A painter's palette: the outline, the thumb hole and four colour dots. + icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01', + }, + { + id: 'scene', + label: 'シーン', + // A framed picture: background, stage and light all sit inside the frame. + icon: 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', + }, + { + id: 'export', + label: '書き出し', + // A download arrow dropping into a tray. + icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6', + }, + ]); + const tabPose = tabBar.panels.pose; + const tabFace = tabBar.panels.face; + const tabView = tabBar.panels.view; + const tabScene = tabBar.panels.scene; + const tabExport = tabBar.panels.export; + + /* ------------------------------------------------------------------ pose */ + + // ポーズ例: ready-made poses, because most people start by picking one. + const posePresetSection = section(tabPose, 'ポーズ例'); + posePresetSection.add(buttons({ + items: POSE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + onClick: () => applyPosePreset(preset), + })), + })); + + // 全身とボーンのスライダー: pick a bone, then turn it on the three axes. + const poseSection = section(tabPose, '全身とボーンのスライダー'); + + const boneList = h('div', { class: 'bone-list' }); + const boneButtons = new Map(); + for (const bone of model.bones) { + const button = h('button', { type: 'button', class: 'bone-item' }, + h('span', { text: bone.label }), + h('small', { text: bone.name })); + button.addEventListener('click', () => rig.select(bone.name)); + boneButtons.set(bone.name, button); + boneList.append(button); + } + poseSection.add(controlRow(null, boneList, { wide: true })); + + const axisSliders = { + x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }), + y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }), + z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }), + }; + for (const widget of Object.values(axisSliders)) poseSection.add(widget.el); + + function setAxis(axis, value) { + const name = rig.selected; + if (!name) return; + const delta = rig.getDelta(name); + delta[axis] = value; + rig.setDelta(name, delta); + app.actions.capturePose(); + app.needsRender = true; + } + + poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。' + + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。')); + + // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the + // main way in, so they stay folded away inside the bone section. + const rootSliders = [ + { label: 'よこ移動', min: -20, max: 20 }, + { label: 'たて移動', min: -10, max: 20 }, + { label: 'おくゆき', min: -20, max: 20 }, + ].map(({ label, min, max }, index) => slider({ + label, min, max, step: 0.01, value: 0, + format: (v) => v.toFixed(2), + onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; }, + })); + const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)'); + for (const widget of rootSliders) poseBox.add(widget.el); + + const gizmoToggle = check({ + label: '回転ギズモを表示', + // Off by default: the rings belong to the model's surface, so having them + // drawn on top of the character is not what you want while composing a + // picture. The 'g' key toggles it too. + value: false, + onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; }, + }); + poseBox.add(controlRow(null, gizmoToggle.el, { wide: true })); + + // --- 操作 ----------------------------------------------------------------- + // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the + // undo/redo pair are used often enough that they should be on screen without + // opening anything first. + tabPose.append(buttons({ + items: [ + { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } }, + { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() }, + { id: 'random', label: '少しランダムに', onClick: () => randomPose() }, + ], + }).el); + tabPose.append(hint('「元に戻す」「やり直す」は、パネル上部のアイコンにあります(どのタブでも使えます)。')); + + /** Accepts a preset object (from the buttons) or a preset id (from tools). */ + function applyPosePreset(presetOrId) { + const preset = typeof presetOrId === 'string' + ? POSE_PRESETS.find((item) => item.id === presetOrId) + : presetOrId; + if (!preset) return; + // Some poses already raise the arms; leaving the てをふる motion running would + // add to the arm the pose just set and fold the flipper over the face, so + // switching to any pose stops that motion. + if (state.anim.mode === 'wave') { + state.anim.mode = 'off'; + app.animator.reset(); + app.needsRender = true; + } + state.pose = { bones: {}, root: [0, 0, 0] }; + applyPatch(state.pose, preset.pose ?? {}); + if (!state.pose.root) state.pose.root = [0, 0, 0]; + replace({}, 'pose'); + sync(); + } + + function randomPose() { + const names = model.bones.map((bone) => bone.name); + for (const name of names) { + if (name === 'master') continue; + const scale = name.startsWith('arm') ? 22 : 12; + rig.setDelta(name, { + x: (Math.random() - 0.5) * scale, + y: (Math.random() - 0.5) * scale, + z: (Math.random() - 0.5) * scale, + }); + } + app.actions.capturePose(); + syncBones(); + app.needsRender = true; + } + + /* ------------------------------------------------------------------ face */ + + let faceSection = section(tabFace, 'プリセット'); + + // The face tab's sections are created here in the order they should read on + // screen (プリセット -> 目 -> 見る先 -> 口 -> 眼鏡 -> 鼻ちょうちん -> 帽子), + // but filled further down where the widgets are built: the DOM order is fixed + // at creation. + const faceTabSections = { + eye: section(tabFace, '目'), + look: section(tabFace, '見る先'), + mouth: section(tabFace, '口'), + glasses: section(tabFace, '眼鏡・サングラス'), + snot: section(tabFace, '鼻ちょうちん'), + hat: section(tabFace, '帽子'), + }; + + faceSection.add(buttons({ + items: FACE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + onClick: () => applyFacePreset(preset.id), + })), + })); + + function applyFacePreset(id) { + const preset = FACE_PRESETS.find((item) => item.id === id); + if (!preset) return; + state.face = defaultState().face; + applyPatch(state.face, preset.face); + app.actions.refresh('face'); + sync(); + } + + // --- eyes + faceSection = faceTabSections.eye; + + /** Tear sliders, which are added to the こまかい設定 box once it exists. */ + const tearWidgets = []; + // The link switch sits directly above the *left* eye's open slider, because that + // is the slider it drives - it was previously below both eyes, where it looked + // like it belonged to whatever came next. + const linkedToggle = check({ + label: '左右を連動させる', + value: true, + onChange: (value) => { + state.face.eyes.linked = value; + if (value) { + // Catch the two eyes up with each other, so switching the link on does + // something visible even if they had drifted apart. + state.face.eyes.right.open = state.face.eyes.left.open; + state.face.eyes.right.closed = state.face.eyes.left.closed; + state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; + state.face.eyes.right.irisShape = state.face.eyes.left.irisShape; + state.face.eyes.right.shape = state.face.eyes.left.shape; + // The lids are per eye now too, so link them along with the rest. + state.face.eyes.right.lowerLid = state.face.eyes.left.lowerLid; + state.face.eyes.right.lidShape = state.face.eyes.left.lidShape; + state.face.eyes.right.lidWidth = state.face.eyes.left.lidWidth; + state.face.eyes.right.lidTilt = state.face.eyes.left.lidTilt; + state.face.eyes.right.lashes = state.face.eyes.left.lashes; + state.face.eyes.right.lashAngle = state.face.eyes.left.lashAngle; + state.face.eyes.right.lashPos = state.face.eyes.left.lashPos; + state.face.eyes.right.white = state.face.eyes.left.white ?? null; + state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null; + state.face.eyes.right.lookX = state.face.eyes.left.lookX; + state.face.eyes.right.lookY = state.face.eyes.left.lookY; + // The tears mirror as a pair: the size and the height match, while the + // sideways position and the tilt flip sign. + state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0; + state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0; + state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0); + state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0); + } + app.actions.refresh('face'); + sync(); + }, + }); + // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into + // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and + // writes them together so the menu always shows the current look. + const eyeShapeOf = (eye) => { + const shape = eye.shape; + const explicitShut = shape === 'three' || shape === 'arch' + || (typeof shape === 'string' && shape.startsWith('line')); + if (explicitShut) return shape; + if ((eye.open ?? 1) <= 0.02) { + if (!shape) { + if (eye.closed === 'three') return 'three'; + if (eye.closed === 'arch') return 'arch'; + return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`; + } + // An open/heart eye whose lid is all the way down is just a blink. + return 'line1'; + } + if (shape) return shape; + return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; + }; + const applyEyeShape = (eye, value) => { + eye.shape = value; + eye.irisShape = value === 'heart' ? 'heart' : 'circle'; + if (value === 'open' || value === 'heart') { + eye.open = 1; + // Clear any stale shut fields so a blink (open -> 0) shows a plain line and + // never the 3 or the arch that was picked before. + eye.closed = 'line'; + eye.closedLines = 1; + return; + } + // A shut artwork is drawn as the eye's content with the lids open; the lid + // height is its own slider (上まぶたの高さ), so selecting one opens the lid. + eye.open = 1; + if (value.startsWith('line')) { + eye.closed = 'line'; + eye.closedLines = Number(value.slice(4)) || 1; + return; + } + eye.closed = value; + }; + const eyeWhiteOf = (eye) => { + if (eye.white != null) return eye.white === true; + // The usual: an open eye shows its white; a shut eye or a heart does not. + return eyeShapeOf(eye) === 'open'; + }; + const eyeHighlightOf = (eye) => (eye.highlight != null + ? eye.highlight === true + : state.face.eyes.highlight !== false); + + const EYE_ICONS = { + both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + left: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + right: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>', + }; + // Each eye group carries the same controls. Linked, one group ("両目") drives + // both eyes; unlinked, a group per eye appears and the gaze pad doubles. + const eyeGroup = (title, icon = null) => { + const body = h('div', { class: 'eye-group-body' }); + const head = h('div', { class: 'eye-group-head' }, + icon ? svgIcon(EYE_ICONS[icon] ?? '') : null, + h('span', { text: title })); + const el = h('div', { class: 'eye-group' }, head, body); + return { + el, + add: (child) => (body.append(child?.el ?? child), child), + setVisible: (visible) => { el.style.display = visible ? '' : 'none'; }, + }; + }; + + const makeEyeControls = (mode) => { + const keys = mode === 'both' ? ['left', 'right'] : [mode]; + const primary = keys[0]; + const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); }; + const touched = () => { app.actions.refresh('face'); sync(); }; + const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左目' : '右目'), mode); + + // --- まぶた (per eye) ------------------------------------------------ + // 上まぶたの高さ and 下まぶたの高さ share one opening, so each is held to the + // other: the lower lid cannot rise past the upper, and the upper cannot drop + // below the lower. The sliders' own limits are kept in step in `syncOne`. + const lidSub = eyeGroup('まぶた', 'lid'); + const open = slider({ + label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1, + format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), + onInput: (v) => { + const floor = state.face.eyes[primary].lowerLid ?? state.face.eyes.lowerLid ?? 0; + const vv = Math.max(v, floor); + write((e) => { e.open = vv; }); + if (vv !== v) open.set(vv); + touched(); + }, + }); + const lowerLid = slider({ + label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { + const ceil = state.face.eyes[primary].open ?? 1; + const vv = Math.min(v, ceil); + write((e) => { e.lowerLid = vv; }); + if (vv !== v) lowerLid.set(vv); + touched(); + }, + }); + const lidShape = segmented({ + label: 'まぶたの形', + options: [ + { value: 'curve', label: '曲線' }, + { value: 'flat', label: '直線(平ら)' }, + ], + value: 'curve', + onChange: (v) => { write((e) => { e.lidShape = v; }); touched(); }, + }); + const lidWidth = slider({ + label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, + onInput: (v) => { write((e) => { e.lidWidth = v; }); touched(); }, + }); + const lidTilt = slider({ + label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { write((e) => { e.lidTilt = v; }); touched(); }, + }); + const lashes = check({ + label: 'まつげをつける', + value: false, + onChange: (v) => { write((e) => { e.lashes = v; }); touched(); }, + }); + const lashAngle = slider({ + label: 'まつげの角度', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { write((e) => { e.lashAngle = v; }); touched(); }, + }); + const lashPos = slider({ + label: 'まつげの位置', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { write((e) => { e.lashPos = v; }); touched(); }, + }); + lidSub.add(lidShape); + lidSub.add(open); + lidSub.add(lowerLid); + lidSub.add(lidWidth); + lidSub.add(lidTilt); + lidSub.add(controlRow(null, lashes.el, { wide: true })); + lidSub.add(lashAngle); + lidSub.add(lashPos); + group.add(lidSub); + const openInput = open.el.querySelector('input'); + const lowerLidInput = lowerLid.el.querySelector('input'); + + // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open, + // so 上まぶたの高さ can then be lowered onto it. + const shape = segmented({ + label: '目の形', + options: [ + { value: 'open', label: 'ふつう' }, + { value: 'line1', label: '1線' }, + { value: 'line2', label: '2線' }, + { value: 'line3', label: '3線' }, + { value: 'three', label: '3の目' }, + { value: 'arch', label: 'わらう' }, + { value: 'heart', label: 'ハート' }, + ], + value: 'open', + onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); }, + }); + // The heart colour only matters for the heart, so it sits right under 目の形 + // and shows only then. + const heartColor = colorField({ + label: 'ハートの色', value: '#e0344f', + swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'], + onChange: (v) => { state.face.eyes.heartColor = v; touched(); }, + }); + // 大きさ: one knob for the iris, the heart and the shut artwork. + const size = slider({ + label: '大きさ', min: 0.4, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.irisScale = v; touched(); }, + }); + const threeFlip = segmented({ + label: '3の向き', + options: [ + { value: 'normal', label: '3' }, + { value: 'flip', label: '3(逆)' }, + ], + value: 'normal', + onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); }, + }); + const white = check({ + label: '白目を出す', + value: true, + onChange: (v) => { write((e) => { e.white = v; }); touched(); }, + }); + const highlight = check({ + label: '光彩(瞳の白い丸)を出す', + value: true, + onChange: (v) => { write((e) => { e.highlight = v; }); touched(); }, + }); + const pad = xyPad({ + value: { x: 0, y: 0 }, + center: 'eye', + onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); }, + }); + // 涙: per eye. Linked, one set drives both - size and height match, while the + // sideways position and the tilt mirror so the pair stays symmetric. + const tearOn = check({ + label: '涙を出す', + value: false, + onChange: (v) => { write((e) => { e.tearOn = v; }); touched(); }, + }); + const writeTear = (patch) => { + Object.assign(state.face.eyes[primary], patch); + if (mode === 'both') { + const mirrored = { ...patch }; + if (patch.tearX != null) mirrored.tearX = -patch.tearX; + if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt; + Object.assign(state.face.eyes[primary === 'left' ? 'right' : 'left'], mirrored); + } + touched(); + }; + const tear = slider({ + label: '涙の大きさ', min: 0.3, max: 1.6, step: 0.01, value: 0.3, format: (v) => v.toFixed(2), + onInput: (v) => writeTear({ tear: v }), + }); + const tearY = slider({ + label: '涙の高さ', min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => writeTear({ tearY: v }), + }); + const tearX = slider({ + label: '涙のよこ位置', min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => writeTear({ tearX: v }), + }); + const tearTilt = slider({ + label: '涙の傾き', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => writeTear({ tearTilt: v }), + }); + + group.add(shape); + group.add(heartColor); + group.add(size); + group.add(threeFlip); + group.add(controlRow(null, white.el, { wide: true })); + group.add(controlRow(null, highlight.el, { wide: true })); + group.add(controlRow(null, tearOn.el, { wide: true })); + group.add(tear); + group.add(tearY); + group.add(tearX); + group.add(tearTilt); + group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el, + h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' })))); + + const syncOne = () => { + const e = state.face.eyes[primary]; + const shapeValue = eyeShapeOf(e); + const lower = e.lowerLid ?? state.face.eyes.lowerLid ?? 0; + const upper = e.open ?? 1; + // Keep each lid's slider range from crossing the other before applying the + // values, so the displayed thumb cannot sit past the other lid either. + if (openInput) openInput.min = String(Math.min(lower, upper)); + if (lowerLidInput) lowerLidInput.max = String(Math.max(lower, upper)); + open.set(upper); + lowerLid.set(lower); + lidShape.set(e.lidShape ?? state.face.eyes.lidShape ?? 'curve'); + lidWidth.set(e.lidWidth ?? state.face.eyes.lidWidth ?? 14); + lidTilt.set(e.lidTilt ?? state.face.eyes.lidTilt ?? 0); + lashes.set((e.lashes ?? state.face.eyes.lashes) === true); + const lashesOn = (e.lashes ?? state.face.eyes.lashes) === true; + lashAngle.set(e.lashAngle ?? state.face.eyes.lashAngle ?? 0); + lashPos.set(e.lashPos ?? state.face.eyes.lashPos ?? 0); + lashAngle.el.style.display = lashesOn ? '' : 'none'; + lashPos.el.style.display = lashesOn ? '' : 'none'; + shape.set(shapeValue); + heartColor.set(state.face.eyes.heartColor ?? '#e0344f'); + heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none'; + size.set(state.face.eyes.irisScale ?? 1); + threeFlip.set(e.threeFlip ? 'flip' : 'normal'); + threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; + white.set(eyeWhiteOf(e)); + highlight.set(eyeHighlightOf(e)); + const tearShown = e.tearOn === true; + tearOn.set(tearShown); + for (const widget of [tear, tearY, tearX, tearTilt]) { + widget.el.style.display = tearShown ? '' : 'none'; + } + tear.set(e.tear ?? 0.3); + tearY.set(e.tearY ?? 0); + tearX.set(e.tearX ?? 0); + tearTilt.set(e.tearTilt ?? 0); + pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 }); + }; + return { group, sync: syncOne }; + }; + + const bothControls = makeEyeControls('both'); + const leftControls = makeEyeControls('left'); + const rightControls = makeEyeControls('right'); + + faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); + faceSection.add(bothControls.group); + faceSection.add(leftControls.group); + faceSection.add(rightControls.group); + faceSection.add(hint('「目の形」の「2線」「3線」は端点がつながった形になります。' + + '閉じ目の形も、上まぶた・下まぶたで上下から隠せます。')); + + // 横位置 (per eye) rarely changes, so it gets its own small box. + const eyeAdvancedWidgets = {}; + for (const key of ['left', 'right']) { + const label = key === 'left' ? '左目' : '右目'; + const eyeX = slider({ + label: `${label}の横位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); }, + }); + eyeAdvancedWidgets[key] = { eyeX }; + tearWidgets.push(eyeX.el); + } + + // One button to put every eye setting back to the artwork's defaults. The + // artwork *source* is kept: resetting the numbers should not throw away a + // hand-drawn image the user loaded. + faceSection.add(buttons({ + items: [{ + id: 'eyesReset', + label: '目の設定をリセット', + onClick: () => { + const source = state.face.eyes.source; + state.face.eyes = { ...defaultState().face.eyes, source }; + app.actions.refresh('face'); + sync(); + }, + }], + })); + + faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' + + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' + + 'はずすと「左目」「右目」の設定がそれぞれに出て、目線のパッドも2つになります。' + + '「左目」「右目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。')); + + const eyeColors = { + white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), + iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }), + line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }), + }; + const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)'); + for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el); + + // 横位置 (per eye) is rarely used, so it stays in its own small box. + const tearBox = details(faceSection.body, '横位置(左右べつ)'); + for (const el of tearWidgets) tearBox.add(el); + + // --- glasses / sunglasses (drawn into the same texture as the eyes) + faceSection = faceTabSections.glasses; + faceSection.add(hint('眼鏡やサングラスを、目と同じ絵にかけます。' + + '線画のときはレンズを塗らず、フレームの線だけになります。')); + + // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 + // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の + // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を + // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても + // 失われない。 + const GLASSES_KINDS = { + glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 }, + sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 }, + }; + const wearSunglasses = () => { + state.face.eyes.glasses.lensOpacity = 1; + state.face.eyes.left.open = 0; + state.face.eyes.right.open = 0; + }; + const setGlassesKind = (kind) => { + Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] }); + // サングラス hides the eye behind an opaque lens, so both eyes shut with it; + // 眼鏡's lens is clear, so switching back opens them again. + const open = kind === 'sunglasses' ? 0 : 1; + state.face.eyes.left.open = open; + state.face.eyes.right.open = open; + }; + + const glassesToggle = check({ + label: '眼鏡をかける', + value: false, + onChange: (value) => { + state.face.eyes.glasses.enabled = value; + // A loaded state can already be on サングラス, so enabling the shades also + // shuts the eyes rather than showing them through the opaque lens. + if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses(); + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); + + const glassesKindSegment = segmented({ + label: '種類', + options: [ + { value: 'glasses', label: '眼鏡' }, + { value: 'sunglasses', label: 'サングラス' }, + ], + value: 'glasses', + onChange: (value) => { + setGlassesKind(value); + // Picking a kind means you mean to wear it, so put the glasses on rather + // than leaving the checkbox off and nothing showing on the face. + state.face.eyes.glasses.enabled = true; + glassesToggle.set(true); + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(glassesKindSegment.el); + + const glassesSliders = { + scale: slider({ + label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); }, + }), + lensOpacity: slider({ + label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22, + format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)), + onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); }, + }), + lensGap: slider({ + label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); }, + }), + frameWidth: slider({ + label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.glasses.offsetY = v; app.actions.refresh('face'); }, + }), + tilt: slider({ + label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.eyes.glasses.tilt = v; app.actions.refresh('face'); }, + }), + }; + faceSection.add(glassesSliders.scale.el); + faceSection.add(glassesSliders.lensOpacity.el); + const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)'); + glassesBox.add(glassesSliders.frameWidth.el); + glassesBox.add(glassesSliders.offsetY.el); + glassesBox.add(glassesSliders.tilt.el); + glassesBox.add(glassesSliders.lensGap.el); + const glassesFrameColor = colorField({ + label: 'フレームの色', value: '#2a1e33', + swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'], + onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); }, + }); + const glassesLensColor = colorField({ + label: 'レンズの色', value: '#2b2433', + swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'], + onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); }, + }); + glassesBox.add(glassesFrameColor.el); + glassesBox.add(glassesLensColor.el); + glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。' + + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。' + + '「傾き」は左右のレンズが一緒に傾きます。')); + glassesBox.add(buttons({ + items: [ + { + id: 'glasses', + label: '眼鏡', + onClick: () => { + setGlassesKind('glasses'); + state.face.eyes.glasses.enabled = true; + app.actions.refresh('face'); + sync(); + }, + }, + { + id: 'sunglasses', + label: 'サングラス', + onClick: () => { + setGlassesKind('sunglasses'); + state.face.eyes.glasses.enabled = true; + app.actions.refresh('face'); + sync(); + }, + }, + ], + })); + + // --- 鼻ちょうちん: the snot bubble of a sleeping face. + faceSection = faceTabSections.snot; + const snotToggle = check({ + label: '鼻ちょうちんを出す', + value: false, + onChange: (value) => { state.face.eyes.snot.enabled = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, snotToggle.el, { wide: true })); + const snotSize = slider({ + label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.snot.size = v; app.actions.refresh('face'); }, + }); + faceSection.add(snotSize.el); + const snotBox = details(faceSection.body, 'こまかい設定(位置・色)'); + const snotSliders = { + offsetX: slider({ + label: 'よこの位置', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetX = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetY = v; app.actions.refresh('face'); }, + }), + offsetZ: slider({ + label: 'おくゆき', min: -10, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); }, + }), + }; + for (const widget of Object.values(snotSliders)) snotBox.add(widget.el); + const snotColor = colorField({ + label: '泡の色', value: '#e3ecff', + swatches: ['#e3ecff', '#ffffff', '#cfe0ff', '#bfe8d0'], + onChange: (v) => { state.face.eyes.snot.color = v; app.actions.refresh('face'); }, + }); + snotBox.add(snotColor.el); + snotBox.add(hint('寝ている顔の横に、鼻から出る泡を描きます。' + + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' + + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); + + // --- mouth + faceSection = faceTabSections.mouth; + + const mouthToggle = check({ + label: '口を表示', + value: true, + onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, mouthToggle.el, { wide: true })); + + // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、 + // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために + // スライダー自体は残してある。 + const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile))); + + const mouthSliders = { + smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), + open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), + round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), + width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), + thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), + tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), + offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), + tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }), + tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }), + corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), + cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }), + }; + // 口には「ふつうの口」と「丸く開く(O)」の2つの形があり、使うスライダーが + // まったく違う。形を切り替えると、その形で使うものだけを出す。 + const mouthModeOf = (mouth) => (mouth.round > 0.004 ? 'round' : 'curve'); + const mouthRowKeys = { + curve: ['smile', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'], + round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'], + }; + // DOM order is fixed here; the mode only shows or hides rows. + const mouthRowOrder = ['round', 'smile', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle']; + const mouthMode = segmented({ + label: '口の形', + options: [ + { value: 'curve', label: 'ふつうの口' }, + { value: 'round', label: '丸く開く(O)' }, + ], + value: 'curve', + onChange: (shape) => { + if (shape === 'round') { + if (!(state.face.mouth.round > 0.004)) state.face.mouth.round = 0.6; + } else { + state.face.mouth.round = 0; + } + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(mouthMode); + for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el); + faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。' + + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。')); + faceSection.add(buttons({ + items: [{ + id: 'mouthreset', + label: '口を初期値に戻す', + onClick: () => { + state.face.mouth = defaultState().face.mouth; + app.actions.refresh('face'); + sync(); + }, + }], + })); + + const mouthColors = { + color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }), + innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }), + tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }), + cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }), + }; + const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); + for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); + + /* ------------------------------------------------------ movement (pose tab) */ + + // This is the pose tab's うごき section; the widgets sit here because they are + // driven by the animator alongside the pose state. + const moveSection = section(tabPose, 'うごき'); + + const animWidgets = { + blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }), + lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }), + }; + + const motionSegment = segmented({ + label: 'からだのうごき', + options: [ + { value: 'off', label: 'とまる' }, + { value: 'idle', label: 'ゆれる' }, + { value: 'walk', label: '歩く' }, + { value: 'wave', label: 'てをふる' }, + { value: 'sleep', label: 'ねている' }, + ], + value: 'off', + onChange: (value) => { + state.anim.mode = value; + // Sleeping always shows the snot bubble, so the motion reads without having + // to switch the bubble on as well. + if (value === 'sleep') { + state.face.eyes.snot.enabled = true; + snotToggle.set(true); + app.actions.refresh('face'); + } + app.animator.reset(); + app.needsRender = true; + }, + }); + moveSection.add(motionSegment.el); + for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true })); + + moveSection.add(slider({ + label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`, + onInput: (v) => { state.anim.blinkInterval = v; }, + })); + moveSection.add(slider({ + label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, + onInput: (v) => { state.anim.speed = v; }, + })); + + const recordButton = buttons({ + items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }], + }); + moveSection.add(recordButton.el); + moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。')); + + // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- + const lookAtSection = faceTabSections.look; + // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. + const refreshLookAt = () => { + app.actions.refresh('lookAt'); + app.actions.refresh('face'); + sync(); + }; + const lookAtToggle = check({ + label: '指定した場所を見る', + value: false, + onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); }, + }); + lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true })); + const lookAtSliders = { + x: slider({ + label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.x = v; refreshLookAt(); }, + }), + y: slider({ + label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.y = v; refreshLookAt(); }, + }), + z: slider({ + label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.z = v; refreshLookAt(); }, + }), + amount: slider({ + label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); }, + }), + }; + for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el); + const turnBodyToggle = check({ + label: '体も向ける', + value: false, + onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); }, + }); + lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true })); + lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); + + /* ---------------------------------------------------------------- display */ + + // The face tab's bottom line: a quick way out of whatever has been built up. + tabFace.append(buttons({ + items: [ + { id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') }, + { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() }, + ], + }).el); + + const displaySection = section(tabView, 'スタイル'); + + const styleSegment = segmented({ + label: 'スタイル', + options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })), + value: 'real', + onChange: (value) => replace({ render: { style: value } }, 'render'), + }); + displaySection.add(styleSegment.el); + displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。')); + + const outlineToggle = check({ + label: '輪郭線を出す', value: true, + onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); }, + }); + displaySection.add(controlRow(null, outlineToggle.el, { wide: true })); + const outlineWidthSlider = slider({ + label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3), + onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); }, + }); + displaySection.add(outlineWidthSlider.el); + const outlinePixelsSlider = slider({ + label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2, + format: (v) => `${v.toFixed(1)} px`, + onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); }, + }); + displaySection.add(outlinePixelsSlider.el); + displaySection.add(colorField({ + label: '線の色', value: '#2a1e33', + swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'], + onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); }, + })); + displaySection.add(colorField({ + label: '紙の色', value: '#ffffff', + swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'], + onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); }, + })); + const leafBodyLineCheck = check({ + label: '葉っぱと体の境目に線を出す', value: true, + onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); }, + title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。' + + '「葉が体にめり込んで見える」のが気になるときに切ってください', + }); + displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true })); + displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。')); + displaySection.add(buttons({ + items: [{ + id: 'resetStyle', + label: 'プリセットのスタイルに戻す', + onClick: () => replace({ + render: { + style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2, + outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true, + }, + }, 'render'), + }], + })); + + /* ------------------------------------------------------------- 帽子 (顔タブ) */ + + const hatSection = faceTabSections.hat; + hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。')); + const hatSegment = segmented({ + label: '帽子', + options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })), + value: 'none', + onChange: (value) => { + state.face.hat.kind = value; + // Picking one of the built-in hats (including なし) also clears any GLB hat + // the user imported, so なし really removes the hat. + state.face.hat.custom = false; + app.actions.refresh('all'); + }, + }); + hatSection.add(hatSegment.el); + syncers.push(() => hatSegment.set(state.face.hat?.custom === true ? 'none' : (state.face.hat?.kind ?? 'none'))); + + // 帽子モデル(GLB)の取り込み。頭に乗るよう自動で位置と大きさを合わせる。 + const hatFilePicker = h('input', { type: 'file', accept: '.glb,.gltf,model/gltf-binary,model/gltf+json', style: { display: 'none' } }); + hatFilePicker.addEventListener('change', async () => { + const file = hatFilePicker.files?.[0]; + hatFilePicker.value = ''; + if (!file) return; + try { + await app.actions.loadHatModel(file); + toast(`帽子「${file.name}」を読み込みました`); + } catch (error) { + console.error(error); + toast('帽子モデルを読み込めませんでした(GLBをお使いください)'); + } + }); + hatSection.add(hatFilePicker); + hatSection.add(buttons({ + items: [{ id: 'hatfile', label: '帽子モデルを読み込む(GLB)', onClick: () => hatFilePicker.click() }], + })); + hatSection.add(hint('GLBの帽子を頭に乗せます。大きさと位置は自動で合わせます。')); + + const hatBox = details(hatSection.body, '位置と傾き'); + const hatSliders = { + height: slider({ + label: '高さ', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); }, + }), + x: slider({ + label: 'よこ位置', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.hat.x = v; app.actions.refresh('all'); }, + }), + z: slider({ + label: 'おくゆき', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.hat.z = v; app.actions.refresh('all'); }, + }), + tiltX: slider({ + label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); }, + }), + tiltZ: slider({ + label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); }, + }), + }; + for (const widget of Object.values(hatSliders)) hatBox.add(widget.el); + hatBox.add(hint('「高さ」「よこ位置」「おくゆき」で帽子を動かし、「前後の傾き」「左右の傾き」で斜めにかぶせられます。')); + syncers.push(() => { + hatSliders.height.set(state.face.hat?.height ?? 0); + hatSliders.x.set(state.face.hat?.x ?? 0); + hatSliders.z.set(state.face.hat?.z ?? 0); + hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0); + hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0); + }); + + /* ---------------------------------------------------------- 環境の映りこみ */ + + // The body colours are fixed (see `render.colors` in presets.js), so the 体の色 + // section is gone. The strength of the environment reflection is still a look + // setting, so it lives in 見た目の調整 below. + const envIntensitySlider = slider({ + label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); }, + }); + + /* -------------------------------------------------------------------- look */ + + const lookSection = section(tabView, '見た目の調整'); + const mirrorToggle = check({ + label: '左右反転', + value: false, + onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); }, + }); + lookSection.add(controlRow(null, mirrorToggle.el, { wide: true })); + lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。')); + + const shadowCheck = check({ + label: '影を落とす', + value: true, + onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); }, + }); + lookSection.add(controlRow(null, shadowCheck.el, { wide: true })); + const shadowOpacitySlider = slider({ + label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); }, + }); + const shadowSoftnessSlider = slider({ + label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); }, + }); + lookSection.add(shadowOpacitySlider.el); + lookSection.add(shadowSoftnessSlider.el); + const contactShadowToggle = check({ + label: '接地影だけ', + value: false, + onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); }, + }); + lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true })); + const contactBlobToggle = check({ + label: '足もとの丸い影', + value: false, + onChange: (value) => { state.render.contactBlob = value; app.actions.refresh('render'); }, + }); + lookSection.add(controlRow(null, contactBlobToggle.el, { wide: true })); + lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。' + + '「接地影だけ」は足もとの丸い影だけにします。' + + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。')); + // 環境の映りこみ: the strength of the environment reflection, moved here from + // the old 体の色 section (the body colours themselves are fixed now). + lookSection.add(envIntensitySlider.el); + + const environmentSegment = segmented({ + label: '照明の種類', + options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })), + value: 'gradient', + onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); }, + }); + // 照明の種類は「シーン」タブの「画面・光」に置く(環境の映りこみの元を選ぶものなので)。 + lookSection.add(buttons({ + items: [{ + id: 'resetLook', + label: 'プリセットに戻す', + onClick: () => replace({ + render: { + mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6, + contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1, + }, + }, 'render'), + }], + })); + + /* -------------------------------------------------------------- background */ + + const backdropSection = section(tabScene, '背景'); + // 素材 is one button for two state values (a photo or an effect line). This + // remembers which of the two was picked last, so leaving 素材 and coming back + // returns to it instead of always jumping to the photos. + let lastMaterial = 'preset'; + const backgroundModeSegment = segmented({ + label: '背景', + options: [ + { value: 'solid', label: '単色' }, + { value: 'transparent', label: '透明' }, + { value: 'material', label: '素材' }, + { value: 'image', label: '自分の画像' }, + { value: 'camera', label: 'カメラ=AR' }, + ], + value: 'solid', + onChange: (value) => { + if (value === 'camera') { + // The action owns the stream, so let it decide and re-read the state after. + Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); + return; + } + if (state.view.background === 'camera') app.actions.toggleCamera(false); + state.view.background = value === 'material' ? lastMaterial : value; + app.actions.refresh('view'); + sync(); + }, + }); + backdropSection.add(backgroundModeSegment.el); + // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section. + const backgroundColour = colorField({ + label: '背景の色', value: '#ffffff', + swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'], + onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); }, + }); + backdropSection.add(backgroundColour.el); + + const photoButtons = buttons({ + label: '写真', + items: BACKGROUND_PRESETS.map((preset) => ({ + id: preset.name, + label: preset.label, + onClick: () => { + state.view.backgroundPreset = preset.name; + lastMaterial = 'preset'; + state.view.background = 'preset'; + app.actions.refresh('view'); + sync(); + }, + })), + }); + + const effectButtons = buttons({ + label: '効果線(まんが)', + items: EFFECT_PRESETS.map((effect) => ({ + id: `effect-${effect.name}`, + label: effect.label, + onClick: () => { + state.view.backgroundEffect = effect.name; + lastMaterial = 'effect'; + state.view.background = 'effect'; + app.actions.refresh('view'); + sync(); + }, + })), + }); + + // The two rows are shown only while 素材 is the active mode (see the syncer + // below); the border groups them so they read as one choice, not two. + const materialGroup = h('div', { + class: 'material-group', + style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' }, + }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。')); + backdropSection.add(materialGroup); + + const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); + backgroundImagePicker.addEventListener('change', async () => { + const file = backgroundImagePicker.files?.[0]; + backgroundImagePicker.value = ''; + if (!file) return; + try { + const dataUrl = await app.backdrop.loadImageFile(file); + state.view.backgroundImage = dataUrl; + state.view.background = 'image'; + app.actions.refresh('view'); + sync(); + toast(`背景の画像「${file.name}」を読み込みました`); + } catch (error) { + console.error(error); + toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); + } + }); + backdropSection.add(backgroundImagePicker); + backdropSection.add(buttons({ + items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }], + })); + + const backgroundFitSegment = segmented({ + label: '合わせ方', + options: [ + { value: 'cover', label: '全体' }, + { value: 'contain', label: '収める' }, + { value: 'stretch', label: '伸ばす' }, + { value: 'tile', label: '並べる' }, + ], + value: 'cover', + onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); }, + }); + const backgroundBlurSlider = slider({ + label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`, + onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); }, + }); + const backgroundDarkenSlider = slider({ + label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); }, + }); + const backgroundScaleSlider = slider({ + label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); }, + }); + backdropSection.add(backgroundFitSegment.el); + backdropSection.add(backgroundBlurSlider.el); + backdropSection.add(backgroundDarkenSlider.el); + backdropSection.add(backgroundScaleSlider.el); + + const cameraFacingSegment = segmented({ + label: 'カメラ', + options: [ + { value: 'user', label: '前' }, + { value: 'environment', label: '後ろ' }, + ], + value: 'environment', + onChange: (value) => { + state.view.cameraFacing = value; + // Only a live stream has to be restarted on the other lens. + if (state.view.background === 'camera') { + Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); + return; + } + app.actions.refresh('view'); + }, + }); + const cameraMirrorToggle = check({ + label: '左右反転', + value: false, + onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); }, + }); + backdropSection.add(cameraFacingSegment.el); + backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true })); + + // A gyro reading is a delta from the pose the device held when the switch was + // turned on, so the camera does not jump when it starts listening. + let gyroBase = null; + let gyroOff = null; + const gyroToggle = check({ + label: '端末を傾けて見る', + value: false, + onChange: (value) => { + state.view.gyro = value; + if (!value) { + gyroBase = null; + if (gyroOff) { gyroOff(); gyroOff = null; } + return; + } + gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 }; + app.backdrop?.requestGyro?.(); + gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => { + if (!gyroBase) return; + const yaw = reading?.yaw ?? 0; + const pitch = reading?.pitch ?? 0; + state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw)); + state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch)); + app.actions.refresh('view'); + }) ?? null; + }, + }); + backdropSection.add(controlRow(null, gyroToggle.el, { wide: true })); + backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。')); + backdropSection.add(buttons({ + items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }], + })); + + /* ------------------------------------------------------------------- props */ + + // 小物: the things you put the character *among*. Each stage feature gets its + // own top-level section rather than one shared 舞台 drawer, so they are easier + // to find and open independently. + const propSection = section(tabScene, '小物'); + propSection.add(buttons({ + label: '追加', + items: PROP_LIBRARY.map((entry) => ({ + id: entry.id, + label: entry.label, + onClick: () => { app.actions.addProp(entry.id); sync(); }, + })), + })); + propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。' + + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。')); + const propListBox = h('div', { class: 'prop-list' }); + propSection.add(controlRow(null, propListBox, { wide: true })); + + const propWidgets = []; + let propShape = ''; + + /** + * Rebuild the list only when the *set* of props changes: a rebuild mid-drag + * would replace the slider the user is holding. The handlers read the item by + * index rather than keeping a reference, so a state swap (undo, load) is safe. + */ + const renderProps = () => { + const items = state.props?.items ?? []; + const shape = items.map((item) => item.kind).join('|'); + if (shape === propShape && propWidgets.length === items.length) return; + propShape = shape; + propWidgets.length = 0; + propListBox.replaceChildren(); + items.forEach((item, index) => { + const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind; + const block = h('div', { + class: 'prop-item', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, + }, subhead(items.length > 1 ? `${label} ${index + 1}` : label)); + const patch = (key) => (value) => { + const target = state.props?.items?.[index]; + if (target) target[key] = value; + app.actions.refresh('render'); + sync(); + }; + const sliders = { + x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }), + y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }), + z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }), + rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }), + rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }), + rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }), + scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }), + }; + // The numbers are only a fallback for the drag, so they stay folded away. + const numbers = details(block, '数値で調整'); + for (const widget of Object.values(sliders)) numbers.add(widget.el); + // 看板 only: the writing area (face *and* frame) can be enlarged on its + // own, so the posts and base stay put. The app rebuilds the props from the + // state and carries only the whole-prop scale, so the props syncer + // re-applies this to the fresh mesh (see `applyPropFaceScale`). + let faceScale = null; + let signText = null; + if (item.kind === 'sign') { + signText = textArea({ + label: '看板の文字(改行できます)', + value: '', + onChange: (value) => { + const target = state.props?.items?.[index]; + if (target) target.text = value; + // Paint straight onto the live face rather than rebuilding every prop + // on each keystroke; a later rebuild re-applies it (see main.js). + applyPropText(placedPropGroups(app)[index], value); + app.needsRender = true; + }, + }); + block.insertBefore(signText.el, numbers.el); + faceScale = slider({ + label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, + format: (v) => v.toFixed(2), onInput: patch('faceScale'), + }); + block.insertBefore(faceScale.el, numbers.el); + } + block.append(buttons({ + items: [{ + id: 'remove', + label: '削除', + onClick: () => { + state.props = state.props ?? { items: [] }; + state.props.items = (state.props.items ?? []).filter((_, i) => i !== index); + app.actions.refresh('render'); + renderProps(); + sync(); + }, + }], + }).el); + propListBox.append(block); + propWidgets.push({ index, sliders, faceScale, signText }); + }); + }; + renderProps(); + + /* ------------------------------------------------------------- 擬音 */ + + // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense, + // so the user picks the crop with a marquee instead of the app slicing them + // (see src/gion.js for why). Only the sheet name and the crop rectangle live in + // the state - never the bitmap - so a shared link stays small. + const gionSection = section(tabScene, '擬音(マンガのオノマトペ)'); + gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。' + + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。' + + '貼ったあとはビューポートでドラッグして動かせます。')); + + const gionSheetSelect = selectField({ + label: 'シート', + options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })), + value: GION_SHEETS[0]?.name ?? '', + onChange: () => {}, + }); + if (GION_SHEETS.length) { + gionSection.add(gionSheetSelect.el); + gionSection.add(buttons({ + items: [{ + id: 'pick', + label: '擬音をえらぶ', + primary: true, + onClick: () => openGionPicker(gionSheetSelect.get()), + }], + })); + } else { + // No sheet ships with the studio (see src/gion.js), so there is nothing to + // pick yet - the section stays, but only with a note on how to add one. + gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、' + + 'src/gion.js の GION_SHEETS に登録すると使えます。')); + } + + const gionListBox = h('div', { class: 'gion-list' }); + gionSection.add(controlRow(null, gionListBox, { wide: true })); + + let gionShape = ''; + const gionWidgets = []; + + /** Select a stamp: the viewport outlines it and the list highlights it. */ + const selectGion = (id) => { + app.gionSelected = id ?? null; + app.actions.refresh('gion'); + sync(); + }; + + /** + * A little canvas preview of a stamp's crop, drawn from the sheet once it has + * loaded. The sheet itself is only fetched when a stamp first needs it. + */ + const gionPreviews = new Map(); + function gionPreview(item) { + const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 }); + const draw = (image) => { + if (!image?.naturalWidth) return; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh); + const w = item.sw * scale; + const h = item.sh * scale; + ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, + (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); + }; + const known = gionPreviews.get(item.sheet); + if (known) { + draw(known); + } else { + const image = new Image(); + image.onload = () => draw(image); + image.src = gionSheetUrl(item.sheet); + gionPreviews.set(item.sheet, image); + } + return canvas; + } + + /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */ + const renderGion = () => { + const items = state.gion?.items ?? []; + const shape = items + .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`) + .join('|'); + if (shape === gionShape && gionWidgets.length === items.length) return; + gionShape = shape; + gionWidgets.length = 0; + gionListBox.replaceChildren(); + if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null; + if (items.length === 0) { + gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' })); + return; + } + items.forEach((item, index) => { + const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet; + const block = h('div', { + class: 'gion-item', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, + }); + const preview = gionPreview(item); + preview.title = 'クリックで選択'; + preview.addEventListener('click', () => selectGion(item.id)); + const head = h('div', { class: 'gion-item-head' }, preview, + subhead(`擬音 ${index + 1}(${label})`)); + block.append(head); + + const patch = (key) => (value) => { + const target = state.gion?.items?.[index]; + if (target) target[key] = value; + app.actions.refresh('gion'); + sync(); + }; + const sliders = { + w: slider({ + label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH, + format: (v) => `${Math.round(v)}px`, onInput: patch('w'), + }), + rot: slider({ + label: '回転', min: -180, max: 180, step: 1, value: 0, + format: (v) => `${Math.round(v)}°`, onInput: patch('rot'), + }), + }; + const flip = check({ label: '左右反転', value: false, onChange: patch('flip') }); + block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true })); + block.append(buttons({ + items: [ + { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) }, + { + id: 'remove', + label: '削除', + onClick: () => { + state.gion = state.gion ?? { items: [] }; + state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index); + if (app.gionSelected === item.id) app.gionSelected = null; + app.actions.refresh('gion'); + renderGion(); + sync(); + }, + }, + ], + }).el); + gionListBox.append(block); + gionWidgets.push({ id: item.id, sliders, flip, block }); + }); + }; + renderGion(); + + /** + * The picker: a full-screen overlay showing the whole sheet, on which the user + * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of + * the picture; Esc or キャンセル closes without adding anything. + */ + const openGionPicker = (sheetName) => { + const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0]; + if (!sheet) { toast('擬音のシートがありません'); return; } + + const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) }); + const marquee = h('div', { class: 'gion-picker-marquee' }); + const board = h('div', { class: 'gion-picker-board' }, image, marquee); + const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true }); + const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' }); + const overlay = h('div', { class: 'gion-picker' }, + h('div', { class: 'gion-picker-bar' }, + h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }), + h('div', { class: 'gion-picker-actions' }, + h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }), + addButton, + cancelButton)), + board); + + /** Where the sheet is currently displayed, in board pixels. */ + let sheetRect = null; + /** The marquee the user is drawing, or the last one drawn. */ + let current = null; + let start = null; + + const layoutSheet = () => { + if (!image.naturalWidth) return; + const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 }); + image.style.left = `${rect.x}px`; + image.style.top = `${rect.y}px`; + image.style.width = `${rect.w}px`; + image.style.height = `${rect.h}px`; + sheetRect = rect; + }; + + const updateAdd = () => { + addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect); + }; + + const drawMarquee = () => { + if (!current) { marquee.style.display = 'none'; return; } + marquee.style.display = 'block'; + marquee.style.left = `${current.x}px`; + marquee.style.top = `${current.y}px`; + marquee.style.width = `${current.w}px`; + marquee.style.height = `${current.h}px`; + }; + + const pointIn = (event, bounds) => ({ + x: clamp(event.clientX - bounds.left, 0, bounds.width), + y: clamp(event.clientY - bounds.top, 0, bounds.height), + }); + + board.addEventListener('pointerdown', (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + start = pointIn(event, board.getBoundingClientRect()); + current = null; + drawMarquee(); + updateAdd(); + try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ } + }); + board.addEventListener('pointermove', (event) => { + if (!start) return; + current = normalizeRect(start, pointIn(event, board.getBoundingClientRect())); + drawMarquee(); + updateAdd(); + }); + const endMarquee = (event) => { + if (!start) return; + start = null; + try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ } + updateAdd(); + }; + board.addEventListener('pointerup', endMarquee); + board.addEventListener('pointercancel', endMarquee); + image.addEventListener('load', () => { layoutSheet(); updateAdd(); }); + + addButton.addEventListener('click', () => { + if (!current || !sheetRect) return; + const crop = cropFromMarquee(current, sheetRect, image); + app.actions.addGionStamp({ sheet: sheet.name, ...crop }); + close(); + }); + cancelButton.addEventListener('click', () => close()); + overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); + + const onKey = (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + close(); + }; + const onResize = () => layoutSheet(); + + let closed = false; + const close = () => { + if (closed) return; + closed = true; + document.removeEventListener('keydown', onKey); + window.removeEventListener('resize', onResize); + overlay.remove(); + }; + + document.addEventListener('keydown', onKey); + window.addEventListener('resize', onResize); + document.body.append(overlay); + // A cached sheet is already loaded, so the `load` event never fires for it. + if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); } + }; + + /* ------------------------------------------------------------- 見えない壁 */ + + // 見えない壁: place up to two finite "boards" and bury the character in them, so + // only the part in front still shows. A wall's position is kept *relative to the + // character*, so moving ぶるべー carries its walls along (see src/clip.js). + const wallSection = section(tabScene, '見えない壁'); + wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。' + + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。' + + '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。' + + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。')); + + const WALL_SLOTS = [ + { key: 'wall', label: '壁 1' }, + { key: 'wall2', label: '壁 2' }, + ]; + + const wallGroups = WALL_SLOTS.map(({ key, label }) => { + const box = details(wallSection.body, label); + const cfg = () => (state.render[key] = state.render[key] ?? defaultState().render[key]); + const toggle = check({ + label: `${label}で一部を隠す`, + value: false, + onChange: (value) => { cfg().on = value; app.actions.refresh('render'); sync(); }, + }); + box.add(controlRow(null, toggle.el, { wide: true })); + const sliders = { + x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().x = v; app.actions.refresh('render'); sync(); } }), + y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().y = v; app.actions.refresh('render'); sync(); } }), + z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().z = v; app.actions.refresh('render'); sync(); } }), + yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().yaw = v; app.actions.refresh('render'); sync(); } }), + tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().tilt = v; app.actions.refresh('render'); sync(); } }), + size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { cfg().size = v; app.actions.refresh('render'); sync(); } }), + }; + for (const widget of Object.values(sliders)) box.add(widget.el); + + // The wall writes depth but no colour, so it hides what is behind it without + // being visible. The guide is the tinted copy of that same rectangle, shown + // only while placing - and it is also the switch that says "I am placing the + // wall now", so with it off the wall cannot swallow an orbit drag. + const guide = check({ + label: '板を表示(ビューポートでドラッグして動かせます)', + value: false, + onChange: (value) => { cfg().guide = value; app.actions.refresh('render'); sync(); }, + }); + box.add(controlRow(null, guide.el, { wide: true })); + return { key, label, toggle, sliders, guide }; + }); + + wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**' + + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。' + + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。' + + '「板を表示」した板は、ビューポートでドラッグしても動かせます。')); + wallSection.add(buttons({ + items: [{ + id: 'wallReset', + label: '見えない壁をリセット', + onClick: () => { + state.render.wall = defaultState().render.wall; + state.render.wall2 = defaultState().render.wall2; + app.actions.refresh('render'); + sync(); + }, + }], + })); + + /* ------------------------------------------------------------------ light */ + + // 画面・光: the light rig and the camera, which together decide how the picture + // is lit and framed. + const screenLightSection = section(tabScene, '画面・光'); + const lightSection = details(screenLightSection.body, 'ひかり'); + const lightPad = xyPad({ + value: { x: -0.2, y: 0.45 }, + onChange: ({ x, y }) => { + state.render.lightAzimuth = x * 180; + state.render.lightElevation = 5 + Math.max(0, y) * 80; + app.actions.refresh('render'); + }, + }); + lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' }, + lightPad.el, + h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' })))); + const lightIntensitySlider = slider({ + label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); }, + }); + const ambientSlider = slider({ + label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); }, + }); + const exposureSlider = slider({ + label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); }, + }); + const lightColorField = colorField({ + label: '光の色', value: '#ffffff', + swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'], + onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); }, + }); + const ambientColorField = colorField({ + label: '環境光の色', value: '#ffffff', + swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'], + onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); }, + }); + lightSection.add(environmentSegment.el); + lightSection.add(lightIntensitySlider.el); + lightSection.add(ambientSlider.el); + lightSection.add(lightColorField.el); + lightSection.add(ambientColorField.el); + lightSection.add(exposureSlider.el); + lightSection.add(hint('「照明の種類」は環境の映りこみの元(グラデーション/部屋/なし)です。' + + '「光の色」でライトの色、「環境光の色」で影側の色を変えられます。' + + 'テクスチャ(環境)の映りこみの強さは「見た目」タブの「環境の映りこみ」で調整できます。')); + + /* ----------------------------------------------------------------- camera */ + + const cameraSection = details(screenLightSection.body, 'カメラ'); + + const projectionSegment = segmented({ + label: '映し方', + options: [ + { value: 'persp', label: '遠近あり' }, + { value: 'ortho', label: '正投影' }, + ], + value: 'persp', + onChange: (value) => replace({ view: { projection: value } }, 'view'), + }); + cameraSection.add(projectionSegment.el); + cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。')); + + cameraSection.add(buttons({ + label: '向き', + items: [ + { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') }, + { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') }, + { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') }, + { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') }, + { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') }, + ], + })); + + const cameraSliders = { + azimuth: slider({ + label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); }, + }), + polar: slider({ + label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); }, + }), + size: slider({ + label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1), + onInput: (v) => { + if (state.view.projection === 'ortho') state.view.orthoHeight = v; + else state.view.distance = v; + app.actions.refresh('view'); + }, + }), + targetY: slider({ + label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2), + onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); }, + }), + }; + for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el); + + const autoRotateToggle = check({ + label: '自動で回す', value: false, + onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); }, + }); + cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true })); + cameraSection.add(slider({ + label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1), + onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); }, + })); + + /* ------------------------------------------------------------------ export */ + + const exportSection = section(tabExport, '画像'); + + exportSection.add(segmented({ + label: 'PNGの大きさ', + options: [ + { value: '1', label: '1倍' }, + { value: '2', label: '2倍' }, + { value: '3', label: '3倍' }, + { value: '4', label: '4倍' }, + ], + value: '2', + onChange: (value) => { state.render.pngScale = Number(value); }, + })); + exportSection.add(buttons({ + items: [ + { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() }, + { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() }, + ], + })); + const transparentCheck = check({ + label: 'PNGの背景を透明にする', value: false, + onChange: (v) => { state.render.pngTransparent = v; }, + }); + exportSection.add(transparentCheck.el); + exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。')); + + // まんが(コマ)gets its own section: it is a different kind of output. + const comicSection = section(tabExport, 'まんが'); + const storyScaleSegment = segmented({ + label: '書き出しの大きさ', + options: [ + { value: '1', label: '1×(軽い)' }, + { value: '1.5', label: '1.5×' }, + { value: '2', label: '2×(きれい)' }, + ], + value: '1.5', + hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。' + + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)', + onChange: (value) => { state.story.scale = Number(value); }, + }); + comicSection.add(storyScaleSegment.el); + const storyColumnsSlider = slider({ + label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`, + onInput: (v) => { state.story.columns = Math.round(v); }, + }); + const storyGapSlider = slider({ + label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`, + onInput: (v) => { state.story.gap = v; }, + }); + const storyBackgroundField = colorField({ + label: '台紙の色', value: '#ffffff', + onChange: (v) => { state.story.sheetBackground = v; }, + }); + comicSection.add(storyColumnsSlider.el); + comicSection.add(storyGapSlider.el); + comicSection.add(storyBackgroundField.el); + comicSection.add(buttons({ + items: [ + { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } }, + { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) }, + ], + })); + comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。')); + const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } }); + comicSection.add(controlRow(null, storyPanelBox, { wide: true })); + let storyShape = ''; + + /** Bring back everything a panel saved: pose, face, 擬音 and the camera. */ + const recallStoryPanel = (index) => { + const panel = state.story?.panels?.[index]; + if (!panel) return; + // Replace the bones outright: a merged pose would keep bones the panel never had. + state.pose = { bones: {}, root: [0, 0, 0] }; + app.actions.applyState({ + pose: panel.pose ?? {}, + face: panel.face ?? {}, + // The 擬音 on the shot. A panel saved before this existed clears them, so + // recalling it shows what it actually recorded rather than a leftover. + gion: panel.gion ?? { items: [] }, + // The camera it was recorded with, so the view comes back too. A panel + // saved before this existed simply keeps the current camera. + ...(panel.view ? { view: panel.view } : {}), + }, { scope: 'all', sync: true }); + }; + + const removeStoryPanel = (index) => { + state.story = state.story ?? {}; + state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index); + // The empty patch is only here to route the edit through the app's own apply + // path: that is what records an undo step and re-reads the panel. + app.actions.applyState({}, { scope: 'all', sync: true }); + }; + + /** + * Same rebuild policy as the prop list: only when the set of panels changes. + * + * A preview arrives a moment *after* its panel does (it needs a render), so its + * version is part of the identity: when one turns up, the row is drawn again + * with it. + */ + const renderStoryPanels = () => { + const panels = state.story?.panels ?? []; + const thumbs = app.actions.storyThumbs?.() ?? null; + const shape = `${panels.length}:${panels + .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`) + .join('|')}`; + if (shape === storyShape) return; + storyShape = shape; + storyPanelBox.replaceChildren(); + panels.forEach((panel, index) => { + const thumb = thumbs?.get?.(panel.id)?.url; + const preview = thumb + ? h('img', { + class: 'story-thumb', + alt: panel.label ?? `コマ${index + 1}`, + src: thumb, + title: 'クリックでこのコマを呼び出す', + style: { + width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer', + background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px', + }, + onClick: () => recallStoryPanel(index), + }) + : h('div', { + text: '(プレビューなし)', + style: { + padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd', + color: '#8a80a0', fontSize: '11px', + }, + }); + const block = h('div', { + class: 'story-panel', + style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' }, + }, subhead(panel.label ?? `コマ${index + 1}`), preview); + block.append(buttons({ + items: [ + { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) }, + { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) }, + ], + }).el); + storyPanelBox.append(block); + }); + }; + renderStoryPanels(); + + // The share section comes after まんが, so the reading order is 画像 → まんが → + // 共有 (the order the outputs are usually wanted in). + /* ------------------------------------------------------------ extra export */ + + const extraExportSection = section(tabExport, '共有'); + extraExportSection.add(buttons({ + items: [ + { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() }, + { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') }, + { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') }, + { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() }, + ], + })); + extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。' + + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。')); + + // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは + // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして + // 共有のあとに置く(素材を作る → 見せる、の順)。 + const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す'); + faceMapSection.add(buttons({ + items: [ + { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, + { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, + ], + })); + faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' + + 'そのまま絵を描いて「目の画像を読み込む(PNG)」や「口の画像を読み込む(PNG)」で読み込むと、' + + '描いた場所にそのまま入ります。青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。')); + + /* --------------------------------------------------------------- settings */ + + const settingsSection = section(tabExport, '設定'); + + settingsSection.add(buttons({ + items: [ + { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() }, + { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() }, + ], + })); + + const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } }); + settingsPicker.addEventListener('change', async () => { + const file = settingsPicker.files?.[0]; + settingsPicker.value = ''; + if (!file) return; + try { + const text = await file.text(); + app.actions.loadSettings(text); + } catch (error) { + console.error(error); + toast('設定ファイルを読み込めませんでした'); + } + }); + settingsSection.add(settingsPicker); + + /* -------------------------------------------------------------------- sync */ + + // A tiny read-out of what the renderer is actually doing: if the picture ever + // looks wrong or never appears, this says why (buffer size, quality, fps). + const infoSection = section(tabScene, '画面の情報'); + const statsLine = h('p', { class: 'hint', text: '計測中…' }); + infoSection.add(statsLine); + infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); + + // シーン: the scene controls are built wherever they belong in the code, so put + // them on the tab in the order the user meets them: what is behind the + // character, what is beside it, then the light and the read-out. + tabScene.append( + backdropSection.el, propSection.el, gionSection.el, wallSection.el, + screenLightSection.el, infoSection.el, + ); + + boneSyncers.push(() => { + const name = rig.selected; + for (const [key, button] of boneButtons) button.classList.toggle('active', key === name); + const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 }; + axisSliders.x.set(delta.x); + axisSliders.y.set(delta.y); + axisSliders.z.set(delta.z); + const root = state.pose.root ?? [0, 0, 0]; + rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0)); + }); + + syncers.push(...boneSyncers); + + syncers.push(() => { + const linked = state.face.eyes.linked !== false; + bothControls.group.setVisible(linked); + leftControls.group.setVisible(!linked); + rightControls.group.setVisible(!linked); + bothControls.sync(); + leftControls.sync(); + rightControls.sync(); + + for (const key of ['left', 'right']) { + const eye = state.face.eyes[key]; + const w = eyeAdvancedWidgets[key]; + w.eyeX.set(eye.eyeX ?? 0); + } + + glassesToggle.set(state.face.eyes.glasses.enabled === true); + glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); + for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]); + glassesFrameColor.set(state.face.eyes.glasses.frameColor); + glassesLensColor.set(state.face.eyes.glasses.lensColor); + eyeColors.white.set(state.face.eyes.white); + eyeColors.iris.set(state.face.eyes.iris); + eyeColors.line.set(state.face.eyes.line); + }); + + syncers.push(() => { + snotToggle.set(state.face.eyes.snot.enabled === true); + snotSize.set(state.face.eyes.snot.size); + for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); + snotColor.set(state.face.eyes.snot.color); + }); + + syncers.push(() => { + mouthToggle.set(state.face.mouth.visible); + for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]); + for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]); + const mouthShape = mouthModeOf(state.face.mouth); + mouthMode.set(mouthShape); + const shown = new Set(mouthRowKeys[mouthShape]); + for (const key of mouthRowOrder) mouthSliders[key].el.style.display = shown.has(key) ? '' : 'none'; + }); + + syncers.push(() => { + motionSegment.set(state.anim.mode ?? 'off'); + animWidgets.blink.set(state.anim.blink); + animWidgets.lookAround.set(state.anim.lookAround); + }); + + syncers.push(() => { + styleSegment.set(state.render.style); + outlineToggle.set(state.render.outline); + outlinePixelsSlider.set(state.render.outlinePixels ?? 2); + leafBodyLineCheck.set(state.render.leafBodyLine !== false); + transparentCheck.set(state.render.pngTransparent); + lightPad.set({ + x: state.render.lightAzimuth / 180, + y: Math.max(0, (state.render.lightElevation - 5) / 80), + }); + lightIntensitySlider.set(state.render.lightIntensity ?? 2.1); + ambientSlider.set(state.render.ambient ?? 0.9); + lightColorField.set(state.render.lightColor ?? '#ffffff'); + ambientColorField.set(state.render.ambientColor ?? '#ffffff'); + exposureSlider.set(state.render.exposure ?? 1); + const line = ['lineart', 'outline'].includes(state.render.style); + outlineToggle.el.querySelector('input').disabled = line; + }); + + syncers.push(() => { + projectionSegment.set(state.view.projection); + cameraSliders.azimuth.set(state.view.azimuth); + cameraSliders.polar.set(state.view.polar); + const ortho = state.view.projection === 'ortho'; + cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12)); + cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5); + autoRotateToggle.set(state.view.autoRotate); + }); + + syncers.push(() => { + lookAtToggle.set(state.lookAt?.enabled === true); + for (const [key, widget] of Object.entries(lookAtSliders)) { + widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]); + } + turnBodyToggle.set(state.lookAt?.turnBody === true); + }); + + syncers.push(() => { + envIntensitySlider.set(state.render?.envIntensity ?? 1); + }); + + syncers.push(() => { + mirrorToggle.set(state.render?.mirror === true); + shadowCheck.set(state.render?.shadow !== false); + shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22); + shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6); + contactShadowToggle.set(state.render?.contactShadow === true); + contactBlobToggle.set(state.render?.contactBlob === true); + environmentSegment.set(state.render?.environment ?? 'gradient'); + }); + + syncers.push(() => { + const background = state.view?.background ?? 'solid'; + // 素材 is active for either half of it, and its rows show only then. + const isMaterial = background === 'preset' || background === 'effect'; + backgroundModeSegment.set(isMaterial ? 'material' : background); + materialGroup.style.display = isMaterial ? '' : 'none'; + backgroundColour.set(state.view?.backgroundColor ?? '#ffffff'); + backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover'); + backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0); + backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0); + backgroundScaleSlider.set(state.view?.backgroundScale ?? 1); + cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment'); + cameraMirrorToggle.set(state.view?.cameraMirror === true); + gyroToggle.set(state.view?.gyro === true); + }); + + syncers.push(() => { + renderProps(); + const items = state.props?.items ?? []; + // A rebuild only carries the whole-prop scale, so re-apply each sign's face + // size to the freshly built mesh here (see `placedPropGroups`). + const groups = placedPropGroups(app); + for (const entry of propWidgets) { + const item = items[entry.index] ?? {}; + entry.sliders.x.set(item.x ?? 0); + entry.sliders.y.set(item.y ?? 0); + entry.sliders.z.set(item.z ?? 0); + entry.sliders.rotX.set(item.rotX ?? 0); + entry.sliders.rotY.set(item.rotY ?? 0); + entry.sliders.rotZ.set(item.rotZ ?? 0); + entry.sliders.scale.set(item.scale ?? 1); + entry.faceScale?.set(item.faceScale ?? 1); + applyPropFaceScale(groups[entry.index], item.faceScale); + entry.signText?.set(item.text ?? ''); + } + }); + + syncers.push(() => { + renderGion(); + const items = state.gion?.items ?? []; + for (const entry of gionWidgets) { + const item = items.find((candidate) => candidate.id === entry.id); + if (!item) continue; + entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH); + entry.sliders.rot.set(item.rot ?? 0); + entry.flip.set(item.flip === true); + entry.block.classList.toggle('active', item.id === app.gionSelected); + } + }); + + syncers.push(() => { + for (const group of wallGroups) { + const wall = state.render?.[group.key] ?? defaultState().render[group.key]; + group.toggle.set(wall.on === true); + for (const [key, widget] of Object.entries(group.sliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0)); + group.guide.set(wall.guide === true); + } + }); + + syncers.push(() => { + storyScaleSegment.set(String(state.story?.scale ?? 1.5)); + storyColumnsSlider.set(state.story?.columns ?? 1); + storyGapSlider.set(state.story?.gap ?? 12); + storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff'); + renderStoryPanels(); + }); + + app.actions.applyFacePreset = applyFacePreset; + app.actions.applyPosePreset = applyPosePreset; + + sync(); + + // The 'g' key toggles the gizmo behind the panel's back, so the checkbox + // follows the rig rather than the other way round. + syncers.push(() => gizmoToggle.set(rig.helper.visible === true)); + + return { + sync, + onRigChanged: syncBones, + /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */ + selectGion(id) { + app.gionSelected = id ?? null; + sync(); + }, + /** Open the crop picker for a sheet (the 擬音 section's button calls this). */ + openGionPicker(sheet) { + openGionPicker(sheet ?? gionSheetSelect.get()); + }, + setStats(text) { statsLine.textContent = text; }, + setRecording(recording) { + recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始'); + }, + }; +} diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js new file mode 100644 index 0000000..a31c952 --- /dev/null +++ b/public/bluebey-studio/src/presets.js @@ -0,0 +1,668 @@ +/** + * The complete, serialisable studio state plus the preset libraries. + * + * Everything here is plain JSON: "設定を保存" writes exactly this object, and + * "設定を読み込む" merges it back in, so a look can be archived and shared. + * + * Eye parameters are named after the character's own left/right, NOT the + * viewer's: `left` is the eye on the model's +x side, which is the half of the + * eye texture with u > 0.5. In a front view that eye appears on the right of + * the screen. + */ + +/** + * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or + * two opposite cuts) can be made. A wall's position is kept *relative to the + * character*, so moving ぶるべー carries its walls along (see src/clip.js). + */ +function wallDefaults() { + return { + on: false, + x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character + yaw: 0, // turn about Y, degrees + tilt: 0, // lean about X, degrees + // The guide is the only part of the wall that is ever *drawn* (the wall + // itself writes depth but no colour, so it hides what is behind it + // without being visible). `guide` shows that rectangle while placing, + // which is also when it can be dragged in the viewport. + guide: false, + size: 1, // the wall's size (a real width and height, not a clip) + }; +} + +export function defaultState() { + return { + version: 1, + view: { + projection: 'persp', // persp | ortho + fov: 30, + azimuth: 0, // degrees, 0 = looking at the face + polar: 76, // degrees from +y, 90 = level with the target + distance: 0, // 0 = fit to the model automatically + orthoHeight: 0, // 0 = fit to the model automatically + targetY: 0, // 0 = centre of the model + // The mouse orbit and pan write their own target back here (see + // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps + // the camera the user actually framed. + targetX: 0, + targetZ: 0, + autoRotate: false, + autoRotateSpeed: 0.8, + // solid | transparent | preset | image | effect | camera + // preset = one of the CC0 backdrops in assets/backgrounds + // image = something the user loaded (data URL) + // effect = a procedurally drawn manga effect line (no file at all) + // camera = the phone's camera, for the AR mode + background: 'solid', + backgroundColor: '#ffffff', + backgroundPreset: 'autumn_park', + backgroundEffect: 'focus', // focus | fall | speed | ellipse + backgroundImage: null, // data URL, set by 「画像を読み込む」 + backgroundFit: 'cover', // cover | contain | stretch | tile + backgroundBlur: 0, // px + backgroundDarken: 0, // 0..1 dark veil, helps the character read + backgroundScale: 1, + backgroundOffset: { x: 0, y: 0 }, + // AR: which camera, and whether to mirror (front camera feels mirrored) + cameraFacing: 'environment', // environment | user + cameraMirror: false, + gyro: false, // look around by turning the phone + }, + render: { + style: 'real', // real | flat | lineart | outline + outline: true, + outlineWidth: 0.022, + outlineColor: '#2a1e33', + paper: '#ffffff', + shadow: true, + // The soft round blob at the character's feet. Off by default: it sat at the + // origin and stayed behind when the character was moved. See look.js. + contactBlob: false, + pngScale: 2, + pngTransparent: false, + svgWidth: 2048, + lightAzimuth: -38, + lightElevation: 40, + lightIntensity: 2.1, + ambient: 0.9, + // The colour of the main light and of the ambient (sky) light. + lightColor: '#ffffff', + ambientColor: '#ffffff', + exposure: 1, + // The fixed body palette. ぶるべー keeps its colours, so there is no theme + // picker and no per-part editor; the parts live here so the styles and the + // model have one place to read them from. + colors: { + body: '#c8b0f0', + accent: '#8a4fe0', + nose: '#7a4fb0', + leaf: '#a6dd6a', + vein: '#7fbf3f', + feet: '#7a4fb0', + }, + // Mirror the whole character, for laying out a panel that faces its text. + mirror: false, + // Shadows: the soft blob under the feet, plus the cast shadow. + shadowOpacity: 0.22, + shadowSoftness: 1.6, + shadowOffset: 0, + contactShadow: false, // blob only, no cast shadow + // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is + // the tiny procedural sky the studio always had. + environment: 'gradient', // gradient | room | none + envIntensity: 1, + // Hand-drawn line work (SVG export and the outline pass). + handDrawn: 0, // 0 = off, 1 = a lot + handDrawnSeed: 7, + handDrawnScale: 40, + handDrawnPasses: 1, + // How the outline is drawn - the two methods take the parts they are good + // at. See MODEL-GUIDE.md §5 and src/outline.js. + // + // 'screen' (the default) hands the leaves to the screen-space edge pass, + // because a hull cannot outline a shell that thin: the expanded + // front and back cross inside it and the line breaks up. Every + // other part keeps its hull, whose line is computed from the + // geometry and so comes out smooth, where the pixel grid would + // make it stepped. + // 'hull' the inverted-hull copy everywhere - cheap, and the way it was + // done before, but the leaves then come out as a tangle. + outlineMethod: 'screen', + outlinePixels: 2.4, // 'screen' only: line width in screen pixels + // Whether a leaf gets a line where it meets the *body*. There is no right + // answer here, so it is a setting: + // on - the body counts as paper, so the leaves are outlined where they + // emerge from it. Without this a line drawing cannot tell the + // leaves and the body apart at all (both are paper). + // off - the body blocks the line, so the leaf simply passes behind it and + // nothing is drawn along the meeting. Cleaner, but the leaves and + // the body merge into one white shape. + leafBodyLine: true, + // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide + // whatever is behind them, so the character can be buried in a wall and + // only the rest of the body shows. See src/clip.js. + wall: wallDefaults(), + wall2: wallDefaults(), + }, + pose: { + bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0 + root: [0, 0, 0], // whole-body offset in model units + }, + anim: { + mode: 'off', // off | idle | walk + blink: true, + blinkInterval: 3.4, // seconds between blinks + lookAround: false, + speed: 1, + }, + face: { + // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js. + // `x`/`z` slide it sideways / forwards, `height` lifts it off the head; + // `tiltX` leans it forward/back and `tiltZ` leans it sideways. + hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 }, + eyes: { + source: 'parametric', // parametric | opened | closed | ... (hand-drawn) + linked: true, // move both eyes together + left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null }, + right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null }, + irisScale: 1, + lookMax: 1.2, + highlight: true, + lidWidth: 14, + lowerLid: 0, + lashes: false, + lashAngle: 0, + lashPos: 0, + // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat` + // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the + // narrowed eye. See faceArt.js. + lidShape: 'curve', + lidTilt: 0, + heartScale: 2, + heartColor: '#e0344f', + // The tears live on each eye (see `eyes.left.tear`), so one eye can cry + // on its own; only the colour is shared. + tearColor: '#8fd8ff', + // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same + // plate as the eyes, out to the side of the nose. Starts off. + snot: { + enabled: false, + size: 1.23, + offsetX: -23, + offsetY: 0, + offsetZ: 16, + color: '#e3ecff', + }, + // 眼鏡 / サングラス are drawn into the same texture as the eyes, so they + // travel with the head instead of being a separate 3D mesh. Off by + // default. `kind` only picks which lens defaults the panel offers; the + // drawing itself reads the colours and opacities below. + glasses: { + enabled: false, + kind: 'glasses', // glasses | sunglasses + frameColor: '#2a1e33', + frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke) + lensColor: '#2b2433', + lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark + lensGap: 0, // px, + moves the two lenses further apart + scale: 1, // lens size multiplier + offsetY: 0, // px, artwork units (positive = down) + tilt: 0, // degrees, both lenses turn together + }, + white: '#ffffff', + iris: '#150e1b', + line: '#55386e', + }, + mouth: { + source: 'parametric', // parametric | original + visible: true, + smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown + width: 1, // the smile line's length (the chord); also the O's width + thickness: 1, + open: 0, // 0 = closed lips, 1 = wide open + tilt: 0, // degrees + offsetY: 0, // texture pixels + tongue: 0.96, // 0 hides it; scales the whole tongue + tonguePos: 0.84, // 0..1 along the smile line + corners: 0.71, // length of the corner marks, 0 hides them + cornerAngle: -8, // degrees, tilts the corner marks + round: 0, // >0 turns the mouth into a round "O" + color: '#ff1a44', + innerColor: '#4a0f1e', + tongueColor: '#ff2d2d', + cornerColor: '#725497', + line: '#55386e', + }, + }, + // ----------------------------------------------------------------- look at + // Where the character should look (world units). + lookAt: { + enabled: false, + x: 0, + y: 2.6, + z: 3, + turnBody: false, // also rotate the whole body a little + amount: 1, + // The turn and the gaze are *remembered* rather than derived, so switching + // the mode (or just 体も向ける) off leaves the character where it is instead + // of snapping it back to the front. See `applyLookAt` in main.js. + bodyYawDeg: 0, + freeze: null, + }, + // ------------------------------------------------------------------- props + // 小物. Each entry is a procedural prop from src/props.js. + props: { + items: [], + }, + // -------------------------------------------------------------------- 擬音 + // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop + // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only + // the sheet *name* is stored, never the bitmap, so a shared link stays small + // (see src/gion.js for the geometry). + gion: { + items: [], + }, + // ------------------------------------------------------------------- story + // 4コマ / 紙芝居. Each panel is a small state patch (pose, face). + story: { + columns: 1, + gap: 12, + padding: 20, + sheetBackground: '#ffffff', + // How big each captured panel is, as a multiple of the viewport. 2x doubles + // each side, so the sheet PNG gets four times the pixels per panel. + scale: 1.5, + panels: [], + }, + }; +} + +/** Expression presets. Each patches a copy of the default state. */ +export const FACE_PRESETS = [ + { + id: 'normal', + label: 'ふつう', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // Depth 1 = the hand-drawn original; the corners and tongue below are also + // the settings that reproduce it (measured against the artwork). + mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 }, + }, + }, + { + id: 'laugh', + label: 'にっこり', + face: { + eyes: { + left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, + right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' }, + highlight: false, + }, + // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a + // round mascot reads as a shout, so only びっくり keeps the round O. + mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 }, + }, + }, + { + id: 'eating', + label: 'たべている', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + // A small, cute round mouth (the other round mouth, with びっくり). + mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 }, + }, + }, + { + id: 'wink', + label: 'ウインク', + face: { + eyes: { + // The shut eye is the original `eyes-close-tight` artwork: three strokes + // sharing one vertex, pointing at the nose. + left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 }, + }, + }, + { + id: 'surprised', + label: 'びっくり', + face: { + eyes: { + left: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + right: { open: 1, lookX: 0, lookY: 0, closed: 'line' }, + irisScale: 0.74, + lookMax: 0.35, + highlight: true, + }, + mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 }, + }, + }, + { + id: 'sleepy', + label: 'ねぼけ', + face: { + eyes: { + left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' }, + irisScale: 1, + highlight: true, + // A sleeper's snot bubble, with the defaults from the panel. + snot: { enabled: true }, + }, + mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 }, + }, + }, + { + id: 'shy', + label: 'てれ', + face: { + eyes: { + // Blushing, eyes half shut and looking away. + left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, + right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, + irisScale: 1, + highlight: true, + }, + mouth: { smile: 0.5, width: 0.82, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0.5 }, + }, + }, + { + id: 'sidelong', + label: 'じと目', + face: { + eyes: { + // Flat, narrowed lids tilted inwards - a sideways, unimpressed look. + // 上まぶたの高さ is now independent of the lower lid, so `open` carries the + // whole slit (it used to be measured from the raised lower lid). + left: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' }, + right: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' }, + lowerLid: 0.42, + lidShape: 'flat', + lidTilt: 12, + irisScale: 0.92, + highlight: true, + }, + mouth: { smile: 0.12, width: 0.78, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, + { + id: 'love', + label: 'すき', + face: { + eyes: { + left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + irisScale: 1, + // The white behind a heart would poke out around its lobes, so hide it. + heartColor: '#e0344f', + highlight: false, + }, + mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 }, + }, + }, + { + id: 'grumpy', + label: 'むっと', + face: { + eyes: { + left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 }, + irisScale: 0.86, + highlight: true, + }, + mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, + { + id: 'cry', + label: 'ないてる', + face: { + eyes: { + // Big tears that hang low and lean outwards - the classic "crying hard" + // look. The sideways position and the tilt are mirrored between the eyes, + // so the pair stays symmetric. + left: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: -20 }, + right: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: 20 }, + irisScale: 1.02, + lowerLid: 0.42, + highlight: true, + }, + mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 }, + }, + }, +]; + +/** + * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose. + * + * The axes were measured (turn one bone 40° and watch the hand's centre move), + * and the two sides are NOT the same. That is what used to send the left hand + * behind while the right one came forward: + * + * arm / hand + * X lift. Up on both sides, so the SAME value is symmetric. + * Y roll about the flipper - which way the palm faces. + * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a + * symmetric pose writes the right-hand Z with the opposite sign, while + * a walk (opposite arms) writes the SAME sign on both. + * legsupport + * X out to the side and up (same value on both sides is symmetric) + * Y twist (which way the toe points) + * Z forward/back, mirrored like `arm` + * It pivots at the middle of the body, so the feet travel a long way for + * a small angle - keep it under about 15 degrees. + * master + * X lean forward Y spin around Z roll sideways + * + * `armsupport` still exists in the rig but it is a control bone that pivots at + * the body's centre, so it swings a hand in a wide arc across the face. Every + * preset below drives `arm`/`hand` instead. + */ +export const POSE_PRESETS = [ + { id: 'stand', label: '立ち', pose: { bones: {} } }, + { + id: 'attention', + label: 'きおつけ', + pose: { + bones: { + // Both flippers dropped to the sides. A negative X lowers the arm, and + // the value is the same on both sides (only Z mirrors between them). + 'arm.l': [-62, 0, 0], + 'arm.r': [-62, 0, 0], + }, + }, + }, + { + id: 'bow', + label: 'おじぎ', + pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'deep-bow', + label: 'ふかくおじぎ', + pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'tilt', + label: 'くびかしげ', + pose: { + bones: { + master: [0, 0, 16], + // The roll turns the whole body about its base, which swings the feet up + // and down, so the legs take the difference. The right-hand leg (on the + // left of the screen) is the one that is planted: it opens outwards a + // little and keeps the sole down. The left-hand leg bends back behind it. + 'legsupport.r': [8, 0, 0], + 'legsupport.l': [-6, 0, 14], + }, + }, + }, + { + id: 'wave', + label: 'てをふる', + pose: { + bones: { + master: [0, 0, -4], + // The left flipper goes up and slightly forward. Y is the roll about the + // flipper's own length, which is what turns the palm to face outwards. + 'arm.l': [84, 0, -6], + 'hand.l': [0, -30, 0], + }, + }, + }, + { + id: 'banzai', + label: 'ばんざい', + pose: { + bones: { + master: [-8, 0, 0], + // Both flippers as far up as the shoulder allows (the pivot sits at the + // middle of the body, so they cannot reach above the head). + 'arm.l': [106, 0, -12], + 'arm.r': [106, 0, 12], + }, + root: [0, 0.2, 0], + }, + }, + { + id: 'stretch', + label: 'のびをする', + pose: { + bones: { + master: [-16, 0, 0], + 'arm.l': [112, 0, -8], + 'arm.r': [112, 0, 8], + }, + root: [0, 0.08, 0], + }, + }, + { + id: 'jump', + label: 'ジャンプ', + pose: { + bones: { + master: [-8, 0, 0], + 'arm.l': [68, 0, -10], + 'arm.r': [68, 0, 10], + 'legsupport.l': [10, 0, 0], + 'legsupport.r': [10, 0, 0], + }, + root: [0, 0.62, 0], + }, + }, + { + id: 'leap', + label: '大ジャンプ', + pose: { + bones: { + master: [-18, 0, 0], + 'arm.l': [92, 0, -12], + 'arm.r': [92, 0, 12], + 'legsupport.l': [14, 0, 0], + 'legsupport.r': [14, 0, 0], + }, + root: [0, 1.0, 0], + }, + }, + { + id: 'dance', + label: 'おどる', + pose: { + bones: { + master: [-4, 0, -12], + // Deliberately uneven: the left flipper is up, the right one is out in + // front, and the feet step the other way. + 'arm.l': [78, 0, -14], + 'arm.r': [14, 0, 34], + 'legsupport.l': [0, 0, 8], + 'legsupport.r': [0, 0, 8], + }, + root: [0, 0.14, 0], + }, + }, + { + id: 'tip-over', + label: 'こてん', + pose: { + bones: { + master: [0, 0, 68], + 'arm.l': [26, 0, -18], + 'arm.r': [10, 0, 18], + }, + root: [0, 0.25, 0], + }, + }, + { + id: 'lie', + label: 'ねている', + pose: { + bones: { + // Flat on its back: X tips the body backwards all the way over, so the + // face looks up. The root lift keeps the back resting on the ground + // rather than sinking through it (the body's depth becomes its height). + master: [-88, 0, 0], + // Both flippers rest against the body, loose. + 'arm.l': [-18, 0, 0], + 'arm.r': [-18, 0, 0], + }, + root: [0, 1.5, 0], + }, + }, + { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } }, + { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } }, + { + id: 'present', + label: '右を紹介', + pose: { + bones: { + master: [6, -26, 0], + // The right flipper sweeps forward to point at whatever is on its right + // (+Z is forwards on the right); the left one just steps aside. + 'arm.r': [16, 0, 52], + 'arm.l': [10, 0, -6], + }, + }, + }, + { + id: 'present-left', + label: '左を紹介', + pose: { + bones: { + master: [6, 26, 0], + 'arm.l': [16, 0, -52], + 'arm.r': [10, 0, 6], + }, + }, + }, +]; + +/** Deep-merge `patch` into `target` (plain objects only). */ +export function applyPatch(target, patch) { + for (const [key, value] of Object.entries(patch ?? {})) { + if (value && typeof value === 'object' && !Array.isArray(value) + && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + applyPatch(target[key], value); + } else { + target[key] = Array.isArray(value) ? value.slice() : value; + } + } + return target; +} + +export function cloneState(state) { + return JSON.parse(JSON.stringify(state)); +} diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js new file mode 100644 index 0000000..78525bd --- /dev/null +++ b/public/bluebey-studio/src/props.js @@ -0,0 +1,646 @@ +import * as THREE from 'three'; + +/** + * Procedural 小物 (props) that can be placed on the stage next to the character: + * a lectern, a desk, a microphone stand, a signboard, a potted plant and a + * cardboard box. + * + * Everything is built from three's primitives instead of loaded from a file, for + * three reasons. The character is about 4.15 units tall with its feet on y = 0 + * and its body a sphere of radius ~1.6 centred near y = 2.4, so every generator + * below works from that scale, and every group's origin is its *base centre* - + * dropping one at a ground position and rotating it about y is all the app has + * to do. The studio also re-skins props with the same toon/flat materials and + * the same inverted-hull outline as the body, so the geometry stays chunky: no + * plate thinner than ~0.05 units (a thin plate's offset hull turns into a smear) + * and no textures anywhere. And props must be identical on every run, so every + * size, position and angle here is a literal - no Math.random(). + * + * Colours are never hard-coded on a mesh. Each material is tagged with + * `userData.part`, and `applyPropColors` fills the colour in from the app's + * render.colors palette, which is what lets the colour-theme feature recolour a + * prop that was built minutes ago. The mapping keeps lightness apart from hue, + * so a prop still reads in the near-monochrome すみ theme: + * + * wood the body colour, darkened - painted wood, cardboard, soil + * paper the body colour, nearly white - the blank sign face + * accent the accent colour - legs, frames, pots, mic bodies + * leaf the leaf colour - foliage + * metal a fixed neutral grey - stands, booms, microphone heads + * + * `userData.shade` multiplies a part's colour, so one prop can use two tones of + * the same part - a cardboard box and its darker inner flaps - without adding a + * sixth palette entry. + */ + +/** Reused when something has to be aimed along a direction vector. */ +const UP = new THREE.Vector3(0, 1, 0); +const WHITE = new THREE.Color(0xffffff); +/** A fixed grey: a metal stand has to read in every theme, warm or grey. */ +const METAL_GREY = '#b9bec7'; +/** Enough of the palette to build with when the app has not sent one yet. */ +const FALLBACK_COLORS = { body: '#c8b0f0', accent: '#8a4fe0', leaf: '#a6dd6a' }; + +const PART_NAMES = ['wood', 'metal', 'paper', 'accent', 'leaf']; + +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +/** The lectern's reading surface tilts up towards the audience, i.e. towards +z. */ +const SLAB_TILT = -0.32; + +// --------------------------------------------------------------------------- +// Building blocks +// --------------------------------------------------------------------------- + +/** + * A material per (part, shade) inside one prop. Sharing them keeps the draw + * calls down and, more importantly, lets `disposeProp` release each one once. + */ +function materialCache() { + const cache = new Map(); + return (part, shade = 1) => { + if (!PART_NAMES.includes(part)) throw new Error(`unknown prop material part: ${part}`); + const key = `${part}:${shade}`; + let material = cache.get(key); + if (!material) { + const metal = part === 'metal'; + material = new THREE.MeshStandardMaterial({ + color: 0xffffff, // filled in by applyPropColors + roughness: 0.75, + metalness: metal ? 0.6 : 0, + }); + material.userData.part = part; + material.userData.shade = shade; + cache.set(key, material); + } + return material; + }; +} + +/** + * Wraps a generator so each prop gets its own material cache. The generator + * receives `(group, material, options)`, fills the group and returns nothing. + */ +function prop(generator) { + return (options = {}) => { + const group = new THREE.Group(); + generator(group, materialCache(), options); + return group; + }; +} + +/** Adds a mesh, matching the body's shadow flags, and returns it. */ +function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) { + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(x, y, z); + mesh.castShadow = true; + mesh.receiveShadow = false; + parent.add(mesh); + return mesh; +} + +/** + * The 線画 fill a part takes, as how far it steps from the paper towards the ink + * (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop + * that meet flush - the pencil's lead in its wood, a can's label on its body - come + * out as one white shape. Tagging one of them with a tone puts the seam back; it + * has no effect on リアル / フラット, where the part's own colour draws it. + */ +function tone(mesh, strength) { + mesh.userData.tone = strength; + return mesh; +} + +/** A cuboid, sized as [width, height, depth] and centred on `at`. */ +function addBox(parent, material, size, at = [0, 0, 0]) { + const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A cylinder (or cone, if the two radii differ) centred on `at`. */ +function addCylinder(parent, material, radiusTop, radiusBottom, height, at, segments = 12) { + const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A rounded blob. The caller usually scales it into a leaf or an ellipsoid. */ +function addSphere(parent, material, radius, at, segments = [10, 8]) { + const geometry = new THREE.SphereGeometry(radius, segments[0], segments[1]); + return addMesh(parent, geometry, material, at[0], at[1], at[2]); +} + +/** A rod from `from` to `to`: stems, booms and struts are all just this. */ +function addStrut(parent, material, from, to, radius, segments = 8) { + const start = new THREE.Vector3(from[0], from[1], from[2]); + const end = new THREE.Vector3(to[0], to[1], to[2]); + const axis = end.clone().sub(start); + const length = axis.length(); + const mesh = addMesh( + parent, + new THREE.CylinderGeometry(radius, radius, length, segments), + material, + (start.x + end.x) / 2, + (start.y + end.y) / 2, + (start.z + end.z) / 2, + ); + mesh.quaternion.setFromUnitVectors(UP, axis.normalize()); + return mesh; +} + +// --------------------------------------------------------------------------- +// The props +// --------------------------------------------------------------------------- + +/** + * 演台. A foot plate, a pedestal, a slab that tilts up towards the audience and + * a gooseneck microphone on the corner nearest the camera. The mic is on the + * audience edge on purpose: on the far edge the slab would hide it from a + * slightly-above camera. + */ +const buildPodium = prop((group, material) => { + addBox(group, material('accent'), [1.30, 0.10, 0.98], [0, 0.05, 0]); + addBox(group, material('wood'), [1.10, 1.30, 0.78], [0, 0.75, 0]); + + const slab = addBox(group, material('wood'), [1.42, 0.12, 0.86], [0, 1.42, 0.10]); + slab.rotation.x = SLAB_TILT; + // A lip along the slab's low edge, so the silhouette reads as a lectern top. + const lip = addBox(group, material('accent'), [1.02, 0.10, 0.10], [0, 1.498, 0.527]); + lip.rotation.x = SLAB_TILT; + + const stem = addCylinder(group, material('metal'), 0.032, 0.032, 0.60, [-0.48, 1.80, 0.26], 8); + stem.rotation.x = SLAB_TILT; + const head = addSphere(group, material('metal'), 0.09, [-0.48, 2.11, 0.15], [10, 8]); + head.scale.set(1, 1.25, 1); +}); + +/** + * 机. A top, four legs and a pair of side rails. The legs and rails take the + * darker `accent` tone so the table keeps its shape in a flat or greyscale + * render, where a single flat colour would turn it into a silhouette. + */ +const buildDesk = prop((group, material) => { + addBox(group, material('wood'), [2.30, 0.12, 1.20], [0, 1.44, 0]); + for (const x of [-1.00, 1.00]) { + for (const z of [-0.46, 0.46]) { + addBox(group, material('accent'), [0.14, 1.38, 0.14], [x, 0.69, z]); + } + addBox(group, material('accent'), [0.12, 0.10, 1.00], [x, 0.42, 0]); + } +}); + +/** + * マイク. A round weighted base, a pole, a short boom and a body with a grille + * head, standing about as high as the character's hands. + */ +const buildMic = prop((group, material) => { + addCylinder(group, material('metal'), 0.40, 0.42, 0.10, [0, 0.05, 0], 16); + addCylinder(group, material('metal'), 0.048, 0.058, 1.35, [0, 0.775, 0], 10); + addStrut(group, material('metal'), [0, 1.42, 0], [0, 1.62, 0.28], 0.038); + + const body = addCylinder(group, material('accent'), 0.13, 0.13, 0.44, [0, 1.80, 0.36], 12); + body.rotation.x = 0.30; + const head = addSphere(group, material('metal'), 0.145, [0, 2.05, 0.42], [12, 8]); + head.scale.set(1, 0.9, 1); +}); + +/** + * 看板. A post, a frame and a blank face. The face is a named child tagged + * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it + * without hunting for the writable mesh. The face *and* its frame are grouped, + * so `applyPropFaceScale` can grow the whole board about the face's centre while + * the post and base stay put. Both are thin boxes rather than planes: the outline + * pass offsets geometry along its normals, and a zero-thickness plane gets a + * visibly doubled rim. + */ +const buildSign = prop((group, material) => { + addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]); + addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]); + + // The board is one group so the face and its frame scale together, about the + // board's own centre - which is also the face's centre. + const board = new THREE.Group(); + board.name = 'sign-board'; + board.userData.faceScaleGroup = true; + board.position.set(0, 2.21, 0); + group.add(board); + + addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]); + const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]); + face.name = 'sign-face'; + face.userData.canvasTexture = true; +}); + +/** The branches of 観葉植物: where a stem ends, which way its leaf points. */ +const PLANT_BRANCHES = [ + { end: [0.05, 1.12, 0.04], dir: [0.10, 0.96, 0.14] }, + { end: [-0.30, 0.98, -0.06], dir: [-0.52, 0.76, -0.30] }, + { end: [0.28, 1.00, 0.14], dir: [0.48, 0.74, 0.40] }, + { end: [-0.40, 0.80, -0.16], dir: [-0.86, 0.36, -0.28] }, + { end: [0.38, 0.82, 0.16], dir: [0.88, 0.32, 0.26] }, + { end: [-0.04, 1.06, -0.20], dir: [-0.14, 0.95, -0.28] }, +]; + +/** + * 観葉植物. A tapered pot with a rim, dark soil, and leaves that are just + * squashed spheres aimed along each branch's direction - the cheapest way to get + * a soft, rounded leaf that still takes a clean outline. + */ +const buildPlant = prop((group, material) => { + addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14); + tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18); + tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28); + + for (const branch of PLANT_BRANCHES) { + addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035); + + const direction = new THREE.Vector3(branch.dir[0], branch.dir[1], branch.dir[2]).normalize(); + const leaf = addSphere(group, material('leaf'), 0.30, [0, 0, 0], [8, 6]); + leaf.scale.set(0.46, 1, 0.22); + leaf.position.set(branch.end[0], branch.end[1], branch.end[2]).addScaledVector(direction, 0.26); + leaf.quaternion.setFromUnitVectors(UP, direction); + } +}); + +/** + * 段ボール箱. The body and two lid flaps, plus a tape strip when the lid is + * shut. The flaps are children of pivot groups sitting on the box's top edges, + * so `open: true` is a single rotation each. The flaps are a darker shade of the + * same wood part, which is what makes the box read as cardboard rather than a + * solid block. + */ +const buildBox = prop((group, material, options) => { + const flapsDown = options.open !== true; + addBox(group, material('wood'), [1.24, 1.00, 1.24], [0, 0.50, 0]); + + const back = new THREE.Group(); + back.position.set(0, 1.00, -0.62); + back.rotation.x = flapsDown ? 0 : -1.05; + group.add(back); + tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20); + + const front = new THREE.Group(); + front.position.set(0, 1.00, 0.62); + front.rotation.x = flapsDown ? 0 : 1.05; + group.add(front); + tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20); + + if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]); +}); + +/** + * 空いた缶詰. A short metal can, opened: a foot bead, the body, a label band and + * a mouth rim, plus the single lid peeled back on a hinge at the back of the + * mouth so it stands up behind the opening. It is deliberately + * small next to the 4-unit-tall character - something to be pointed at rather + * than stood behind - so every size here stays under a unit, and the whole can + * leans a little so it does not read as a diagram. The label is the one coloured + * part; everything else is the neutral `metal` grey, so it still reads as a tin. + */ +const buildCan = prop((group, material) => { + addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16); + addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16); + tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20); + addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16); + // A darker disc just inside the rim so the mouth reads as empty, not capped. + tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30); + + // The one lid, peeled back on a hinge at the back of the mouth so its + // underside shows. The hinge sits *on* the can's back rim and the disc is + // placed so its own rim passes through that same point (its centre is one + // radius in front of the hinge), so opening the hinge rotates the lid about + // its edge - the two rims stay joined instead of the lid floating in mid-air. + // It is twisted slightly sideways so it does not read as a knob sitting + // straight up on the can. + const lidRadius = 0.132; + const hinge = new THREE.Group(); + hinge.position.set(0, 0.42, -lidRadius); + hinge.rotation.x = -1.05; + hinge.rotation.z = 0.4; + group.add(hinge); + addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18); + + group.rotation.z = 0.05; +}); + +/** + * ベッド. A wooden frame on four legs with a mattress, a headboard at the far end + * and a pillow, sized so the character can be posed lying on it. + */ +const buildBed = prop((group, material) => { + // Sized to take the character lying down: the body is a sphere of radius ~1.6, + // so the frame is a little over 3 units across and 4 units long. + for (const x of [-1.55, 1.55]) { + for (const z of [-1.85, 1.85]) { + addBox(group, material('wood'), [0.22, 0.5, 0.22], [x, 0.25, z]); + } + } + addBox(group, material('wood'), [3.5, 0.26, 4.3], [0, 0.63, 0]); + // The headboard sits at the far (-z) end, away from the camera. + addBox(group, material('wood'), [3.5, 1.5, 0.2], [0, 1.25, -2.15]); + addBox(group, material('paper'), [3.3, 0.46, 4.1], [0, 0.99, 0]); + + // The pillow, slightly tipped up against the headboard. + const pillow = addBox(group, material('paper', 0.9), [2.2, 0.34, 0.9], [0, 1.39, -1.5]); + pillow.rotation.x = -0.08; +}); + +/** + * 布団. A mattress laid on the floor, a pillow and a quilt folded over the lower + * end. + */ +const buildFuton = prop((group, material) => { + addBox(group, material('paper'), [3.6, 0.3, 4.4], [0, 0.15, 0]); + const pillow = addBox(group, material('paper', 0.9), [2.2, 0.3, 0.9], [0, 0.45, -1.6]); + pillow.rotation.x = -0.06; + addBox(group, material('accent', 0.8), [3.6, 0.24, 2.7], [0, 0.42, 0.85]); +}); + +// --------------------------------------------------------------------------- +// The library +// --------------------------------------------------------------------------- + +/** + * `height` is the approximate height in units, measured from the base, so the UI + * can offer a sensible scale. Where a prop has options, it is the height of the + * default variant: an open cardboard box stands about a third taller again, so + * the offered scale stays sensible either way. + */ +/** 鉛筆: an oversized pencil standing on its eraser, point up. */ +const buildPencil = prop((group, material) => { + const r = 0.13; + // Eraser, ferrule and lead are each a band of the same width against the body, + // so without a tone of their own they vanish into it in 線画. + tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20); + tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24); + addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6); + addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6); + // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the + // two look joined, while its point still pokes out past the wood. + tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45); +}); + +/** コップ: a mug with a rim, a handle and a little drink inside. */ +const buildCup = prop((group, material) => { + addCylinder(group, material('paper'), 0.34, 0.30, 0.66, [0, 0.33, 0], 18); + addCylinder(group, material('paper', 0.86), 0.30, 0.30, 0.05, [0, 0.645, 0], 18); + addCylinder(group, material('wood', 0.5), 0.28, 0.28, 0.02, [0, 0.61, 0], 18); + addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0); +}); + +/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */ +const buildNotebook = prop((group, material) => { + const width = 1.5; + const depth = 2.0; + const pages = 0.18; + const cover = 0.04; + // The cover pokes out past the pages, so the notebook reads from above. + addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]); + addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]); + addBox( + group, + material('accent'), + [0.16, pages + 0.04, depth + 0.02], + [-width / 2 + 0.08, cover + pages / 2, 0], + ); +}); + +/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */ +const buildEraser = prop((group, material) => { + const width = 0.9; + const depth = 0.5; + const half = 0.15; + addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]); + tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22); +}); + +export const PROP_LIBRARY = [ + { id: 'podium', label: '演台', height: 2.2, build: buildPodium }, + { id: 'desk', label: '机', height: 1.5, build: buildDesk }, + { id: 'mic', label: 'マイク', height: 2.2, build: buildMic }, + { id: 'sign', label: '看板', height: 2.7, build: buildSign }, + { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant }, + { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox }, + { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan }, + { id: 'bed', label: 'ベッド', height: 2.0, build: buildBed }, + { id: 'futon', label: '布団', height: 0.65, build: buildFuton }, + { id: 'cup', label: 'コップ', height: 0.66, build: buildCup }, + { id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil }, + { id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook }, + { id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser }, +]; + +/** + * Where a freshly added prop goes. The character's body is a sphere of radius + * ~1.6 around x = 0, z = 0, so anything closer than about 2.2 units would push + * into it; each prop therefore sits 2.6-3.0 units out, in the quadrant that + * suits its height, turned only slightly - a prop square to the camera reads as + * a diagram, and one turned all the way shows its side. + */ +export const PROP_DEFAULTS = { + podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 }, + desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 }, + mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 }, + // `faceScale` only means something for a 看板: it grows the writing area (the + // face and its frame) together, so the post and base stay put. The panel owns + // that control. + sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1, text: '' }, + plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 }, + box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 }, + can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 }, + bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 }, + futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 }, + pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 }, + cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 }, + notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 }, + eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, scale: 1 }, +}; + +/** The palette entry each part reads, derived from the app's colours. */ +function derivePalette(colors = {}) { + const body = new THREE.Color(colors.body ?? FALLBACK_COLORS.body); + const accent = new THREE.Color(colors.accent ?? FALLBACK_COLORS.accent); + const leaf = new THREE.Color(colors.leaf ?? FALLBACK_COLORS.leaf); + return { + // Darkened rather than mixed towards black, so the surface keeps a hint of + // the theme's hue instead of turning into a grey. + wood: body.clone().multiplyScalar(0.72), + paper: body.clone().lerp(WHITE, 0.85), + accent, + leaf, + metal: new THREE.Color(METAL_GREY), + }; +} + +/** + * Builds one prop. + * + * @param {string} id one of PROP_LIBRARY's ids + * @param {object} [options] + * @param {object} [options.colors] the app's render.colors palette + * @param {string} [options.outlineColor] kept on the group for the outline pass + * @param {number} [options.scale] uniform scale for the whole prop + * @returns {THREE.Group} the prop, origin at its base centre, facing +z + */ +export function buildProp(id, options = {}) { + const spec = PROP_LIBRARY.find((entry) => entry.id === id); + if (!spec) { + const known = PROP_LIBRARY.map((entry) => entry.id).join(', '); + throw new Error(`unknown prop id ${JSON.stringify(id)}; known props: ${known}`); + } + + const group = spec.build(options); + group.name = `prop:${id}`; + group.userData.propId = id; + group.userData.outlineColor = options.outlineColor ?? null; + if (Number.isFinite(options.scale) && options.scale !== 1) { + group.scale.setScalar(options.scale); + } + return applyPropColors(group, options.colors); +} + +/** + * Recolours a built prop in place, for the colour-theme feature. Every mesh + * material this module creates carries `userData.part`, so the walk only has to + * look at those; anything else (a future prop built by hand) is left alone. + */ +export function applyPropColors(group, colors = {}) { + const palette = derivePalette(colors); + const seen = new Set(); + + group.traverse((object) => { + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material || seen.has(material)) continue; + seen.add(material); + const part = material.userData?.part; + if (!part) continue; + const shade = Number.isFinite(material.userData.shade) ? material.userData.shade : 1; + material.color.copy(palette[part] ?? palette.wood).multiplyScalar(shade); + material.color.r = clamp01(material.color.r); + material.color.g = clamp01(material.color.g); + material.color.b = clamp01(material.color.b); + } + }); + + return group; +} + +/** + * The writing surface of a prop: the mesh tagged `userData.canvasTexture`, i.e. + * the 看板's face. A prop without one returns `null`, so callers can treat every + * prop the same. + */ +export function propFace(group) { + let face = null; + group?.traverse?.((object) => { + if (!face && object.userData?.canvasTexture) face = object; + }); + return face; +} + +/** + * Scale the writing area - the 看板's face *and* the frame around it - about the + * board's own centre, so the post and base stay where they are. A prop that tags + * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the + * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the + * 看板's board group sits at the face centre, which is what makes this a scale + * about the centre rather than about the prop's base. + * + * WHY this is applied from the panel: the app rebuilds every prop from the state + * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale, + * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards. + */ +export function applyPropFaceScale(group, faceScale) { + let target = null; + group?.traverse?.((object) => { + if (!target && object.userData?.faceScaleGroup) target = object; + }); + target ??= propFace(group); + if (!target) return; + const value = Number(faceScale); + const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1; + target.scale.setScalar(scale); +} + +/** + * Paint text onto a prop's writing surface (the 看板's face), or clear it. + * + * The face's material gets a `CanvasTexture`, drawn here with a system font stack + * (no font file is needed, and Japanese falls back to whatever the device has). + * Long lines and long blocks are shrunk to fit the board, and explicit newlines + * are honoured. The material is per-prop (see `materialCache`), so the map never + * leaks to another sign. + */ +export function applyPropText(group, text) { + if (typeof document === 'undefined') return; // Node (tests): no canvas + const face = propFace(group); + const material = face?.material; + if (!material) return; + const value = typeof text === 'string' ? text : ''; + + if (!value.trim()) { + if (material.map) { + material.map.dispose(); + material.map = null; + material.needsUpdate = true; + } + material.userData.signText = ''; + return; + } + if (material.userData.signText === value) return; // nothing changed + + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 330; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#f4efe2'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#241a30'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const family = '"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif'; + const lines = value.split(/\r\n|\r|\n/); + const maxWidth = canvas.width * 0.86; + let size = 150; + const fits = () => { + ctx.font = `700 ${size}px ${family}`; + return lines.every((line) => ctx.measureText(line).width <= maxWidth) + && lines.length * size * 1.15 <= canvas.height * 0.86; + }; + while (size > 18 && !fits()) size -= 2; + ctx.font = `700 ${size}px ${family}`; + const step = size * 1.15; + const startY = canvas.height / 2 - ((lines.length - 1) * step) / 2; + lines.forEach((line, index) => ctx.fillText(line, canvas.width / 2, startY + index * step)); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + if (material.map) material.map.dispose(); + material.map = texture; + material.userData.signText = value; + material.needsUpdate = true; +} + +/** The world-space box the prop occupies; the app drops its contact shadow from it. */ +export function propBounds(group) { + group.updateMatrixWorld(true); + return new THREE.Box3().setFromObject(group); +} + +/** Releases every geometry and material in the prop and empties the group. */ +export function disposeProp(group) { + const geometries = new Set(); + const materials = new Set(); + + group.traverse((object) => { + if (object.geometry) geometries.add(object.geometry); + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) if (material) materials.add(material); + }); + + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); + group.clear(); +} diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js new file mode 100644 index 0000000..e3c8a8e --- /dev/null +++ b/public/bluebey-studio/src/rig.js @@ -0,0 +1,212 @@ +import * as THREE from 'three'; +import { TransformControls } from 'three/addons/controls/TransformControls.js'; + +/** + * Posing. Each bone is driven as a rotation *relative to its rest pose*, so + * every bone reads 0/0/0 in the original stance and a saved pose stays valid + * even if the model is re-exported with different bone orientations. + * + * `bone.quaternion = rest * delta` and `delta = rest⁻¹ * quaternion`, with the + * delta expressed as XYZ Euler angles in degrees for the UI. + */ + +const RAD = Math.PI / 180; +const DEG = 180 / Math.PI; + +/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */ +const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); + +/** + * The same angle shifted by whole turns to sit next to `reference`. On an exact + * half-turn tie the lower (smaller-magnitude) value wins, which keeps the + * canonical -180..180 reading when two are equally close. + */ +const nearestAngle = (angle, reference) => { + const turns = (reference - angle) / 360; + const low = angle + 360 * Math.floor(turns); + const high = angle + 360 * Math.ceil(turns); + return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high; +}; + +/** True when two quaternions describe the same rotation (double cover included). */ +const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6; + +export class Rig { + constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) { + this.bones = bones; + this.byName = new Map(); + for (const entry of bones) { + this.byName.set(entry.name, entry); + const alias = normalizeName(entry.name); + if (!this.byName.has(alias)) this.byName.set(alias, entry); + } + this.scene = scene; + this.domElement = domElement; + this.orbit = orbit; + this.pickTargets = pickTargets; + this.onChange = onChange; + this.selected = null; + // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler + // has several equivalent triples for one rotation, so getDelta returns the + // one closest to this. That keeps a single-axis drag smooth through the + // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped + // twin, which made the sliders and the next setDelta spin the character. + this.lastDelta = new Map(); + + this.controls = new TransformControls(camera, domElement); + this.controls.setMode('rotate'); + this.controls.setSpace('local'); + this.controls.setSize(0.8); + // A *hidden* TransformControls still grabs the pointer in three: its picker + // stays raycastable even when the helper is not drawn, so grabbing the body + // (the rings sit on the `master` bone, right where you grab) silently turned + // the character. Keep the controls disabled until the gizmo is actually + // shown, so the mouse can only move the character (see setGizmoVisible). + this.controls.enabled = false; + this.helper = this.controls.getHelper(); + this.helper.visible = false; + // Whether the user wants the gizmo at all. It starts OFF: the model is the + // point, and the rings sat on top of it until you went looking for them. + // Selecting a bone must not switch it back on by itself, so `select()` + // consults this instead of always showing the helper. + this.gizmoWanted = false; + scene.add(this.helper); + + this.controls.addEventListener('dragging-changed', (event) => { + this.orbit.enabled = !event.value; + }); + this.controls.addEventListener('objectChange', () => { + if (this.selected) this.onChange?.(this.selected); + }); + } + + /* ------------------------------------------------------------ selection */ + + select(name, { silent = false } = {}) { + if (name && !this.byName.has(name)) return; + this.selected = name ?? null; + if (name) { + this.controls.attach(this.byName.get(name).bone); + this.helper.visible = this.gizmoWanted; + } else { + this.controls.detach(); + this.helper.visible = false; + } + // Only an actually-shown gizmo may take the pointer (see the constructor). + this.controls.enabled = this.helper.visible; + if (!silent) this.onChange?.(this.selected); + } + + // Clicking the model no longer reaches for a bone: the mouse is for composing + // (see the drag handling in src/main.js), and bones are picked from the panel's + // bone list, which calls `select()` directly. So there is no pointer handler on + // the canvas here at all. + + /* ----------------------------------------------------------------- pose */ + + getDelta(name) { + const entry = this.byName.get(name); + if (!entry) return { x: 0, y: 0, z: 0 }; + const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion); + const base = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); + const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 }; + + // For one rotation an XYZ Euler has a second solution - its "flipped" twin + // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle + // stays within +/-90, so past 90 the twin is what continues the drag; offer + // both and keep whichever is closest to the last value. Near gimbal lock the + // twin no longer reproduces the rotation, so it is dropped by the check. + const candidates = [{ x: base.x, y: base.y, z: base.z }]; + const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ'); + if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) { + candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z }); + } + + let best = { x: 0, y: 0, z: 0 }; + let bestDistance = Infinity; + for (const candidate of candidates) { + const x = nearestAngle(candidate.x * DEG, reference.x); + const y = nearestAngle(candidate.y * DEG, reference.y); + const z = nearestAngle(candidate.z * DEG, reference.z); + const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y, z }; + } + } + + this.lastDelta.set(entry, best); + return { ...best }; + } + + setDelta(name, { x = 0, y = 0, z = 0 }) { + const entry = this.byName.get(name); + if (!entry) return; + const delta = new THREE.Quaternion().setFromEuler( + new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'), + ); + entry.bone.quaternion.copy(entry.rest).multiply(delta); + // Remember exactly what the UI asked for so getDelta can hand it straight + // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone + // entry, not the name, so a raw name and its normalised alias agree. + this.lastDelta.set(entry, { x, y, z }); + } + + /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */ + applyPose(pose = {}) { + // Presets and saved files use the glTF bone names (`armsupport.l`) while + // three sanitises them to `armsupportl`, so compare on the normalised form - + // otherwise every preset that touches an arm or leg is silently ignored. + const rotations = new Map(); + for (const [key, value] of Object.entries(pose.bones ?? {})) { + rotations.set(normalizeName(key), value); + } + for (const entry of this.bones) { + const value = rotations.get(normalizeName(entry.name)); + if (Array.isArray(value)) this.setDelta(entry.name, { x: value[0], y: value[1], z: value[2] }); + else this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + } + + getPose({ onlyMoved = true } = {}) { + const rotations = {}; + for (const entry of this.bones) { + const { x, y, z } = this.getDelta(entry.name); + const rounded = [round1(x), round1(y), round1(z)]; + if (onlyMoved && rounded.every((value) => value === 0)) continue; + rotations[entry.name] = rounded; + } + return { bones: rotations }; + } + + reset(name) { + if (name) { + this.setDelta(name, { x: 0, y: 0, z: 0 }); + return; + } + for (const entry of this.bones) this.setDelta(entry.name, { x: 0, y: 0, z: 0 }); + } + + /** True when every bone sits at its rest rotation. */ + isRestPose() { + return this.bones.every((entry) => { + const { x, y, z } = this.getDelta(entry.name); + return Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && Math.abs(z) < 0.01; + }); + } + + setGizmoVisible(visible) { + this.gizmoWanted = visible !== false; + this.helper.visible = this.gizmoWanted && this.selected != null; + // A hidden gizmo must not grab the pointer (see the constructor). + this.controls.enabled = this.helper.visible; + } + + dispose() { + this.controls.detach(); + this.controls.dispose(); + this.helper.removeFromParent(); + } +} + +const round1 = (value) => Math.round(value * 10) / 10; diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css new file mode 100644 index 0000000..a147b1f --- /dev/null +++ b/public/bluebey-studio/src/style.css @@ -0,0 +1,880 @@ +:root { + --accent: #7b53d1; + --accent-soft: #efe8fd; + --accent-2: #b498ff; + --text: #2b2433; + --muted: #6f6682; + --border: #e4dff0; + --card: rgba(255, 255, 255, 0.94); + --radius: 12px; + --panel-width: 344px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + height: 100vh; + /* `dvh` tracks the space mobile browser chrome leaves; the lines above are + the fallbacks for browsers that do not know it yet. */ + height: 100dvh; + overflow: hidden; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Noto Sans JP", Meiryo, sans-serif; + color: var(--text); + background: radial-gradient(120% 120% at 30% 0%, #f7f5fc 0%, #ece8f4 60%, #e4e0ee 100%); + -webkit-font-smoothing: antialiased; +} + +/* ------------------------------------------------------------------ viewport */ + +/* + * The canvas gets its size from #stage, never from its own width/height + * attributes: a canvas is a replaced element, so `width: auto` would make its + * layout follow those attributes - and since the renderer writes them, that + * becomes a resize feedback loop that leaves the picture blank. + */ +#stage { + position: fixed; + inset: 0; +} + +body:not(.panel-hidden) #stage { + right: var(--panel-width); +} + +#view { + display: block; + width: 100%; + height: 100%; + touch-action: none; + outline: none; +} + +/* ------------------------------------------------------------------- topbar */ + +#topbar { + position: fixed; + top: 14px; + left: 16px; + z-index: 20; + display: flex; + align-items: center; + gap: 14px; + max-width: calc(100vw - var(--panel-width) - 48px); +} + +#topbar h1 { + margin: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.02em; + color: #4b3a72; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.topbar-actions { display: flex; flex-wrap: wrap; gap: 6px; } + +#topbar button { + font: inherit; + font-size: 12px; + padding: 6px 11px; + border-radius: 999px; + border: 1px solid rgba(123, 83, 209, 0.22); + background: rgba(255, 255, 255, 0.86); + color: #5a4a7d; + cursor: pointer; + backdrop-filter: blur(6px); + transition: background 0.15s, transform 0.1s; +} + +#topbar button:hover { background: #fff; transform: translateY(-1px); } +#topbar button:active { transform: translateY(0); } +#topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); } + +/* Shared by the mouse hint and the touch hint (see #viewport-hint-touch). */ +.viewport-hint { + position: fixed; + left: 18px; + bottom: 14px; + z-index: 10; + margin: 0; + font-size: 11.5px; + color: #8a80a0; + pointer-events: none; +} + +/* Phones get a touch-specific hint instead of this mouse one; swapped in the + small-screen media query at the end of the viewport/panel section below. */ +#viewport-hint-touch { display: none; } + +/* AR (camera) mode's shutter: a camera-app button over the viewport. It is a DOM + layer, so it never appears in the exported PNG. */ +.ar-shutter { + position: absolute; + left: 50%; + bottom: calc(22px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 4; + width: 64px; + height: 64px; + padding: 0; + border-radius: 50%; + border: 4px solid #ffffff; + background: rgba(255, 255, 255, 0.35); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); + cursor: pointer; +} +.ar-shutter::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 50%; + background: #ffffff; +} +.ar-shutter:active::after { background: #d9d2e8; } +.ar-shutter[hidden] { display: none; } + +/* -------------------------------------------------------------------- panel */ + +#panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: var(--panel-width); + padding: 14px 14px 40px; + overflow-y: auto; + overflow-x: hidden; + background: var(--card); + border-left: 1px solid var(--border); + backdrop-filter: blur(10px); + z-index: 30; + scrollbar-width: thin; +} + +#panel::-webkit-scrollbar { width: 9px; } +#panel::-webkit-scrollbar-thumb { background: #d8d1e8; border-radius: 9px; } + +/* The phone sheet's grab handle is a real element (built in the app) so it can + carry a drag; it is hidden everywhere else. */ +.panel-grip { display: none; } + +body.panel-hidden #panel { display: none; } + +body.panel-hidden #topbar { max-width: calc(100vw - 48px); } + +@media (max-width: 780px) { + :root { --panel-width: min(88vw, 344px); } + #topbar { max-width: calc(100vw - 60px); } +} + +/* + * Phones and other short touch screens. The panel becomes a bottom sheet over + * the 3D view instead of a side column: the canvas keeps the full screen behind + * it, so opening the sheet does not resize the render (and the ≡ button, which + * already calls resizeViewport, is all the control needed). + */ +@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) { + /* Full-screen canvas: the sheet floats over it, so its height never feeds + back into the renderer. */ + #stage, + body:not(.panel-hidden) #stage { + inset: 0; + right: 0; + } + + #topbar { + top: calc(8px + env(safe-area-inset-top)); + left: calc(10px + env(safe-area-inset-left)); + right: calc(10px + env(safe-area-inset-right)); + max-width: none; + gap: 8px; + } + + /* Let the title give way first (it can ellipsize); the buttons keep their + labels and stay on one line. */ + #topbar h1 { + font-size: 13px; + white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .topbar-actions { + flex: 1; + flex-wrap: nowrap; + justify-content: flex-end; + gap: 6px; + } + + /* Comfortable tap targets (>= 40px) in a row that still fits 360px. */ + #topbar button { + font-size: 11px; + padding: 9px 10px; + min-height: 40px; + } + + #btn-panel { min-width: 44px; } + + #viewport-hint { display: none; } + #viewport-hint-touch { + display: block; + bottom: calc(14px + env(safe-area-inset-bottom)); + } + + #panel { + top: auto; + left: 0; + right: 0; + bottom: 0; + width: auto; + max-height: 65dvh; + padding: 0 14px calc(20px + env(safe-area-inset-bottom)); + border-left: 0; + border-top: 1px solid var(--border); + border-radius: 18px 18px 0 0; + box-shadow: 0 -6px 24px rgba(40, 20, 80, 0.18); + } + + /* The grab handle: sticky at the top of the scroll area, so it stays grabbable + however far the menu is scrolled, and `touch-action: none` so a drag on it + resizes the sheet instead of scrolling it. */ + .panel-grip { + display: flex; + align-items: center; + justify-content: center; + position: sticky; + top: 0; + z-index: 6; + height: 24px; + margin: 0 -14px; + background: var(--card); + touch-action: none; + cursor: grab; + } + .panel-grip::after { + content: ""; + width: 44px; + height: 5px; + border-radius: 999px; + background: #cfc6e4; + } + .panel-grip:active { cursor: grabbing; } + + body.panel-hidden #topbar { max-width: none; } + + /* The grab handle is sticky at the top of the sheet, so the action bar and the + tabs start below it. */ + .panel-topbar { top: 24px; } + .tabs { top: 58px; } +} + +/* ----------------------------------------------------------------- sections */ + +.tabs { + position: sticky; + /* Sits just below the sticky .panel-topbar (which is 34px tall). */ + top: 34px; + z-index: 3; + display: flex; + align-items: flex-end; + gap: 3px; + padding: 2px 0 0; + margin: -2px 0 8px; + border-bottom: 2px solid var(--border); + background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0)); +} + +/* Each tab is a rounded-top tab sitting on the strip's baseline; only the icon + shows, and the active one fills with the accent and covers the baseline. */ +.tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font: inherit; + padding: 9px 2px 7px; + border: 1px solid var(--border); + border-bottom: none; + border-radius: 9px 9px 0 0; + background: var(--accent-soft); + color: var(--muted); + cursor: pointer; + white-space: nowrap; +} + +.tab svg { display: block; width: 20px; height: 20px; } +.tab:hover { color: var(--accent-2); border-color: var(--accent-2); } +.tab.active { + margin-bottom: -2px; + padding-bottom: 9px; + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.tab-panel[hidden] { display: none; } + +.details { + border: 1px dashed var(--border); + border-radius: 9px; + padding: 0 9px; + background: #fdfcff; +} +.details > summary { + font-size: 11px; + color: var(--muted); + cursor: pointer; + padding: 6px 0; + list-style-position: inside; +} +.details[open] > summary { color: var(--accent); } +.details-body { display: grid; gap: 8px; padding: 2px 0 9px; } + +.sec { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 10px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(53, 38, 90, 0.04); +} + +.sec-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 10px 12px; + margin: 0; + border: 0; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 700; + color: #4b3a72; + text-align: left; + cursor: pointer; +} + +.sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; } +.sec.closed .chev { transform: rotate(-90deg); } +.sec-title { display: flex; align-items: center; gap: 8px; min-width: 0; } +.sec-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); } +.sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; } +.sec.closed .sec-body { display: none; } + +/* -------------------------------------------------------------------- rows */ + +.row { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; align-items: center; } +.row.wide { grid-template-columns: 1fr; gap: 5px; } +.row > .label { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.control { display: flex; align-items: center; gap: 7px; min-width: 0; } +.hint { font-size: 11px; line-height: 1.55; color: var(--muted); } +.hint b { color: #5a4a7d; } +.row.is-off .control, .row.is-off > .label { opacity: 0.4; } +.row.is-off .control input { cursor: default; } +.subhead { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + color: #8d80ab; + text-transform: uppercase; + margin-top: 3px; +} +.eye-group { + border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 8px; + display: grid; + gap: 8px; +} +.eye-group-head { + font-size: 11.5px; + font-weight: 700; + color: #6b5c92; + display: flex; + align-items: center; + gap: 6px; +} +.eye-group-head .grp-icon { color: #8a7ab8; } +.eye-group-body { display: grid; gap: 8px; } +.select { + flex: 1; + min-width: 0; + font: inherit; + font-size: 11.5px; + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); +} +.stack { display: grid; gap: 6px; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } + +input[type="range"] { + flex: 1; + min-width: 0; + height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +.num { + flex: 0 0 auto; + min-width: 40px; + font-size: 11px; + color: #5a4a7d; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.seg { display: inline-flex; flex-wrap: wrap; padding: 2px; gap: 2px; background: #f2eff9; border-radius: 999px; } +.seg button { + font: inherit; + font-size: 11px; + padding: 4px 9px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; + white-space: nowrap; +} +.seg button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(53, 38, 90, 0.16); } + +.btn { + font: inherit; + font-size: 11.5px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + cursor: pointer; + white-space: nowrap; +} +.btn:hover { border-color: var(--accent-2); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover { background: #6b45c2; } +.btn:disabled { opacity: 0.45; cursor: default; } +.buttons { display: flex; flex-wrap: wrap; gap: 6px; } + +.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #4b3a72; cursor: pointer; } +.chk input { accent-color: var(--accent); width: 14px; height: 14px; } + +.pad-wrap { display: flex; gap: 10px; align-items: center; } +.pad { + position: relative; + flex: 0 0 auto; + width: 118px; + height: 118px; + border-radius: 14px; + border: 1px solid var(--border); + background: + linear-gradient(#f0ecfa, #f0ecfa) 50% 0 / 1px 100% no-repeat, + linear-gradient(#f0ecfa, #f0ecfa) 0 50% / 100% 1px no-repeat, + #faf8ff; + cursor: crosshair; + touch-action: none; +} +.pad-dot { + position: absolute; + width: 13px; + height: 13px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35); + pointer-events: none; +} +.pad-eye { + position: absolute; + inset: 0; + margin: auto; + width: 46px; + height: 46px; + border-radius: 50%; + border: 1px dashed #d9d1ec; + pointer-events: none; +} + +/* Tail picker: a 3x3 grid where a dot's place is the direction it means, so + the whole pad fits next to an 82px label even in a narrow panel. */ +.tail-pad { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + place-items: center; + flex: 0 0 auto; + width: 128px; + height: 128px; + padding: 10px; + border-radius: 14px; + border: 1px solid var(--border); + background: #faf8ff; +} +.tail-dot { + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 50%; + background: #ded6f0; + cursor: pointer; + transition: background 0.12s, transform 0.1s; +} +.tail-dot:hover { background: var(--accent-2); transform: scale(1.1); } +.tail-dot.active { + background: var(--accent); + box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35); +} + +.swatches { display: flex; gap: 5px; flex-wrap: wrap; } +.swatch { width: 17px; height: 17px; border-radius: 5px; border: 1px solid rgba(43, 36, 51, 0.18); cursor: pointer; } +input[type="color"] { + width: 30px; + height: 22px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.bone-list { + max-height: 168px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 9px; + padding: 4px; + display: grid; + gap: 2px; + background: #fcfbff; +} +.bone-item { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 8px; + border: 0; + border-radius: 6px; + background: transparent; + font: inherit; + font-size: 11.5px; + color: var(--text); + text-align: left; + cursor: pointer; +} +.bone-item:hover { background: #f4f0fd; } +.bone-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.bone-item small { color: var(--muted); font-size: 10.5px; } + +/* --------------------------------------------------------------------- 擬音 */ + +/* + * The stamps are painted onto the caption canvas, which is `pointer-events: + * none`, so each one gets its own transparent drag box on top - the same trick + * the bubbles use. The box follows the stamp's rotation. Its z-index sits below + * the caption handles (3), because the bubbles are drawn over the stamps and so + * must stay grabbable where the two overlap. + */ +.gion-handle { + position: absolute; + display: none; + cursor: move; + touch-action: none; + pointer-events: auto; + z-index: 2; + border: 1px dashed transparent; + border-radius: 4px; +} +.gion-handle.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(123, 83, 209, 0.18); +} + +.gion-list { display: grid; gap: 10px; } +.gion-item-head { display: flex; align-items: center; gap: 8px; } +.gion-thumb { + flex: 0 0 auto; + width: 84px; + height: 56px; + background: #f3f0fa; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} +.gion-item.active .gion-thumb { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } + +/* The picker covers the whole window, so even a big sheet has room to draw on. */ +.gion-picker { + position: fixed; + inset: 0; + z-index: 95; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: rgba(24, 16, 44, 0.86); + backdrop-filter: blur(4px); +} +.gion-picker-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + color: #fff; +} +.gion-picker-title { font-size: 13.5px; font-weight: 700; } +.gion-picker-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.gion-picker-actions .hint { color: #cfc6e6; } +.gion-picker-board { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border-radius: 12px; + /* A checkerboard reads as "transparent", so a PNG sheet's holes are obvious. */ + background: + linear-gradient(45deg, #2b2344 25%, transparent 25%) 0 0 / 22px 22px, + linear-gradient(-45deg, #2b2344 25%, transparent 25%) 0 11px / 22px 22px, + linear-gradient(45deg, transparent 75%, #2b2344 75%) 11px -11px / 22px 22px, + linear-gradient(-45deg, transparent 75%, #2b2344 75%) -11px 0 / 22px 22px, + #241d3a; + cursor: crosshair; + touch-action: none; +} +.gion-picker-sheet { + position: absolute; + display: block; + user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} +.gion-picker-marquee { + position: absolute; + display: none; + border: 1.5px solid #fff; + background: rgba(180, 152, 255, 0.28); + box-shadow: 0 0 0 9999px rgba(20, 12, 40, 0.42); + pointer-events: none; +} + +/* -------------------------------------------------------------------- toast */ + +#notice { + position: fixed; + left: 50%; + top: 14px; + transform: translateX(-50%); + z-index: 95; + max-width: min(560px, 90vw); + padding: 10px 16px; + border-radius: 12px; + background: #fff4e5; + border: 1px solid #f0c48a; + color: #7a4a12; + font-size: 12px; + line-height: 1.65; + white-space: pre-line; + box-shadow: 0 6px 20px rgba(43, 36, 51, 0.15); +} + +#notice[hidden] { display: none; } + +/* ------------------------------------------------ 起動時のご利用について */ +#start-notice { + position: fixed; + inset: 0; + z-index: 96; + display: grid; + place-items: center; + padding: 24px; + background: rgba(43, 36, 51, 0.5); + backdrop-filter: blur(3px); +} +#start-notice[hidden] { display: none; } + +.start-sheet { + width: min(560px, 100%); + max-height: 86vh; + overflow: auto; + padding: 22px 26px; + border-radius: 16px; + background: #fff; + color: #2b2433; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); + font-size: 13.5px; + line-height: 1.75; +} +.start-sheet h2 { margin: 0 0 12px; font-size: 17px; } +.start-sheet p { margin: 0 0 10px; } +.start-sheet ul { margin: 0 0 12px; padding-left: 1.3em; } +.start-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; +} +.start-skip { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #6b5d7a; } +.start-ok { + border: 0; + border-radius: 8px; + padding: 9px 22px; + background: #6b4ea8; + color: #fff; + font-size: 13.5px; + cursor: pointer; +} + +#toast { + position: fixed; + left: 50%; + bottom: 26px; + transform: translate(-50%, 14px); + z-index: 70; + padding: 8px 16px; + border-radius: 999px; + background: rgba(43, 36, 51, 0.9); + color: #fff; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; +} +#toast.show { opacity: 1; transform: translate(-50%, 0); } + +/* ------------------------------------------------------------------ loading */ + +#loading { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + background: rgba(244, 242, 250, 0.94); + transition: opacity 0.35s; +} +#loading.done { opacity: 0; pointer-events: none; } + +.loading-box { text-align: center; max-width: 520px; padding: 24px; } +.loading-spinner { + width: 34px; + height: 34px; + margin: 0 auto 14px; + border-radius: 50%; + border: 3px solid #e0d8f2; + border-top-color: var(--accent); + animation: spin 0.9s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +#loading-text { margin: 0; font-size: 13px; color: #5a4a7d; } +#loading-error { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.7; + color: #b3261e; + white-space: pre-wrap; + text-align: left; +} + +/* --------------------------------------------------------------------- help */ + +#help { + position: fixed; + inset: 0; + z-index: 90; + display: grid; + place-items: center; + padding: 24px; + background: rgba(43, 36, 51, 0.42); + backdrop-filter: blur(3px); +} +#help[hidden] { display: none; } + +.help-sheet { + position: relative; + width: min(760px, 100%); + max-height: min(84vh, 900px); + overflow-y: auto; + padding: 26px 30px 32px; + background: #fff; + border-radius: 18px; + box-shadow: 0 24px 60px rgba(20, 12, 40, 0.3); + font-size: 13px; + line-height: 1.75; +} +.help-sheet h2 { margin: 0 0 14px; font-size: 17px; color: #4b3a72; } +.help-sheet h3 { margin: 20px 0 6px; font-size: 13.5px; color: var(--accent); } +.help-sheet ul { margin: 0; padding-left: 1.25em; } +.help-sheet li { margin: 3px 0; } +.help-sheet code { background: #f3f0fa; padding: 1px 5px; border-radius: 5px; font-size: 12px; } +.help-sheet .keys { list-style: none; padding: 0; display: grid; gap: 4px; } +.help-close { + position: absolute; + top: 12px; + right: 14px; + width: 32px; + height: 32px; + border: 0; + border-radius: 50%; + background: #f3f0fa; + color: #5a4a7d; + font-size: 19px; + line-height: 1; + cursor: pointer; +} +.help-close:hover { background: #e8e2f7; } + +kbd { + display: inline-block; + min-width: 20px; + padding: 1px 6px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 5px; + background: #faf9fe; + font: inherit; + font-size: 11px; + text-align: center; +} + +/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム. + Sticky, so it (and the tabs below it) stay on screen while the panel scrolls. */ +.panel-topbar { + position: sticky; + top: 0; + z-index: 5; + background: #fdfcff; + display: flex; + gap: 4px; + justify-content: flex-end; + padding: 4px 8px 2px; +} +.panel-topbar .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: #2b2433; + cursor: pointer; +} +.panel-topbar .icon-btn:hover { background: #f4f0fb; } +.panel-topbar .icon-btn:active { transform: translateY(1px); } diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js new file mode 100644 index 0000000..2e4f62d --- /dev/null +++ b/public/bluebey-studio/src/styles.js @@ -0,0 +1,457 @@ +import * as THREE from 'three'; + +/** + * Render styles for the body meshes, plus the outline pass. + * + * real the PBR materials exactly as authored in the GLB + * flat two-tone toon shading, the classic mascot look + * lineart white paper + lines, the face artwork drawn as strokes + * outline nothing but the lines, kept invisible via colorWrite = false so the + * result composites on top of anything (used by the SVG export) + * + * The lines come from two systems, each used where it is strong (see + * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin + * overlapping leaves are handed to the screen-space pass instead (outline.js), + * which the caller arranges with `setHullHidden`. + */ + +export const STYLE_DEFS = [ + { value: 'real', label: 'リアル' }, + { value: 'flat', label: 'フラット' }, + { value: 'lineart', label: '線画' }, +]; + +// 'outline' is still used internally by the SVG export (lines on a transparent +// background), it is just no longer offered as a viewing style. +const LINE_STYLES = new Set(['lineart', 'outline']); + +export const isLineStyle = (value) => LINE_STYLES.has(value); + +/** Parts that need no outline at all. */ +const OUTLINE_SKIP = new Set(); + +/** + * Parts whose outline is only useful in the line-art styles. + * + * Empty now: the leaves used to be listed here, but the line styles get their + * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the + * hull is the wrong tool for them in either direction. + */ +const OUTLINE_LINE_ONLY = new Set(); + +/** + * Parts that should be outlined in the SHADED styles only. + * + * The inverted hull draws an outline by expanding a copy of the mesh along its + * normals and rendering the back faces. That works on one big round body, but + * the leaf skirt is a dozen thin shells lying on top of each other: each hull's + * far side shows through its neighbours, which is what produced the faint + * overlapping hairlines. In the SVG pass the leaves get their line from their + * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped + * there and kept for the cartoon styles, where it does read as an edge. + */ +const OUTLINE_SHADED_ONLY = new Set(['vein']); + +/** + * Per-part outline thickness, as a fraction of the global width. + * + * The hull is offset by a fixed distance in world units, so the right factor + * depends on how fine a part's mesh is compared with that offset: + * + * - the nose is only ~0.24 units across, and the offset that looks right on the + * body reads as a thick ring on it, hence the small factor. In the line-art + * styles it is the other way round (a hairline looks like dust on paper), so + * the nose gets a fuller line there - see OUTLINE_SCALE_LINE. + * - the leaves need a *larger* factor, which only shows up in the "hull only" + * style: the 13 blades overlap and interpenetrate, so each one's expanded + * shell cuts across its neighbours and the line breaks up. A thicker hull + * merges those scratches back into a band. Making the leaves thicker does NOT + * fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the + * blocker, not the leaf's own thickness. + */ +const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 }; + +/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */ +const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 }; + +/** + * Parts whose outline should only survive where the part itself sticks out past + * the body. Their hull is drawn first with depth testing off, so everything the + * body covers paints over it: the nose then only gets a line in the views where + * it actually pokes out of the silhouette, instead of a ring around it - which is + * what the original artwork does, the nose reads by its own colour there. + * + * In the line-art styles there is no colour to read it by (the nose is paper on + * paper), so this is switched off and the nose goes to the screen-space pass + * instead - see `screenParts` in main.js. + */ +const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']); + +export class Styles { + constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) { + this.meshes = meshes; + this.paper = paper; + this.style = 'real'; + this.outlineEnabled = true; + /** + * Source meshes whose hull outline must stay off, because another method + * (the screen-space pass) is drawing them. Empty = every hull is available. + * Only the leaves ever land here: see MODEL-GUIDE.md §5. + */ + this.hullHidden = new Set(); + + this.originals = new Map(); + for (const mesh of meshes) { + this.originals.set(mesh, mesh.material); + mesh.castShadow = true; + mesh.receiveShadow = false; + } + + this.gradientMap = makeGradientMap(); + this.toonMaterials = new Map(); + this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false }); + // A hat is not flat: in a line drawing a white hat on a white head merges + // into one shape, so hats get a light tone instead of paper (see addMesh). + // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it + // steps from the paper towards the ink is cached by strength in + // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a + // pencil's lead) can ask for a darker fill and its seam then reads. + this.toneMaterials = new Map(); + /** Runtime meshes that want a tone fill rather than paper in 線画. */ + this.toneMeshes = new Map(); + this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true }); + + this.outlineWidth = outlineWidth; + this.outlineColor = outlineColor; + /** scale -> { material, uniform }, so the width slider updates all of them. */ + this.hullMaterials = new Map(); + this.outlineMeshes = this.createOutlines(); + this.setStyle('real'); + } + + /** + * The part kind, taken from the material the GLB shipped with. + * + * It must be read from `originals`, not from `mesh.material`: `setStyle` + * replaces the materials, and the replacements (paper, ink, toon) carry no + * name, so after the first swap every part would look like an unknown one and + * the leaf/nose special cases would silently stop applying. + */ + kindOf(mesh) { + return (this.originals.get(mesh)?.name ?? '').toLowerCase(); + } + + hullMaterialFor(scale, overlay = false) { + const key = `${scale}:${overlay ? 'overlay' : 'solid'}`; + let entry = this.hullMaterials.get(key); + if (!entry) { + const uniform = { value: this.outlineWidth * scale }; + const material = makeHullMaterial(this.outlineColor, uniform); + if (overlay) { + // Depth-*tested*, but writing none: the hull is drawn before the body + // (renderOrder -1), so the body still paints over its interior and the + // nose only gets a line where it pokes out of the silhouette. Testing + // (rather than ignoring) depth is what lets the 見えない壁 hide it too - + // with the test off, a nose buried in the wall left a filled blob, + // because nothing was left to paint over the hull's inside. + material.depthTest = true; + material.depthWrite = false; + } + entry = { uniform, material, scale }; + this.hullMaterials.set(key, entry); + } + return entry.material; + } + + /** + * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far + * it steps from the paper towards the ink. `userData.tone` on the mesh (set by + * the builder) carries the amount; the hat default comes from `options.tone`. + */ + toneMaterialFor(strength = DEFAULT_TONE) { + const key = String(strength); + let material = this.toneMaterials.get(key); + if (!material) { + material = new THREE.MeshBasicMaterial({ + color: toneOf(this.paper, strength), + toneMapped: false, + }); + material.userData.tone = strength; + this.toneMaterials.set(key, material); + } + return material; + } + + createOutlines() { + const hulls = []; + for (const mesh of this.meshes) { + const kind = this.kindOf(mesh); + if (OUTLINE_SKIP.has(kind)) continue; + const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); + const material = this.hullMaterialFor(this.scaleFor(kind), overlay); + const hull = mesh.isSkinnedMesh + ? new THREE.SkinnedMesh(mesh.geometry, material) + : new THREE.Mesh(mesh.geometry, material); + hull.name = `${mesh.name || 'mesh'}:outline`; + hull.position.copy(mesh.position); + hull.quaternion.copy(mesh.quaternion); + hull.scale.copy(mesh.scale); + hull.frustumCulled = false; + hull.castShadow = false; + hull.receiveShadow = false; + if (overlay) hull.renderOrder = -1; + hull.userData.kind = kind; + hull.userData.source = mesh; + hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind); + hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); + if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix); + mesh.parent.add(hull); + hulls.push(hull); + } + return hulls; + } + + /** + * Register a mesh that is built at runtime (a hat), so it follows the render + * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it. + * + * `options.tone` gives the mesh the light tone fill in 線画 instead of paper, + * for parts whose shape would otherwise merge with the body (the hats). A + * number instead of `true` picks how far to step towards the ink, and the + * builder can override it per mesh with `userData.tone` - which is how a part + * draws its seam against a same-coloured neighbour. + * + * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh + * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット. + */ + addMesh(mesh, options = {}) { + if (!mesh || this.originals.has(mesh)) return; + this.originals.set(mesh, mesh.material); + this.meshes.push(mesh); + const tone = mesh.userData.tone ?? options.tone; + if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone)); + const kind = this.kindOf(mesh); + if (!OUTLINE_SKIP.has(kind)) { + const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); + const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay)); + hull.name = `${mesh.name || 'mesh'}:outline`; + hull.position.copy(mesh.position); + hull.quaternion.copy(mesh.quaternion); + hull.scale.copy(mesh.scale); + hull.frustumCulled = false; + hull.castShadow = false; + hull.receiveShadow = false; + if (overlay) hull.renderOrder = -1; + hull.userData.kind = kind; + hull.userData.source = mesh; + hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind); + hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); + mesh.parent?.add(hull); + this.outlineMeshes.push(hull); + mesh.userData.hull = hull; + } + this.setStyle(this.style); + } + + /** Take a runtime mesh (and its outline hull) back out. */ + removeMesh(mesh) { + if (!mesh || !this.originals.has(mesh)) return; + const original = this.originals.get(mesh); + const hull = mesh.userData.hull; + if (hull) { + hull.removeFromParent(); + const i = this.outlineMeshes.indexOf(hull); + if (i >= 0) this.outlineMeshes.splice(i, 1); + delete mesh.userData.hull; + } + // Put the mesh's own material back before dropping it: while a line style is + // on it is wearing the *shared* paper/tone material, and the caller is about + // to dispose it - which would blank every part using it. + if (original) mesh.material = original; + this.originals.delete(mesh); + this.toneMeshes.delete(mesh); + const j = this.meshes.indexOf(mesh); + if (j >= 0) this.meshes.splice(j, 1); + } + + /** The outline factor for a part in the current style (line art wants more). */ + scaleFor(kind) { + const line = LINE_STYLES.has(this.style); + const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE; + return table[kind] ?? OUTLINE_SCALE[kind] ?? 1; + } + + setStyle(value) { + this.style = value; + const line = LINE_STYLES.has(value); + for (const mesh of this.meshes) { + const original = this.originals.get(mesh); + let material; + if (line) { + // Paper on paper in `lineart`; a light tone for hats, which would + // otherwise merge into the head; invisible in `outline`, which is the pass + // the SVG trace reads. Either way every edge - the body, the nose, and + // the leaves - is drawn by the screen-space outline, so no part needs a + // material trick of its own any more. + material = value === 'lineart' + ? (this.toneMeshes.get(mesh) ?? this.paperMaterial) + : this.invisibleMaterial; + } else if (value === 'flat') { + material = this.toonFor(original); + } else { + material = original; + } + if (mesh.material !== material) mesh.material = material; + } + + // The nose only keeps a line where it pokes out of the body in the shaded + // styles; in a line drawing it is handed to the screen-space pass instead + // (see `screenParts` in main.js), because a hull cannot ring a bump that sits + // flush on the surface it is drawn on - the expanded shell lands *inside* the + // body and loses the depth test. + for (const hull of this.outlineMeshes) { + const kind = hull.userData.kind; + const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line; + hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly); + hull.renderOrder = silhouetteOnly ? -1 : 0; + } + + // Lines are the whole point of the line-art styles. + this.setOutlineVisible(line ? true : this.outlineEnabled); + } + + /** + * Hand a set of parts to the screen-space outline, or take them back. + * + * The hull and the screen-space pass each have a shape they cannot draw: a hull + * cannot outline a thin closed shell (the leaves), and the screen-space pass + * draws a stepped line because it works on the pixel grid. So the leaves go to + * the screen-space pass and everything else keeps its hull, which is drawn from + * the geometry and therefore comes out smooth. + */ + setHullHidden(meshes) { + this.hullHidden = new Set(meshes ?? []); + this.setStyle(this.style); + } + + toonFor(original) { + let material = this.toonMaterials.get(original); + if (!material) { + material = new THREE.MeshToonMaterial({ + color: original.color ? original.color.clone() : new THREE.Color(0xffffff), + map: original.map ?? null, + vertexColors: original.vertexColors === true, + gradientMap: this.gradientMap, + side: original.side, + transparent: original.transparent === true, + alphaTest: original.alphaTest ?? 0, + depthWrite: original.depthWrite !== false, + }); + this.toonMaterials.set(original, material); + } + return material; + } + + setPaper(color) { + this.paper = color; + this.paperMaterial.color.set(color); + for (const material of this.toneMaterials.values()) { + material.color.copy(toneOf(color, material.userData.tone)); + } + } + + setOutlineVisible(visible) { + const lineMode = LINE_STYLES.has(this.style); + for (const hull of this.outlineMeshes) { + const lineOnly = hull.userData.lineOnly === true; + const shadedOnly = hull.userData.shadedOnly === true; + hull.visible = visible + && (!lineOnly || lineMode) + && !(shadedOnly && lineMode) + && !this.hullHidden.has(hull.userData.source); + } + } + + setOutlineEnabled(enabled) { + this.outlineEnabled = enabled; + if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled); + } + + setOutlineWidth(width) { + this.outlineWidth = width; + for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale; + } + + setOutlineColor(color) { + this.outlineColor = color; + for (const entry of this.hullMaterials.values()) entry.material.color.set(color); + } + + /** + * The toon materials are cached copies of the originals, so a colour theme that + * edits `original.color` has to be copied across or the flat style keeps + * showing the old colour. See src/look.js. + */ + refreshColors() { + for (const [mesh, original] of this.originals) { + const toon = this.toonMaterials.get(original); + if (toon && original.color) toon.color.copy(original.color); + // The outline hull is a colour of its own, so it is left alone. + void mesh; + } + return this; + } + + dispose() { + for (const hull of this.outlineMeshes) { + hull.removeFromParent(); + hull.skeleton = null; + } + this.outlineMeshes = []; + for (const entry of this.hullMaterials.values()) entry.material.dispose(); + this.hullMaterials.clear(); + this.paperMaterial.dispose(); + for (const material of this.toneMaterials.values()) material.dispose(); + this.toneMaterials.clear(); + this.invisibleMaterial.dispose(); + this.gradientMap.dispose(); + for (const material of this.toonMaterials.values()) material.dispose(); + this.toonMaterials.clear(); + } +} + +/** How far the hat default steps from the paper towards the ink in 線画. */ +const DEFAULT_TONE = 0.14; + +/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */ +function toneOf(paper, strength = DEFAULT_TONE) { + return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength); +} + +/** A 3-step ramp gives crisper cartoon bands than the shader's default. */ +function makeGradientMap() { + const steps = new Uint8Array([90, 165, 255]); + const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +} + +function makeHullMaterial(color, uniform) { + const material = new THREE.MeshBasicMaterial({ + color, + side: THREE.BackSide, + toneMapped: false, + }); + material.onBeforeCompile = (shader) => { + shader.uniforms.uOutline = uniform; + shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace( + '#include <begin_vertex>', + '#include <begin_vertex>\n\ttransformed += normal * uOutline;', + ); + }; + material.customProgramCacheKey = () => 'bluebey-outline-hull'; + return material; +} diff --git a/public/bluebey-studio/src/textOutlines.js b/public/bluebey-studio/src/textOutlines.js new file mode 100644 index 0000000..334a92d --- /dev/null +++ b/public/bluebey-studio/src/textOutlines.js @@ -0,0 +1,432 @@ +import opentype from 'opentype.js'; + +/** + * Caption text as outlines. + * + * A caption drawn with <text> changes shape in every viewer, because the glyphs + * come from whatever font that viewer happens to have. The studio ships one + * subset font (M PLUS Rounded 1c) and this module turns the caption into plain + * SVG paths instead, so an exported SVG looks the same everywhere and stays + * editable as vector art. + * + * The module runs unchanged in the browser and in Node: it touches no DOM and no + * Node built-in at module scope, so the tests can parse the font straight from + * disk. `loadFont` is the only asynchronous export; everything else is a pure + * function of the font, the text and the options. + * + * Widths are summed one character at a time, without kerning between them. That + * costs a fraction of a pixel on Latin pairs but keeps measuring and wrapping in + * exact agreement, which matters more for a short caption. + */ + +/** Families tried in order when a caption falls back to live <text>. */ +const FALLBACK_FAMILIES = [ + 'M PLUS Rounded 1c', + 'Hiragino Maru Gothic ProN', + 'Yu Gothic', + 'Meiryo', + 'sans-serif', +]; + +/** CSS keywords that must stay unquoted inside a font stack. */ +const GENERIC_FAMILIES = new Set([ + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', + 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', + 'fangsong', +]); + +/** + * Closing marks and brackets that should not start a line. + * + * A full kinsoku table needs per-font metrics; this short list covers the marks + * a caption actually uses, and dragging the preceding character down is enough + * to fix the rest. + */ +const CLOSING_PUNCTUATION = new Set([ + '、', '。', ',', '.', ':', ';', '!', '?', + ')', ']', '}', '〕', '〉', '》', '」', '』', '】', '〗', '〙', '〟', + '”', '’', '⦆', '»', +]); + +const SPACE = /\s/; +const LINE_BREAK = /\r\n|\r|\n/; + +const DEFAULT_FONT_SIZE = 16; +const DEFAULT_LINE_HEIGHT = 1.4; + +/** + * Parsed fonts, keyed by URL. The stored value is the in-flight promise, so two + * callers asking for the same URL share one request instead of loading twice. + */ +const fontCache = new Map(); + +/** Family name of the most recent font from `loadFont`, read by `captionFontStack`. */ +let loadedFamily = null; + +/** + * Load a font and remember it under `url`. + * + * Resolves to an `opentype.Font`; rejects if the font cannot be fetched or + * parsed, and does not cache that failure, so a later call can retry. + * + * @param {string} url URL (browser) or file path (Node) of the font + * @returns {Promise<import('opentype.js').Font>} + */ +export async function loadFont(url) { + if (!url) throw new Error('loadFont: a font URL is required'); + if (fontCache.has(url)) return fontCache.get(url); + + const pending = opentype.load(url).then((font) => { + loadedFamily = familyNameOf(font) ?? loadedFamily; + return font; + }); + // A rejected promise left in the cache would make every retry fail forever. + pending.catch(() => fontCache.delete(url)); + fontCache.set(url, pending); + return pending; +} + +/** + * Synchronous twin of `loadFont` for tests and offline use. + * + * @param {ArrayBuffer} arrayBuffer font bytes; a typed-array view is accepted too + * @returns {import('opentype.js').Font} + */ +export function parseFont(arrayBuffer) { + // `opentype.parse` needs a real ArrayBuffer, so unwrap a view (for example a + // Node Buffer) before handing it over. + const view = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : null; + const buffer = view + ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) + : arrayBuffer; + return opentype.parse(buffer); +} + +/** + * Does the font really cover every character of `text`? + * + * Callers use this to decide between outlines and live text: a single missing + * glyph means the caption would come out with a hole in it, so the answer is + * then `false`. Newlines carry no glyph and are ignored. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @returns {boolean} + */ +export function hasGlyphs(font, text) { + if (!font || typeof text !== 'string') return false; + for (const ch of text) { + if (ch === '\n' || ch === '\r') continue; + if (font.charToGlyphIndex(ch) === 0) return false; // 0 is .notdef + } + return true; +} + +/** + * Size one string in pixels. + * + * The width is the widest line, so the same call works for a single line and for + * text that already contains breaks. `ascent` is above the baseline and + * `descent` below it, both in the font's own sign convention. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @param {number} [fontSize] + * @returns {{width: number, ascent: number, descent: number}} + */ +export function measureText(font, text, fontSize = DEFAULT_FONT_SIZE) { + const size = Number.isFinite(fontSize) ? fontSize : DEFAULT_FONT_SIZE; + const scale = size / (font.unitsPerEm || 1000); + + let width = 0; + for (const line of String(text ?? '').split(LINE_BREAK)) { + width = Math.max(width, measureLine(font, line, size)); + } + return { width, ascent: font.ascender * scale, descent: font.descender * scale }; +} + +/** + * Wrap `text` into lines and report the block's size. + * + * Rules, in the order they apply: + * - an explicit `\n` always ends a line; + * - a Latin word breaks only at a space, never in the middle; + * - CJK characters break anywhere, one break opportunity per character; + * - a piece that is too wide on its own still gets a line (nothing is dropped); + * - a line is not allowed to start with closing punctuation when dragging the + * previous character down would avoid it. + * + * @param {import('opentype.js').Font} font + * @param {string} text + * @param {object} [options] + * @param {number} [options.fontSize=16] + * @param {number|null} [options.maxWidth=0] 0/null/undefined means "no wrapping" + * @param {number} [options.lineHeight=1.4] multiplier of `fontSize` + * @param {'left'|'center'|'right'} [options.align='left'] + * @returns {{ + * lines: Array<{text: string, width: number}>, + * width: number, height: number, lineHeight: number, + * fontSize: number, align: string, + * }} + */ +export function layoutText(font, text, options = {}) { + const opts = options ?? {}; + const fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : DEFAULT_FONT_SIZE; + const lineHeight = Number.isFinite(opts.lineHeight) ? opts.lineHeight : DEFAULT_LINE_HEIGHT; + const align = opts.align === 'center' || opts.align === 'right' ? opts.align : 'left'; + const maxWidth = opts.maxWidth; + const wrapWidth = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity; + + const lines = []; + for (const paragraph of String(text ?? '').split(LINE_BREAK)) { + const atoms = tokenise(font, paragraph, fontSize); + for (const wrapped of wrapAtoms(atoms, wrapWidth)) { + lines.push({ + text: wrapped.map((atom) => atom.text).join(''), + width: wrapped.reduce((sum, atom) => sum + atom.width, 0), + }); + } + } + + let width = 0; + for (const line of lines) width = Math.max(width, line.width); + + const height = lines.length * fontSize * lineHeight; + return { lines, width, height, lineHeight, fontSize, align }; +} + +/** + * Convert a layout to one SVG path `d` string. + * + * `x`/`y` is the top-left corner of the text block. Each line sits on its own + * baseline, placed with half-leading so a line box of `fontSize * lineHeight` + * surrounds the glyphs evenly, and shifted sideways by `layout.align`. + * + * Characters the font does not cover are skipped rather than drawn as .notdef, + * and a line whose outline cannot be built cleanly is dropped, so the result + * never carries `NaN` into the document. + * + * @param {import('opentype.js').Font} font + * @param {object} layout value returned by `layoutText` + * @param {object} [options] + * @param {number} [options.x=0] + * @param {number} [options.y=0] + * @param {number} [options.round=2] decimal places in the output + * @returns {string} + */ +export function textToPathData(font, layout, options = {}) { + const opts = options ?? {}; + const x = Number.isFinite(opts.x) ? opts.x : 0; + const y = Number.isFinite(opts.y) ? opts.y : 0; + const round = Number.isFinite(opts.round) ? Math.max(0, Math.floor(opts.round)) : 2; + + if (!font || !layout || !Array.isArray(layout.lines) || layout.lines.length === 0) return ''; + + const fontSize = Number.isFinite(layout.fontSize) ? layout.fontSize : DEFAULT_FONT_SIZE; + const lineHeight = Number.isFinite(layout.lineHeight) ? layout.lineHeight : DEFAULT_LINE_HEIGHT; + const blockWidth = Number.isFinite(layout.width) ? layout.width : 0; + + const step = fontSize * lineHeight; + const scale = fontSize / (font.unitsPerEm || 1000); + const ascent = font.ascender * scale; + const descent = -font.descender * scale; // depth below the baseline, positive + const halfLeading = (step - (ascent + descent)) / 2; + + const parts = []; + for (let i = 0; i < layout.lines.length; i++) { + const line = layout.lines[i]; + const drawable = drawableText(font, line.text); + if (!drawable) continue; + + const baseline = y + i * step + halfLeading + ascent; + const lineX = x + alignOffset(layout.align, blockWidth, line.width); + const d = font.getPath(drawable, lineX, baseline, fontSize).toPathData(round); + if (!d || d.includes('NaN') || d.includes('Infinity')) continue; + parts.push(d); + } + return parts.join(' '); +} + +/** + * The CSS `font-family` the app should use for live `<text>` or canvas captions. + * + * Starts with the family of the most recently loaded font, so the fallback text + * looks as close as possible to the outlines, and ends with Japanese-safe + * families that exist on the machines the studio runs on. + * + * @returns {string} + */ +export function captionFontStack() { + const families = [loadedFamily ?? FALLBACK_FAMILIES[0]]; + const seen = new Set(families.map((name) => name.toLowerCase())); + for (const name of FALLBACK_FAMILIES) { + if (seen.has(name.toLowerCase())) continue; + seen.add(name.toLowerCase()); + families.push(name); + } + return families.map(cssFamily).join(', '); +} + +// --- internals --------------------------------------------------------------- + +/** Width of one line of text, in pixels. */ +function measureLine(font, line, fontSize) { + let width = 0; + for (const ch of line) width += font.getAdvanceWidth(ch, fontSize); + return width; +} + +/** Family name of a font, or `null` when it does not carry one. */ +function familyNameOf(font) { + const names = font?.names?.fontFamily; + if (!names) return null; + // A parsed font stores one entry per language tag; a font built from scratch + // stores a plain string. Accept both. + const value = typeof names === 'string' ? names : names.en ?? Object.values(names)[0]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +} + +/** Only the characters the font can actually draw, newlines removed. */ +function drawableText(font, text) { + let out = ''; + for (const ch of String(text ?? '')) { + if (ch === '\n' || ch === '\r') continue; + if (font.charToGlyphIndex(ch) === 0) continue; // no outline to draw + out += ch; + } + return out; +} + +/** Sideways shift of a line inside the block, for the block's alignment. */ +function alignOffset(align, blockWidth, lineWidth) { + const slack = blockWidth - lineWidth; + if (align === 'center') return slack / 2; + if (align === 'right') return slack; + return 0; +} + +/** Quote a family for CSS unless it is a generic keyword. */ +function cssFamily(name) { + if (GENERIC_FAMILIES.has(name.toLowerCase())) return name; + return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +/** `true` for scripts that may break between any two characters. */ +function isCjk(ch) { + const c = ch.codePointAt(0); + return ( + (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo + (c >= 0x2e80 && c <= 0x303f) || // radicals, CJK punctuation + (c >= 0x3040 && c <= 0x30ff) || // hiragana, katakana + (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo + (c >= 0x3400 && c <= 0x4dbf) || // CJK extension A + (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs + (c >= 0xa960 && c <= 0xa97f) || // Hangul Jamo extended A + (c >= 0xac00 && c <= 0xd7ff) || // Hangul syllables + (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs + (c >= 0xfe10 && c <= 0xfe4f) || // vertical and compatibility forms + (c >= 0xff00 && c <= 0xffef) || // fullwidth and halfwidth forms + (c >= 0x1f200 && c <= 0x1f2ff) || // enclosed ideographic supplement + (c >= 0x20000 && c <= 0x2fa1f) // CJK extensions B onwards + ); +} + +/** One breakable piece of text together with its advance width. */ +function makeAtom(font, text, fontSize, space, closing) { + return { text, width: font.getAdvanceWidth(text, fontSize), space, closing }; +} + +/** + * Split a paragraph into the smallest pieces a line may break between. + * + * A Latin run stays one atom so it can never be split; every CJK character is + * its own atom so a break may fall on either side of it. + * + * @returns {Array<{text: string, width: number, space: boolean, closing: boolean}>} + */ +function tokenise(font, paragraph, fontSize) { + const atoms = []; + let word = null; + + const endWord = () => { + if (word !== null) { + atoms.push(word); + word = null; + } + }; + + for (const ch of paragraph) { + if (SPACE.test(ch)) { + endWord(); + atoms.push(makeAtom(font, ch, fontSize, true, false)); + } else if (isCjk(ch)) { + endWord(); + atoms.push(makeAtom(font, ch, fontSize, false, CLOSING_PUNCTUATION.has(ch))); + } else { + if (word === null) word = makeAtom(font, '', fontSize, false, false); + word.text += ch; + word.width += font.getAdvanceWidth(ch, fontSize); + } + } + endWord(); + return atoms; +} + +/** + * Greedy line breaker over atoms. Always returns at least one line, so an empty + * paragraph comes back as one empty line and explicit breaks are preserved. + * + * @param {Array<object>} atoms + * @param {number} maxWidth `Infinity` disables wrapping + * @returns {Array<Array<object>>} + */ +function wrapAtoms(atoms, maxWidth) { + const lines = []; + let current = []; + + const currentWidth = () => current.reduce((sum, atom) => sum + atom.width, 0); + + const finish = () => { + // A break swallows the spaces next to it, so no line ends or starts blank. + while (current.length > 0 && current[current.length - 1].space) current.pop(); + while (current.length > 0 && current[0].space) current.shift(); + lines.push(current); + current = []; + }; + + for (const atom of atoms) { + if (current.length === 0) { + if (atom.space) continue; // never start a line with a space + current.push(atom); + continue; + } + if (currentWidth() + atom.width <= maxWidth) { + current.push(atom); + continue; + } + + // The atom does not fit. Keep closing punctuation off the start of the next + // line by dragging the previous character down, but only when that leaves + // something behind and the pair still respects the maximum width. + const carried = current[current.length - 1]; + const visible = current.filter((a) => !a.space); + if ( + atom.closing && + visible.length > 1 && + !carried.space && + carried.width + atom.width <= maxWidth + ) { + current.pop(); + finish(); + current.push(carried); + } else { + finish(); + } + // The break already stands in for a space, so it does not start the new line. + if (atom.space) continue; + current.push(atom); + } + + finish(); + return lines; +} diff --git a/public/bluebey-studio/src/trace.js b/public/bluebey-studio/src/trace.js new file mode 100644 index 0000000..9b61d6d --- /dev/null +++ b/public/bluebey-studio/src/trace.js @@ -0,0 +1,410 @@ +/** + * Zero-dependency marching-squares contour tracer. + * + * Runs unchanged in the browser and in Node: the module has no imports at all, + * never touches the DOM and never writes to the console. + * + * Coordinate system + * ----------------- + * Pixel (x, y) is the unit square whose top-left corner sits at (x, y), so the + * centre of pixel (x, y) is at (x + 0.5, y + 0.5) and y grows downwards + * (row 0 is the top row of the input). + * + * Contours + * -------- + * Every returned contour is a *closed* polyline of sub-pixel points; the first + * point is not repeated at the end. Contours are oriented so that the inside of + * the shape (the region where the sample is at or above the threshold) lies to + * the left of the direction of travel. See `contourArea` for the sign this + * implies. + */ + +const EDGE_TOP = 0; +const EDGE_RIGHT = 1; +const EDGE_BOTTOM = 2; +const EDGE_LEFT = 3; + +/** + * Directed segments emitted by each unambiguous marching-squares case. + * + * The case index is built from the cell corners as + * `tl | tr << 1 | br << 2 | bl << 3`. Entries are flat `[from, to, from, to]` + * pairs of edge ids; the direction keeps the inside region on the left. + */ +const CELL_SEGMENTS = [ + null, // 0 - nothing inside + [EDGE_LEFT, EDGE_TOP, -1, -1], // 1 - top-left only + [EDGE_TOP, EDGE_RIGHT, -1, -1], // 2 - top-right only + [EDGE_LEFT, EDGE_RIGHT, -1, -1], // 3 - top row + [EDGE_RIGHT, EDGE_BOTTOM, -1, -1], // 4 - bottom-right only + null, // 5 - saddle (top-left + bottom-right) + [EDGE_TOP, EDGE_BOTTOM, -1, -1], // 6 - right column + [EDGE_LEFT, EDGE_BOTTOM, -1, -1], // 7 - everything but bottom-left + [EDGE_BOTTOM, EDGE_LEFT, -1, -1], // 8 - bottom-left only + [EDGE_BOTTOM, EDGE_TOP, -1, -1], // 9 - left column + null, // 10 - saddle (top-right + bottom-left) + [EDGE_BOTTOM, EDGE_RIGHT, -1, -1], // 11 - everything but bottom-right + [EDGE_RIGHT, EDGE_LEFT, -1, -1], // 12 - bottom row + [EDGE_RIGHT, EDGE_TOP, -1, -1], // 13 - everything but top-right + [EDGE_TOP, EDGE_LEFT, -1, -1], // 14 - everything but top-left + null, // 15 - everything inside +]; + +// Case 5 (top-left + bottom-right inside). When the centre of the cell is +// inside, the two inside corners are joined through the middle and the two +// outside corners are separated; otherwise the inside corners are separated. +const SADDLE_5_CONNECTED = [EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT, EDGE_TOP]; +const SADDLE_5_SPLIT = [EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM]; + +// Case 10 (top-right + bottom-left inside): the mirror image of case 5. +const SADDLE_10_CONNECTED = [EDGE_TOP, EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT]; +const SADDLE_10_SPLIT = [EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM, EDGE_LEFT]; + +const DUPLICATE_EPS = 1e-9; + +/** + * Signed area of a closed polyline, via the shoelace formula. The polyline is + * implicitly closed (the last point is joined back to the first), so an open + * ring is fine. + * + * Sign convention: this is the plain shoelace sum `Σ (x_i·y_{i+1} − x_{i+1}·y_i) / 2` + * evaluated in the tracer's y-down pixel coordinates. A ring that runs + * clockwise *as seen on screen* is therefore positive, and a counter-clockwise + * one is negative. Because `traceAlphaContours` keeps the inside on the left, + * the outer boundary of a filled region comes out negative and a hole in it + * comes out positive. + * + * @param {Array<{x: number, y: number}>} points + * @returns {number} signed area in square pixels + */ +export function contourArea(points) { + const n = points.length; + if (!points || n < 3) return 0; + let sum = 0; + for (let i = 0; i < n; i++) { + const a = points[i]; + const b = i + 1 === n ? points[0] : points[i + 1]; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +/** + * Build an SVG path `d` attribute with one closed subpath per contour. + * + * `mapPoint(x, y)` returns the `[X, Y]` pair written to the output, which is + * what makes it possible to flip the y axis or apply a scale without touching + * the tracer. Coordinates are rounded to `decimals` places. + * + * @param {Array<Array<{x: number, y: number}>>} contours + * @param {(x: number, y: number) => [number, number]} mapPoint + * @param {number} [decimals] + * @returns {string} + */ +export function contoursToPathData(contours, mapPoint, decimals = 2) { + const places = Math.max(0, Math.floor(decimals)); + const factor = Math.pow(10, places); + const parts = []; + for (let c = 0; c < contours.length; c++) { + const points = contours[c]; + if (!points || points.length < 2) continue; + for (let i = 0; i < points.length; i++) { + const mapped = mapPoint(points[i].x, points[i].y); + // Rounding before formatting keeps `-0.00` out of the output. + const rx = Math.round(mapped[0] * factor) / factor; + const ry = Math.round(mapped[1] * factor) / factor; + parts.push((i === 0 ? 'M' : 'L') + rx.toFixed(places) + ' ' + ry.toFixed(places)); + } + parts.push('Z'); + } + return parts.join(' '); +} + +// --- closed-ring simplification helpers ------------------------------------ + +/** Drop points that repeat their predecessor (including across the wrap). */ +function removeConsecutiveDuplicates(points) { + const out = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const last = out[out.length - 1]; + if (last && Math.abs(last.x - p.x) <= DUPLICATE_EPS && Math.abs(last.y - p.y) <= DUPLICATE_EPS) { + continue; + } + out.push(p); + } + while (out.length > 1) { + const first = out[0]; + const last = out[out.length - 1]; + if (Math.abs(first.x - last.x) <= DUPLICATE_EPS && Math.abs(first.y - last.y) <= DUPLICATE_EPS) { + out.pop(); + } else { + break; + } + } + return out; +} + +/** Drop points that sit on the straight segment between their two neighbours. */ +function removeCollinear(points) { + let list = points; + let changed = true; + while (changed && list.length > 3) { + changed = false; + const n = list.length; + const out = []; + for (let i = 0; i < n; i++) { + const a = list[i === 0 ? n - 1 : i - 1]; + const b = list[i]; + const c = list[i + 1 === n ? 0 : i + 1]; + const abx = b.x - a.x; + const aby = b.y - a.y; + const bcx = c.x - b.x; + const bcy = c.y - b.y; + const cross = abx * bcy - aby * bcx; + // |cross| / (|ab| * |bc|) is sin(turn angle); a small value means a + // straight-through point, which carries no shape information. + const scale = Math.sqrt((abx * abx + aby * aby) * (bcx * bcx + bcy * bcy)); + const straight = scale <= DUPLICATE_EPS || Math.abs(cross) <= 1e-9 * scale; + if (straight && abx * bcx + aby * bcy >= 0) { + changed = true; + } else { + out.push(b); + } + } + list = out; + } + return list; +} + +/** + * Iterative Douglas–Peucker for an *open* polyline. The two end points are + * always kept; the recursion uses an explicit stack so long contours cannot + * overflow the call stack. + */ +function douglasPeucker(points, tolerance) { + const n = points.length; + if (n <= 2) return points.slice(); + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [0, n - 1]; + while (stack.length > 0) { + const i1 = stack.pop(); + const i0 = stack.pop(); + if (i1 <= i0 + 1) continue; + const a = points[i0]; + const b = points[i1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy); + let maxDistance = -1; + let maxIndex = -1; + if (len <= DUPLICATE_EPS) { + // Degenerate segment: fall back to the distance from the anchor point. + for (let i = i0 + 1; i < i1; i++) { + const px = points[i].x - a.x; + const py = points[i].y - a.y; + const d = Math.sqrt(px * px + py * py); + if (d > maxDistance) { + maxDistance = d; + maxIndex = i; + } + } + } else { + for (let i = i0 + 1; i < i1; i++) { + const p = points[i]; + const d = Math.abs(dy * (p.x - a.x) - dx * (p.y - a.y)) / len; + if (d > maxDistance) { + maxDistance = d; + maxIndex = i; + } + } + } + if (maxDistance > tolerance && maxIndex > i0) { + keep[maxIndex] = 1; + stack.push(i0, maxIndex, maxIndex, i1); + } + } + const out = []; + for (let i = 0; i < n; i++) { + if (keep[i]) out.push(points[i]); + } + return out; +} + +/** + * Simplify a closed ring, wrap-around segment included. + * + * The ring is cut at the point farthest from `points[0]`, which gives two open + * polylines that together cover every segment of the loop exactly once; each + * half is then simplified with Douglas–Peucker and the halves are stitched + * back together (without duplicating the shared anchors). + */ +function simplifyClosedRing(points, tolerance) { + const n = points.length; + if (n <= 3) return points.slice(); + let far = 0; + let farDistance = -1; + const first = points[0]; + for (let i = 1; i < n; i++) { + const dx = points[i].x - first.x; + const dy = points[i].y - first.y; + const d = dx * dx + dy * dy; + if (d > farDistance) { + farDistance = d; + far = i; + } + } + // A ring whose points all coincide carries no shape; leave it to minArea. + if (far <= 0 || farDistance <= DUPLICATE_EPS) return points.slice(); + + const head = douglasPeucker(points.slice(0, far + 1), tolerance); + const tail = douglasPeucker(points.slice(far).concat([first]), tolerance); + + // `head` ends and `tail` starts on the same anchor, and both end on + // `points[0]`; drop the duplicated join so every point appears once. + return head.slice(0, -1).concat(tail.slice(0, -1)); +} + +/** + * Trace the iso-contour of a scalar field at `options.threshold`. + * + * @param {ArrayLike<number>} alpha width*height samples, row-major, row 0 on top + * @param {number} width + * @param {number} height + * @param {object} [options] + * @param {number} [options.threshold=0.5] inside when `alpha/255 >= threshold` + * @param {number} [options.simplifyTolerance=0.35] Douglas-Peucker tolerance, px + * @param {number} [options.minArea=2] drop rings smaller than this, px² + * @returns {Array<Array<{x: number, y: number}>>} + */ +export function traceAlphaContours(alpha, width, height, options = {}) { + const threshold = options.threshold ?? 0.5; + const tolerance = options.simplifyTolerance ?? 0.35; + const minArea = options.minArea ?? 2; + + const w = Math.floor(width); + const h = Math.floor(height); + if (!alpha || w < 1 || h < 1 || alpha.length < w * h) return []; + if (w < 2 && h < 2) return []; + + // Work on a signed field, padded with a 1px outside border: `s >= 0` is + // inside. The padding guarantees every crossing is strictly interior, so the + // marching always yields closed rings and never touches the array edges. + // The border holds the same value a fully transparent pixel maps to, which + // makes shapes that run off the image close exactly on the image edge. + const pw = w + 2; + const ph = h + 2; + const s = new Float32Array(pw * ph); + s.fill(-threshold); + for (let y = 0; y < h; y++) { + const src = y * w; + const dst = (y + 1) * pw + 1; + for (let x = 0; x < w; x++) s[dst + x] = alpha[src + x] / 255 - threshold; + } + const sample = (x, y) => s[y * pw + x]; + + // Crossing points, keyed by the grid edge they sit on. Both cells sharing an + // edge call these with the same sample pair in the same order (top→bottom, + // left→right), so the coordinates come out bit-identical and can be matched + // by key alone. + const points = new Map(); + function crossing(key, a, b, x0, y0, dx, dy) { + let p = points.get(key); + if (p === undefined) { + const t = a / (a - b); + p = { x: x0 + dx * t, y: y0 + dy * t }; + points.set(key, p); + } + return p; + } + // Horizontal edge of the padded grid at row `py`, spanning columns px..px+1. + const hKey = (px, py) => `h:${px}:${py}`; + const hPoint = (px, py) => crossing(hKey(px, py), sample(px, py), sample(px + 1, py), px - 0.5, py - 0.5, 1, 0); + // Vertical edge of the padded grid at column `px`, spanning rows py..py+1. + const vKey = (px, py) => `v:${px}:${py}`; + const vPoint = (px, py) => crossing(vKey(px, py), sample(px, py), sample(px, py + 1), px - 0.5, py - 0.5, 0, 1); + + const edgeKey = [ + (px, py) => hKey(px, py), // EDGE_TOP + (px, py) => vKey(px + 1, py), // EDGE_RIGHT + (px, py) => hKey(px, py + 1), // EDGE_BOTTOM + (px, py) => vKey(px, py), // EDGE_LEFT + ]; + const edgePoint = [ + (px, py) => hPoint(px, py), // EDGE_TOP + (px, py) => vPoint(px + 1, py), // EDGE_RIGHT + (px, py) => hPoint(px, py + 1), // EDGE_BOTTOM + (px, py) => vPoint(px, py), // EDGE_LEFT + ]; + + // Directed segments: `from` -> `to`, inside region on the left of travel. + const fromKeys = []; + const toKeys = []; + const fromPoints = []; + + for (let py = 0; py < ph - 1; py++) { + for (let px = 0; px < pw - 1; px++) { + const tl = sample(px, py); + const tr = sample(px + 1, py); + const br = sample(px + 1, py + 1); + const bl = sample(px, py + 1); + const inside = (v) => (v >= 0 ? 1 : 0); + const code = inside(tl) | (inside(tr) << 1) | (inside(br) << 2) | (inside(bl) << 3); + let segments = CELL_SEGMENTS[code]; + if (code === 5) { + // With the cell centre inside, the two inside corners join through the + // middle; otherwise each is cut off on its own. + segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_5_CONNECTED : SADDLE_5_SPLIT; + } else if (code === 10) { + segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_10_CONNECTED : SADDLE_10_SPLIT; + } + if (!segments) continue; + for (let i = 0; i < segments.length; i += 2) { + const a = segments[i]; + const b = segments[i + 1]; + if (a < 0 || b < 0) continue; // padding of the single-segment cases + fromKeys.push(edgeKey[a](px, py)); + fromPoints.push(edgePoint[a](px, py)); + toKeys.push(edgeKey[b](px, py)); + edgePoint[b](px, py); // make sure the shared crossing exists + } + } + } + + // Every crossing has exactly one incoming and one outgoing segment, so the + // segments can be walked into closed rings without any ambiguity. + const outgoing = new Map(); + for (let i = 0; i < fromKeys.length; i++) outgoing.set(fromKeys[i], i); + + const segmentCount = fromKeys.length; + const used = new Uint8Array(segmentCount); + const contours = []; + + for (let start = 0; start < segmentCount; start++) { + if (used[start]) continue; + const ring = []; + let current = start; + for (let guard = 0; guard <= segmentCount; guard++) { + used[current] = 1; + ring.push(fromPoints[current]); + const next = outgoing.get(toKeys[current]); + if (next === undefined || next === start) break; + if (used[next]) break; + current = next; + } + if (ring.length >= 3) contours.push(ring); + } + + const result = []; + for (let i = 0; i < contours.length; i++) { + let ring = removeConsecutiveDuplicates(contours[i]); + ring = simplifyClosedRing(ring, tolerance); + ring = removeCollinear(ring); + if (ring.length < 3) continue; + if (Math.abs(contourArea(ring)) < minArea) continue; + result.push(ring); + } + return result; +}
\ No newline at end of file diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js new file mode 100644 index 0000000..91e852b --- /dev/null +++ b/public/bluebey-studio/src/ui.js @@ -0,0 +1,367 @@ +/** + * Small DOM widget kit for the control panel: hyperscript, collapsible sections + * and a handful of labelled controls. Everything returns `{ el, set, get }` so + * callers can push state back into the widgets when presets are applied. + */ + +export function h(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props ?? {})) { + if (value == null || value === false) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = value; + else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value); + else if (key === 'dataset' && typeof value === 'object') Object.assign(node.dataset, value); + else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); + else if (value === true) node.setAttribute(key, ''); + else node.setAttribute(key, String(value)); + } + for (const child of children.flat()) { + if (child == null || child === false) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +let toastTimer = 0; + +export function toast(message) { + const node = document.getElementById('toast'); + if (!node) return; + node.textContent = message; + node.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => node.classList.remove('show'), 2400); +} + +export function section(parent, title, { open = false, icon = null } = {}) { + const body = h('div', { class: 'sec-body' }); + const head = h('button', { type: 'button', class: 'sec-head' }, + h('span', { class: 'sec-title' }, + icon ? secIcon(icon) : null, + h('span', { text: title })), + h('span', { class: 'chev', text: '▾' })); + const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body); + head.addEventListener('click', () => el.classList.toggle('closed')); + parent.append(el); + // `add` accepts either an element or a widget object (`{ el }`), so callers + // cannot accidentally append `[object Object]`. + return { el, body, add: (child) => (body.append(child?.el ?? child), child), setOpen: (open) => el.classList.toggle('closed', !open) }; +} + +/** A label + control row. Pass `label` as null for a full-width control. */ +export function controlRow(label, control, { wide = false } = {}) { + if (label == null || wide) return h('div', { class: 'row wide' }, label ? h('span', { class: 'label', text: label }) : null, control); + return h('div', { class: 'row' }, + h('span', { class: 'label', text: label, title: label }), + control); +} + +/** + * A tab's optional icon: one 24x24 stroke path, drawn in `currentColor` so it + * follows the tab's text colour (and turns white when the tab is active). + */ +function tabIcon(d) { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '18'); + svg.setAttribute('height', '18'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.style.verticalAlign = 'baseline'; + svg.style.flex = '0 0 auto'; + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + svg.append(path); + return svg; +} + +/** A small stroke icon built from raw SVG markup (see the icons in panel.js). */ +export function svgIcon(inner, { size = 16, viewBox = '0 0 24 24', className = 'grp-icon' } = {}) { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', viewBox); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('class', className); + svg.innerHTML = inner; + return svg; +} + +function secIcon(d) { + const svg = tabIcon(d); + svg.setAttribute('class', 'sec-icon'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.style.verticalAlign = 'middle'; + return svg; +} + +/** + * A standalone 24x24 stroke icon (the same drawing as a tab's), for a plain + * button that has no label of its own. + */ +export function icon(d, size = 18) { + const svg = tabIcon(d); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + return svg; +} + +/** + * Tabbed container: returns the per-tab panels to fill in. When a definition + * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the + * label becomes its tooltip and accessible name; without one the label shows. + */ +export function tabs(parent, definitions) { + const bar = h('div', { class: 'tabs' }); + const buttons = new Map(); + const panels = {}; + let active = definitions[0]?.id ?? null; + + const select = (id) => { + if (!panels[id]) return; + active = id; + for (const [key, button] of buttons) button.classList.toggle('active', key === id); + for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== id; + }; + + for (const definition of definitions) { + const button = h('button', { type: 'button', class: 'tab' }); + if (definition.icon) { + button.append(tabIcon(definition.icon)); + button.title = definition.label; + button.setAttribute('aria-label', definition.label); + } else { + button.append(h('span', { class: 'tab-label', text: definition.label })); + } + button.addEventListener('click', () => select(definition.id)); + buttons.set(definition.id, button); + bar.append(button); + panels[definition.id] = h('div', { class: 'tab-panel' }); + } + + parent.append(bar); + for (const definition of definitions) parent.append(panels[definition.id]); + select(active); + return { select, panels, get active() { return active; } }; +} + +/** A collapsed "more options" block, using the native details element. */ +export function details(parent, summary, { open = false } = {}) { + const body = h('div', { class: 'details-body' }); + const el = h('details', { class: 'details' }, h('summary', { text: summary }), body); + if (open) el.open = true; + parent.append(el); + return { el, body, add: (child) => (body.append(child?.el ?? child), child) }; +} + +export function subhead(text) { + return h('div', { class: 'subhead', text }); +} + +export function hint(...lines) { + return h('p', { class: 'hint' }, ...lines.flat().map((line) => (line instanceof Node ? line : line))); +} + +export function slider({ label, min = 0, max = 1, step = 0.01, value = 0, format, onInput, onCommit, wide = false }) { + const input = h('input', { type: 'range', min, max, step, value }); + const num = h('span', { class: 'num' }); + const fmt = format ?? ((v) => (step >= 1 ? String(Math.round(v)) : v.toFixed(2))); + const sync = () => { num.textContent = fmt(Number(input.value)); }; + input.addEventListener('input', () => { sync(); onInput?.(Number(input.value)); }); + input.addEventListener('change', () => onCommit?.(Number(input.value))); + sync(); + const control = h('div', { class: 'control' }, input, num); + return { + el: controlRow(label, control, { wide }), + set(v) { input.value = String(v); sync(); }, + get: () => Number(input.value), + }; +} + +export function check({ label, value = false, onChange, title }) { + const input = h('input', { type: 'checkbox' }); + input.checked = !!value; + input.addEventListener('change', () => onChange?.(input.checked)); + const el = h('label', { class: 'chk', title: title ?? '' }, input, h('span', { text: label })); + return { el, set: (v) => { input.checked = !!v; }, get: () => input.checked }; +} + +export function segmented({ label, options, value, onChange, wide = false }) { + const el = h('div', { class: 'seg' }); + const nodes = new Map(); + let current = value; + const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; + const build = (list) => { + el.replaceChildren(); + nodes.clear(); + for (const option of list) { + const node = h('button', { type: 'button', title: option.title ?? option.label, text: option.label }); + node.addEventListener('click', () => { + if (current === option.value) return; + current = option.value; + sync(); + onChange?.(option.value); + }); + nodes.set(option.value, node); + el.append(node); + } + sync(); + }; + build(options); + return { + el: controlRow(label, el, { wide }), + set(v) { current = v; sync(); }, + get: () => current, + setOptions: build, + }; +} + +export function buttons({ label, items, wide = true }) { + const el = h('div', { class: 'buttons' }); + const nodes = new Map(); + for (const item of items) { + const node = h('button', { + type: 'button', + class: `btn${item.primary ? ' primary' : ''}`, + title: item.title ?? '', + text: item.label, + }); + node.addEventListener('click', () => item.onClick?.()); + const key = item.id ?? item.label; + nodes.set(key, node); + el.append(node); + } + return { + el: controlRow(label, el, { wide }), + button: (key) => nodes.get(key)?.el, + setDisabled(key, disabled) { const node = nodes.get(key); if (node) node.disabled = disabled; }, + setLabel(key, text) { const node = nodes.get(key); if (node) node.textContent = text; }, + }; +} + +export function colorField({ label, value = '#ffffff', onChange, swatches = [], wide = false }) { + const input = h('input', { type: 'color', value }); + input.addEventListener('input', () => onChange?.(input.value)); + const control = h('div', { class: 'control' }, input); + if (swatches.length) { + const strip = h('div', { class: 'swatches' }); + for (const color of swatches) { + const chip = h('button', { type: 'button', class: 'swatch', title: color, style: { background: color } }); + chip.addEventListener('click', () => { input.value = color; onChange?.(color); }); + strip.append(chip); + } + control.append(strip); + } + return { + el: controlRow(label, control, { wide }), + set(v) { input.value = v; }, + get: () => input.value, + }; +} + +/** + * Two-axis drag pad. `value` is `{ x, y }` with both components in -1..1 and + * `y` positive upwards (screen-like, but flipped so up = up). + */ +export function xyPad({ value = { x: 0, y: 0 }, onChange, onCommit, center = null }) { + const dot = h('span', { class: 'pad-dot' }); + const el = h('div', { class: 'pad' }, center === 'eye' ? h('span', { class: 'pad-eye' }) : null, dot); + let current = { x: value.x ?? 0, y: value.y ?? 0 }; + let dragging = false; + + const place = () => { + dot.style.left = `${((current.x + 1) / 2) * 100}%`; + dot.style.top = `${((1 - (current.y + 1) / 2)) * 100}%`; + }; + const fromEvent = (event) => { + const rect = el.getBoundingClientRect(); + const x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1); + const y = clamp(1 - ((event.clientY - rect.top) / rect.height) * 2, -1, 1); + current = { x, y }; + place(); + onChange?.({ ...current }); + }; + + el.addEventListener('pointerdown', (event) => { + dragging = true; + el.setPointerCapture(event.pointerId); + fromEvent(event); + }); + el.addEventListener('pointermove', (event) => { if (dragging) fromEvent(event); }); + const end = (event) => { + if (!dragging) return; + dragging = false; + if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId); + onCommit?.({ ...current }); + }; + el.addEventListener('pointerup', end); + el.addEventListener('pointercancel', end); + el.addEventListener('dblclick', () => { current = { x: 0, y: 0 }; place(); onChange?.({ ...current }); onCommit?.({ ...current }); }); + + place(); + return { + el, + set(v) { current = { x: v.x ?? 0, y: v.y ?? 0 }; place(); }, + get: () => ({ ...current }), + }; +} + +/** + * Tail-direction picker: a 3x3 grid of dots whose *position* is the direction, + * which reads at a glance in a way a flat row of "左上/右上" buttons does not. + * The centre dot means no tail. Same `{ el, set, get }` contract as the rest. + */ +export function tailPad({ label, value = 'left', onChange }) { + // Row-major, so the array order is what the user sees on screen. + const CELLS = [ + ['topLeft', '左上'], ['top', '上'], ['topRight', '右上'], + ['left', '左'], ['none', 'なし'], ['right', '右'], + ['bottomLeft', '左下'], ['bottom', '下'], ['bottomRight', '右下'], + ]; + const el = h('div', { class: 'tail-pad' }); + const nodes = new Map(); + let current = value; + const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; + for (const [cell, text] of CELLS) { + const node = h('button', { type: 'button', class: 'tail-dot', title: text, 'aria-label': text }); + node.addEventListener('click', () => { + if (current === cell) return; + current = cell; + sync(); + onChange?.(cell); + }); + nodes.set(cell, node); + el.append(node); + } + sync(); + return { + el: controlRow(label, el), + set(v) { current = v; sync(); }, + get: () => current, + }; +} + +export function selectField({ label, options, value, onChange, wide = false }) { + const select = h('select', { class: 'select' }); + for (const option of options) select.append(h('option', { value: option.value, text: option.label })); + select.value = value; + select.addEventListener('change', () => onChange?.(select.value)); + return { + el: controlRow(label, select, { wide }), + set(v) { select.value = v; }, + get: () => select.value, + }; +} diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js new file mode 100644 index 0000000..d6fb6bd --- /dev/null +++ b/public/bluebey-studio/src/urlState.js @@ -0,0 +1,237 @@ +/** + * Shareable links: the whole look, packed into the URL fragment. + * + * A studio session is a lot of state, and "save a JSON file and send it" is a + * poor answer to "how do I show you what I made" - one link you can paste into + * chat is much better. So the state is JSON-encoded, deflated and + * base64url-encoded into something short enough to sit in a `#` fragment. + * + * Three details make it survive the trip: + * + * - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user + * loaded). No link can carry that, so `stripForUrl` replaces just that one + * field with `null` and keeps everything else - caption text, story panels, + * props. Nothing else is ever dropped. + * + * - The payload starts with a version tag ('1' = raw deflate, '0' = plain + * bytes) so the decoder knows whether to inflate. A browser without + * `CompressionStream` falls back to the uncompressed form rather than + * failing to produce a link at all. + * + * - base64url uses `-` and `_` and carries no `=` padding, so the fragment is + * safe to paste into chat, Markdown or HTML without being mangled or escaped. + * + * `decodeState` is deliberately total: any malformed, truncated or hostile input + * returns `null` rather than throwing, because it is fed whatever came out of + * the address bar. + * + * Most of a session is still at its default, and a default costs bytes on every + * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and + * `restoreDefaults` merges what is left back onto a fresh default, so the link + * carries only what the user actually changed. The pruning is lossless and the + * decoder still accepts the older, full-state links. + */ + +import { applyPatch, defaultState } from './presets.js'; + +/** Above this length a `data:` background image is a photo, not a link. */ +const MAX_INLINE_IMAGE = 2048; + +/** Version tags. '1' is the deflated body, '0' the fallback plain body. */ +const TAG_DEFLATE = '1'; +const TAG_PLAIN = '0'; + +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +/** + * A copy of `state` that is safe to put in a URL. Only the inline background + * image is dropped (replaced with `null`), and only when it is a `data:` URL + * long enough to blow up the link. + */ +export function stripForUrl(state) { + // A JSON round-trip gives the copy for free and drops anything that could not + // be serialised anyway, so the link and the live state cannot diverge. + const copy = JSON.parse(JSON.stringify(state ?? null)); + if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy; + + const image = copy.view && copy.view.backgroundImage; + if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) { + copy.view.backgroundImage = null; + } + return copy; +} + +/** Plain objects only: not `null`, not an array. */ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Structural equality for the JSON-shaped values this module deals in. */ +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b); + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])); +} + +/** A detached copy of a JSON-shaped value. */ +function cloneValue(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +/** Marks a value that equals its default, so its key can be left out entirely. */ +const OMIT = Symbol('urlState.omit'); + +/** + * `value` with every sub-tree that deep-equals the matching `def` removed. + * Arrays are kept whole - never pruned element by element - because that is how + * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the + * value matched its default and can be dropped from the parent. + */ +function pruneValue(value, def) { + if (deepEqual(value, def)) return OMIT; + if (isPlainObject(value)) { + if (!isPlainObject(def)) return cloneValue(value); + const out = {}; + for (const [key, child] of Object.entries(value)) { + const pruned = pruneValue(child, def[key]); + if (pruned !== OMIT) out[key] = pruned; + } + return Object.keys(out).length === 0 ? OMIT : out; + } + return Array.isArray(value) ? cloneValue(value) : value; +} + +/** + * A copy of `state` with every value equal to `defaultState()` omitted. Lossless: + * `restoreDefaults` merges the result onto a fresh default. Arrays that differ + * from the default are carried whole so `applyPatch` can replace them. + */ +export function pruneDefaults(state) { + const pruned = pruneValue(state, defaultState()); + return pruned === OMIT ? {} : pruned; +} + +/** + * The inverse of `pruneDefaults`: a full state, with every field the partial does + * not mention left at its default. Merging a previously full state is idempotent, + * so links made by the older encoder still decode. + */ +export function restoreDefaults(partial) { + return applyPatch(defaultState(), partial); +} + +/** True when the encoded fragment is bigger than a URL can comfortably hold. */ +export function isTooLong(text, limit = 1800) { + return typeof text === 'string' && text.length > limit; +} + +/** + * base64url, without padding. Chunked so a large payload does not blow the + * argument limit of `String.fromCharCode`. + */ +function toBase64Url(bytes) { + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */ +function fromBase64Url(text) { + const base64 = text.replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (base64.length % 4)) % 4; + const binary = atob(base64 + '='.repeat(pad)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** Read a whole ReadableStream into one Uint8Array. */ +async function drain(stream) { + const chunks = []; + let length = 0; + for await (const chunk of stream) { + const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); + chunks.push(bytes); + length += bytes.length; + } + const out = new Uint8Array(length); + let offset = 0; + for (const bytes of chunks) { + out.set(bytes, offset); + offset += bytes.length; + } + return out; +} + +async function deflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return drain(stream); +} + +async function inflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); + return drain(stream); +} + +/** + * Serialise, deflate and base64url-encode a state. The result is a fragment + * payload: it survives a URL, a chat message and an HTML attribute. + * + * @param {object} state the studio state + * @returns {Promise<string>} a URL-safe string (tag + base64url) + */ +export async function encodeState(state) { + const json = JSON.stringify(pruneDefaults(stripForUrl(state))); + const bytes = new TextEncoder().encode(json); + + if (typeof CompressionStream === 'function') { + try { + return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes)); + } catch { + // Fall through: an unusable compressing stream should not cost the link. + } + } + return TAG_PLAIN + toBase64Url(bytes); +} + +/** + * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed + * inflate or JSON that is not a plain object all come back as `null`. + * + * @param {string} text the fragment payload + * @returns {Promise<{ state: object } | null>} + */ +export async function decodeState(text) { + try { + if (typeof text !== 'string' || text.length < 2) return null; + + const tag = text[0]; + if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null; + + const body = text.slice(1); + if (!BASE64URL.test(body)) return null; + + let bytes = fromBase64Url(body); + if (tag === TAG_DEFLATE) { + if (typeof DecompressionStream !== 'function') return null; + bytes = await inflateRaw(bytes); + } + + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return { state: restoreDefaults(parsed) }; + } catch { + return null; + } +} diff --git a/public/bluebey-studio/src/zip.js b/public/bluebey-studio/src/zip.js new file mode 100644 index 0000000..21a1955 --- /dev/null +++ b/public/bluebey-studio/src/zip.js @@ -0,0 +1,218 @@ +/** + * Store-only ZIP writer. + * + * The studio exports a whole batch of images at once (one PNG per comic panel + * or sticker), and a single download is far nicer to handle than a dozen files, + * so the batch is packed into one archive here. + * + * PNG payloads are already DEFLATE-compressed, so running deflate over them a + * second time costs time and saves nothing measurable: every entry is stored + * verbatim (compression method 0, `compressedSize === uncompressedSize`) and no + * data descriptor is needed. + * + * The module has no imports, never touches the DOM beyond `Blob`, and stamps + * entries with a fixed DOS date unless one is passed in, so the same input + * always produces a byte-for-byte identical archive. That keeps the output safe + * to cache, diff, hash and test. + * + * Archive layout (every integer little-endian, no extra fields, no comments, no + * directory entries): + * + * [local file header + stored data] one per file + * [central directory entry] one per file + * [end of central directory record] + */ + +/** Signature of a local file header ("PK\x03\x04"). */ +const LOCAL_SIGNATURE = 0x04034b50; +/** Signature of a central directory entry ("PK\x01\x02"). */ +const CENTRAL_SIGNATURE = 0x02014b50; +/** Signature of the end of central directory record ("PK\x05\x06"). */ +const EOCD_SIGNATURE = 0x06054b50; + +/** ZIP 2.0 is the oldest version that covers everything this writer emits. */ +const VERSION_NEEDED = 20; +/** General purpose bit 11: the entry name is UTF-8, not CP437. */ +const FLAG_UTF8 = 0x0800; +/** Compression method 0: stored. */ +const METHOD_STORE = 0; + +/** Largest value a 32-bit ZIP field can hold. Nothing may reach 4 GiB. */ +const MAX_FIELD = 0xffffffff; +/** Largest value a 16-bit ZIP field can hold (entry count, name length). */ +const MAX_SHORT = 0xffff; + +const LOCAL_HEADER_SIZE = 30; +const CENTRAL_HEADER_SIZE = 46; +const EOCD_SIZE = 22; + +/** 1980-01-01 00:00 in DOS form: year offset 0, month 1, day 1, midnight. */ +const DEFAULT_TIME = 0; +const DEFAULT_DATE = (1 << 5) | 1; + +/** + * CRC-32 (polynomial 0xEDB88320, reflected), the checksum every ZIP entry must + * carry. The table is built once at module load; eight table-driven bits per + * byte is fast enough that a several-megabyte PNG batch stays imperceptible. + */ +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +/** + * @param {Uint8Array} bytes + * @returns {number} unsigned 32-bit CRC-32 + */ +export function crc32(bytes) { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** Clip a Date into the DOS date/time pair stored in the headers. */ +function toDosDateTime(date) { + const year = date.getFullYear(); + // The DOS epoch starts in 1980; anything older is clamped to the epoch. + if (year < 1980) return { time: DEFAULT_TIME, date: DEFAULT_DATE }; + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1); + const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { time: time & MAX_SHORT, date: day & MAX_SHORT }; +} + +/** + * Entry names live inside the archive, where the separator is always "/": a + * backslash (what Windows paths use) would be taken as part of the name, and a + * leading slash would look like an absolute path to some extractors. + */ +function normalizeName(name) { + return String(name ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/, ''); +} + +/** Accept any byte source, plus plain strings meaning UTF-8 text. */ +function toBytes(data, encoder) { + if (typeof data === 'string') return encoder.encode(data); + if (data instanceof Uint8Array) return data; + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) return new Uint8Array(data); + throw new TypeError('ZIP: file data must be a Uint8Array, an ArrayBuffer or a string'); +} + +function writeU16(view, offset, value) { + view.setUint16(offset, value & MAX_SHORT, true); +} + +function writeU32(view, offset, value) { + view.setUint32(offset, value >>> 0, true); +} + +/** + * Pack `files` into a stored (uncompressed) ZIP archive. + * + * @param {Array<{name: string, data: Uint8Array | string}>} files + * a string `data` is encoded as UTF-8 text + * @param {object} [options] + * @param {Date} [options.date] + * timestamp for every entry; defaults to 1980-01-01 00:00 so that the same + * input always yields the same bytes + * @returns {Blob} an `application/zip` blob + */ +export function createZip(files, { date } = {}) { + const encoder = new TextEncoder(); + const stamp = date ? toDosDateTime(date) : { time: DEFAULT_TIME, date: DEFAULT_DATE }; + + // First pass: normalise the input and measure it, so the output buffer can be + // allocated exactly once instead of being grown and copied. + const entries = []; + let localTotal = 0; + let centralTotal = 0; + + for (const file of files ?? []) { + const nameBytes = encoder.encode(normalizeName(file.name)); + const data = toBytes(file.data, encoder); + + if (data.length > MAX_FIELD) { + throw new Error('ZIP: a file is 4 GiB or larger; zip64 is not supported'); + } + if (nameBytes.length > MAX_SHORT) { + throw new Error('ZIP: a file name is longer than 65535 bytes'); + } + + const length = LOCAL_HEADER_SIZE + nameBytes.length + data.length; + localTotal += length; + centralTotal += CENTRAL_HEADER_SIZE + nameBytes.length; + entries.push({ nameBytes, data, crc: crc32(data), length, offset: 0 }); + } + + if (entries.length > MAX_SHORT) { + throw new Error('ZIP: more than 65535 files; zip64 is not supported'); + } + if (localTotal + centralTotal + EOCD_SIZE > MAX_FIELD) { + throw new Error('ZIP: the archive is 4 GiB or larger; zip64 is not supported'); + } + + const bytes = new Uint8Array(localTotal + centralTotal + EOCD_SIZE); + const view = new DataView(bytes.buffer); + + let offset = 0; + for (const entry of entries) { + entry.offset = offset; + writeU32(view, offset + 0, LOCAL_SIGNATURE); + writeU16(view, offset + 4, VERSION_NEEDED); + writeU16(view, offset + 6, FLAG_UTF8); + writeU16(view, offset + 8, METHOD_STORE); + writeU16(view, offset + 10, stamp.time); + writeU16(view, offset + 12, stamp.date); + writeU32(view, offset + 14, entry.crc); + writeU32(view, offset + 18, entry.data.length); + writeU32(view, offset + 22, entry.data.length); + writeU16(view, offset + 26, entry.nameBytes.length); + writeU16(view, offset + 28, 0); // extra field length + bytes.set(entry.nameBytes, offset + LOCAL_HEADER_SIZE); + bytes.set(entry.data, offset + LOCAL_HEADER_SIZE + entry.nameBytes.length); + offset += entry.length; + } + + const centralOffset = offset; + for (const entry of entries) { + writeU32(view, offset + 0, CENTRAL_SIGNATURE); + writeU16(view, offset + 4, VERSION_NEEDED); // version made by (host 0 = MS-DOS) + writeU16(view, offset + 6, VERSION_NEEDED); + writeU16(view, offset + 8, FLAG_UTF8); + writeU16(view, offset + 10, METHOD_STORE); + writeU16(view, offset + 12, stamp.time); + writeU16(view, offset + 14, stamp.date); + writeU32(view, offset + 16, entry.crc); + writeU32(view, offset + 20, entry.data.length); + writeU32(view, offset + 24, entry.data.length); + writeU16(view, offset + 28, entry.nameBytes.length); + writeU16(view, offset + 30, 0); // extra field length + writeU16(view, offset + 32, 0); // file comment length + writeU16(view, offset + 34, 0); // disk number start + writeU16(view, offset + 36, 0); // internal attributes + writeU32(view, offset + 38, 0); // external attributes + writeU32(view, offset + 42, entry.offset); + bytes.set(entry.nameBytes, offset + CENTRAL_HEADER_SIZE); + offset += CENTRAL_HEADER_SIZE + entry.nameBytes.length; + } + + writeU32(view, offset + 0, EOCD_SIGNATURE); + writeU16(view, offset + 4, 0); // number of this disk + writeU16(view, offset + 6, 0); // disk holding the central directory + writeU16(view, offset + 8, entries.length); + writeU16(view, offset + 10, entries.length); + writeU32(view, offset + 12, offset - centralOffset); + writeU32(view, offset + 16, centralOffset); + writeU16(view, offset + 20, 0); // archive comment length + + return new Blob([bytes], { type: 'application/zip' }); +} |
